@iwonz/ywtc 3.2.2 → 3.4.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 +36 -0
- package/README.md +48 -43
- package/dist/adapters/confluence.d.ts +37 -2
- package/dist/adapters/confluence.js +115 -2
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.d.ts +26 -2
- package/dist/adapters/yandex.js +113 -4
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/cli-arguments.d.ts +5 -1
- package/dist/cli-arguments.js +10 -0
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli.js +16 -6
- package/dist/cli.js.map +1 -1
- package/dist/clients/confluence-cloud.d.ts +17 -1
- package/dist/clients/confluence-cloud.js +116 -0
- package/dist/clients/confluence-cloud.js.map +1 -1
- package/dist/clients/confluence.d.ts +13 -1
- package/dist/clients/confluence.js +89 -0
- package/dist/clients/confluence.js.map +1 -1
- package/dist/clients/yandex-wiki.d.ts +13 -2
- package/dist/clients/yandex-wiki.js +67 -5
- package/dist/clients/yandex-wiki.js.map +1 -1
- package/dist/comments.d.ts +66 -0
- package/dist/comments.js +478 -0
- package/dist/comments.js.map +1 -0
- package/dist/content-model.js +3 -2
- package/dist/content-model.js.map +1 -1
- package/dist/doctor.d.ts +14 -2
- package/dist/doctor.js +26 -4
- package/dist/doctor.js.map +1 -1
- package/dist/domain.d.ts +116 -2
- package/dist/domain.js +7 -1
- package/dist/domain.js.map +1 -1
- package/dist/filesystem-export.d.ts +16 -1
- package/dist/filesystem-export.js +88 -2
- package/dist/filesystem-export.js.map +1 -1
- package/dist/graph.d.ts +2 -1
- package/dist/graph.js +105 -15
- package/dist/graph.js.map +1 -1
- package/dist/issues.d.ts +1 -1
- package/dist/migrator.d.ts +4 -1
- package/dist/migrator.js +638 -27
- package/dist/migrator.js.map +1 -1
- package/dist/report-catalog.d.ts +30 -1
- package/dist/report-catalog.js +26 -0
- package/dist/report-catalog.js.map +1 -1
- package/dist/report.d.ts +47 -3
- package/dist/report.js +66 -3
- package/dist/report.js.map +1 -1
- package/dist/state.d.ts +42 -2
- package/dist/state.js +67 -1
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +79 -1
- package/package.json +1 -1
package/dist/migrator.js
CHANGED
|
@@ -4,10 +4,12 @@ import path from 'node:path';
|
|
|
4
4
|
import PQueue from 'p-queue';
|
|
5
5
|
import { createProviderAdapter } from './adapters/factory.js';
|
|
6
6
|
import { appendGridSnapshots, assertDestinationContent, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
|
|
7
|
+
import { commentAttachmentUses, createCommentArchiveZip, renderCommentArchive, renderCommentArchiveIndex, resolveCommentAttachmentReferences, verifyCommentArchive, } from './comments.js';
|
|
7
8
|
import { ConfirmedDestinationMissingError, verifyConfirmedDestinationPage } from './consistency.js';
|
|
8
9
|
import { assertTransferCapabilities } from './domain.js';
|
|
9
10
|
import { FilesystemExporter } from './filesystem-export.js';
|
|
10
11
|
import { discoverPageGraph } from './graph.js';
|
|
12
|
+
import { safeFilename } from './input.js';
|
|
11
13
|
import { profileBaseUrl, sameRemoteInstance } from './locators.js';
|
|
12
14
|
import { TransferReport, } from './report.js';
|
|
13
15
|
import { DestinationNameAllocator, preferredDestinationName, verifiedCacheFile } from './resource-reconciliation.js';
|
|
@@ -137,6 +139,14 @@ function reportPage(input) {
|
|
|
137
139
|
});
|
|
138
140
|
const knownBytes = resourceItems.reduce((total, resource) => total + (resource.sourceBytes ?? 0), 0);
|
|
139
141
|
const transferredBytes = resourceItems.reduce((total, resource) => total + (resource.transferredBytes ?? 0), 0);
|
|
142
|
+
const commentsEnabled = input.commentsEnabled ?? true;
|
|
143
|
+
const inventory = input.commentInventory;
|
|
144
|
+
const commentState = input.commentState;
|
|
145
|
+
const commentStatus = !commentsEnabled
|
|
146
|
+
? 'skipped'
|
|
147
|
+
: commentState?.status
|
|
148
|
+
?? (inventory?.status === 'failed' ? 'failed' : inventory?.status === 'empty' ? 'complete' : 'planned');
|
|
149
|
+
const commentError = commentState?.lastError ?? inventory?.error;
|
|
140
150
|
return {
|
|
141
151
|
sourceCanonicalId: input.page.canonicalId,
|
|
142
152
|
sourceId: input.page.id,
|
|
@@ -166,6 +176,20 @@ function reportPage(input) {
|
|
|
166
176
|
warnings: [...new Set([...input.document.warnings, ...(input.warnings ?? [])])],
|
|
167
177
|
normalizations: [...new Set([...(input.document.normalizations ?? []), ...(input.normalizations ?? [])])],
|
|
168
178
|
...(input.error ? { error: input.error } : {}),
|
|
179
|
+
comments: {
|
|
180
|
+
status: commentStatus,
|
|
181
|
+
threads: commentState?.threads ?? inventory?.threads.length ?? 0,
|
|
182
|
+
comments: commentState?.comments ?? inventory?.comments ?? 0,
|
|
183
|
+
participants: commentState?.participants ?? inventory?.participants ?? 0,
|
|
184
|
+
attachments: commentState?.attachments ?? inventory?.attachments ?? 0,
|
|
185
|
+
bytes: commentState?.bytes ?? inventory?.knownBytes ?? 0,
|
|
186
|
+
...(commentState?.archiveName ? { archiveName: commentState.archiveName } : {}),
|
|
187
|
+
...(commentState?.destinationId ? { destinationId: commentState.destinationId } : {}),
|
|
188
|
+
...(commentState?.previousDestinationIds?.length ? { revision: true } : {}),
|
|
189
|
+
...(commentState?.status === 'stale' ? { stale: true } : {}),
|
|
190
|
+
...(commentError ? { error: commentError } : {}),
|
|
191
|
+
...(commentState?.preview ? { preview: commentState.preview } : {}),
|
|
192
|
+
},
|
|
169
193
|
};
|
|
170
194
|
}
|
|
171
195
|
function runStatus(errors, warnings, degradations) {
|
|
@@ -211,11 +235,13 @@ export class Migrator {
|
|
|
211
235
|
this.consistencyOptions = consistencyOptions;
|
|
212
236
|
}
|
|
213
237
|
async run(options) {
|
|
238
|
+
const sourceStatus = options.sourceStatus ?? 'all';
|
|
239
|
+
const commentsEnabled = options.comments ?? true;
|
|
214
240
|
const source = this.adapterFactory(options.source.profile, this.config);
|
|
215
241
|
const destination = options.destination.type === 'remote'
|
|
216
242
|
? this.adapterFactory(options.destination.profile, this.config)
|
|
217
243
|
: undefined;
|
|
218
|
-
assertTransferCapabilities(source, destination, options.scope);
|
|
244
|
+
assertTransferCapabilities(source, destination, options.scope, sourceStatus, commentsEnabled);
|
|
219
245
|
const status = options.onStatus ?? (() => undefined);
|
|
220
246
|
status('Checking source and destination capabilities');
|
|
221
247
|
const [sourcePlatform, destinationPlatform, root, container] = await Promise.all([
|
|
@@ -239,10 +265,12 @@ export class Migrator {
|
|
|
239
265
|
await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
|
|
240
266
|
if (!options.dryRun)
|
|
241
267
|
await store.save();
|
|
242
|
-
const run = await store.beginRun(options.scope, options.dryRun);
|
|
268
|
+
const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
|
|
243
269
|
const report = new TransferReport({
|
|
244
270
|
dryRun: options.dryRun,
|
|
271
|
+
commentsEnabled,
|
|
245
272
|
scope: options.scope,
|
|
273
|
+
sourceStatus,
|
|
246
274
|
direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
|
|
247
275
|
source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
|
|
248
276
|
destination: options.destination.type === 'directory'
|
|
@@ -265,11 +293,14 @@ export class Migrator {
|
|
|
265
293
|
adapter: source,
|
|
266
294
|
root,
|
|
267
295
|
scope: options.scope,
|
|
296
|
+
sourceStatus,
|
|
268
297
|
...(options.signal ? { signal: options.signal } : {}),
|
|
269
298
|
onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
|
|
270
299
|
});
|
|
271
300
|
report.registerPages(graph.pages);
|
|
272
301
|
report.setEdges(graph.edges);
|
|
302
|
+
report.setSelection(graph.selection);
|
|
303
|
+
await store.setRunSelection(run.id, graph.selection);
|
|
273
304
|
for (const warning of graph.warnings)
|
|
274
305
|
report.warning(warning);
|
|
275
306
|
if (container
|
|
@@ -278,6 +309,7 @@ export class Migrator {
|
|
|
278
309
|
&& graph.pages.some((page) => page.id === container.id)) {
|
|
279
310
|
throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
|
|
280
311
|
}
|
|
312
|
+
status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
|
|
281
313
|
status(`Caching source content for ${graph.pages.length} pages`);
|
|
282
314
|
sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
|
|
283
315
|
await store.setGraph(graph.rootId, graph.edges);
|
|
@@ -305,8 +337,9 @@ export class Migrator {
|
|
|
305
337
|
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
306
338
|
}
|
|
307
339
|
const resourcesByPage = new Map();
|
|
340
|
+
const commentsByPage = new Map();
|
|
308
341
|
const inventoryQueue = new PQueue({ concurrency: options.concurrency });
|
|
309
|
-
const inventory = { resources: 0, bytes: 0, unknownSizes: 0, grids: 0, links: 0, images: 0, tables: 0, codeBlocks: 0 };
|
|
342
|
+
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 };
|
|
310
343
|
status(`Inspecting content and resources for ${graph.pages.length} pages`);
|
|
311
344
|
const inventoryErrors = [];
|
|
312
345
|
await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
|
|
@@ -316,6 +349,36 @@ export class Migrator {
|
|
|
316
349
|
const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
|
|
317
350
|
const resources = await source.listResources(page);
|
|
318
351
|
resourcesByPage.set(page.canonicalId, resources);
|
|
352
|
+
let commentInventory;
|
|
353
|
+
if (commentsEnabled) {
|
|
354
|
+
try {
|
|
355
|
+
if (!source.readComments)
|
|
356
|
+
throw new Error(`${source.profile.name} does not implement comment reads`);
|
|
357
|
+
commentInventory = await source.readComments(cachedPage);
|
|
358
|
+
resolveCommentAttachmentReferences(commentInventory, resources);
|
|
359
|
+
commentsByPage.set(page.canonicalId, commentInventory);
|
|
360
|
+
inventory.commentThreads += commentInventory.threads.length;
|
|
361
|
+
inventory.comments += commentInventory.comments;
|
|
362
|
+
inventory.commentParticipants += commentInventory.participants;
|
|
363
|
+
inventory.commentAttachments += commentInventory.attachments;
|
|
364
|
+
inventory.commentBytes += commentInventory.knownBytes;
|
|
365
|
+
for (const warning of commentInventory.warnings)
|
|
366
|
+
report.issue({
|
|
367
|
+
code: `comment-inventory-warning:${warning}`,
|
|
368
|
+
severity: 'warning',
|
|
369
|
+
phase: 'inventory',
|
|
370
|
+
pageId: page.canonicalId,
|
|
371
|
+
retryable: false,
|
|
372
|
+
message: `${page.title}: ${warning}`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
377
|
+
commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
|
|
378
|
+
commentsByPage.set(page.canonicalId, commentInventory);
|
|
379
|
+
report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
319
382
|
const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
|
|
320
383
|
await cacheDocument(options.cacheDirectory, page.canonicalId, document);
|
|
321
384
|
inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
|
|
@@ -331,6 +394,8 @@ export class Migrator {
|
|
|
331
394
|
status: 'planned',
|
|
332
395
|
resources,
|
|
333
396
|
document,
|
|
397
|
+
commentsEnabled,
|
|
398
|
+
...(commentInventory ? { commentInventory } : {}),
|
|
334
399
|
}), undefined, false);
|
|
335
400
|
status(`Inspected ${page.title} · ${resources.length} resources`);
|
|
336
401
|
}
|
|
@@ -348,6 +413,7 @@ export class Migrator {
|
|
|
348
413
|
});
|
|
349
414
|
}
|
|
350
415
|
})));
|
|
416
|
+
await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
|
|
351
417
|
if (inventoryErrors.length > 0) {
|
|
352
418
|
const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
|
|
353
419
|
report.finish(finalStatus);
|
|
@@ -360,14 +426,14 @@ export class Migrator {
|
|
|
360
426
|
});
|
|
361
427
|
throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
|
|
362
428
|
}
|
|
363
|
-
const planSummary = `Plan ready: ${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`;
|
|
429
|
+
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'}`;
|
|
364
430
|
status(planSummary);
|
|
365
431
|
this.logger.info(planSummary);
|
|
366
432
|
let output;
|
|
367
433
|
let archive;
|
|
368
434
|
try {
|
|
369
435
|
if (options.destination.type === 'directory') {
|
|
370
|
-
const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage);
|
|
436
|
+
const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
|
|
371
437
|
output = result.directory;
|
|
372
438
|
archive = result.archive;
|
|
373
439
|
report.output('directory', result.directory);
|
|
@@ -387,6 +453,7 @@ export class Migrator {
|
|
|
387
453
|
report,
|
|
388
454
|
sourceFingerprints,
|
|
389
455
|
resourcesByPage,
|
|
456
|
+
commentsByPage,
|
|
390
457
|
});
|
|
391
458
|
output = container.url;
|
|
392
459
|
report.output('destination', container.url);
|
|
@@ -431,7 +498,7 @@ export class Migrator {
|
|
|
431
498
|
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
432
499
|
}
|
|
433
500
|
}
|
|
434
|
-
async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage) {
|
|
501
|
+
async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
|
|
435
502
|
const exporter = new FilesystemExporter(source, options.concurrency);
|
|
436
503
|
const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
|
|
437
504
|
const total = Math.max(1, graph.pages.length + totalResources);
|
|
@@ -450,6 +517,8 @@ export class Migrator {
|
|
|
450
517
|
options.onStatus?.(`Progress ${Math.min(100, Math.floor((processed / total) * 100))}% · ${processed}/${total} · ${message}`);
|
|
451
518
|
} } : {}),
|
|
452
519
|
resourcesByPage,
|
|
520
|
+
commentsByPage,
|
|
521
|
+
commentsEnabled: options.comments ?? true,
|
|
453
522
|
loadPage: (page) => loadCachedPage(options.cacheDirectory, page.canonicalId),
|
|
454
523
|
loadDocument: (page) => loadCachedDocument(options.cacheDirectory, page.canonicalId),
|
|
455
524
|
});
|
|
@@ -473,7 +542,8 @@ export class Migrator {
|
|
|
473
542
|
updatedAt: new Date().toISOString(),
|
|
474
543
|
}];
|
|
475
544
|
}));
|
|
476
|
-
const
|
|
545
|
+
const commentIncomplete = manifestPage?.comments && !['complete', 'skipped'].includes(manifestPage.comments.status);
|
|
546
|
+
const pageStatus = options.dryRun ? 'planned' : manifestPage?.degraded || commentIncomplete ? 'partial' : 'complete';
|
|
477
547
|
await store.setPage({
|
|
478
548
|
sourceId: page.id,
|
|
479
549
|
sourceCanonicalId: page.canonicalId,
|
|
@@ -489,6 +559,19 @@ export class Migrator {
|
|
|
489
559
|
status: pageStatus,
|
|
490
560
|
resources,
|
|
491
561
|
degradations: manifestPage?.warnings ?? [],
|
|
562
|
+
...(manifestPage?.comments ? { comments: {
|
|
563
|
+
status: manifestPage.comments.status,
|
|
564
|
+
...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
|
|
565
|
+
...(manifestPage.comments.archivePath ? { archivePath: manifestPage.comments.archivePath } : {}),
|
|
566
|
+
...(manifestPage.comments.archiveName ? { archiveName: manifestPage.comments.archiveName } : {}),
|
|
567
|
+
threads: manifestPage.comments.threads,
|
|
568
|
+
comments: manifestPage.comments.comments,
|
|
569
|
+
participants: manifestPage.comments.participants,
|
|
570
|
+
attachments: manifestPage.comments.attachments,
|
|
571
|
+
bytes: manifestPage.comments.bytes,
|
|
572
|
+
...(manifestPage.comments.error ? { lastError: manifestPage.comments.error } : {}),
|
|
573
|
+
updatedAt: new Date().toISOString(),
|
|
574
|
+
} } : {}),
|
|
492
575
|
updatedAt: new Date().toISOString(),
|
|
493
576
|
});
|
|
494
577
|
report.addPage(reportPage({
|
|
@@ -499,7 +582,35 @@ export class Migrator {
|
|
|
499
582
|
resourceStates: resources,
|
|
500
583
|
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
501
584
|
...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
|
|
585
|
+
commentsEnabled: options.comments ?? true,
|
|
586
|
+
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
587
|
+
...(manifestPage?.comments ? { commentState: {
|
|
588
|
+
status: manifestPage.comments.status,
|
|
589
|
+
...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
|
|
590
|
+
...(manifestPage.comments.archivePath ? { archivePath: manifestPage.comments.archivePath } : {}),
|
|
591
|
+
...(manifestPage.comments.archiveName ? { archiveName: manifestPage.comments.archiveName } : {}),
|
|
592
|
+
threads: manifestPage.comments.threads,
|
|
593
|
+
comments: manifestPage.comments.comments,
|
|
594
|
+
participants: manifestPage.comments.participants,
|
|
595
|
+
attachments: manifestPage.comments.attachments,
|
|
596
|
+
bytes: manifestPage.comments.bytes,
|
|
597
|
+
...(manifestPage.comments.error ? { lastError: manifestPage.comments.error } : {}),
|
|
598
|
+
updatedAt: new Date().toISOString(),
|
|
599
|
+
} } : {}),
|
|
502
600
|
}), { source: normalizedPreviewHtml(document), destination: normalizedPreviewHtml(document) });
|
|
601
|
+
if (manifestPage?.comments
|
|
602
|
+
&& !['complete', 'skipped', 'planned'].includes(manifestPage.comments.status)
|
|
603
|
+
&& !(manifestPage.comments.status === 'failed' && commentsByPage.get(page.canonicalId)?.status === 'failed')
|
|
604
|
+
&& !(manifestPage.comments.status === 'partial' && (commentsByPage.get(page.canonicalId)?.warnings.length ?? 0) > 0)) {
|
|
605
|
+
report.issue({
|
|
606
|
+
code: `comment-${manifestPage.comments.status}`,
|
|
607
|
+
severity: manifestPage.comments.status === 'partial' || manifestPage.comments.status === 'stale' ? 'degradation' : 'error',
|
|
608
|
+
phase: 'comment',
|
|
609
|
+
pageId: page.canonicalId,
|
|
610
|
+
retryable: manifestPage.comments.status === 'failed',
|
|
611
|
+
message: `${page.title}: ${manifestPage.comments.error ?? `comment archive is ${manifestPage.comments.status}`}`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
503
614
|
}
|
|
504
615
|
for (const warning of result.warnings)
|
|
505
616
|
report.warning(warning);
|
|
@@ -507,18 +618,328 @@ export class Migrator {
|
|
|
507
618
|
report.error(error);
|
|
508
619
|
return result;
|
|
509
620
|
}
|
|
621
|
+
async transferRemoteComments(input) {
|
|
622
|
+
const { source, destination, page, destinationPage, inventory, resources, pageState, report, store } = input;
|
|
623
|
+
const now = () => new Date().toISOString();
|
|
624
|
+
const counts = {
|
|
625
|
+
threads: inventory?.threads.length ?? 0,
|
|
626
|
+
comments: inventory?.comments ?? 0,
|
|
627
|
+
participants: inventory?.participants ?? 0,
|
|
628
|
+
attachments: inventory?.attachments ?? 0,
|
|
629
|
+
bytes: inventory?.knownBytes ?? 0,
|
|
630
|
+
};
|
|
631
|
+
if (!inventory)
|
|
632
|
+
return { status: 'skipped', ...counts, updatedAt: now() };
|
|
633
|
+
if (inventory.status === 'failed') {
|
|
634
|
+
return { status: 'failed', ...counts, ...(inventory.error ? { lastError: inventory.error } : {}), updatedAt: now() };
|
|
635
|
+
}
|
|
636
|
+
if (inventory.status === 'empty') {
|
|
637
|
+
if (pageState.comments?.destinationId) {
|
|
638
|
+
return { ...pageState.comments, status: 'stale', lastError: 'Source no longer returns the previously archived comments; the destination archive was preserved.', updatedAt: now() };
|
|
639
|
+
}
|
|
640
|
+
return { status: 'complete', ...counts, updatedAt: now() };
|
|
641
|
+
}
|
|
642
|
+
const fingerprint = inventory.fingerprint;
|
|
643
|
+
if (!fingerprint)
|
|
644
|
+
return { status: 'failed', ...counts, lastError: 'Comment inventory fingerprint is missing', updatedAt: now() };
|
|
645
|
+
if (input.dryRun)
|
|
646
|
+
return { status: 'planned', fingerprint, ...counts, updatedAt: now() };
|
|
647
|
+
if (!destination.createArchiveComment || !destination.readArchiveComment) {
|
|
648
|
+
return { status: 'failed', fingerprint, ...counts, lastError: `${destination.profile.name} does not implement comment archive writes`, updatedAt: now() };
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
if (input.signal?.aborted)
|
|
652
|
+
throw input.signal.reason ?? new Error('Transfer cancelled');
|
|
653
|
+
const resourceById = new Map(resources.map((resource) => [resource.id, resource]));
|
|
654
|
+
const downloads = new Map();
|
|
655
|
+
const archiveFiles = [];
|
|
656
|
+
for (const use of commentAttachmentUses(inventory)) {
|
|
657
|
+
if (input.signal?.aborted)
|
|
658
|
+
throw input.signal.reason ?? new Error('Transfer cancelled');
|
|
659
|
+
const resourceId = use.reference.resourceId;
|
|
660
|
+
if (!resourceId)
|
|
661
|
+
continue;
|
|
662
|
+
const resource = resourceById.get(resourceId);
|
|
663
|
+
if (!resource)
|
|
664
|
+
continue;
|
|
665
|
+
let downloaded = downloads.get(resourceId);
|
|
666
|
+
if (!downloaded) {
|
|
667
|
+
const resourceState = pageState.resources[resourceId];
|
|
668
|
+
const filename = containedCachePath(input.cacheDirectory, resourceState?.cachePath)
|
|
669
|
+
?? cachePath(input.cacheDirectory, page, resource);
|
|
670
|
+
const cached = await verifiedCacheFile(filename, resourceState?.sha256 ? {
|
|
671
|
+
sha256: resourceState.sha256,
|
|
672
|
+
...(resourceState.size !== undefined ? { size: resourceState.size } : {}),
|
|
673
|
+
} : undefined);
|
|
674
|
+
downloaded = cached ?? await source.downloadResource(resource, filename);
|
|
675
|
+
downloads.set(resourceId, downloaded);
|
|
676
|
+
}
|
|
677
|
+
archiveFiles.push({
|
|
678
|
+
commentId: use.commentId,
|
|
679
|
+
reference: use.reference.href,
|
|
680
|
+
originalName: resource.preferredName ?? resource.name,
|
|
681
|
+
...downloaded,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
const zip = await createCommentArchiveZip({
|
|
685
|
+
pageKey: page.id,
|
|
686
|
+
fingerprint,
|
|
687
|
+
files: archiveFiles,
|
|
688
|
+
destinationDirectory: path.join(input.cacheDirectory, 'comments', sha256(page.canonicalId).slice(0, 16)),
|
|
689
|
+
});
|
|
690
|
+
let current;
|
|
691
|
+
const previous = pageState.comments;
|
|
692
|
+
if (previous?.destinationId) {
|
|
693
|
+
try {
|
|
694
|
+
current = await destination.readArchiveComment({ page: destinationPage, commentId: previous.destinationId });
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
report.issue({
|
|
698
|
+
code: 'comment-mapping-missing', severity: 'warning', phase: 'comment', pageId: page.canonicalId,
|
|
699
|
+
retryable: true, message: `${page.title}: mapped comment ${previous.destinationId} could not be read and will be recreated: ${error instanceof Error ? error.message : String(error)}`,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
current ??= await destination.findArchiveComment?.(destinationPage, fingerprint);
|
|
704
|
+
if (current && previous?.destinationFingerprint && current.fingerprint !== previous.destinationFingerprint) {
|
|
705
|
+
const conflict = {
|
|
706
|
+
fingerprint,
|
|
707
|
+
destinationId: current.id,
|
|
708
|
+
destinationFingerprint: current.fingerprint,
|
|
709
|
+
...counts,
|
|
710
|
+
...(previous.archiveResourceId ? { archiveResourceId: previous.archiveResourceId } : {}),
|
|
711
|
+
...(previous.archiveName ? { archiveName: previous.archiveName } : {}),
|
|
712
|
+
...(previous.archivePath ? { archivePath: previous.archivePath } : {}),
|
|
713
|
+
...(previous.archiveSha256 ? { archiveSha256: previous.archiveSha256 } : {}),
|
|
714
|
+
status: 'conflict',
|
|
715
|
+
lastError: 'Destination comment archive was changed manually and was preserved.',
|
|
716
|
+
updatedAt: now(),
|
|
717
|
+
};
|
|
718
|
+
return conflict;
|
|
719
|
+
}
|
|
720
|
+
const remoteResources = await destination.listUploadedResources(destinationPage);
|
|
721
|
+
const remoteMatches = async (remote, file, trustedMapping) => {
|
|
722
|
+
if (remote.size !== undefined && remote.size !== file.size)
|
|
723
|
+
return false;
|
|
724
|
+
if (!destination.downloadUploadedResource)
|
|
725
|
+
return trustedMapping && (remote.size === undefined || remote.size === file.size);
|
|
726
|
+
const comparison = `${file.path}.destination-${sha256(remote.id).slice(0, 8)}`;
|
|
727
|
+
try {
|
|
728
|
+
const actual = await destination.downloadUploadedResource({ page: destinationPage, resource: remote, destination: comparison });
|
|
729
|
+
return actual.size === file.size && actual.sha256 === file.sha256;
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
await rm(comparison, { force: true });
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
const uploadArchive = async (file, mappedId, mimeType) => {
|
|
736
|
+
const mapped = mappedId ? remoteResources.find((resource) => resource.id === mappedId) : undefined;
|
|
737
|
+
const byName = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === file.name.toLocaleLowerCase());
|
|
738
|
+
const reusable = mapped && await remoteMatches(mapped, file, true)
|
|
739
|
+
? mapped
|
|
740
|
+
: byName && await remoteMatches(byName, file, false) ? byName : undefined;
|
|
741
|
+
if (reusable)
|
|
742
|
+
return reusable;
|
|
743
|
+
const extension = path.extname(file.name);
|
|
744
|
+
const alternativeName = `${file.name.slice(0, file.name.length - extension.length)}-${file.sha256.slice(0, 8)}${extension}`;
|
|
745
|
+
const alternative = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === alternativeName.toLocaleLowerCase());
|
|
746
|
+
if (alternative && await remoteMatches(alternative, file, false))
|
|
747
|
+
return alternative;
|
|
748
|
+
const uploadName = mapped || byName ? alternativeName : file.name;
|
|
749
|
+
return destination.uploadResource({
|
|
750
|
+
page: destinationPage,
|
|
751
|
+
name: uploadName,
|
|
752
|
+
filePath: file.path,
|
|
753
|
+
size: file.size,
|
|
754
|
+
...(mimeType ? { mimeType } : {}),
|
|
755
|
+
});
|
|
756
|
+
};
|
|
757
|
+
const archiveResource = zip
|
|
758
|
+
? await uploadArchive(zip, previous?.archiveResourceId, 'application/zip')
|
|
759
|
+
: undefined;
|
|
760
|
+
if (input.signal?.aborted)
|
|
761
|
+
throw input.signal.reason ?? new Error('Transfer cancelled');
|
|
762
|
+
const attachmentNames = new Map();
|
|
763
|
+
if (zip) {
|
|
764
|
+
const nameByHash = new Map(zip.files.map((entry) => [entry.sha256, entry.archiveName]));
|
|
765
|
+
for (const [resourceId, downloaded] of downloads) {
|
|
766
|
+
const name = nameByHash.get(downloaded.sha256);
|
|
767
|
+
if (name)
|
|
768
|
+
attachmentNames.set(resourceId, name);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const render = (presentation, replaces) => renderCommentArchive(inventory, destination.kind, {
|
|
772
|
+
sourceUrl: page.url,
|
|
773
|
+
...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
|
|
774
|
+
...(replaces ? { archiveReplaces: replaces } : {}),
|
|
775
|
+
attachmentNames,
|
|
776
|
+
pages: input.destinationPages,
|
|
777
|
+
sourcePages: input.sourcePages,
|
|
778
|
+
presentation,
|
|
779
|
+
});
|
|
780
|
+
const archiveState = zip ? {
|
|
781
|
+
...(archiveResource ? { archiveResourceId: archiveResource.id } : {}),
|
|
782
|
+
archiveName: archiveResource?.name ?? zip.name,
|
|
783
|
+
archivePath: zip.path,
|
|
784
|
+
archiveSha256: zip.sha256,
|
|
785
|
+
} : {};
|
|
786
|
+
const replacement = destination.capabilities.updateComments ? undefined : current?.id;
|
|
787
|
+
let expectedBody = render('native', replacement);
|
|
788
|
+
let fallbackReason;
|
|
789
|
+
let fullArchiveState = {};
|
|
790
|
+
const maximumInlineBytes = 1_000_000;
|
|
791
|
+
const prepareFullArchive = async (reason) => {
|
|
792
|
+
if (fullArchiveState.fullArchiveName)
|
|
793
|
+
return;
|
|
794
|
+
const fullBody = renderCommentArchive(inventory, 'markdown', {
|
|
795
|
+
sourceUrl: page.url,
|
|
796
|
+
...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
|
|
797
|
+
...(replacement ? { archiveReplaces: replacement } : {}),
|
|
798
|
+
attachmentNames,
|
|
799
|
+
pages: input.destinationPages,
|
|
800
|
+
sourcePages: input.sourcePages,
|
|
801
|
+
});
|
|
802
|
+
const fullArchiveName = `ywtc-comments-${safeFilename(page.id)}-${fingerprint.slice(0, 12)}.md`;
|
|
803
|
+
const fullArchivePath = path.join(input.cacheDirectory, 'comments', sha256(page.canonicalId).slice(0, 16), fullArchiveName);
|
|
804
|
+
await mkdir(path.dirname(fullArchivePath), { recursive: true, mode: 0o700 });
|
|
805
|
+
await writeFile(fullArchivePath, `${fullBody}\n`, { mode: 0o600 });
|
|
806
|
+
const fullArchiveSha256 = sha256(`${fullBody}\n`);
|
|
807
|
+
const fullArchiveResource = await uploadArchive({
|
|
808
|
+
path: fullArchivePath,
|
|
809
|
+
name: fullArchiveName,
|
|
810
|
+
size: Buffer.byteLength(`${fullBody}\n`),
|
|
811
|
+
sha256: fullArchiveSha256,
|
|
812
|
+
}, previous?.fullArchiveResourceId, 'text/markdown');
|
|
813
|
+
const destinationFullArchiveName = fullArchiveResource.name;
|
|
814
|
+
expectedBody = renderCommentArchiveIndex(inventory, destination.kind, {
|
|
815
|
+
sourceUrl: page.url,
|
|
816
|
+
fullArchiveName: destinationFullArchiveName,
|
|
817
|
+
fullArchiveHref: fullArchiveResource.url ?? destinationFullArchiveName,
|
|
818
|
+
...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
|
|
819
|
+
});
|
|
820
|
+
fullArchiveState = {
|
|
821
|
+
fullArchiveResourceId: fullArchiveResource.id,
|
|
822
|
+
fullArchiveName: destinationFullArchiveName,
|
|
823
|
+
fullArchivePath,
|
|
824
|
+
fullArchiveSha256,
|
|
825
|
+
};
|
|
826
|
+
fallbackReason = reason;
|
|
827
|
+
};
|
|
828
|
+
if (Buffer.byteLength(expectedBody) > maximumInlineBytes) {
|
|
829
|
+
await prepareFullArchive(`Full comment archive exceeded ${maximumInlineBytes} inline bytes and was attached as Markdown.`);
|
|
830
|
+
}
|
|
831
|
+
let result = current;
|
|
832
|
+
const requiresWrite = !result || previous?.fingerprint !== fingerprint;
|
|
833
|
+
if (requiresWrite) {
|
|
834
|
+
const write = async (body) => {
|
|
835
|
+
if (input.signal?.aborted)
|
|
836
|
+
throw input.signal.reason ?? new Error('Transfer cancelled');
|
|
837
|
+
if (!result || !destination.capabilities.updateComments || !destination.updateArchiveComment) {
|
|
838
|
+
return destination.createArchiveComment({ page: destinationPage, body });
|
|
839
|
+
}
|
|
840
|
+
return destination.updateArchiveComment({ comment: result, body });
|
|
841
|
+
};
|
|
842
|
+
const presentations = fallbackReason ? [] : ['native', 'quote', 'code'];
|
|
843
|
+
let lastError;
|
|
844
|
+
if (fallbackReason) {
|
|
845
|
+
result = await write(expectedBody);
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
for (const presentation of presentations) {
|
|
849
|
+
try {
|
|
850
|
+
expectedBody = render(presentation, replacement);
|
|
851
|
+
result = await write(expectedBody);
|
|
852
|
+
if (presentation !== 'native')
|
|
853
|
+
fallbackReason = `Destination rejected the native collapsible archive; ${presentation} fallback was used.`;
|
|
854
|
+
lastError = undefined;
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
lastError = error;
|
|
859
|
+
const status = error.status;
|
|
860
|
+
if (status === 413 || /(?:status[=: ]+413|too large|payload too large)/i.test(error instanceof Error ? error.message : String(error))) {
|
|
861
|
+
try {
|
|
862
|
+
await prepareFullArchive('Destination rejected the inline archive size; the full archive was attached as Markdown.');
|
|
863
|
+
result = await write(expectedBody);
|
|
864
|
+
lastError = undefined;
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
catch (fallbackError) {
|
|
868
|
+
lastError = fallbackError;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const reconciled = await destination.findArchiveComment?.(destinationPage, fingerprint);
|
|
872
|
+
if (reconciled) {
|
|
873
|
+
result = reconciled;
|
|
874
|
+
expectedBody = reconciled.body;
|
|
875
|
+
fallbackReason = presentation === 'native' ? undefined : `Destination accepted the ${presentation} fallback after an ambiguous response.`;
|
|
876
|
+
lastError = undefined;
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (lastError)
|
|
882
|
+
throw lastError;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (!result)
|
|
886
|
+
throw new Error('Destination comment archive was not created');
|
|
887
|
+
const verified = await destination.readArchiveComment({ page: destinationPage, commentId: result.id });
|
|
888
|
+
const verification = verifyCommentArchive(fullArchiveState.fullArchiveName ? fingerprint : inventory, verified.body, archiveResource?.name);
|
|
889
|
+
if (fullArchiveState.fullArchiveName && !verified.body.includes(fullArchiveState.fullArchiveName)) {
|
|
890
|
+
verification.ok = false;
|
|
891
|
+
verification.differences.push('full Markdown comment archive reference is missing');
|
|
892
|
+
}
|
|
893
|
+
if (!verification.ok)
|
|
894
|
+
throw new Error(`Comment verification failed: ${verification.differences.join('; ')}`);
|
|
895
|
+
const previousIds = result.id !== current?.id && current
|
|
896
|
+
? [...new Set([...(previous?.previousDestinationIds ?? []), current.id])]
|
|
897
|
+
: previous?.previousDestinationIds;
|
|
898
|
+
const state = {
|
|
899
|
+
fingerprint,
|
|
900
|
+
destinationId: verified.id,
|
|
901
|
+
destinationFingerprint: verified.fingerprint,
|
|
902
|
+
...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
|
|
903
|
+
...archiveState,
|
|
904
|
+
...fullArchiveState,
|
|
905
|
+
status: inventory.warnings.length > 0 || fallbackReason ? 'partial' : 'complete',
|
|
906
|
+
...counts,
|
|
907
|
+
preview: verified.body,
|
|
908
|
+
...(fallbackReason ? { lastError: fallbackReason } : {}),
|
|
909
|
+
updatedAt: now(),
|
|
910
|
+
};
|
|
911
|
+
pageState.comments = state;
|
|
912
|
+
await store.setPage({ ...pageState, comments: state, updatedAt: now() });
|
|
913
|
+
return state;
|
|
914
|
+
}
|
|
915
|
+
catch (error) {
|
|
916
|
+
if (input.signal?.aborted)
|
|
917
|
+
throw input.signal.reason ?? error;
|
|
918
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
919
|
+
return {
|
|
920
|
+
fingerprint,
|
|
921
|
+
...counts,
|
|
922
|
+
...(pageState.comments?.destinationId ? { destinationId: pageState.comments.destinationId } : {}),
|
|
923
|
+
...(pageState.comments?.destinationFingerprint ? { destinationFingerprint: pageState.comments.destinationFingerprint } : {}),
|
|
924
|
+
status: /conflict/i.test(message) ? 'conflict' : 'failed',
|
|
925
|
+
lastError: message,
|
|
926
|
+
updatedAt: now(),
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
}
|
|
510
930
|
async transferRemote(input) {
|
|
511
|
-
const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
|
|
931
|
+
const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
|
|
512
932
|
const destinationPages = new Map();
|
|
513
933
|
const stagingFailures = new Map();
|
|
514
934
|
const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
|
|
515
935
|
const bootstrapBodies = new Map();
|
|
516
936
|
const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
|
|
517
|
-
const
|
|
518
|
-
const
|
|
937
|
+
const totalCommentArchives = (options.comments ?? true) ? graph.pages.length : 0;
|
|
938
|
+
const progress = { staged: 0, pages: 0, resources: 0, comments: 0 };
|
|
939
|
+
const totalUnits = Math.max(1, graph.pages.length * 2 + totalResources + totalCommentArchives);
|
|
519
940
|
const updateProgress = (active) => {
|
|
520
|
-
const completed = progress.staged + progress.pages + progress.resources;
|
|
521
|
-
options.onStatus?.(`Progress ${Math.min(100, Math.floor((completed / totalUnits) * 100))}% · pages ${progress.pages}/${graph.pages.length} · files ${progress.resources}/${totalResources} · ${active}`);
|
|
941
|
+
const completed = progress.staged + progress.pages + progress.resources + progress.comments;
|
|
942
|
+
options.onStatus?.(`Progress ${Math.min(100, Math.floor((completed / totalUnits) * 100))}% · pages ${progress.pages}/${graph.pages.length} · files ${progress.resources}/${totalResources} · comments ${progress.comments}/${totalCommentArchives} · ${active}`);
|
|
522
943
|
};
|
|
523
944
|
for (const page of graph.pages) {
|
|
524
945
|
const [document, cachedPage] = await Promise.all([
|
|
@@ -566,8 +987,12 @@ export class Migrator {
|
|
|
566
987
|
let created;
|
|
567
988
|
let title = saved?.destinationTitle ?? page.title;
|
|
568
989
|
let resourcesForState = saved?.resources ?? {};
|
|
990
|
+
let commentsForState = saved?.comments;
|
|
569
991
|
let previousDestinationIds = saved?.previousDestinationIds ?? [];
|
|
570
992
|
let mappingRecovered = false;
|
|
993
|
+
const findTitleConflict = (candidateTitle) => destination.findTitleConflict
|
|
994
|
+
? destination.findTitleConflict(parent, candidateTitle)
|
|
995
|
+
: destination.findChild(parent, candidateTitle);
|
|
571
996
|
if (saved?.destinationId) {
|
|
572
997
|
const mapped = destinationFromState(saved, destination);
|
|
573
998
|
try {
|
|
@@ -613,14 +1038,31 @@ export class Migrator {
|
|
|
613
1038
|
}
|
|
614
1039
|
else {
|
|
615
1040
|
let recoveryTitle = title;
|
|
616
|
-
|
|
1041
|
+
let titleConflict = candidate;
|
|
1042
|
+
if (!titleConflict && !recoveryLookupFailed) {
|
|
1043
|
+
try {
|
|
1044
|
+
titleConflict = await findTitleConflict(recoveryTitle);
|
|
1045
|
+
}
|
|
1046
|
+
catch (lookupError) {
|
|
1047
|
+
recoveryLookupFailed = true;
|
|
1048
|
+
report.issue({
|
|
1049
|
+
code: 'destination-title-lookup-failed',
|
|
1050
|
+
severity: 'warning',
|
|
1051
|
+
phase: 'page',
|
|
1052
|
+
pageId: page.canonicalId,
|
|
1053
|
+
retryable: true,
|
|
1054
|
+
message: `${page.title}: destination title lookup failed; bootstrap recreation will be attempted with a stable suffix: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (titleConflict || recoveryLookupFailed) {
|
|
617
1059
|
const base = collisionTitle(page);
|
|
618
1060
|
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
619
1061
|
recoveryTitle = base;
|
|
620
1062
|
let sequence = 0;
|
|
621
1063
|
while (!recoveryLookupFailed) {
|
|
622
1064
|
try {
|
|
623
|
-
if (!await
|
|
1065
|
+
if (!await findTitleConflict(recoveryTitle))
|
|
624
1066
|
break;
|
|
625
1067
|
sequence += 1;
|
|
626
1068
|
recoveryTitle = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
@@ -645,6 +1087,7 @@ export class Migrator {
|
|
|
645
1087
|
}
|
|
646
1088
|
previousDestinationIds = [...new Set([...previousDestinationIds, mapped.id])];
|
|
647
1089
|
resourcesForState = resetResourceDestinations(saved.resources);
|
|
1090
|
+
commentsForState = undefined;
|
|
648
1091
|
mappingRecovered = true;
|
|
649
1092
|
report.issue({
|
|
650
1093
|
code: 'destination-mapping-recovered',
|
|
@@ -670,6 +1113,92 @@ export class Migrator {
|
|
|
670
1113
|
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
671
1114
|
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
672
1115
|
}
|
|
1116
|
+
if (!mappingRecovered && created.parentId !== parent.id) {
|
|
1117
|
+
const oldPage = created;
|
|
1118
|
+
if (options.dryRun) {
|
|
1119
|
+
report.addHierarchyRepair({
|
|
1120
|
+
sourceCanonicalId: page.canonicalId,
|
|
1121
|
+
mode: destination.capabilities.reparentPages && destination.reparentPage ? 'moved' : 'recreated',
|
|
1122
|
+
...(created.parentId ? { oldParentId: created.parentId } : {}),
|
|
1123
|
+
newParentId: parent.id,
|
|
1124
|
+
oldDestinationId: created.id,
|
|
1125
|
+
newDestinationId: created.id,
|
|
1126
|
+
verified: false,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
else if (destination.capabilities.reparentPages && destination.reparentPage) {
|
|
1130
|
+
created = await destination.reparentPage({
|
|
1131
|
+
page: created,
|
|
1132
|
+
parent,
|
|
1133
|
+
...(created.fingerprint ? { expectedFingerprint: created.fingerprint } : {}),
|
|
1134
|
+
});
|
|
1135
|
+
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
1136
|
+
if (created.parentId !== parent.id) {
|
|
1137
|
+
throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
1138
|
+
}
|
|
1139
|
+
report.addHierarchyRepair({
|
|
1140
|
+
sourceCanonicalId: page.canonicalId,
|
|
1141
|
+
mode: 'moved',
|
|
1142
|
+
...(oldPage.parentId ? { oldParentId: oldPage.parentId } : {}),
|
|
1143
|
+
newParentId: parent.id,
|
|
1144
|
+
oldDestinationId: oldPage.id,
|
|
1145
|
+
newDestinationId: created.id,
|
|
1146
|
+
verified: true,
|
|
1147
|
+
});
|
|
1148
|
+
report.issue({
|
|
1149
|
+
code: 'destination-hierarchy-repaired',
|
|
1150
|
+
severity: 'normalization',
|
|
1151
|
+
phase: 'page',
|
|
1152
|
+
pageId: page.canonicalId,
|
|
1153
|
+
retryable: false,
|
|
1154
|
+
message: `${page.title}: moved destination page ${created.id} from parent ${oldPage.parentId ?? 'none'} to ${parent.id} and verified the hierarchy`,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
else {
|
|
1158
|
+
let replacement = await destination.findChild(parent, title);
|
|
1159
|
+
const replacementMatches = replacement
|
|
1160
|
+
&& sourceDerivedTextMatches(bootstrap.body, replacement.content, destination.kind, cachedPage);
|
|
1161
|
+
const titleConflict = replacementMatches ? undefined : replacement ?? await findTitleConflict(title);
|
|
1162
|
+
if (titleConflict) {
|
|
1163
|
+
const base = collisionTitle(page);
|
|
1164
|
+
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
1165
|
+
title = base;
|
|
1166
|
+
let sequence = 0;
|
|
1167
|
+
while (await findTitleConflict(title)) {
|
|
1168
|
+
sequence += 1;
|
|
1169
|
+
title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
1170
|
+
}
|
|
1171
|
+
replacement = undefined;
|
|
1172
|
+
}
|
|
1173
|
+
created = replacement ?? await destination.createPage({ container: parent, title, body: bootstrap.body });
|
|
1174
|
+
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
1175
|
+
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
1176
|
+
if (created.parentId !== parent.id) {
|
|
1177
|
+
throw new Error(`Destination hierarchy verification failed for recreated page ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
1178
|
+
}
|
|
1179
|
+
previousDestinationIds = [...new Set([...previousDestinationIds, oldPage.id])];
|
|
1180
|
+
resourcesForState = resetResourceDestinations(saved.resources);
|
|
1181
|
+
commentsForState = undefined;
|
|
1182
|
+
mappingRecovered = true;
|
|
1183
|
+
report.addHierarchyRepair({
|
|
1184
|
+
sourceCanonicalId: page.canonicalId,
|
|
1185
|
+
mode: 'recreated',
|
|
1186
|
+
...(oldPage.parentId ? { oldParentId: oldPage.parentId } : {}),
|
|
1187
|
+
newParentId: parent.id,
|
|
1188
|
+
oldDestinationId: oldPage.id,
|
|
1189
|
+
newDestinationId: created.id,
|
|
1190
|
+
verified: true,
|
|
1191
|
+
});
|
|
1192
|
+
report.issue({
|
|
1193
|
+
code: 'destination-hierarchy-recreated',
|
|
1194
|
+
severity: 'normalization',
|
|
1195
|
+
phase: 'page',
|
|
1196
|
+
pageId: page.canonicalId,
|
|
1197
|
+
retryable: false,
|
|
1198
|
+
message: `${page.title}: created verified replacement ${created.id} below ${parent.id}; previous page ${oldPage.id} was left untouched`,
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
673
1202
|
}
|
|
674
1203
|
else if (options.dryRun) {
|
|
675
1204
|
created = {
|
|
@@ -680,25 +1209,45 @@ export class Migrator {
|
|
|
680
1209
|
title,
|
|
681
1210
|
url: container.url,
|
|
682
1211
|
locator: `dry-run:${page.id}`,
|
|
1212
|
+
parentId: parent.id,
|
|
683
1213
|
content: bootstrap.body,
|
|
684
1214
|
contentLength: Buffer.byteLength(bootstrap.body),
|
|
685
1215
|
...(container.space ? { space: container.space } : {}),
|
|
686
1216
|
};
|
|
687
1217
|
}
|
|
688
1218
|
else {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1219
|
+
const existingChild = await destination.findChild(parent, title);
|
|
1220
|
+
if (existingChild && sourceDerivedTextMatches(bootstrap.body, existingChild.content, destination.kind, cachedPage)) {
|
|
1221
|
+
created = existingChild;
|
|
1222
|
+
mappingRecovered = true;
|
|
1223
|
+
report.issue({
|
|
1224
|
+
code: 'destination-unmapped-page-recovered',
|
|
1225
|
+
severity: 'normalization',
|
|
1226
|
+
phase: 'page',
|
|
1227
|
+
pageId: page.canonicalId,
|
|
1228
|
+
retryable: false,
|
|
1229
|
+
message: `${page.title}: adopted an existing source-derived page below the expected parent`,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
const titleConflict = existingChild ?? await findTitleConflict(title);
|
|
1234
|
+
if (titleConflict) {
|
|
1235
|
+
const base = collisionTitle(page);
|
|
1236
|
+
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
1237
|
+
title = base;
|
|
1238
|
+
let sequence = 0;
|
|
1239
|
+
while (await findTitleConflict(title)) {
|
|
1240
|
+
sequence += 1;
|
|
1241
|
+
title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
1242
|
+
}
|
|
697
1243
|
}
|
|
1244
|
+
created = await destination.createPage({ container: parent, title, body: bootstrap.body });
|
|
1245
|
+
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
1246
|
+
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
698
1247
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1248
|
+
}
|
|
1249
|
+
if (!options.dryRun && created.parentId !== parent.id) {
|
|
1250
|
+
throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
702
1251
|
}
|
|
703
1252
|
destinationPages.set(page.canonicalId, created);
|
|
704
1253
|
const next = {
|
|
@@ -716,6 +1265,7 @@ export class Migrator {
|
|
|
716
1265
|
...(created.fingerprint ? { destinationFingerprint: created.fingerprint } : {}),
|
|
717
1266
|
status: options.dryRun ? 'planned' : saved?.status ?? 'skeleton-created',
|
|
718
1267
|
resources: resourcesForState,
|
|
1268
|
+
...(commentsForState ? { comments: commentsForState } : {}),
|
|
719
1269
|
degradations: saved?.degradations ?? [],
|
|
720
1270
|
updatedAt: new Date().toISOString(),
|
|
721
1271
|
};
|
|
@@ -723,6 +1273,8 @@ export class Migrator {
|
|
|
723
1273
|
options.onStatus?.(`${options.dryRun ? 'Planned' : 'Staged content for'} ${page.title}`);
|
|
724
1274
|
}
|
|
725
1275
|
catch (error) {
|
|
1276
|
+
if (options.signal?.aborted)
|
|
1277
|
+
throw options.signal.reason ?? error;
|
|
726
1278
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
727
1279
|
const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
|
|
728
1280
|
stagingFailures.set(page.canonicalId, { status: failureStatus, message });
|
|
@@ -762,6 +1314,9 @@ export class Migrator {
|
|
|
762
1314
|
document,
|
|
763
1315
|
...(failedState?.resources ? { resourceStates: failedState.resources } : {}),
|
|
764
1316
|
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
1317
|
+
commentsEnabled: options.comments ?? true,
|
|
1318
|
+
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
1319
|
+
...(failedState?.comments ? { commentState: failedState.comments } : {}),
|
|
765
1320
|
error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
|
|
766
1321
|
}));
|
|
767
1322
|
progress.pages += 1;
|
|
@@ -805,7 +1360,9 @@ export class Migrator {
|
|
|
805
1360
|
await store.setPage({ ...state, status: 'partial', lastError: detail, updatedAt: new Date().toISOString() });
|
|
806
1361
|
};
|
|
807
1362
|
try {
|
|
808
|
-
let remoteResources = options.dryRun
|
|
1363
|
+
let remoteResources = options.dryRun || transferableResources.length === 0
|
|
1364
|
+
? []
|
|
1365
|
+
: await destination.listUploadedResources(destinationPage);
|
|
809
1366
|
const nameAllocator = new DestinationNameAllocator(remoteResources.map((resource) => resource.name));
|
|
810
1367
|
for (const resourceState of Object.values(state.resources)) {
|
|
811
1368
|
if (resourceState.status === 'complete')
|
|
@@ -1004,6 +1561,9 @@ export class Migrator {
|
|
|
1004
1561
|
};
|
|
1005
1562
|
const rendered = await renderRemoteDocument({ destination, container, document, page: cachedPage, context });
|
|
1006
1563
|
if (rendered.degraded && !options.dryRun) {
|
|
1564
|
+
if (transferableResources.length === 0) {
|
|
1565
|
+
remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1566
|
+
}
|
|
1007
1567
|
const extension = page.rawFormat === 'yfm' ? 'yfm' : 'storage.xml';
|
|
1008
1568
|
const snapshotName = `ywtc-source-${sha256(page.canonicalId).slice(0, 8)}.${extension}`;
|
|
1009
1569
|
const snapshotPath = path.join(options.cacheDirectory, 'snapshots', snapshotName);
|
|
@@ -1107,7 +1667,51 @@ export class Migrator {
|
|
|
1107
1667
|
const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
|
|
1108
1668
|
verificationDegradations = verification.status === 'partial' ? verification.differences : [];
|
|
1109
1669
|
}
|
|
1110
|
-
const
|
|
1670
|
+
const commentInventory = commentsByPage.get(page.canonicalId);
|
|
1671
|
+
const commentState = (options.comments ?? true)
|
|
1672
|
+
? await this.transferRemoteComments({
|
|
1673
|
+
source,
|
|
1674
|
+
destination,
|
|
1675
|
+
page: cachedPage,
|
|
1676
|
+
destinationPage: verified,
|
|
1677
|
+
inventory: commentInventory,
|
|
1678
|
+
resources,
|
|
1679
|
+
pageState: state,
|
|
1680
|
+
cacheDirectory: options.cacheDirectory,
|
|
1681
|
+
dryRun: options.dryRun,
|
|
1682
|
+
destinationPages,
|
|
1683
|
+
sourcePages,
|
|
1684
|
+
report,
|
|
1685
|
+
store,
|
|
1686
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1687
|
+
})
|
|
1688
|
+
: {
|
|
1689
|
+
status: 'skipped',
|
|
1690
|
+
threads: 0,
|
|
1691
|
+
comments: 0,
|
|
1692
|
+
participants: 0,
|
|
1693
|
+
attachments: 0,
|
|
1694
|
+
bytes: 0,
|
|
1695
|
+
updatedAt: new Date().toISOString(),
|
|
1696
|
+
};
|
|
1697
|
+
if (options.comments ?? true) {
|
|
1698
|
+
progress.comments += 1;
|
|
1699
|
+
updateProgress(`comments ${commentState.status} → ${page.title}`);
|
|
1700
|
+
}
|
|
1701
|
+
if (['failed', 'conflict', 'stale', 'partial'].includes(commentState.status)
|
|
1702
|
+
&& !(commentState.status === 'failed' && commentInventory?.status === 'failed')
|
|
1703
|
+
&& !(commentState.status === 'partial' && (commentInventory?.warnings.length ?? 0) > 0 && !commentState.lastError)) {
|
|
1704
|
+
report.issue({
|
|
1705
|
+
code: `comment-${commentState.status}`,
|
|
1706
|
+
severity: commentState.status === 'partial' || commentState.status === 'stale' ? 'degradation' : 'error',
|
|
1707
|
+
phase: 'comment',
|
|
1708
|
+
pageId: page.canonicalId,
|
|
1709
|
+
retryable: commentState.status === 'failed',
|
|
1710
|
+
message: `${page.title}: ${commentState.lastError ?? `comment archive is ${commentState.status}`}`,
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
const commentPartial = !['complete', 'skipped', 'planned'].includes(commentState.status);
|
|
1714
|
+
const partial = rendered.degraded || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
|
|
1111
1715
|
const lastResourceProblem = resourceProblems.at(-1);
|
|
1112
1716
|
const clearedState = { ...state };
|
|
1113
1717
|
delete clearedState.lastError;
|
|
@@ -1119,6 +1723,7 @@ export class Migrator {
|
|
|
1119
1723
|
destinationTitle: verified.title,
|
|
1120
1724
|
...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
|
|
1121
1725
|
status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
|
|
1726
|
+
comments: commentState,
|
|
1122
1727
|
degradations: [...new Set([...rendered.warnings, ...resourceProblems, ...verificationDegradations])],
|
|
1123
1728
|
...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
|
|
1124
1729
|
updatedAt: new Date().toISOString(),
|
|
@@ -1141,6 +1746,9 @@ export class Migrator {
|
|
|
1141
1746
|
resourceFallbacks: fallbackDetails,
|
|
1142
1747
|
warnings: [...rendered.warnings, ...verificationDegradations],
|
|
1143
1748
|
normalizations: rendered.normalizations,
|
|
1749
|
+
commentsEnabled: options.comments ?? true,
|
|
1750
|
+
...(commentInventory ? { commentInventory } : {}),
|
|
1751
|
+
commentState,
|
|
1144
1752
|
}), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(verified.content ?? rendered.body, destination.kind) });
|
|
1145
1753
|
}
|
|
1146
1754
|
catch (error) {
|
|
@@ -1188,6 +1796,9 @@ export class Migrator {
|
|
|
1188
1796
|
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
1189
1797
|
resourceFallbacks: fallbackDetails,
|
|
1190
1798
|
error: message,
|
|
1799
|
+
commentsEnabled: options.comments ?? true,
|
|
1800
|
+
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
1801
|
+
...(state.comments ? { commentState: state.comments } : {}),
|
|
1191
1802
|
}), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
|
|
1192
1803
|
}
|
|
1193
1804
|
finally {
|