@iwonz/ywtc 3.0.0 → 3.2.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 +30 -0
- package/README.md +36 -12
- package/dist/adapters/confluence.js +12 -3
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.js +8 -3
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/cli-arguments.d.ts +12 -2
- package/dist/cli-arguments.js +23 -3
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli.js +70 -1
- package/dist/cli.js.map +1 -1
- package/dist/clients/confluence-cloud.d.ts +1 -0
- package/dist/clients/confluence-cloud.js +6 -0
- package/dist/clients/confluence-cloud.js.map +1 -1
- package/dist/clients/confluence.d.ts +1 -0
- package/dist/clients/confluence.js +9 -0
- package/dist/clients/confluence.js.map +1 -1
- package/dist/clients/yandex-wiki.d.ts +2 -1
- package/dist/clients/yandex-wiki.js +12 -0
- package/dist/clients/yandex-wiki.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +15 -2
- package/dist/config.js.map +1 -1
- package/dist/connection-verifier.d.ts +17 -0
- package/dist/connection-verifier.js +94 -0
- package/dist/connection-verifier.js.map +1 -0
- package/dist/content-model.d.ts +7 -1
- package/dist/content-model.js +188 -16
- package/dist/content-model.js.map +1 -1
- package/dist/conversion.d.ts +1 -0
- package/dist/conversion.js +17 -0
- package/dist/conversion.js.map +1 -1
- package/dist/domain.d.ts +15 -4
- package/dist/domain.js.map +1 -1
- package/dist/filesystem-export.js +1 -1
- package/dist/filesystem-export.js.map +1 -1
- package/dist/http.d.ts +2 -1
- package/dist/http.js +4 -2
- package/dist/http.js.map +1 -1
- package/dist/init-prompts.d.ts +49 -0
- package/dist/init-prompts.js +63 -0
- package/dist/init-prompts.js.map +1 -0
- package/dist/interactive-config.d.ts +37 -0
- package/dist/interactive-config.js +226 -0
- package/dist/interactive-config.js.map +1 -0
- package/dist/interactive-init.d.ts +20 -0
- package/dist/interactive-init.js +450 -0
- package/dist/interactive-init.js.map +1 -0
- package/dist/migrator.d.ts +5 -0
- package/dist/migrator.js +283 -114
- package/dist/migrator.js.map +1 -1
- package/dist/report-catalog.d.ts +65 -0
- package/dist/report-catalog.js +117 -0
- package/dist/report-catalog.js.map +1 -0
- package/dist/report.d.ts +4 -0
- package/dist/report.js +1 -0
- package/dist/report.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/workspace.d.ts +5 -0
- package/dist/workspace.js +10 -1
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -1
package/dist/migrator.js
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import PQueue from 'p-queue';
|
|
5
5
|
import { createProviderAdapter } from './adapters/factory.js';
|
|
6
|
-
import { appendGridSnapshots, normalizePageContent, normalizedPreviewHtml, renderedPreviewHtml, renderConfluenceStorage, renderYfm, } from './content-model.js';
|
|
6
|
+
import { appendGridSnapshots, assertDestinationContent, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, sourceResourceBindings, } from './content-model.js';
|
|
7
7
|
import { assertTransferCapabilities } from './domain.js';
|
|
8
8
|
import { FilesystemExporter } from './filesystem-export.js';
|
|
9
9
|
import { discoverPageGraph } from './graph.js';
|
|
@@ -62,11 +62,6 @@ async function cacheDocument(cacheDirectory, canonicalId, document) {
|
|
|
62
62
|
async function loadCachedDocument(cacheDirectory, canonicalId) {
|
|
63
63
|
return JSON.parse(await readFile(cachedDocumentPath(cacheDirectory, canonicalId), 'utf8'));
|
|
64
64
|
}
|
|
65
|
-
function placeholder(adapter, page) {
|
|
66
|
-
return adapter.kind === 'confluence'
|
|
67
|
-
? `<p><em>ywtc transfer in progress from <a href="${page.url.replaceAll('&', '&').replaceAll('"', '"')}">${page.provider}</a>.</em></p>`
|
|
68
|
-
: `_ywtc transfer in progress from [${page.provider}](${page.url})._`;
|
|
69
|
-
}
|
|
70
65
|
function collisionTitle(page) {
|
|
71
66
|
const suffix = page.locator.split('/').filter(Boolean).at(-1) ?? page.id;
|
|
72
67
|
return `${page.title} [${suffix}]`;
|
|
@@ -111,7 +106,8 @@ function reportPage(input) {
|
|
|
111
106
|
images: input.document.metrics.images,
|
|
112
107
|
tables: input.document.metrics.tables,
|
|
113
108
|
codeBlocks: input.document.metrics.codeBlocks,
|
|
114
|
-
degraded: input.document.degraded,
|
|
109
|
+
degraded: input.document.degraded || (input.resourceFallbacks?.length ?? 0) > 0,
|
|
110
|
+
resourceFallbacks: input.resourceFallbacks ?? [],
|
|
115
111
|
warnings: [...input.document.warnings, ...(input.warnings ?? [])],
|
|
116
112
|
...(input.error ? { error: input.error } : {}),
|
|
117
113
|
};
|
|
@@ -121,6 +117,29 @@ function runStatus(errors, warnings) {
|
|
|
121
117
|
return 'partial';
|
|
122
118
|
return warnings > 0 ? 'completed-with-warnings' : 'completed';
|
|
123
119
|
}
|
|
120
|
+
export class MigrationRunError extends Error {
|
|
121
|
+
reportPaths;
|
|
122
|
+
stateFilename;
|
|
123
|
+
constructor(message, reportPaths, stateFilename, options) {
|
|
124
|
+
super(message, options);
|
|
125
|
+
this.reportPaths = reportPaths;
|
|
126
|
+
this.stateFilename = stateFilename;
|
|
127
|
+
this.name = 'MigrationRunError';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function renderRemoteDocument(input) {
|
|
131
|
+
try {
|
|
132
|
+
const rendered = input.destination.kind === 'confluence'
|
|
133
|
+
? await renderConfluenceStorage(input.document, input.page, input.context, input.container.space ?? '')
|
|
134
|
+
: await renderYfm(input.document, input.context);
|
|
135
|
+
if (!rendered.body.trim())
|
|
136
|
+
return rawContentFallback(input.page, input.destination.kind, 'renderer returned an empty body');
|
|
137
|
+
return rendered;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return rawContentFallback(input.page, input.destination.kind, error instanceof Error ? error.message : String(error));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
124
143
|
export class Migrator {
|
|
125
144
|
config;
|
|
126
145
|
logger;
|
|
@@ -208,7 +227,7 @@ export class Migrator {
|
|
|
208
227
|
const reportPaths = await report.write(options.reportsDirectory);
|
|
209
228
|
if (!options.dryRun)
|
|
210
229
|
await store.finishRun(run.id, finalStatus, { pages: 0, warnings: 0, errors: 1 });
|
|
211
|
-
throw new
|
|
230
|
+
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
212
231
|
}
|
|
213
232
|
const resourcesByPage = new Map();
|
|
214
233
|
const inventoryQueue = new PQueue({ concurrency: options.concurrency });
|
|
@@ -245,7 +264,7 @@ export class Migrator {
|
|
|
245
264
|
const reportPaths = await report.write(options.reportsDirectory);
|
|
246
265
|
if (!options.dryRun)
|
|
247
266
|
await store.finishRun(run.id, finalStatus, { pages: graph.pages.length, warnings: graph.warnings.length, errors: inventoryErrors.length });
|
|
248
|
-
throw new
|
|
267
|
+
throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
|
|
249
268
|
}
|
|
250
269
|
const planSummary = `Plan ready: ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} bytes) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks`;
|
|
251
270
|
status(planSummary);
|
|
@@ -312,7 +331,7 @@ export class Migrator {
|
|
|
312
331
|
if (!options.dryRun)
|
|
313
332
|
await store.finishRun(run.id, finalStatus, { pages: graph.pages.length, warnings: transferWarnings, errors: transferErrors + 1 });
|
|
314
333
|
this.logger.error(`${message}. Report: ${reportPaths.html}`);
|
|
315
|
-
throw error;
|
|
334
|
+
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
316
335
|
}
|
|
317
336
|
}
|
|
318
337
|
async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage) {
|
|
@@ -385,15 +404,36 @@ export class Migrator {
|
|
|
385
404
|
async transferRemote(input) {
|
|
386
405
|
const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
|
|
387
406
|
const destinationPages = new Map();
|
|
407
|
+
const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
|
|
408
|
+
const bootstrapBodies = new Map();
|
|
388
409
|
let errors = 0;
|
|
389
410
|
let warnings = 0;
|
|
390
411
|
const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
|
|
391
|
-
const progress = {
|
|
412
|
+
const progress = { staged: 0, pages: 0, resources: 0 };
|
|
392
413
|
const totalUnits = Math.max(1, graph.pages.length * 2 + totalResources);
|
|
393
414
|
const updateProgress = (active) => {
|
|
394
|
-
const completed = progress.
|
|
415
|
+
const completed = progress.staged + progress.pages + progress.resources;
|
|
395
416
|
options.onStatus?.(`Progress ${Math.min(100, Math.floor((completed / totalUnits) * 100))}% · pages ${progress.pages}/${graph.pages.length} · files ${progress.resources}/${totalResources} · ${active}`);
|
|
396
417
|
};
|
|
418
|
+
for (const page of graph.pages) {
|
|
419
|
+
const [document, cachedPage] = await Promise.all([
|
|
420
|
+
loadCachedDocument(options.cacheDirectory, page.canonicalId),
|
|
421
|
+
loadCachedPage(options.cacheDirectory, page.canonicalId),
|
|
422
|
+
]);
|
|
423
|
+
const resources = resourcesByPage.get(page.canonicalId) ?? [];
|
|
424
|
+
bootstrapBodies.set(page.canonicalId, await renderRemoteDocument({
|
|
425
|
+
destination,
|
|
426
|
+
container,
|
|
427
|
+
document,
|
|
428
|
+
page: cachedPage,
|
|
429
|
+
context: {
|
|
430
|
+
pages: new Map(),
|
|
431
|
+
resources: sourceResourceBindings(cachedPage, resources),
|
|
432
|
+
sourcePages,
|
|
433
|
+
currentPageId: page.canonicalId,
|
|
434
|
+
},
|
|
435
|
+
}));
|
|
436
|
+
}
|
|
397
437
|
for (const page of graph.pages) {
|
|
398
438
|
if (options.signal?.aborted)
|
|
399
439
|
throw options.signal.reason ?? new Error('Transfer cancelled');
|
|
@@ -403,18 +443,32 @@ export class Migrator {
|
|
|
403
443
|
const message = `Destination parent is unavailable for ${page.title}`;
|
|
404
444
|
errors += 1;
|
|
405
445
|
report.error(message);
|
|
406
|
-
progress.
|
|
446
|
+
progress.staged += 1;
|
|
407
447
|
updateProgress(message);
|
|
408
448
|
continue;
|
|
409
449
|
}
|
|
410
450
|
try {
|
|
451
|
+
const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
|
|
452
|
+
const bootstrap = bootstrapBodies.get(page.canonicalId);
|
|
411
453
|
let created;
|
|
412
454
|
let title = saved?.destinationTitle ?? page.title;
|
|
413
455
|
if (saved?.destinationId) {
|
|
414
456
|
created = await destination.verifyPage(destinationFromState(saved, destination));
|
|
415
|
-
|
|
457
|
+
const generatedPlaceholder = created.title === saved.destinationTitle
|
|
458
|
+
&& isLegacyTransferPlaceholder(created.content, destination.kind, cachedPage);
|
|
459
|
+
if (['complete', 'partial'].includes(saved.status) && saved.destinationFingerprint && created.fingerprint !== saved.destinationFingerprint && !generatedPlaceholder) {
|
|
416
460
|
throw new Error(`Destination conflict for ${page.title}: content changed since the last verified run`);
|
|
417
461
|
}
|
|
462
|
+
if (generatedPlaceholder && !options.dryRun) {
|
|
463
|
+
created = await destination.updatePage({
|
|
464
|
+
page: created,
|
|
465
|
+
title: created.title,
|
|
466
|
+
body: bootstrap.body,
|
|
467
|
+
...(created.fingerprint ? { expectedFingerprint: created.fingerprint } : {}),
|
|
468
|
+
});
|
|
469
|
+
created = await destination.verifyPage(created);
|
|
470
|
+
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
471
|
+
}
|
|
418
472
|
}
|
|
419
473
|
else if (options.dryRun) {
|
|
420
474
|
created = {
|
|
@@ -425,6 +479,8 @@ export class Migrator {
|
|
|
425
479
|
title,
|
|
426
480
|
url: container.url,
|
|
427
481
|
locator: `dry-run:${page.id}`,
|
|
482
|
+
content: bootstrap.body,
|
|
483
|
+
contentLength: Buffer.byteLength(bootstrap.body),
|
|
428
484
|
...(container.space ? { space: container.space } : {}),
|
|
429
485
|
};
|
|
430
486
|
}
|
|
@@ -439,7 +495,9 @@ export class Migrator {
|
|
|
439
495
|
title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
440
496
|
}
|
|
441
497
|
}
|
|
442
|
-
created = await destination.createPage({ container: parent, title, body:
|
|
498
|
+
created = await destination.createPage({ container: parent, title, body: bootstrap.body });
|
|
499
|
+
created = await destination.verifyPage(created);
|
|
500
|
+
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
443
501
|
}
|
|
444
502
|
destinationPages.set(page.canonicalId, created);
|
|
445
503
|
const next = {
|
|
@@ -460,7 +518,7 @@ export class Migrator {
|
|
|
460
518
|
updatedAt: new Date().toISOString(),
|
|
461
519
|
};
|
|
462
520
|
await store.setPage(next);
|
|
463
|
-
options.onStatus?.(`${options.dryRun ? 'Planned' : '
|
|
521
|
+
options.onStatus?.(`${options.dryRun ? 'Planned' : 'Staged content for'} ${page.title}`);
|
|
464
522
|
}
|
|
465
523
|
catch (error) {
|
|
466
524
|
errors += 1;
|
|
@@ -471,8 +529,8 @@ export class Migrator {
|
|
|
471
529
|
await store.setPage({ ...savedState, status: /conflict/i.test(message) ? 'conflict' : 'failed', lastError: message, updatedAt: new Date().toISOString() });
|
|
472
530
|
}
|
|
473
531
|
finally {
|
|
474
|
-
progress.
|
|
475
|
-
updateProgress(`
|
|
532
|
+
progress.staged += 1;
|
|
533
|
+
updateProgress(`content staged ${page.title}`);
|
|
476
534
|
}
|
|
477
535
|
}
|
|
478
536
|
const contentQueue = new PQueue({ concurrency: options.concurrency });
|
|
@@ -490,73 +548,135 @@ export class Migrator {
|
|
|
490
548
|
status: failedState?.status ?? 'blocked',
|
|
491
549
|
resources,
|
|
492
550
|
document,
|
|
493
|
-
error: failedState?.lastError ?? 'Destination
|
|
551
|
+
error: failedState?.lastError ?? 'Destination page with source-derived content was not created',
|
|
494
552
|
}));
|
|
495
553
|
progress.pages += 1;
|
|
496
554
|
updateProgress(`blocked ${page.title}`);
|
|
497
555
|
return;
|
|
498
556
|
}
|
|
499
557
|
const state = store.value.pages[page.canonicalId];
|
|
500
|
-
const
|
|
558
|
+
const bootstrap = bootstrapBodies.get(page.canonicalId);
|
|
559
|
+
const bindings = sourceResourceBindings(cachedPage, resources);
|
|
560
|
+
const resourceProblems = [];
|
|
561
|
+
const failedResourceIds = new Set();
|
|
562
|
+
const recordResourceFailure = async (resource, detail) => {
|
|
563
|
+
if (!failedResourceIds.has(resource.id)) {
|
|
564
|
+
failedResourceIds.add(resource.id);
|
|
565
|
+
errors += 1;
|
|
566
|
+
resourceProblems.push(detail);
|
|
567
|
+
report.error(`${page.title}: ${detail}`);
|
|
568
|
+
}
|
|
569
|
+
const previous = state.resources[resource.id];
|
|
570
|
+
state.resources[resource.id] = {
|
|
571
|
+
sourceId: resource.id,
|
|
572
|
+
sourceName: resource.name,
|
|
573
|
+
...(previous?.destinationId ? { destinationId: previous.destinationId } : {}),
|
|
574
|
+
destinationName: previous?.destinationName ?? safeFilename(resource.name),
|
|
575
|
+
...(previous?.sha256 ? { sha256: previous.sha256 } : {}),
|
|
576
|
+
...(previous?.size !== undefined ? { size: previous.size } : resource.size !== undefined ? { size: resource.size } : {}),
|
|
577
|
+
status: 'failed',
|
|
578
|
+
lastError: detail,
|
|
579
|
+
updatedAt: new Date().toISOString(),
|
|
580
|
+
};
|
|
581
|
+
await store.setPage({ ...state, status: 'partial', lastError: detail, updatedAt: new Date().toISOString() });
|
|
582
|
+
};
|
|
501
583
|
try {
|
|
502
584
|
for (const resource of resources.filter((item) => item.kind !== 'grid')) {
|
|
503
585
|
const key = resource.name.toLocaleLowerCase();
|
|
504
586
|
const existing = state.resources[resource.id];
|
|
505
587
|
if (existing?.status === 'complete' && existing.destinationId) {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
588
|
+
bindings.set(key, { kind: 'uploaded', resource: {
|
|
589
|
+
id: existing.destinationId,
|
|
590
|
+
name: existing.destinationName,
|
|
591
|
+
...(existing.size !== undefined ? { size: existing.size } : {}),
|
|
592
|
+
...(existing.sha256 ? { fingerprint: existing.sha256 } : {}),
|
|
593
|
+
} });
|
|
512
594
|
progress.resources += 1;
|
|
513
595
|
updateProgress(`reused ${resource.name}`);
|
|
514
596
|
continue;
|
|
515
597
|
}
|
|
516
598
|
if (options.dryRun) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
599
|
+
bindings.set(key, { kind: 'uploaded', resource: {
|
|
600
|
+
id: `dry-run:${resource.id}`,
|
|
601
|
+
name: safeFilename(resource.name),
|
|
602
|
+
...(resource.size !== undefined ? { size: resource.size } : {}),
|
|
603
|
+
} });
|
|
522
604
|
progress.resources += 1;
|
|
523
605
|
updateProgress(`planned ${resource.name}`);
|
|
524
606
|
continue;
|
|
525
607
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
608
|
+
try {
|
|
609
|
+
const filename = cachePath(options.cacheDirectory, page, resource);
|
|
610
|
+
const downloaded = await source.downloadResource(resource, filename);
|
|
611
|
+
const result = await destination.uploadResource({
|
|
612
|
+
page: destinationPage,
|
|
613
|
+
name: safeFilename(resource.name),
|
|
614
|
+
filePath: downloaded.path,
|
|
615
|
+
size: downloaded.size,
|
|
616
|
+
...(downloaded.mimeType ?? resource.mimeType ? { mimeType: downloaded.mimeType ?? resource.mimeType } : {}),
|
|
617
|
+
...(existing?.destinationId ? { existingId: existing.destinationId } : {}),
|
|
618
|
+
});
|
|
619
|
+
bindings.set(key, { kind: 'uploaded', resource: result });
|
|
620
|
+
state.resources[resource.id] = {
|
|
621
|
+
sourceId: resource.id,
|
|
622
|
+
sourceName: resource.name,
|
|
623
|
+
destinationId: result.id,
|
|
624
|
+
destinationName: result.name,
|
|
625
|
+
sha256: downloaded.sha256,
|
|
626
|
+
size: downloaded.size,
|
|
627
|
+
status: 'complete',
|
|
628
|
+
updatedAt: new Date().toISOString(),
|
|
629
|
+
};
|
|
630
|
+
await store.setPage({ ...state, updatedAt: new Date().toISOString() });
|
|
631
|
+
updateProgress(`uploaded ${resource.name} → ${page.title}`);
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
await recordResourceFailure(resource, `Resource ${resource.name} was not transferred: ${error instanceof Error ? error.message : String(error)}`);
|
|
635
|
+
updateProgress(`fallback ${resource.name} → ${page.title}`);
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
progress.resources += 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (!options.dryRun && resources.some((resource) => resource.kind !== 'grid')) {
|
|
642
|
+
try {
|
|
643
|
+
const remoteResources = await destination.listUploadedResources(destinationPage);
|
|
644
|
+
for (const resource of resources.filter((item) => item.kind !== 'grid')) {
|
|
645
|
+
const transferred = state.resources[resource.id];
|
|
646
|
+
if (transferred?.status !== 'complete')
|
|
647
|
+
continue;
|
|
648
|
+
const remote = remoteResources.find((item) => item.id === transferred.destinationId || item.name === transferred.destinationName);
|
|
649
|
+
const sizeMismatch = destination.kind === 'confluence'
|
|
650
|
+
&& transferred.size !== undefined
|
|
651
|
+
&& remote?.size !== undefined
|
|
652
|
+
&& transferred.size !== remote.size;
|
|
653
|
+
if (!remote || sizeMismatch) {
|
|
654
|
+
bindings.set(resource.name.toLocaleLowerCase(), sourceResourceBindings(cachedPage, [resource]).get(resource.name.toLocaleLowerCase()));
|
|
655
|
+
await recordResourceFailure(resource, !remote
|
|
656
|
+
? `Verification did not find destination resource for ${resource.name}`
|
|
657
|
+
: `Verification size mismatch for ${resource.name}: ${transferred.size} source bytes, ${remote.size} destination bytes`);
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
bindings.set(resource.name.toLocaleLowerCase(), { kind: 'uploaded', resource: remote });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
for (const resource of resources.filter((item) => item.kind !== 'grid')) {
|
|
666
|
+
if (state.resources[resource.id]?.status !== 'complete')
|
|
667
|
+
continue;
|
|
668
|
+
bindings.set(resource.name.toLocaleLowerCase(), sourceResourceBindings(cachedPage, [resource]).get(resource.name.toLocaleLowerCase()));
|
|
669
|
+
await recordResourceFailure(resource, `Destination resource verification failed for ${resource.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
550
672
|
}
|
|
551
673
|
const context = {
|
|
552
674
|
pages: destinationPages,
|
|
553
|
-
resources:
|
|
554
|
-
sourcePages
|
|
675
|
+
resources: bindings,
|
|
676
|
+
sourcePages,
|
|
555
677
|
currentPageId: page.canonicalId,
|
|
556
678
|
};
|
|
557
|
-
const rendered = destination
|
|
558
|
-
? await renderConfluenceStorage(document, cachedPage, context, container.space ?? '')
|
|
559
|
-
: await renderYfm(document, context);
|
|
679
|
+
const rendered = await renderRemoteDocument({ destination, container, document, page: cachedPage, context });
|
|
560
680
|
warnings += rendered.warnings.length;
|
|
561
681
|
if (rendered.degraded && !options.dryRun) {
|
|
562
682
|
const extension = page.rawFormat === 'yfm' ? 'yfm' : 'storage.xml';
|
|
@@ -566,92 +686,141 @@ export class Migrator {
|
|
|
566
686
|
await writeFile(snapshotPath, cachedPage.rawContent, { mode: 0o600 });
|
|
567
687
|
const snapshotKey = `snapshot:${page.canonicalId}`;
|
|
568
688
|
const existingSnapshot = state.resources[snapshotKey];
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
689
|
+
try {
|
|
690
|
+
const snapshot = existingSnapshot?.status === 'complete' && existingSnapshot.destinationId
|
|
691
|
+
? {
|
|
692
|
+
id: existingSnapshot.destinationId,
|
|
693
|
+
name: existingSnapshot.destinationName,
|
|
694
|
+
...(existingSnapshot.size !== undefined ? { size: existingSnapshot.size } : {}),
|
|
695
|
+
}
|
|
696
|
+
: await destination.uploadResource({
|
|
697
|
+
page: destinationPage,
|
|
698
|
+
name: snapshotName,
|
|
699
|
+
filePath: snapshotPath,
|
|
700
|
+
size: Buffer.byteLength(cachedPage.rawContent),
|
|
701
|
+
mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
|
|
702
|
+
...(existingSnapshot?.destinationId ? { existingId: existingSnapshot.destinationId } : {}),
|
|
703
|
+
});
|
|
704
|
+
state.resources[snapshotKey] = {
|
|
705
|
+
sourceId: snapshotKey,
|
|
706
|
+
sourceName: snapshotName,
|
|
707
|
+
destinationId: snapshot.id,
|
|
708
|
+
destinationName: snapshot.name,
|
|
709
|
+
...(snapshot.size !== undefined ? { size: snapshot.size } : {}),
|
|
710
|
+
sha256: sha256(cachedPage.rawContent),
|
|
711
|
+
status: 'complete',
|
|
712
|
+
updatedAt: new Date().toISOString(),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
const detail = `Raw source snapshot was not transferred: ${error instanceof Error ? error.message : String(error)}`;
|
|
717
|
+
errors += 1;
|
|
718
|
+
resourceProblems.push(detail);
|
|
719
|
+
report.error(`${page.title}: ${detail}`);
|
|
720
|
+
state.resources[snapshotKey] = {
|
|
721
|
+
sourceId: snapshotKey,
|
|
722
|
+
sourceName: snapshotName,
|
|
723
|
+
destinationName: snapshotName,
|
|
724
|
+
status: 'failed',
|
|
725
|
+
lastError: detail,
|
|
726
|
+
updatedAt: new Date().toISOString(),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
state.degradations = [...new Set([...state.degradations, ...rendered.warnings, ...resourceProblems])];
|
|
595
730
|
}
|
|
596
731
|
let verified = destinationPage;
|
|
597
732
|
if (!options.dryRun) {
|
|
733
|
+
const current = await destination.verifyPage(destinationPage);
|
|
734
|
+
if (destinationPage.contentFingerprint
|
|
735
|
+
&& current.contentFingerprint
|
|
736
|
+
&& current.contentFingerprint !== destinationPage.contentFingerprint) {
|
|
737
|
+
throw new Error(`Destination conflict for ${page.title}: content changed while resources were transferred`);
|
|
738
|
+
}
|
|
598
739
|
verified = await destination.updatePage({
|
|
599
|
-
page:
|
|
600
|
-
title:
|
|
740
|
+
page: current,
|
|
741
|
+
title: current.title,
|
|
601
742
|
body: rendered.body,
|
|
602
|
-
...(
|
|
743
|
+
...(current.fingerprint ? { expectedFingerprint: current.fingerprint } : {}),
|
|
603
744
|
});
|
|
604
745
|
if (verified.fingerprint) {
|
|
605
746
|
state.destinationFingerprint = verified.fingerprint;
|
|
606
747
|
await store.setPage({ ...state, updatedAt: new Date().toISOString() });
|
|
607
748
|
}
|
|
608
749
|
verified = await destination.verifyPage(verified);
|
|
609
|
-
|
|
610
|
-
throw new Error(`Verification found empty destination content for ${page.title}`);
|
|
611
|
-
}
|
|
612
|
-
const remoteResources = await destination.listUploadedResources(verified);
|
|
613
|
-
for (const resource of resources.filter((item) => item.kind !== 'grid')) {
|
|
614
|
-
const transferred = state.resources[resource.id];
|
|
615
|
-
const remote = transferred && remoteResources.find((item) => (item.id === transferred.destinationId || item.name === transferred.destinationName));
|
|
616
|
-
if (!transferred || !remote) {
|
|
617
|
-
throw new Error(`Verification did not find destination resource for ${resource.name}`);
|
|
618
|
-
}
|
|
619
|
-
if (destination.kind === 'confluence'
|
|
620
|
-
&& transferred.size !== undefined
|
|
621
|
-
&& remote.size !== undefined
|
|
622
|
-
&& transferred.size !== remote.size) {
|
|
623
|
-
throw new Error(`Verification size mismatch for ${resource.name}: ${transferred.size} source bytes, ${remote.size} destination bytes`);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
750
|
+
assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
|
|
626
751
|
}
|
|
752
|
+
const partial = rendered.degraded || resourceProblems.length > 0;
|
|
753
|
+
const lastResourceProblem = resourceProblems.at(-1);
|
|
754
|
+
const clearedState = { ...state };
|
|
755
|
+
delete clearedState.lastError;
|
|
627
756
|
const finalState = {
|
|
628
|
-
...
|
|
757
|
+
...clearedState,
|
|
629
758
|
destinationId: verified.id,
|
|
630
759
|
destinationCanonicalId: verified.canonicalId,
|
|
631
760
|
destinationLocator: verified.url,
|
|
632
761
|
destinationTitle: verified.title,
|
|
633
762
|
...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
|
|
634
|
-
status: options.dryRun ? 'planned' :
|
|
635
|
-
degradations: [...new Set([...state.degradations, ...rendered.warnings])],
|
|
763
|
+
status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
|
|
764
|
+
degradations: [...new Set([...state.degradations, ...rendered.warnings, ...resourceProblems])],
|
|
765
|
+
...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
|
|
636
766
|
updatedAt: new Date().toISOString(),
|
|
637
767
|
};
|
|
638
768
|
await store.setPage(finalState);
|
|
769
|
+
const fallbackDetails = resources.flatMap((resource) => {
|
|
770
|
+
const resourceState = finalState.resources[resource.id];
|
|
771
|
+
return resourceState?.status === 'failed'
|
|
772
|
+
? [{ name: resource.name, error: resourceState.lastError ?? 'Resource was not transferred' }]
|
|
773
|
+
: [];
|
|
774
|
+
});
|
|
639
775
|
report.addPage(reportPage({
|
|
640
776
|
page,
|
|
641
777
|
destination: verified,
|
|
642
778
|
status: finalState.status,
|
|
643
779
|
resources,
|
|
644
780
|
document,
|
|
645
|
-
|
|
646
|
-
|
|
781
|
+
resourceFallbacks: fallbackDetails,
|
|
782
|
+
warnings: [...rendered.warnings, ...resourceProblems],
|
|
783
|
+
}), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(verified.content ?? rendered.body, destination.kind) });
|
|
647
784
|
}
|
|
648
785
|
catch (error) {
|
|
649
786
|
errors += 1;
|
|
650
787
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
651
|
-
|
|
788
|
+
let failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
|
|
789
|
+
let actual;
|
|
790
|
+
if (failureStatus !== 'conflict' && !options.dryRun) {
|
|
791
|
+
try {
|
|
792
|
+
actual = await destination.verifyPage(destinationPage);
|
|
793
|
+
assertDestinationContent(bootstrap.body, actual.content, destination.kind, cachedPage);
|
|
794
|
+
failureStatus = 'partial';
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
failureStatus = 'failed';
|
|
798
|
+
}
|
|
799
|
+
}
|
|
652
800
|
report.error(message);
|
|
653
|
-
await store.setPage({
|
|
654
|
-
|
|
801
|
+
await store.setPage({
|
|
802
|
+
...state,
|
|
803
|
+
...(actual?.fingerprint ? { destinationFingerprint: actual.fingerprint } : {}),
|
|
804
|
+
status: failureStatus,
|
|
805
|
+
lastError: message,
|
|
806
|
+
updatedAt: new Date().toISOString(),
|
|
807
|
+
});
|
|
808
|
+
const fallbackDetails = resources.flatMap((resource) => {
|
|
809
|
+
const resourceState = state.resources[resource.id];
|
|
810
|
+
return resourceState?.status === 'failed'
|
|
811
|
+
? [{ name: resource.name, error: resourceState.lastError ?? 'Resource was not transferred' }]
|
|
812
|
+
: [];
|
|
813
|
+
});
|
|
814
|
+
report.addPage(reportPage({
|
|
815
|
+
page,
|
|
816
|
+
destination: actual ?? destinationPage,
|
|
817
|
+
status: failureStatus,
|
|
818
|
+
resources,
|
|
819
|
+
document,
|
|
820
|
+
resourceFallbacks: fallbackDetails,
|
|
821
|
+
warnings: resourceProblems,
|
|
822
|
+
error: message,
|
|
823
|
+
}), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
|
|
655
824
|
}
|
|
656
825
|
finally {
|
|
657
826
|
progress.pages += 1;
|