@iwonz/ywtc 3.6.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +17 -14
- package/dist/adapters/confluence.d.ts +0 -2
- package/dist/adapters/confluence.js +61 -27
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.d.ts +7 -9
- package/dist/adapters/yandex.js +68 -27
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/clients/confluence-cloud.d.ts +1 -0
- package/dist/clients/confluence-cloud.js +2 -2
- package/dist/clients/confluence-cloud.js.map +1 -1
- package/dist/clients/confluence.d.ts +1 -0
- package/dist/clients/confluence.js +4 -3
- package/dist/clients/confluence.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 +27 -4
- package/dist/domain.js +20 -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/issues.d.ts +1 -1
- package/dist/migrator.js +604 -366
- 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 +6 -6
- package/dist/report.js +34 -23
- package/dist/report.js.map +1 -1
- package/dist/state.d.ts +18 -0
- package/dist/state.js +60 -32
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/dist/migrator.js
CHANGED
|
@@ -5,10 +5,10 @@ import { pathToFileURL } from 'node:url';
|
|
|
5
5
|
import PQueue from 'p-queue';
|
|
6
6
|
import { createProviderAdapter } from './adapters/factory.js';
|
|
7
7
|
import { secretValues } from './config.js';
|
|
8
|
-
import { appendGridSnapshots, assertDestinationContent, emptyRendererFallbackReason,
|
|
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 { 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';
|
|
@@ -105,6 +105,89 @@ function sourceDerivedTextMatches(expectedBody, actualBody, provider, page) {
|
|
|
105
105
|
const text = (value) => renderedPreviewHtml(value, provider).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
106
106
|
return text(expectedBody) === text(actualBody);
|
|
107
107
|
}
|
|
108
|
+
const REVISION_RETRY_DELAYS_MS = [500, 1_000, 2_000];
|
|
109
|
+
async function revisionDelay(attempt, options) {
|
|
110
|
+
const base = REVISION_RETRY_DELAYS_MS[attempt];
|
|
111
|
+
const random = options.random ?? Math.random;
|
|
112
|
+
const delay = Math.max(1, Math.round(base * (0.8 + random() * 0.4)));
|
|
113
|
+
await (options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))))(delay);
|
|
114
|
+
return delay;
|
|
115
|
+
}
|
|
116
|
+
async function updateMappedPage(input) {
|
|
117
|
+
let current = input.current;
|
|
118
|
+
let title = input.title;
|
|
119
|
+
let retainedMappedTitle = false;
|
|
120
|
+
for (let attempt = 0;; attempt += 1) {
|
|
121
|
+
const verification = verifyDestinationContent(input.body, current.content, input.destination.kind, input.sourcePage);
|
|
122
|
+
if (verification.status === 'complete' && current.title === title) {
|
|
123
|
+
return { page: current, updated: false, normalized: current.content !== input.body, verification };
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const result = await input.destination.updatePage({ page: current, title, body: input.body });
|
|
127
|
+
const confirmed = await verifyConfirmedDestinationPage(input.destination, result, input.consistencyOptions);
|
|
128
|
+
return {
|
|
129
|
+
page: confirmed,
|
|
130
|
+
updated: true,
|
|
131
|
+
normalized: confirmed.content !== input.body,
|
|
132
|
+
verification: verifyDestinationContent(input.body, confirmed.content, input.destination.kind, input.sourcePage),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
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
|
+
}
|
|
149
|
+
if (!(error instanceof DestinationRevisionConflictError) || attempt >= REVISION_RETRY_DELAYS_MS.length)
|
|
150
|
+
throw error;
|
|
151
|
+
const delay = await revisionDelay(attempt, input.consistencyOptions);
|
|
152
|
+
input.report.issue({
|
|
153
|
+
code: 'destination-revision-retry',
|
|
154
|
+
severity: 'normalization',
|
|
155
|
+
phase: 'page',
|
|
156
|
+
pageId: input.sourcePage.canonicalId,
|
|
157
|
+
retryable: true,
|
|
158
|
+
message: `${input.sourcePage.title}: destination revision changed concurrently; retry ${attempt + 1} after ${delay} ms`,
|
|
159
|
+
});
|
|
160
|
+
current = await verifyConfirmedDestinationPage(input.destination, current, input.consistencyOptions);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function reparentMappedPage(input) {
|
|
165
|
+
if (!input.destination.reparentPage)
|
|
166
|
+
throw new Error(`${input.destination.profile.name} does not implement page moves`);
|
|
167
|
+
let current = input.current;
|
|
168
|
+
for (let attempt = 0;; attempt += 1) {
|
|
169
|
+
if (current.parentId === input.parent.id)
|
|
170
|
+
return current;
|
|
171
|
+
try {
|
|
172
|
+
const moved = await input.destination.reparentPage({ page: current, parent: input.parent });
|
|
173
|
+
return verifyConfirmedDestinationPage(input.destination, moved, input.consistencyOptions);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (!(error instanceof DestinationRevisionConflictError) || attempt >= REVISION_RETRY_DELAYS_MS.length)
|
|
177
|
+
throw error;
|
|
178
|
+
const delay = await revisionDelay(attempt, input.consistencyOptions);
|
|
179
|
+
input.report.issue({
|
|
180
|
+
code: 'destination-revision-retry',
|
|
181
|
+
severity: 'normalization',
|
|
182
|
+
phase: 'page',
|
|
183
|
+
pageId: input.sourcePage.canonicalId,
|
|
184
|
+
retryable: true,
|
|
185
|
+
message: `${input.sourcePage.title}: destination revision changed during hierarchy repair; retry ${attempt + 1} after ${delay} ms`,
|
|
186
|
+
});
|
|
187
|
+
current = await verifyConfirmedDestinationPage(input.destination, current, input.consistencyOptions);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
108
191
|
function ambiguousCreateOutcome(error) {
|
|
109
192
|
return error instanceof HttpRequestError
|
|
110
193
|
&& (error.status === undefined || error.status >= 500);
|
|
@@ -173,21 +256,23 @@ async function createPageWithAllocatedTitle(input) {
|
|
|
173
256
|
throw new Error(`Confluence rejected all ${candidates.length} deterministic destination titles for ${input.sourcePage.title} because they are occupied`);
|
|
174
257
|
}
|
|
175
258
|
function destinationFromState(state, adapter) {
|
|
176
|
-
if (!state.destinationId
|
|
259
|
+
if (!state.destinationId) {
|
|
177
260
|
throw new Error(`Incomplete mapped destination for ${state.sourceTitle}`);
|
|
178
261
|
}
|
|
179
262
|
return {
|
|
180
263
|
provider: adapter.kind,
|
|
181
264
|
profile: adapter.profile.name,
|
|
182
265
|
id: state.destinationId,
|
|
183
|
-
canonicalId: state.destinationCanonicalId
|
|
266
|
+
canonicalId: state.destinationCanonicalId ?? `${adapter.profile.name}:${adapter.kind}:${state.destinationId}`,
|
|
184
267
|
title: state.destinationTitle,
|
|
185
|
-
url: state.destinationLocator,
|
|
268
|
+
url: state.destinationLocator ?? state.destinationId,
|
|
186
269
|
locator: state.destinationId,
|
|
187
270
|
...(state.destinationFingerprint ? { fingerprint: state.destinationFingerprint } : {}),
|
|
271
|
+
...(state.destinationVersion ? { version: state.destinationVersion } : {}),
|
|
188
272
|
};
|
|
189
273
|
}
|
|
190
274
|
function reportPage(input) {
|
|
275
|
+
const effectiveWarnings = input.warnings ?? input.document.warnings;
|
|
191
276
|
const inventory = input.commentInventory;
|
|
192
277
|
const commentResourceIds = new Set();
|
|
193
278
|
for (const thread of inventory?.threads ?? []) {
|
|
@@ -290,11 +375,14 @@ function reportPage(input) {
|
|
|
290
375
|
tables: input.document.metrics.tables,
|
|
291
376
|
grids: input.resources.filter((resource) => resource.kind === 'grid').length,
|
|
292
377
|
codeBlocks: input.document.metrics.codeBlocks,
|
|
293
|
-
degraded:
|
|
378
|
+
degraded: effectiveWarnings.length > 0 || (input.resourceFallbacks?.length ?? 0) > 0,
|
|
294
379
|
resourceFallbacks: input.resourceFallbacks ?? [],
|
|
295
|
-
warnings: [...new Set(
|
|
380
|
+
warnings: [...new Set(effectiveWarnings)],
|
|
296
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,
|
|
297
384
|
...(input.error ? { error: input.error } : {}),
|
|
385
|
+
...(input.conflictEvidence ? { conflictEvidence: input.conflictEvidence } : {}),
|
|
298
386
|
...(input.status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
|
|
299
387
|
comments: {
|
|
300
388
|
status: commentStatus,
|
|
@@ -354,13 +442,14 @@ function runStatus(errors, warnings, degradations) {
|
|
|
354
442
|
return 'partial';
|
|
355
443
|
return warnings + degradations > 0 ? 'completed-with-warnings' : 'completed';
|
|
356
444
|
}
|
|
357
|
-
function attentionCount(summary) {
|
|
358
|
-
return summary.warnings + summary.degradations;
|
|
359
|
-
}
|
|
360
445
|
function runOutcomeCounts(summary, pages) {
|
|
361
446
|
return {
|
|
362
447
|
pages,
|
|
363
|
-
warnings:
|
|
448
|
+
warnings: summary.warnings,
|
|
449
|
+
degradations: summary.degradations,
|
|
450
|
+
adaptations: summary.adaptations,
|
|
451
|
+
normalizations: summary.normalizations,
|
|
452
|
+
preservedExternalReferences: summary.preservedExternalReferences,
|
|
364
453
|
errors: summary.errors,
|
|
365
454
|
rootErrors: summary.rootErrors,
|
|
366
455
|
dependentPages: summary.dependentPages,
|
|
@@ -432,277 +521,282 @@ export class Migrator {
|
|
|
432
521
|
});
|
|
433
522
|
const store = new StateStore(stateLocation.filename);
|
|
434
523
|
await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
|
|
435
|
-
if (!options.dryRun)
|
|
436
|
-
await store.save();
|
|
437
|
-
const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
|
|
438
|
-
const report = new TransferReport({
|
|
439
|
-
secrets: secretValues(this.config),
|
|
440
|
-
dryRun: options.dryRun,
|
|
441
|
-
commentsEnabled,
|
|
442
|
-
scope: options.scope,
|
|
443
|
-
sourceStatus,
|
|
444
|
-
direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
|
|
445
|
-
source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
|
|
446
|
-
destination: options.destination.type === 'directory'
|
|
447
|
-
? { provider: 'filesystem', id: options.destination.path, format: options.destination.format }
|
|
448
|
-
: { provider: destination.kind, profile: destination.profile.name, id: container.id, url: container.url },
|
|
449
|
-
state: {
|
|
450
|
-
id: stateLocation.id,
|
|
451
|
-
path: stateLocation.filename,
|
|
452
|
-
mode: stateLocation.mode,
|
|
453
|
-
source: store.loadInfo.source,
|
|
454
|
-
...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
|
|
455
|
-
},
|
|
456
|
-
});
|
|
457
|
-
report.registerPages([{ ...root, depth: 0 }]);
|
|
458
|
-
let graph;
|
|
459
|
-
let sourceFingerprints;
|
|
460
524
|
try {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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,
|
|
465
532
|
scope: options.scope,
|
|
466
533
|
sourceStatus,
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
&& graph.pages.some((page) => page.id === container.id)) {
|
|
480
|
-
throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
|
|
481
|
-
}
|
|
482
|
-
status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
|
|
483
|
-
status(`Caching source content for ${graph.pages.length} pages`);
|
|
484
|
-
sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
|
|
485
|
-
await store.setGraph(graph.rootId, graph.edges);
|
|
486
|
-
}
|
|
487
|
-
catch (error) {
|
|
488
|
-
const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
|
|
489
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
490
|
-
report.updatePage(root.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
|
|
491
|
-
report.issue({
|
|
492
|
-
code: 'source-discovery-failed',
|
|
493
|
-
severity: 'error',
|
|
494
|
-
phase: 'discovery',
|
|
495
|
-
pageId: root.canonicalId,
|
|
496
|
-
retryable: true,
|
|
497
|
-
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
|
+
},
|
|
498
546
|
});
|
|
499
|
-
report.
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
|
|
503
|
-
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
504
|
-
}
|
|
505
|
-
const resourcesByPage = new Map();
|
|
506
|
-
const commentsByPage = new Map();
|
|
507
|
-
const inventoryQueue = new PQueue({ concurrency: options.concurrency });
|
|
508
|
-
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 };
|
|
509
|
-
status(`Inspecting content and resources for ${graph.pages.length} pages`);
|
|
510
|
-
const inventoryErrors = [];
|
|
511
|
-
await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
|
|
547
|
+
report.registerPages([{ ...root, depth: 0 }]);
|
|
548
|
+
let graph;
|
|
549
|
+
let sourceFingerprints;
|
|
512
550
|
try {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
code: `comment-inventory-warning:${warning}`,
|
|
534
|
-
severity: 'warning',
|
|
535
|
-
phase: 'inventory',
|
|
536
|
-
pageId: page.canonicalId,
|
|
537
|
-
retryable: false,
|
|
538
|
-
message: `${page.title}: ${warning}`,
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
catch (error) {
|
|
542
|
-
const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
543
|
-
commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
|
|
544
|
-
commentsByPage.set(page.canonicalId, commentInventory);
|
|
545
|
-
report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
|
|
546
|
-
}
|
|
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.');
|
|
547
571
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
inventory.unknownSizes += resources.filter((resource) => resource.kind !== 'grid' && resource.size === undefined).length;
|
|
553
|
-
inventory.grids += resources.filter((resource) => resource.kind === 'grid').length;
|
|
554
|
-
inventory.links += document.metrics.links;
|
|
555
|
-
inventory.images += document.metrics.images;
|
|
556
|
-
inventory.tables += document.metrics.tables;
|
|
557
|
-
inventory.codeBlocks += document.metrics.codeBlocks;
|
|
558
|
-
report.addPage(reportPage({
|
|
559
|
-
page,
|
|
560
|
-
status: 'planned',
|
|
561
|
-
resources,
|
|
562
|
-
document,
|
|
563
|
-
commentsEnabled,
|
|
564
|
-
...(commentInventory ? { commentInventory } : {}),
|
|
565
|
-
}), undefined, false);
|
|
566
|
-
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);
|
|
567
576
|
}
|
|
568
577
|
catch (error) {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
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 });
|
|
572
581
|
report.issue({
|
|
573
|
-
code: 'source-
|
|
582
|
+
code: 'source-discovery-failed',
|
|
574
583
|
severity: 'error',
|
|
575
|
-
phase: '
|
|
576
|
-
pageId:
|
|
584
|
+
phase: 'discovery',
|
|
585
|
+
pageId: root.canonicalId,
|
|
577
586
|
retryable: true,
|
|
578
587
|
message,
|
|
579
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 });
|
|
580
594
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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'
|
|
606
691
|
? {
|
|
607
|
-
status: '
|
|
608
|
-
|
|
609
|
-
participants: commentInventory.participants, attachments: commentInventory.attachments,
|
|
610
|
-
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(),
|
|
611
694
|
}
|
|
612
|
-
:
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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: [],
|
|
622
726
|
updatedAt: new Date().toISOString(),
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
sourceId: page.id,
|
|
626
|
-
sourceCanonicalId: page.canonicalId,
|
|
627
|
-
sourceLocator: page.locator,
|
|
628
|
-
sourceTitle: page.title,
|
|
629
|
-
...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
|
|
630
|
-
destinationTitle: page.title,
|
|
631
|
-
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
632
|
-
status: 'discovered',
|
|
633
|
-
resources: resourceStates,
|
|
634
|
-
...(commentState ? { comments: commentState } : {}),
|
|
635
|
-
degradations: [],
|
|
636
|
-
updatedAt: new Date().toISOString(),
|
|
637
|
-
});
|
|
727
|
+
});
|
|
728
|
+
}
|
|
638
729
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
+
};
|
|
657
783
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
});
|
|
671
|
-
output = container.url;
|
|
672
|
-
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 });
|
|
673
796
|
}
|
|
674
|
-
report.finish('completed');
|
|
675
|
-
const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
|
|
676
|
-
await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
|
|
677
|
-
report.finish(finalStatus);
|
|
678
|
-
const reportPaths = await report.write(options.reportsDirectory);
|
|
679
|
-
if (!options.dryRun)
|
|
680
|
-
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
681
|
-
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`);
|
|
682
|
-
return {
|
|
683
|
-
status: finalStatus,
|
|
684
|
-
hasErrors: report.data.summary.errors > 0,
|
|
685
|
-
reportPaths,
|
|
686
|
-
stateId: stateLocation.id,
|
|
687
|
-
stateFilename: stateLocation.filename,
|
|
688
|
-
...(store.loadInfo.backupFilename ? { stateBackupFilename: store.loadInfo.backupFilename } : {}),
|
|
689
|
-
...(output ? { output } : {}),
|
|
690
|
-
...(archive ? { archive } : {}),
|
|
691
|
-
summary: report.data.summary,
|
|
692
|
-
};
|
|
693
797
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const finalStatus = cancelled ? 'cancelled' : 'failed';
|
|
697
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
698
|
-
report.error(message);
|
|
699
|
-
await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
|
|
700
|
-
report.finish(finalStatus);
|
|
701
|
-
const reportPaths = await report.write(options.reportsDirectory);
|
|
702
|
-
if (!options.dryRun)
|
|
703
|
-
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
704
|
-
this.logger.error(`${message}. Report: ${reportPaths.html}`);
|
|
705
|
-
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
798
|
+
finally {
|
|
799
|
+
await store.release();
|
|
706
800
|
}
|
|
707
801
|
}
|
|
708
802
|
async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
|
|
@@ -767,6 +861,7 @@ export class Migrator {
|
|
|
767
861
|
status: pageStatus,
|
|
768
862
|
resources,
|
|
769
863
|
degradations: manifestPage?.warnings ?? [],
|
|
864
|
+
adaptations: manifestPage?.adaptations ?? [],
|
|
770
865
|
...(manifestPage?.comments ? { comments: {
|
|
771
866
|
status: manifestPage.comments.status,
|
|
772
867
|
...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
|
|
@@ -798,6 +893,8 @@ export class Migrator {
|
|
|
798
893
|
resourceStates: resources,
|
|
799
894
|
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
800
895
|
...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
|
|
896
|
+
...(manifestPage?.adaptations ? { adaptations: manifestPage.adaptations } : {}),
|
|
897
|
+
preservedExternalReferences: manifestPage?.preservedExternalReferences ?? 0,
|
|
801
898
|
commentsEnabled: options.comments ?? true,
|
|
802
899
|
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
803
900
|
...(manifestPage?.comments ? { commentState: {
|
|
@@ -917,23 +1014,7 @@ export class Migrator {
|
|
|
917
1014
|
}
|
|
918
1015
|
}
|
|
919
1016
|
current ??= await destination.findArchiveComment?.(destinationPage, fingerprint);
|
|
920
|
-
|
|
921
|
-
const conflict = {
|
|
922
|
-
fingerprint,
|
|
923
|
-
destinationId: current.id,
|
|
924
|
-
destinationFingerprint: current.fingerprint,
|
|
925
|
-
...counts,
|
|
926
|
-
...(previous.archiveResourceId ? { archiveResourceId: previous.archiveResourceId } : {}),
|
|
927
|
-
...(previous.archiveName ? { archiveName: previous.archiveName } : {}),
|
|
928
|
-
...(previous.archivePath ? { archivePath: previous.archivePath } : {}),
|
|
929
|
-
...(previous.archiveSha256 ? { archiveSha256: previous.archiveSha256 } : {}),
|
|
930
|
-
status: 'conflict',
|
|
931
|
-
lastError: 'Destination comment archive was changed manually and was preserved.',
|
|
932
|
-
updatedAt: now(),
|
|
933
|
-
};
|
|
934
|
-
return conflict;
|
|
935
|
-
}
|
|
936
|
-
const remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1017
|
+
let remoteResources = await destination.listUploadedResources(destinationPage);
|
|
937
1018
|
const remoteMatches = async (remote, file, trustedMapping) => {
|
|
938
1019
|
if (remote.size !== undefined && remote.size !== file.size)
|
|
939
1020
|
return false;
|
|
@@ -957,18 +1038,37 @@ export class Migrator {
|
|
|
957
1038
|
if (reusable)
|
|
958
1039
|
return reusable;
|
|
959
1040
|
const extension = path.extname(file.name);
|
|
960
|
-
const
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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')}`);
|
|
972
1072
|
};
|
|
973
1073
|
const archiveResource = zip
|
|
974
1074
|
? await uploadArchive(zip, previous?.archiveResourceId, 'application/zip')
|
|
@@ -1046,7 +1146,15 @@ export class Migrator {
|
|
|
1046
1146
|
await prepareFullArchive(`Full comment archive exceeded ${maximumInlineBytes} inline bytes and was attached as Markdown.`);
|
|
1047
1147
|
}
|
|
1048
1148
|
let result = current;
|
|
1049
|
-
const
|
|
1149
|
+
const verifies = (comment) => {
|
|
1150
|
+
if (!comment)
|
|
1151
|
+
return false;
|
|
1152
|
+
const verification = verifyCommentArchive(fullArchiveState.fullArchiveName ? fingerprint : inventory, comment.body, archiveResource?.name);
|
|
1153
|
+
if (fullArchiveState.fullArchiveName && !comment.body.includes(fullArchiveState.fullArchiveName))
|
|
1154
|
+
return false;
|
|
1155
|
+
return verification.ok && verifyDestinationContent(expectedBody, comment.body, destination.kind, page).status === 'complete';
|
|
1156
|
+
};
|
|
1157
|
+
const requiresWrite = !verifies(result);
|
|
1050
1158
|
if (requiresWrite) {
|
|
1051
1159
|
const write = async (body) => {
|
|
1052
1160
|
if (input.signal?.aborted)
|
|
@@ -1054,7 +1162,30 @@ export class Migrator {
|
|
|
1054
1162
|
if (!result || !destination.capabilities.updateComments || !destination.updateArchiveComment) {
|
|
1055
1163
|
return destination.createArchiveComment({ page: destinationPage, body });
|
|
1056
1164
|
}
|
|
1057
|
-
|
|
1165
|
+
let currentComment = result;
|
|
1166
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1167
|
+
try {
|
|
1168
|
+
return await destination.updateArchiveComment({ comment: currentComment, body });
|
|
1169
|
+
}
|
|
1170
|
+
catch (error) {
|
|
1171
|
+
if (!(error instanceof DestinationRevisionConflictError) || attempt >= REVISION_RETRY_DELAYS_MS.length)
|
|
1172
|
+
throw error;
|
|
1173
|
+
const delay = await revisionDelay(attempt, this.consistencyOptions);
|
|
1174
|
+
report.issue({
|
|
1175
|
+
code: 'destination-comment-revision-retry',
|
|
1176
|
+
severity: 'normalization',
|
|
1177
|
+
phase: 'comment',
|
|
1178
|
+
pageId: page.canonicalId,
|
|
1179
|
+
commentId: currentComment.id,
|
|
1180
|
+
retryable: true,
|
|
1181
|
+
message: `${page.title}: comment archive revision changed concurrently; retry ${attempt + 1} after ${delay} ms`,
|
|
1182
|
+
});
|
|
1183
|
+
currentComment = await destination.readArchiveComment({ page: destinationPage, commentId: currentComment.id });
|
|
1184
|
+
if (verifies(currentComment)) {
|
|
1185
|
+
return currentComment;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1058
1189
|
};
|
|
1059
1190
|
const presentations = fallbackReason ? [] : ['native', 'quote', 'code'];
|
|
1060
1191
|
let lastError;
|
|
@@ -1101,14 +1232,41 @@ export class Migrator {
|
|
|
1101
1232
|
}
|
|
1102
1233
|
if (!result)
|
|
1103
1234
|
throw new Error('Destination comment archive was not created');
|
|
1235
|
+
if (requiresWrite && current && destination.capabilities.updateComments && result.id === current.id) {
|
|
1236
|
+
report.issue({
|
|
1237
|
+
code: 'destination-revision-updated',
|
|
1238
|
+
severity: 'normalization',
|
|
1239
|
+
phase: 'comment',
|
|
1240
|
+
pageId: page.canonicalId,
|
|
1241
|
+
commentId: result.id,
|
|
1242
|
+
retryable: false,
|
|
1243
|
+
message: `${page.title}: applied the current source discussion as comment version ${result.version ?? 'confirmed'}`,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1104
1246
|
const verified = await destination.readArchiveComment({ page: destinationPage, commentId: result.id });
|
|
1105
1247
|
const verification = verifyCommentArchive(fullArchiveState.fullArchiveName ? fingerprint : inventory, verified.body, archiveResource?.name);
|
|
1106
1248
|
if (fullArchiveState.fullArchiveName && !verified.body.includes(fullArchiveState.fullArchiveName)) {
|
|
1107
1249
|
verification.ok = false;
|
|
1108
1250
|
verification.differences.push('full Markdown comment archive reference is missing');
|
|
1109
1251
|
}
|
|
1252
|
+
const semanticVerification = verifyDestinationContent(expectedBody, verified.body, destination.kind, page);
|
|
1253
|
+
if (semanticVerification.status !== 'complete') {
|
|
1254
|
+
verification.ok = false;
|
|
1255
|
+
verification.differences.push(...semanticVerification.differences);
|
|
1256
|
+
}
|
|
1110
1257
|
if (!verification.ok)
|
|
1111
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
|
+
}
|
|
1112
1270
|
const previousIds = result.id !== current?.id && current
|
|
1113
1271
|
? [...new Set([...(previous?.previousDestinationIds ?? []), current.id])]
|
|
1114
1272
|
: previous?.previousDestinationIds;
|
|
@@ -1116,14 +1274,14 @@ export class Migrator {
|
|
|
1116
1274
|
fingerprint,
|
|
1117
1275
|
destinationId: verified.id,
|
|
1118
1276
|
destinationFingerprint: verified.fingerprint,
|
|
1277
|
+
...(verified.version ? { destinationVersion: verified.version } : {}),
|
|
1119
1278
|
...(verified.url ? { destinationUrl: verified.url } : {}),
|
|
1120
1279
|
...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
|
|
1121
1280
|
...archiveState,
|
|
1122
1281
|
...fullArchiveState,
|
|
1123
|
-
status: inventory.warnings.length > 0
|
|
1282
|
+
status: inventory.warnings.length > 0 ? 'partial' : 'complete',
|
|
1124
1283
|
...counts,
|
|
1125
1284
|
preview: verified.body,
|
|
1126
|
-
...(fallbackReason ? { lastError: fallbackReason } : {}),
|
|
1127
1285
|
updatedAt: now(),
|
|
1128
1286
|
};
|
|
1129
1287
|
pageState.comments = state;
|
|
@@ -1139,7 +1297,8 @@ export class Migrator {
|
|
|
1139
1297
|
...counts,
|
|
1140
1298
|
...(pageState.comments?.destinationId ? { destinationId: pageState.comments.destinationId } : {}),
|
|
1141
1299
|
...(pageState.comments?.destinationFingerprint ? { destinationFingerprint: pageState.comments.destinationFingerprint } : {}),
|
|
1142
|
-
|
|
1300
|
+
...(pageState.comments?.destinationVersion ? { destinationVersion: pageState.comments.destinationVersion } : {}),
|
|
1301
|
+
status: 'failed',
|
|
1143
1302
|
lastError: message,
|
|
1144
1303
|
updatedAt: now(),
|
|
1145
1304
|
};
|
|
@@ -1148,6 +1307,16 @@ export class Migrator {
|
|
|
1148
1307
|
async transferRemote(input) {
|
|
1149
1308
|
const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
|
|
1150
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 }])));
|
|
1151
1320
|
const stagingFailures = new Map();
|
|
1152
1321
|
const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
|
|
1153
1322
|
const bootstrapBodies = new Map();
|
|
@@ -1208,6 +1377,7 @@ export class Migrator {
|
|
|
1208
1377
|
...(previous?.previousDestinationIds ? { previousDestinationIds: previous.previousDestinationIds } : {}),
|
|
1209
1378
|
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
1210
1379
|
...(previous?.destinationFingerprint ? { destinationFingerprint: previous.destinationFingerprint } : {}),
|
|
1380
|
+
...(previous?.destinationVersion ? { destinationVersion: previous.destinationVersion } : {}),
|
|
1211
1381
|
status: 'blocked',
|
|
1212
1382
|
resources: blockedResourceStates(resources, previous?.resources ?? {}, cause),
|
|
1213
1383
|
...(comments ? { comments } : {}),
|
|
@@ -1232,10 +1402,16 @@ export class Migrator {
|
|
|
1232
1402
|
continue;
|
|
1233
1403
|
}
|
|
1234
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
|
+
}
|
|
1235
1409
|
const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
|
|
1236
1410
|
const bootstrap = bootstrapBodies.get(page.canonicalId);
|
|
1237
1411
|
let created;
|
|
1238
|
-
const title = saved
|
|
1412
|
+
const title = saved && saved.destinationTitle !== saved.sourceTitle
|
|
1413
|
+
? saved.destinationTitle
|
|
1414
|
+
: page.title;
|
|
1239
1415
|
let resourcesForState = saved?.resources ?? {};
|
|
1240
1416
|
let commentsForState = saved?.comments;
|
|
1241
1417
|
let previousDestinationIds = saved?.previousDestinationIds ?? [];
|
|
@@ -1285,20 +1461,18 @@ export class Migrator {
|
|
|
1285
1461
|
message: `${page.title}: replaced unavailable destination mapping ${mapped.id} with ${created.id}`,
|
|
1286
1462
|
});
|
|
1287
1463
|
}
|
|
1288
|
-
|
|
1289
|
-
&&
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1464
|
+
if (!mappingRecovered
|
|
1465
|
+
&& saved.destinationFingerprint
|
|
1466
|
+
&& created.fingerprint
|
|
1467
|
+
&& created.fingerprint !== saved.destinationFingerprint) {
|
|
1468
|
+
report.issue({
|
|
1469
|
+
code: 'destination-revision-updated',
|
|
1470
|
+
severity: 'normalization',
|
|
1471
|
+
phase: 'page',
|
|
1472
|
+
pageId: page.canonicalId,
|
|
1473
|
+
retryable: false,
|
|
1474
|
+
message: `${page.title}: current destination revision differs from the last confirmed state and will be reconciled from source`,
|
|
1299
1475
|
});
|
|
1300
|
-
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
1301
|
-
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
1302
1476
|
}
|
|
1303
1477
|
if (!mappingRecovered && created.parentId !== parent.id) {
|
|
1304
1478
|
const oldPage = created;
|
|
@@ -1314,12 +1488,14 @@ export class Migrator {
|
|
|
1314
1488
|
});
|
|
1315
1489
|
}
|
|
1316
1490
|
else if (destination.capabilities.reparentPages && destination.reparentPage) {
|
|
1317
|
-
created = await
|
|
1318
|
-
|
|
1491
|
+
created = await reparentMappedPage({
|
|
1492
|
+
destination,
|
|
1493
|
+
current: created,
|
|
1319
1494
|
parent,
|
|
1320
|
-
|
|
1495
|
+
sourcePage: cachedPage,
|
|
1496
|
+
consistencyOptions: this.consistencyOptions,
|
|
1497
|
+
report,
|
|
1321
1498
|
});
|
|
1322
|
-
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
1323
1499
|
if (created.parentId !== parent.id) {
|
|
1324
1500
|
throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
1325
1501
|
}
|
|
@@ -1410,11 +1586,12 @@ export class Migrator {
|
|
|
1410
1586
|
destinationId: created.id,
|
|
1411
1587
|
destinationCanonicalId: created.canonicalId,
|
|
1412
1588
|
destinationLocator: created.url,
|
|
1413
|
-
destinationTitle: created.title,
|
|
1589
|
+
destinationTitle: saved?.destinationId && !mappingRecovered ? title : created.title,
|
|
1414
1590
|
...(previousDestinationIds.length > 0 ? { previousDestinationIds } : {}),
|
|
1415
1591
|
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
1416
1592
|
...(created.fingerprint ? { destinationFingerprint: created.fingerprint } : {}),
|
|
1417
|
-
|
|
1593
|
+
...(created.version ? { destinationVersion: created.version } : {}),
|
|
1594
|
+
status: options.dryRun ? 'planned' : 'skeleton-created',
|
|
1418
1595
|
resources: resourcesForState,
|
|
1419
1596
|
...(commentsForState ? { comments: commentsForState } : {}),
|
|
1420
1597
|
degradations: saved?.degradations ?? [],
|
|
@@ -1427,8 +1604,9 @@ export class Migrator {
|
|
|
1427
1604
|
if (options.signal?.aborted)
|
|
1428
1605
|
throw options.signal.reason ?? error;
|
|
1429
1606
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1430
|
-
const
|
|
1431
|
-
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';
|
|
1432
1610
|
stagingFailures.set(page.canonicalId, {
|
|
1433
1611
|
status: failureStatus,
|
|
1434
1612
|
message,
|
|
@@ -1446,13 +1624,14 @@ export class Migrator {
|
|
|
1446
1624
|
});
|
|
1447
1625
|
const savedState = store.value.pages[page.canonicalId];
|
|
1448
1626
|
if (!options.dryRun) {
|
|
1627
|
+
const { conflictEvidence: _staleConflictEvidence, ...baseState } = savedState ?? {};
|
|
1449
1628
|
const cause = { blockedByPageId: page.canonicalId, blockedByCode: failureCode, blockedReason: message };
|
|
1450
1629
|
const resources = resourcesByPage.get(page.canonicalId) ?? [];
|
|
1451
1630
|
const comments = (options.comments ?? true)
|
|
1452
1631
|
? blockedCommentState(commentsByPage.get(page.canonicalId), savedState?.comments, cause)
|
|
1453
1632
|
: undefined;
|
|
1454
1633
|
await store.setPage({
|
|
1455
|
-
...
|
|
1634
|
+
...baseState,
|
|
1456
1635
|
sourceId: page.id,
|
|
1457
1636
|
sourceCanonicalId: page.canonicalId,
|
|
1458
1637
|
sourceLocator: page.locator,
|
|
@@ -1464,6 +1643,8 @@ export class Migrator {
|
|
|
1464
1643
|
resources: blockedResourceStates(resources, savedState?.resources ?? {}, cause),
|
|
1465
1644
|
...(comments ? { comments } : {}),
|
|
1466
1645
|
degradations: savedState?.degradations ?? [],
|
|
1646
|
+
adaptations: savedState?.adaptations ?? [],
|
|
1647
|
+
...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
|
|
1467
1648
|
lastError: message,
|
|
1468
1649
|
updatedAt: new Date().toISOString(),
|
|
1469
1650
|
});
|
|
@@ -1503,6 +1684,7 @@ export class Migrator {
|
|
|
1503
1684
|
commentsEnabled: options.comments ?? true,
|
|
1504
1685
|
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
1505
1686
|
...(commentState ? { commentState } : {}),
|
|
1687
|
+
...(failedState?.conflictEvidence ? { conflictEvidence: failedState.conflictEvidence } : {}),
|
|
1506
1688
|
blockedBy,
|
|
1507
1689
|
error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
|
|
1508
1690
|
}));
|
|
@@ -1641,8 +1823,8 @@ export class Migrator {
|
|
|
1641
1823
|
updateProgress(`reconciled ${resource.name}`);
|
|
1642
1824
|
continue;
|
|
1643
1825
|
}
|
|
1644
|
-
const updateExisting = existing?.status === 'complete' && mappedRemote ? mappedRemote : undefined;
|
|
1645
|
-
|
|
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);
|
|
1646
1828
|
state.resources[resource.id] = {
|
|
1647
1829
|
sourceId: resource.id,
|
|
1648
1830
|
sourceName: resource.name,
|
|
@@ -1655,9 +1837,19 @@ export class Migrator {
|
|
|
1655
1837
|
updatedAt: new Date().toISOString(),
|
|
1656
1838
|
};
|
|
1657
1839
|
await store.setPage({ ...state, updatedAt: new Date().toISOString() });
|
|
1658
|
-
|
|
1659
|
-
let
|
|
1660
|
-
|
|
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
|
+
}
|
|
1661
1853
|
try {
|
|
1662
1854
|
result = await destination.uploadResource({
|
|
1663
1855
|
page: destinationPage,
|
|
@@ -1665,26 +1857,25 @@ export class Migrator {
|
|
|
1665
1857
|
filePath: downloaded.path,
|
|
1666
1858
|
size: downloaded.size,
|
|
1667
1859
|
...(mimeType ? { mimeType } : {}),
|
|
1668
|
-
...(updateExisting ? { existingId: updateExisting.id } : {}),
|
|
1860
|
+
...(attempt === 0 && updateExisting ? { existingId: updateExisting.id } : {}),
|
|
1669
1861
|
});
|
|
1670
1862
|
}
|
|
1671
1863
|
catch (uploadError) {
|
|
1864
|
+
lastUploadError = uploadError;
|
|
1672
1865
|
remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1866
|
+
for (const remote of remoteResources)
|
|
1867
|
+
nameAllocator.reserve(remote.name);
|
|
1673
1868
|
const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
|
|
1674
|
-
if (uncertain && await remoteMatches(uncertain, downloaded))
|
|
1869
|
+
if (uncertain && await remoteMatches(uncertain, downloaded)) {
|
|
1675
1870
|
result = uncertain;
|
|
1676
|
-
|
|
1677
|
-
const retryName = nameAllocator.allocate(resource);
|
|
1678
|
-
result = await destination.uploadResource({
|
|
1679
|
-
page: destinationPage,
|
|
1680
|
-
name: retryName,
|
|
1681
|
-
filePath: downloaded.path,
|
|
1682
|
-
size: downloaded.size,
|
|
1683
|
-
...(mimeType ? { mimeType } : {}),
|
|
1684
|
-
}).catch((retryError) => { throw new Error(`${retryError instanceof Error ? retryError.message : String(retryError)} (initial upload: ${uploadError instanceof Error ? uploadError.message : String(uploadError)})`); });
|
|
1871
|
+
break;
|
|
1685
1872
|
}
|
|
1873
|
+
destinationName = nameAllocator.allocate(resource);
|
|
1686
1874
|
}
|
|
1687
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
|
+
}
|
|
1688
1879
|
if (!remoteResources.some((item) => item.id === result.id))
|
|
1689
1880
|
remoteResources.push(result);
|
|
1690
1881
|
setSourceResourceBinding(bindings, cachedPage, resources, resource, { kind: 'uploaded', resource: result });
|
|
@@ -1767,31 +1958,63 @@ export class Migrator {
|
|
|
1767
1958
|
sha256: sha256(cachedPage.rawContent),
|
|
1768
1959
|
};
|
|
1769
1960
|
try {
|
|
1770
|
-
const
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
? knownSnapshot
|
|
1961
|
+
const mappedSnapshot = remoteResources.find((item) => item.id === existingSnapshot?.destinationId);
|
|
1962
|
+
let snapshot = mappedSnapshot && await remoteMatches(mappedSnapshot, snapshotFile, true)
|
|
1963
|
+
? mappedSnapshot
|
|
1774
1964
|
: undefined;
|
|
1775
|
-
|
|
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
|
+
}
|
|
1776
1992
|
try {
|
|
1777
1993
|
snapshot = await destination.uploadResource({
|
|
1778
1994
|
page: destinationPage,
|
|
1779
|
-
name:
|
|
1995
|
+
name: destinationName,
|
|
1780
1996
|
filePath: snapshotPath,
|
|
1781
1997
|
size: snapshotFile.size,
|
|
1782
1998
|
mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
|
|
1783
|
-
...(
|
|
1999
|
+
...(attempt === 0 && mappedSnapshot && destination.kind === 'confluence' ? { existingId: mappedSnapshot.id } : {}),
|
|
1784
2000
|
});
|
|
1785
2001
|
}
|
|
1786
2002
|
catch (uploadError) {
|
|
2003
|
+
lastUploadError = uploadError;
|
|
1787
2004
|
remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1788
|
-
const
|
|
1789
|
-
|
|
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)) {
|
|
1790
2009
|
snapshot = uncertain;
|
|
1791
|
-
|
|
1792
|
-
|
|
2010
|
+
break;
|
|
2011
|
+
}
|
|
2012
|
+
destinationName = nameAllocator.allocate(snapshotResource);
|
|
1793
2013
|
}
|
|
1794
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
|
+
}
|
|
1795
2018
|
if (!remoteResources.some((item) => item.id === snapshot.id))
|
|
1796
2019
|
remoteResources.push(snapshot);
|
|
1797
2020
|
state.resources[snapshotKey] = {
|
|
@@ -1831,30 +2054,34 @@ export class Migrator {
|
|
|
1831
2054
|
state.degradations = [...new Set([...rendered.warnings, ...resourceProblems])];
|
|
1832
2055
|
}
|
|
1833
2056
|
let verified = destinationPage;
|
|
2057
|
+
let verificationAdaptations = [];
|
|
1834
2058
|
let verificationDegradations = [];
|
|
1835
2059
|
if (!options.dryRun) {
|
|
1836
2060
|
const current = await verifyConfirmedDestinationPage(destination, destinationPage, this.consistencyOptions);
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
if (normalization.status !== 'complete') {
|
|
1842
|
-
throw new Error(`Destination conflict for ${page.title}: content changed while resources were transferred`);
|
|
1843
|
-
}
|
|
1844
|
-
rendered.normalizations.push('Destination normalized page markup without semantic content changes');
|
|
1845
|
-
}
|
|
1846
|
-
verified = await destination.updatePage({
|
|
1847
|
-
page: current,
|
|
1848
|
-
title: current.title,
|
|
2061
|
+
const update = await updateMappedPage({
|
|
2062
|
+
destination,
|
|
2063
|
+
current,
|
|
2064
|
+
title: state.destinationTitle,
|
|
1849
2065
|
body: rendered.body,
|
|
1850
|
-
|
|
2066
|
+
sourcePage: cachedPage,
|
|
2067
|
+
consistencyOptions: this.consistencyOptions,
|
|
2068
|
+
report,
|
|
1851
2069
|
});
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
2070
|
+
verified = update.page;
|
|
2071
|
+
if (update.normalized)
|
|
2072
|
+
rendered.normalizations.push('Destination normalized page markup without semantic content changes');
|
|
2073
|
+
if (update.updated) {
|
|
2074
|
+
report.issue({
|
|
2075
|
+
code: 'destination-revision-updated',
|
|
2076
|
+
severity: 'normalization',
|
|
2077
|
+
phase: 'page',
|
|
2078
|
+
pageId: page.canonicalId,
|
|
2079
|
+
retryable: false,
|
|
2080
|
+
message: `${page.title}: applied the current source representation as destination version ${verified.version ?? 'confirmed'}`,
|
|
2081
|
+
});
|
|
1855
2082
|
}
|
|
1856
|
-
verified = await verifyConfirmedDestinationPage(destination, verified, this.consistencyOptions);
|
|
1857
2083
|
const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
|
|
2084
|
+
verificationAdaptations = verification.adaptations;
|
|
1858
2085
|
verificationDegradations = verification.status === 'partial' ? verification.differences : [];
|
|
1859
2086
|
}
|
|
1860
2087
|
const commentInventory = commentsByPage.get(page.canonicalId);
|
|
@@ -1901,7 +2128,10 @@ export class Migrator {
|
|
|
1901
2128
|
});
|
|
1902
2129
|
}
|
|
1903
2130
|
const commentPartial = !['complete', 'skipped', 'planned'].includes(commentState.status);
|
|
1904
|
-
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;
|
|
1905
2135
|
const lastResourceProblem = resourceProblems.at(-1);
|
|
1906
2136
|
const clearedState = { ...state };
|
|
1907
2137
|
delete clearedState.lastError;
|
|
@@ -1912,9 +2142,11 @@ export class Migrator {
|
|
|
1912
2142
|
destinationLocator: verified.url,
|
|
1913
2143
|
destinationTitle: verified.title,
|
|
1914
2144
|
...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
|
|
2145
|
+
...(verified.version ? { destinationVersion: verified.version } : {}),
|
|
1915
2146
|
status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
|
|
1916
2147
|
comments: commentState,
|
|
1917
|
-
degradations: [...new Set([...
|
|
2148
|
+
degradations: [...new Set([...renderedDegradations, ...resourceProblems, ...verificationDegradations])],
|
|
2149
|
+
adaptations: [...new Set([...rendered.adaptations, ...verificationAdaptations])],
|
|
1918
2150
|
...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
|
|
1919
2151
|
updatedAt: new Date().toISOString(),
|
|
1920
2152
|
};
|
|
@@ -1936,6 +2168,8 @@ export class Migrator {
|
|
|
1936
2168
|
resourceFallbacks: fallbackDetails,
|
|
1937
2169
|
warnings: [...rendered.warnings, ...verificationDegradations],
|
|
1938
2170
|
normalizations: rendered.normalizations,
|
|
2171
|
+
adaptations: [...rendered.adaptations, ...verificationAdaptations],
|
|
2172
|
+
preservedExternalReferences: rendered.preservedExternalReferences,
|
|
1939
2173
|
commentsEnabled: options.comments ?? true,
|
|
1940
2174
|
...(commentInventory ? { commentInventory } : {}),
|
|
1941
2175
|
commentState,
|
|
@@ -1943,7 +2177,8 @@ export class Migrator {
|
|
|
1943
2177
|
}
|
|
1944
2178
|
catch (error) {
|
|
1945
2179
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1946
|
-
|
|
2180
|
+
const ownershipConflict = error instanceof DestinationOwnershipConflictError ? error : undefined;
|
|
2181
|
+
let failureStatus = ownershipConflict ? 'conflict' : 'failed';
|
|
1947
2182
|
let actual;
|
|
1948
2183
|
if (failureStatus !== 'conflict' && !options.dryRun) {
|
|
1949
2184
|
try {
|
|
@@ -1956,7 +2191,7 @@ export class Migrator {
|
|
|
1956
2191
|
}
|
|
1957
2192
|
}
|
|
1958
2193
|
report.issue({
|
|
1959
|
-
code:
|
|
2194
|
+
code: ownershipConflict ? 'destination-ownership-ambiguity' : 'page-transfer-failed',
|
|
1960
2195
|
severity: 'error',
|
|
1961
2196
|
phase: 'page',
|
|
1962
2197
|
pageId: page.canonicalId,
|
|
@@ -1966,7 +2201,9 @@ export class Migrator {
|
|
|
1966
2201
|
await store.setPage({
|
|
1967
2202
|
...state,
|
|
1968
2203
|
...(actual?.fingerprint ? { destinationFingerprint: actual.fingerprint } : {}),
|
|
2204
|
+
...(actual?.version ? { destinationVersion: actual.version } : {}),
|
|
1969
2205
|
status: failureStatus,
|
|
2206
|
+
...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
|
|
1970
2207
|
lastError: message,
|
|
1971
2208
|
updatedAt: new Date().toISOString(),
|
|
1972
2209
|
});
|
|
@@ -1989,6 +2226,7 @@ export class Migrator {
|
|
|
1989
2226
|
commentsEnabled: options.comments ?? true,
|
|
1990
2227
|
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
1991
2228
|
...(state.comments ? { commentState: state.comments } : {}),
|
|
2229
|
+
...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
|
|
1992
2230
|
}), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
|
|
1993
2231
|
}
|
|
1994
2232
|
finally {
|