@iwonz/ywtc 3.3.0 → 3.4.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 +32 -0
- package/README.md +67 -43
- package/dist/adapters/confluence.d.ts +15 -1
- package/dist/adapters/confluence.js +87 -3
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.d.ts +11 -1
- package/dist/adapters/yandex.js +64 -0
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/cli-arguments.d.ts +2 -0
- package/dist/cli-arguments.js +6 -0
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli.js +12 -5
- package/dist/cli.js.map +1 -1
- package/dist/clients/confluence-cloud.d.ts +16 -1
- package/dist/clients/confluence-cloud.js +109 -0
- package/dist/clients/confluence-cloud.js.map +1 -1
- package/dist/clients/confluence.d.ts +12 -1
- package/dist/clients/confluence.js +88 -0
- package/dist/clients/confluence.js.map +1 -1
- package/dist/clients/yandex-wiki.d.ts +10 -1
- package/dist/clients/yandex-wiki.js +44 -0
- 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.d.ts +1 -0
- package/dist/content-model.js +13 -0
- package/dist/content-model.js.map +1 -1
- package/dist/doctor.d.ts +8 -1
- package/dist/doctor.js +20 -3
- package/dist/doctor.js.map +1 -1
- package/dist/domain.d.ts +74 -1
- package/dist/domain.js +13 -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/issues.d.ts +4 -2
- package/dist/issues.js.map +1 -1
- package/dist/migrator.d.ts +2 -0
- package/dist/migrator.js +756 -164
- package/dist/migrator.js.map +1 -1
- package/dist/report-catalog.d.ts +27 -1
- package/dist/report-catalog.js +26 -0
- package/dist/report-catalog.js.map +1 -1
- package/dist/report.d.ts +42 -2
- package/dist/report.js +109 -7
- package/dist/report.js.map +1 -1
- package/dist/state.d.ts +54 -5
- package/dist/state.js +93 -3
- package/dist/state.js.map +1 -1
- package/dist/title-allocation.d.ts +2 -0
- package/dist/title-allocation.js +43 -0
- package/dist/title-allocation.js.map +1 -0
- package/dist/types.d.ts +75 -0
- package/package.json +1 -1
package/dist/migrator.js
CHANGED
|
@@ -3,14 +3,19 @@ import { mkdir, readFile, rm, 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, assertDestinationContent, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
|
|
6
|
+
import { appendGridSnapshots, assertDestinationContent, emptyRendererFallbackReason, 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';
|
|
9
|
+
import { DestinationTitleConflictError } from './domain.js';
|
|
8
10
|
import { assertTransferCapabilities } from './domain.js';
|
|
9
11
|
import { FilesystemExporter } from './filesystem-export.js';
|
|
10
12
|
import { discoverPageGraph } from './graph.js';
|
|
13
|
+
import { HttpRequestError } from './http.js';
|
|
14
|
+
import { safeFilename } from './input.js';
|
|
11
15
|
import { profileBaseUrl, sameRemoteInstance } from './locators.js';
|
|
12
16
|
import { TransferReport, } from './report.js';
|
|
13
17
|
import { DestinationNameAllocator, preferredDestinationName, verifiedCacheFile } from './resource-reconciliation.js';
|
|
18
|
+
import { destinationTitleCandidates } from './title-allocation.js';
|
|
14
19
|
import { resolveStateLocation, StateStore, } from './state.js';
|
|
15
20
|
function sourceIdentity(locator, root) {
|
|
16
21
|
return {
|
|
@@ -63,10 +68,6 @@ async function cacheDocument(cacheDirectory, canonicalId, document) {
|
|
|
63
68
|
async function loadCachedDocument(cacheDirectory, canonicalId) {
|
|
64
69
|
return JSON.parse(await readFile(cachedDocumentPath(cacheDirectory, canonicalId), 'utf8'));
|
|
65
70
|
}
|
|
66
|
-
function collisionTitle(page) {
|
|
67
|
-
const suffix = page.locator.split('/').filter(Boolean).at(-1) ?? page.id;
|
|
68
|
-
return `${page.title} [${suffix}]`;
|
|
69
|
-
}
|
|
70
71
|
function cachePath(cacheDirectory, page, resource) {
|
|
71
72
|
const pageKey = sha256(page.canonicalId).slice(0, 12);
|
|
72
73
|
const resourceKey = sha256(`${resource.id}:${resource.name}`).slice(0, 12);
|
|
@@ -102,6 +103,73 @@ function sourceDerivedTextMatches(expectedBody, actualBody, provider, page) {
|
|
|
102
103
|
const text = (value) => renderedPreviewHtml(value, provider).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
103
104
|
return text(expectedBody) === text(actualBody);
|
|
104
105
|
}
|
|
106
|
+
function ambiguousCreateOutcome(error) {
|
|
107
|
+
return error instanceof HttpRequestError
|
|
108
|
+
&& (error.status === undefined || error.status >= 500);
|
|
109
|
+
}
|
|
110
|
+
async function createPageWithAllocatedTitle(input) {
|
|
111
|
+
const candidates = destinationTitleCandidates(input.sourcePage, input.preferredTitle);
|
|
112
|
+
for (const [index, title] of candidates.entries()) {
|
|
113
|
+
let child;
|
|
114
|
+
try {
|
|
115
|
+
child = input.destination.findRecoverablePage
|
|
116
|
+
? await input.destination.findRecoverablePage(input.parent, title)
|
|
117
|
+
: await input.destination.findChild(input.parent, title);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
input.report.issue({
|
|
121
|
+
code: 'destination-recovery-lookup-failed',
|
|
122
|
+
severity: 'warning',
|
|
123
|
+
phase: 'page',
|
|
124
|
+
pageId: input.sourcePage.canonicalId,
|
|
125
|
+
retryable: true,
|
|
126
|
+
message: `${input.sourcePage.title}: parent-scoped recovery lookup failed for "${title}"; authoritative creation will be attempted: ${error instanceof Error ? error.message : String(error)}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (child && sourceDerivedTextMatches(input.bootstrap.body, child.content, input.destination.kind, input.sourcePage)) {
|
|
130
|
+
return { page: child, reused: true };
|
|
131
|
+
}
|
|
132
|
+
const occupied = child ?? (input.destination.findTitleConflict
|
|
133
|
+
? await input.destination.findTitleConflict(input.parent, title)
|
|
134
|
+
: undefined);
|
|
135
|
+
if (occupied)
|
|
136
|
+
continue;
|
|
137
|
+
let created;
|
|
138
|
+
try {
|
|
139
|
+
created = await input.destination.createPage({ container: input.parent, title, body: input.bootstrap.body });
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error instanceof DestinationTitleConflictError)
|
|
143
|
+
continue;
|
|
144
|
+
if (ambiguousCreateOutcome(error)) {
|
|
145
|
+
const recovered = input.destination.findRecoverablePage
|
|
146
|
+
? await input.destination.findRecoverablePage(input.parent, title)
|
|
147
|
+
: await input.destination.findChild(input.parent, title);
|
|
148
|
+
if (recovered && sourceDerivedTextMatches(input.bootstrap.body, recovered.content, input.destination.kind, input.sourcePage)) {
|
|
149
|
+
return { page: recovered, reused: true };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
created = await verifyConfirmedDestinationPage(input.destination, created, input.consistencyOptions);
|
|
155
|
+
assertDestinationContent(input.bootstrap.body, created.content, input.destination.kind, input.sourcePage);
|
|
156
|
+
if (created.parentId !== input.parent.id) {
|
|
157
|
+
throw new Error(`Destination hierarchy verification failed for ${input.sourcePage.title}: expected parent ${input.parent.id}, received ${created.parentId ?? 'none'}`);
|
|
158
|
+
}
|
|
159
|
+
if (index > 0) {
|
|
160
|
+
input.report.issue({
|
|
161
|
+
code: 'destination-title-renamed',
|
|
162
|
+
severity: 'normalization',
|
|
163
|
+
phase: 'page',
|
|
164
|
+
pageId: input.sourcePage.canonicalId,
|
|
165
|
+
retryable: false,
|
|
166
|
+
message: `${input.sourcePage.title}: destination title was allocated as "${created.title}" because an earlier candidate was occupied`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return { page: created, reused: false };
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Confluence rejected all ${candidates.length} deterministic destination titles for ${input.sourcePage.title} because they are occupied`);
|
|
172
|
+
}
|
|
105
173
|
function destinationFromState(state, adapter) {
|
|
106
174
|
if (!state.destinationId || !state.destinationCanonicalId || !state.destinationLocator) {
|
|
107
175
|
throw new Error(`Incomplete mapped destination for ${state.sourceTitle}`);
|
|
@@ -133,10 +201,19 @@ function reportPage(input) {
|
|
|
133
201
|
...(state?.destinationId ? { destinationId: state.destinationId } : {}),
|
|
134
202
|
...(state?.destinationName ? { destinationName: state.destinationName } : {}),
|
|
135
203
|
...(state?.lastError ? { error: state.lastError } : {}),
|
|
204
|
+
...(status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
|
|
136
205
|
};
|
|
137
206
|
});
|
|
138
207
|
const knownBytes = resourceItems.reduce((total, resource) => total + (resource.sourceBytes ?? 0), 0);
|
|
139
208
|
const transferredBytes = resourceItems.reduce((total, resource) => total + (resource.transferredBytes ?? 0), 0);
|
|
209
|
+
const commentsEnabled = input.commentsEnabled ?? true;
|
|
210
|
+
const inventory = input.commentInventory;
|
|
211
|
+
const commentState = input.commentState;
|
|
212
|
+
const commentStatus = !commentsEnabled
|
|
213
|
+
? 'skipped'
|
|
214
|
+
: commentState?.status
|
|
215
|
+
?? (inventory?.status === 'failed' ? 'failed' : inventory?.status === 'empty' ? 'complete' : 'planned');
|
|
216
|
+
const commentError = commentState?.lastError ?? inventory?.error;
|
|
140
217
|
return {
|
|
141
218
|
sourceCanonicalId: input.page.canonicalId,
|
|
142
219
|
sourceId: input.page.id,
|
|
@@ -166,6 +243,55 @@ function reportPage(input) {
|
|
|
166
243
|
warnings: [...new Set([...input.document.warnings, ...(input.warnings ?? [])])],
|
|
167
244
|
normalizations: [...new Set([...(input.document.normalizations ?? []), ...(input.normalizations ?? [])])],
|
|
168
245
|
...(input.error ? { error: input.error } : {}),
|
|
246
|
+
...(input.status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
|
|
247
|
+
comments: {
|
|
248
|
+
status: commentStatus,
|
|
249
|
+
threads: commentState?.threads ?? inventory?.threads.length ?? 0,
|
|
250
|
+
comments: commentState?.comments ?? inventory?.comments ?? 0,
|
|
251
|
+
participants: commentState?.participants ?? inventory?.participants ?? 0,
|
|
252
|
+
attachments: commentState?.attachments ?? inventory?.attachments ?? 0,
|
|
253
|
+
bytes: commentState?.bytes ?? inventory?.knownBytes ?? 0,
|
|
254
|
+
...(commentState?.archiveName ? { archiveName: commentState.archiveName } : {}),
|
|
255
|
+
...(commentState?.destinationId ? { destinationId: commentState.destinationId } : {}),
|
|
256
|
+
...(commentState?.previousDestinationIds?.length ? { revision: true } : {}),
|
|
257
|
+
...(commentState?.status === 'stale' ? { stale: true } : {}),
|
|
258
|
+
...(commentError ? { error: commentError } : {}),
|
|
259
|
+
...(commentState?.preview ? { preview: commentState.preview } : {}),
|
|
260
|
+
...(commentStatus === 'blocked' && input.blockedBy ? input.blockedBy : {}),
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function blockedResourceStates(resources, previous, cause) {
|
|
265
|
+
return {
|
|
266
|
+
...previous,
|
|
267
|
+
...Object.fromEntries(resources.filter((resource) => resource.kind !== 'grid').map((resource) => {
|
|
268
|
+
const existing = previous[resource.id];
|
|
269
|
+
return [resource.id, {
|
|
270
|
+
...(existing ?? {}),
|
|
271
|
+
sourceId: resource.id,
|
|
272
|
+
sourceName: resource.name,
|
|
273
|
+
destinationName: existing?.destinationName ?? preferredDestinationName(resource),
|
|
274
|
+
...(existing?.size !== undefined ? { size: existing.size } : resource.size !== undefined ? { size: resource.size } : {}),
|
|
275
|
+
status: 'blocked',
|
|
276
|
+
...cause,
|
|
277
|
+
lastError: cause.blockedReason,
|
|
278
|
+
updatedAt: new Date().toISOString(),
|
|
279
|
+
}];
|
|
280
|
+
})),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function blockedCommentState(inventory, previous, cause) {
|
|
284
|
+
return {
|
|
285
|
+
...(previous ?? {}),
|
|
286
|
+
status: 'blocked',
|
|
287
|
+
threads: previous?.threads ?? inventory?.threads.length ?? 0,
|
|
288
|
+
comments: previous?.comments ?? inventory?.comments ?? 0,
|
|
289
|
+
participants: previous?.participants ?? inventory?.participants ?? 0,
|
|
290
|
+
attachments: previous?.attachments ?? inventory?.attachments ?? 0,
|
|
291
|
+
bytes: previous?.bytes ?? inventory?.knownBytes ?? 0,
|
|
292
|
+
...cause,
|
|
293
|
+
lastError: cause.blockedReason,
|
|
294
|
+
updatedAt: new Date().toISOString(),
|
|
169
295
|
};
|
|
170
296
|
}
|
|
171
297
|
function runStatus(errors, warnings, degradations) {
|
|
@@ -176,6 +302,17 @@ function runStatus(errors, warnings, degradations) {
|
|
|
176
302
|
function attentionCount(summary) {
|
|
177
303
|
return summary.warnings + summary.degradations;
|
|
178
304
|
}
|
|
305
|
+
function runOutcomeCounts(summary, pages) {
|
|
306
|
+
return {
|
|
307
|
+
pages,
|
|
308
|
+
warnings: attentionCount(summary),
|
|
309
|
+
errors: summary.errors,
|
|
310
|
+
rootErrors: summary.rootErrors,
|
|
311
|
+
dependentPages: summary.dependentPages,
|
|
312
|
+
dependentFiles: summary.dependentFiles,
|
|
313
|
+
dependentComments: summary.dependentComments,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
179
316
|
export class MigrationRunError extends Error {
|
|
180
317
|
reportPaths;
|
|
181
318
|
stateFilename;
|
|
@@ -192,7 +329,7 @@ async function renderRemoteDocument(input) {
|
|
|
192
329
|
? await renderConfluenceStorage(input.document, input.page, input.context, input.container.space ?? '')
|
|
193
330
|
: await renderYfm(input.document, input.context);
|
|
194
331
|
if (!rendered.body.trim())
|
|
195
|
-
return rawContentFallback(input.page, input.destination.kind,
|
|
332
|
+
return rawContentFallback(input.page, input.destination.kind, emptyRendererFallbackReason(input.document));
|
|
196
333
|
return rendered;
|
|
197
334
|
}
|
|
198
335
|
catch (error) {
|
|
@@ -212,11 +349,12 @@ export class Migrator {
|
|
|
212
349
|
}
|
|
213
350
|
async run(options) {
|
|
214
351
|
const sourceStatus = options.sourceStatus ?? 'all';
|
|
352
|
+
const commentsEnabled = options.comments ?? true;
|
|
215
353
|
const source = this.adapterFactory(options.source.profile, this.config);
|
|
216
354
|
const destination = options.destination.type === 'remote'
|
|
217
355
|
? this.adapterFactory(options.destination.profile, this.config)
|
|
218
356
|
: undefined;
|
|
219
|
-
assertTransferCapabilities(source, destination, options.scope, sourceStatus);
|
|
357
|
+
assertTransferCapabilities(source, destination, options.scope, sourceStatus, commentsEnabled);
|
|
220
358
|
const status = options.onStatus ?? (() => undefined);
|
|
221
359
|
status('Checking source and destination capabilities');
|
|
222
360
|
const [sourcePlatform, destinationPlatform, root, container] = await Promise.all([
|
|
@@ -240,9 +378,10 @@ export class Migrator {
|
|
|
240
378
|
await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
|
|
241
379
|
if (!options.dryRun)
|
|
242
380
|
await store.save();
|
|
243
|
-
const run = await store.beginRun(options.scope, options.dryRun, sourceStatus);
|
|
381
|
+
const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
|
|
244
382
|
const report = new TransferReport({
|
|
245
383
|
dryRun: options.dryRun,
|
|
384
|
+
commentsEnabled,
|
|
246
385
|
scope: options.scope,
|
|
247
386
|
sourceStatus,
|
|
248
387
|
direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
|
|
@@ -303,16 +442,13 @@ export class Migrator {
|
|
|
303
442
|
report.finish(finalStatus);
|
|
304
443
|
const reportPaths = await report.write(options.reportsDirectory);
|
|
305
444
|
if (!options.dryRun)
|
|
306
|
-
await store.finishRun(run.id, finalStatus,
|
|
307
|
-
pages: 0,
|
|
308
|
-
warnings: attentionCount(report.data.summary),
|
|
309
|
-
errors: report.data.summary.errors,
|
|
310
|
-
});
|
|
445
|
+
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
|
|
311
446
|
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
312
447
|
}
|
|
313
448
|
const resourcesByPage = new Map();
|
|
449
|
+
const commentsByPage = new Map();
|
|
314
450
|
const inventoryQueue = new PQueue({ concurrency: options.concurrency });
|
|
315
|
-
const inventory = { resources: 0, bytes: 0, unknownSizes: 0, grids: 0, links: 0, images: 0, tables: 0, codeBlocks: 0 };
|
|
451
|
+
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 };
|
|
316
452
|
status(`Inspecting content and resources for ${graph.pages.length} pages`);
|
|
317
453
|
const inventoryErrors = [];
|
|
318
454
|
await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
|
|
@@ -322,6 +458,36 @@ export class Migrator {
|
|
|
322
458
|
const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
|
|
323
459
|
const resources = await source.listResources(page);
|
|
324
460
|
resourcesByPage.set(page.canonicalId, resources);
|
|
461
|
+
let commentInventory;
|
|
462
|
+
if (commentsEnabled) {
|
|
463
|
+
try {
|
|
464
|
+
if (!source.readComments)
|
|
465
|
+
throw new Error(`${source.profile.name} does not implement comment reads`);
|
|
466
|
+
commentInventory = await source.readComments(cachedPage);
|
|
467
|
+
resolveCommentAttachmentReferences(commentInventory, resources);
|
|
468
|
+
commentsByPage.set(page.canonicalId, commentInventory);
|
|
469
|
+
inventory.commentThreads += commentInventory.threads.length;
|
|
470
|
+
inventory.comments += commentInventory.comments;
|
|
471
|
+
inventory.commentParticipants += commentInventory.participants;
|
|
472
|
+
inventory.commentAttachments += commentInventory.attachments;
|
|
473
|
+
inventory.commentBytes += commentInventory.knownBytes;
|
|
474
|
+
for (const warning of commentInventory.warnings)
|
|
475
|
+
report.issue({
|
|
476
|
+
code: `comment-inventory-warning:${warning}`,
|
|
477
|
+
severity: 'warning',
|
|
478
|
+
phase: 'inventory',
|
|
479
|
+
pageId: page.canonicalId,
|
|
480
|
+
retryable: false,
|
|
481
|
+
message: `${page.title}: ${warning}`,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
486
|
+
commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
|
|
487
|
+
commentsByPage.set(page.canonicalId, commentInventory);
|
|
488
|
+
report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
325
491
|
const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
|
|
326
492
|
await cacheDocument(options.cacheDirectory, page.canonicalId, document);
|
|
327
493
|
inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
|
|
@@ -337,6 +503,8 @@ export class Migrator {
|
|
|
337
503
|
status: 'planned',
|
|
338
504
|
resources,
|
|
339
505
|
document,
|
|
506
|
+
commentsEnabled,
|
|
507
|
+
...(commentInventory ? { commentInventory } : {}),
|
|
340
508
|
}), undefined, false);
|
|
341
509
|
status(`Inspected ${page.title} · ${resources.length} resources`);
|
|
342
510
|
}
|
|
@@ -354,26 +522,72 @@ export class Migrator {
|
|
|
354
522
|
});
|
|
355
523
|
}
|
|
356
524
|
})));
|
|
525
|
+
await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
|
|
357
526
|
if (inventoryErrors.length > 0) {
|
|
358
527
|
const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
|
|
359
528
|
report.finish(finalStatus);
|
|
360
529
|
const reportPaths = await report.write(options.reportsDirectory);
|
|
361
530
|
if (!options.dryRun)
|
|
362
|
-
await store.finishRun(run.id, finalStatus,
|
|
363
|
-
pages: graph.pages.length,
|
|
364
|
-
warnings: attentionCount(report.data.summary),
|
|
365
|
-
errors: report.data.summary.errors,
|
|
366
|
-
});
|
|
531
|
+
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
367
532
|
throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
|
|
368
533
|
}
|
|
369
|
-
|
|
534
|
+
if (!options.dryRun) {
|
|
535
|
+
for (const page of graph.pages) {
|
|
536
|
+
if (store.value.pages[page.canonicalId])
|
|
537
|
+
continue;
|
|
538
|
+
const commentInventory = commentsByPage.get(page.canonicalId);
|
|
539
|
+
const commentState = !commentsEnabled
|
|
540
|
+
? { status: 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
|
|
541
|
+
: commentInventory?.status === 'empty'
|
|
542
|
+
? { status: 'complete', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
|
|
543
|
+
: commentInventory?.status === 'failed'
|
|
544
|
+
? {
|
|
545
|
+
status: 'failed', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0,
|
|
546
|
+
...(commentInventory.error ? { lastError: commentInventory.error } : {}), updatedAt: new Date().toISOString(),
|
|
547
|
+
}
|
|
548
|
+
: commentInventory
|
|
549
|
+
? {
|
|
550
|
+
status: 'planned', ...(commentInventory.fingerprint ? { fingerprint: commentInventory.fingerprint } : {}),
|
|
551
|
+
threads: commentInventory.threads.length, comments: commentInventory.comments,
|
|
552
|
+
participants: commentInventory.participants, attachments: commentInventory.attachments,
|
|
553
|
+
bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
|
|
554
|
+
}
|
|
555
|
+
: undefined;
|
|
556
|
+
const resourceStates = Object.fromEntries((resourcesByPage.get(page.canonicalId) ?? [])
|
|
557
|
+
.filter((resource) => resource.kind !== 'grid')
|
|
558
|
+
.map((resource) => [resource.id, {
|
|
559
|
+
sourceId: resource.id,
|
|
560
|
+
sourceName: resource.name,
|
|
561
|
+
destinationName: preferredDestinationName(resource),
|
|
562
|
+
...(resource.size !== undefined ? { size: resource.size } : {}),
|
|
563
|
+
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
|
|
564
|
+
status: 'planned',
|
|
565
|
+
updatedAt: new Date().toISOString(),
|
|
566
|
+
}]));
|
|
567
|
+
await store.setPage({
|
|
568
|
+
sourceId: page.id,
|
|
569
|
+
sourceCanonicalId: page.canonicalId,
|
|
570
|
+
sourceLocator: page.locator,
|
|
571
|
+
sourceTitle: page.title,
|
|
572
|
+
...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
|
|
573
|
+
destinationTitle: page.title,
|
|
574
|
+
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
575
|
+
status: 'discovered',
|
|
576
|
+
resources: resourceStates,
|
|
577
|
+
...(commentState ? { comments: commentState } : {}),
|
|
578
|
+
degradations: [],
|
|
579
|
+
updatedAt: new Date().toISOString(),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
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'}`;
|
|
370
584
|
status(planSummary);
|
|
371
585
|
this.logger.info(planSummary);
|
|
372
586
|
let output;
|
|
373
587
|
let archive;
|
|
374
588
|
try {
|
|
375
589
|
if (options.destination.type === 'directory') {
|
|
376
|
-
const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage);
|
|
590
|
+
const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
|
|
377
591
|
output = result.directory;
|
|
378
592
|
archive = result.archive;
|
|
379
593
|
report.output('directory', result.directory);
|
|
@@ -393,20 +607,18 @@ export class Migrator {
|
|
|
393
607
|
report,
|
|
394
608
|
sourceFingerprints,
|
|
395
609
|
resourcesByPage,
|
|
610
|
+
commentsByPage,
|
|
396
611
|
});
|
|
397
612
|
output = container.url;
|
|
398
613
|
report.output('destination', container.url);
|
|
399
614
|
}
|
|
400
615
|
report.finish('completed');
|
|
401
616
|
const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
|
|
617
|
+
await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
|
|
402
618
|
report.finish(finalStatus);
|
|
403
619
|
const reportPaths = await report.write(options.reportsDirectory);
|
|
404
620
|
if (!options.dryRun)
|
|
405
|
-
await store.finishRun(run.id, finalStatus,
|
|
406
|
-
pages: graph.pages.length,
|
|
407
|
-
warnings: attentionCount(report.data.summary),
|
|
408
|
-
errors: report.data.summary.errors,
|
|
409
|
-
});
|
|
621
|
+
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
410
622
|
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`);
|
|
411
623
|
return {
|
|
412
624
|
status: finalStatus,
|
|
@@ -425,19 +637,16 @@ export class Migrator {
|
|
|
425
637
|
const finalStatus = cancelled ? 'cancelled' : 'failed';
|
|
426
638
|
const message = error instanceof Error ? error.message : String(error);
|
|
427
639
|
report.error(message);
|
|
640
|
+
await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
|
|
428
641
|
report.finish(finalStatus);
|
|
429
642
|
const reportPaths = await report.write(options.reportsDirectory);
|
|
430
643
|
if (!options.dryRun)
|
|
431
|
-
await store.finishRun(run.id, finalStatus,
|
|
432
|
-
pages: graph.pages.length,
|
|
433
|
-
warnings: attentionCount(report.data.summary),
|
|
434
|
-
errors: report.data.summary.errors,
|
|
435
|
-
});
|
|
644
|
+
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
436
645
|
this.logger.error(`${message}. Report: ${reportPaths.html}`);
|
|
437
646
|
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
438
647
|
}
|
|
439
648
|
}
|
|
440
|
-
async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage) {
|
|
649
|
+
async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
|
|
441
650
|
const exporter = new FilesystemExporter(source, options.concurrency);
|
|
442
651
|
const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
|
|
443
652
|
const total = Math.max(1, graph.pages.length + totalResources);
|
|
@@ -456,6 +665,8 @@ export class Migrator {
|
|
|
456
665
|
options.onStatus?.(`Progress ${Math.min(100, Math.floor((processed / total) * 100))}% · ${processed}/${total} · ${message}`);
|
|
457
666
|
} } : {}),
|
|
458
667
|
resourcesByPage,
|
|
668
|
+
commentsByPage,
|
|
669
|
+
commentsEnabled: options.comments ?? true,
|
|
459
670
|
loadPage: (page) => loadCachedPage(options.cacheDirectory, page.canonicalId),
|
|
460
671
|
loadDocument: (page) => loadCachedDocument(options.cacheDirectory, page.canonicalId),
|
|
461
672
|
});
|
|
@@ -479,7 +690,8 @@ export class Migrator {
|
|
|
479
690
|
updatedAt: new Date().toISOString(),
|
|
480
691
|
}];
|
|
481
692
|
}));
|
|
482
|
-
const
|
|
693
|
+
const commentIncomplete = manifestPage?.comments && !['complete', 'skipped'].includes(manifestPage.comments.status);
|
|
694
|
+
const pageStatus = options.dryRun ? 'planned' : manifestPage?.degraded || commentIncomplete ? 'partial' : 'complete';
|
|
483
695
|
await store.setPage({
|
|
484
696
|
sourceId: page.id,
|
|
485
697
|
sourceCanonicalId: page.canonicalId,
|
|
@@ -495,6 +707,19 @@ export class Migrator {
|
|
|
495
707
|
status: pageStatus,
|
|
496
708
|
resources,
|
|
497
709
|
degradations: manifestPage?.warnings ?? [],
|
|
710
|
+
...(manifestPage?.comments ? { comments: {
|
|
711
|
+
status: manifestPage.comments.status,
|
|
712
|
+
...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
|
|
713
|
+
...(manifestPage.comments.archivePath ? { archivePath: manifestPage.comments.archivePath } : {}),
|
|
714
|
+
...(manifestPage.comments.archiveName ? { archiveName: manifestPage.comments.archiveName } : {}),
|
|
715
|
+
threads: manifestPage.comments.threads,
|
|
716
|
+
comments: manifestPage.comments.comments,
|
|
717
|
+
participants: manifestPage.comments.participants,
|
|
718
|
+
attachments: manifestPage.comments.attachments,
|
|
719
|
+
bytes: manifestPage.comments.bytes,
|
|
720
|
+
...(manifestPage.comments.error ? { lastError: manifestPage.comments.error } : {}),
|
|
721
|
+
updatedAt: new Date().toISOString(),
|
|
722
|
+
} } : {}),
|
|
498
723
|
updatedAt: new Date().toISOString(),
|
|
499
724
|
});
|
|
500
725
|
report.addPage(reportPage({
|
|
@@ -505,7 +730,35 @@ export class Migrator {
|
|
|
505
730
|
resourceStates: resources,
|
|
506
731
|
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
507
732
|
...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
|
|
733
|
+
commentsEnabled: options.comments ?? true,
|
|
734
|
+
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
735
|
+
...(manifestPage?.comments ? { commentState: {
|
|
736
|
+
status: manifestPage.comments.status,
|
|
737
|
+
...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
|
|
738
|
+
...(manifestPage.comments.archivePath ? { archivePath: manifestPage.comments.archivePath } : {}),
|
|
739
|
+
...(manifestPage.comments.archiveName ? { archiveName: manifestPage.comments.archiveName } : {}),
|
|
740
|
+
threads: manifestPage.comments.threads,
|
|
741
|
+
comments: manifestPage.comments.comments,
|
|
742
|
+
participants: manifestPage.comments.participants,
|
|
743
|
+
attachments: manifestPage.comments.attachments,
|
|
744
|
+
bytes: manifestPage.comments.bytes,
|
|
745
|
+
...(manifestPage.comments.error ? { lastError: manifestPage.comments.error } : {}),
|
|
746
|
+
updatedAt: new Date().toISOString(),
|
|
747
|
+
} } : {}),
|
|
508
748
|
}), { source: normalizedPreviewHtml(document), destination: normalizedPreviewHtml(document) });
|
|
749
|
+
if (manifestPage?.comments
|
|
750
|
+
&& !['complete', 'skipped', 'planned'].includes(manifestPage.comments.status)
|
|
751
|
+
&& !(manifestPage.comments.status === 'failed' && commentsByPage.get(page.canonicalId)?.status === 'failed')
|
|
752
|
+
&& !(manifestPage.comments.status === 'partial' && (commentsByPage.get(page.canonicalId)?.warnings.length ?? 0) > 0)) {
|
|
753
|
+
report.issue({
|
|
754
|
+
code: `comment-${manifestPage.comments.status}`,
|
|
755
|
+
severity: manifestPage.comments.status === 'partial' || manifestPage.comments.status === 'stale' ? 'degradation' : 'error',
|
|
756
|
+
phase: 'comment',
|
|
757
|
+
pageId: page.canonicalId,
|
|
758
|
+
retryable: manifestPage.comments.status === 'failed',
|
|
759
|
+
message: `${page.title}: ${manifestPage.comments.error ?? `comment archive is ${manifestPage.comments.status}`}`,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
509
762
|
}
|
|
510
763
|
for (const warning of result.warnings)
|
|
511
764
|
report.warning(warning);
|
|
@@ -513,18 +766,328 @@ export class Migrator {
|
|
|
513
766
|
report.error(error);
|
|
514
767
|
return result;
|
|
515
768
|
}
|
|
769
|
+
async transferRemoteComments(input) {
|
|
770
|
+
const { source, destination, page, destinationPage, inventory, resources, pageState, report, store } = input;
|
|
771
|
+
const now = () => new Date().toISOString();
|
|
772
|
+
const counts = {
|
|
773
|
+
threads: inventory?.threads.length ?? 0,
|
|
774
|
+
comments: inventory?.comments ?? 0,
|
|
775
|
+
participants: inventory?.participants ?? 0,
|
|
776
|
+
attachments: inventory?.attachments ?? 0,
|
|
777
|
+
bytes: inventory?.knownBytes ?? 0,
|
|
778
|
+
};
|
|
779
|
+
if (!inventory)
|
|
780
|
+
return { status: 'skipped', ...counts, updatedAt: now() };
|
|
781
|
+
if (inventory.status === 'failed') {
|
|
782
|
+
return { status: 'failed', ...counts, ...(inventory.error ? { lastError: inventory.error } : {}), updatedAt: now() };
|
|
783
|
+
}
|
|
784
|
+
if (inventory.status === 'empty') {
|
|
785
|
+
if (pageState.comments?.destinationId) {
|
|
786
|
+
return { ...pageState.comments, status: 'stale', lastError: 'Source no longer returns the previously archived comments; the destination archive was preserved.', updatedAt: now() };
|
|
787
|
+
}
|
|
788
|
+
return { status: 'complete', ...counts, updatedAt: now() };
|
|
789
|
+
}
|
|
790
|
+
const fingerprint = inventory.fingerprint;
|
|
791
|
+
if (!fingerprint)
|
|
792
|
+
return { status: 'failed', ...counts, lastError: 'Comment inventory fingerprint is missing', updatedAt: now() };
|
|
793
|
+
if (input.dryRun)
|
|
794
|
+
return { status: 'planned', fingerprint, ...counts, updatedAt: now() };
|
|
795
|
+
if (!destination.createArchiveComment || !destination.readArchiveComment) {
|
|
796
|
+
return { status: 'failed', fingerprint, ...counts, lastError: `${destination.profile.name} does not implement comment archive writes`, updatedAt: now() };
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
if (input.signal?.aborted)
|
|
800
|
+
throw input.signal.reason ?? new Error('Transfer cancelled');
|
|
801
|
+
const resourceById = new Map(resources.map((resource) => [resource.id, resource]));
|
|
802
|
+
const downloads = new Map();
|
|
803
|
+
const archiveFiles = [];
|
|
804
|
+
for (const use of commentAttachmentUses(inventory)) {
|
|
805
|
+
if (input.signal?.aborted)
|
|
806
|
+
throw input.signal.reason ?? new Error('Transfer cancelled');
|
|
807
|
+
const resourceId = use.reference.resourceId;
|
|
808
|
+
if (!resourceId)
|
|
809
|
+
continue;
|
|
810
|
+
const resource = resourceById.get(resourceId);
|
|
811
|
+
if (!resource)
|
|
812
|
+
continue;
|
|
813
|
+
let downloaded = downloads.get(resourceId);
|
|
814
|
+
if (!downloaded) {
|
|
815
|
+
const resourceState = pageState.resources[resourceId];
|
|
816
|
+
const filename = containedCachePath(input.cacheDirectory, resourceState?.cachePath)
|
|
817
|
+
?? cachePath(input.cacheDirectory, page, resource);
|
|
818
|
+
const cached = await verifiedCacheFile(filename, resourceState?.sha256 ? {
|
|
819
|
+
sha256: resourceState.sha256,
|
|
820
|
+
...(resourceState.size !== undefined ? { size: resourceState.size } : {}),
|
|
821
|
+
} : undefined);
|
|
822
|
+
downloaded = cached ?? await source.downloadResource(resource, filename);
|
|
823
|
+
downloads.set(resourceId, downloaded);
|
|
824
|
+
}
|
|
825
|
+
archiveFiles.push({
|
|
826
|
+
commentId: use.commentId,
|
|
827
|
+
reference: use.reference.href,
|
|
828
|
+
originalName: resource.preferredName ?? resource.name,
|
|
829
|
+
...downloaded,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
const zip = await createCommentArchiveZip({
|
|
833
|
+
pageKey: page.id,
|
|
834
|
+
fingerprint,
|
|
835
|
+
files: archiveFiles,
|
|
836
|
+
destinationDirectory: path.join(input.cacheDirectory, 'comments', sha256(page.canonicalId).slice(0, 16)),
|
|
837
|
+
});
|
|
838
|
+
let current;
|
|
839
|
+
const previous = pageState.comments;
|
|
840
|
+
if (previous?.destinationId) {
|
|
841
|
+
try {
|
|
842
|
+
current = await destination.readArchiveComment({ page: destinationPage, commentId: previous.destinationId });
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
report.issue({
|
|
846
|
+
code: 'comment-mapping-missing', severity: 'warning', phase: 'comment', pageId: page.canonicalId,
|
|
847
|
+
retryable: true, message: `${page.title}: mapped comment ${previous.destinationId} could not be read and will be recreated: ${error instanceof Error ? error.message : String(error)}`,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
current ??= await destination.findArchiveComment?.(destinationPage, fingerprint);
|
|
852
|
+
if (current && previous?.destinationFingerprint && current.fingerprint !== previous.destinationFingerprint) {
|
|
853
|
+
const conflict = {
|
|
854
|
+
fingerprint,
|
|
855
|
+
destinationId: current.id,
|
|
856
|
+
destinationFingerprint: current.fingerprint,
|
|
857
|
+
...counts,
|
|
858
|
+
...(previous.archiveResourceId ? { archiveResourceId: previous.archiveResourceId } : {}),
|
|
859
|
+
...(previous.archiveName ? { archiveName: previous.archiveName } : {}),
|
|
860
|
+
...(previous.archivePath ? { archivePath: previous.archivePath } : {}),
|
|
861
|
+
...(previous.archiveSha256 ? { archiveSha256: previous.archiveSha256 } : {}),
|
|
862
|
+
status: 'conflict',
|
|
863
|
+
lastError: 'Destination comment archive was changed manually and was preserved.',
|
|
864
|
+
updatedAt: now(),
|
|
865
|
+
};
|
|
866
|
+
return conflict;
|
|
867
|
+
}
|
|
868
|
+
const remoteResources = await destination.listUploadedResources(destinationPage);
|
|
869
|
+
const remoteMatches = async (remote, file, trustedMapping) => {
|
|
870
|
+
if (remote.size !== undefined && remote.size !== file.size)
|
|
871
|
+
return false;
|
|
872
|
+
if (!destination.downloadUploadedResource)
|
|
873
|
+
return trustedMapping && (remote.size === undefined || remote.size === file.size);
|
|
874
|
+
const comparison = `${file.path}.destination-${sha256(remote.id).slice(0, 8)}`;
|
|
875
|
+
try {
|
|
876
|
+
const actual = await destination.downloadUploadedResource({ page: destinationPage, resource: remote, destination: comparison });
|
|
877
|
+
return actual.size === file.size && actual.sha256 === file.sha256;
|
|
878
|
+
}
|
|
879
|
+
finally {
|
|
880
|
+
await rm(comparison, { force: true });
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
const uploadArchive = async (file, mappedId, mimeType) => {
|
|
884
|
+
const mapped = mappedId ? remoteResources.find((resource) => resource.id === mappedId) : undefined;
|
|
885
|
+
const byName = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === file.name.toLocaleLowerCase());
|
|
886
|
+
const reusable = mapped && await remoteMatches(mapped, file, true)
|
|
887
|
+
? mapped
|
|
888
|
+
: byName && await remoteMatches(byName, file, false) ? byName : undefined;
|
|
889
|
+
if (reusable)
|
|
890
|
+
return reusable;
|
|
891
|
+
const extension = path.extname(file.name);
|
|
892
|
+
const alternativeName = `${file.name.slice(0, file.name.length - extension.length)}-${file.sha256.slice(0, 8)}${extension}`;
|
|
893
|
+
const alternative = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === alternativeName.toLocaleLowerCase());
|
|
894
|
+
if (alternative && await remoteMatches(alternative, file, false))
|
|
895
|
+
return alternative;
|
|
896
|
+
const uploadName = mapped || byName ? alternativeName : file.name;
|
|
897
|
+
return destination.uploadResource({
|
|
898
|
+
page: destinationPage,
|
|
899
|
+
name: uploadName,
|
|
900
|
+
filePath: file.path,
|
|
901
|
+
size: file.size,
|
|
902
|
+
...(mimeType ? { mimeType } : {}),
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
const archiveResource = zip
|
|
906
|
+
? await uploadArchive(zip, previous?.archiveResourceId, 'application/zip')
|
|
907
|
+
: undefined;
|
|
908
|
+
if (input.signal?.aborted)
|
|
909
|
+
throw input.signal.reason ?? new Error('Transfer cancelled');
|
|
910
|
+
const attachmentNames = new Map();
|
|
911
|
+
if (zip) {
|
|
912
|
+
const nameByHash = new Map(zip.files.map((entry) => [entry.sha256, entry.archiveName]));
|
|
913
|
+
for (const [resourceId, downloaded] of downloads) {
|
|
914
|
+
const name = nameByHash.get(downloaded.sha256);
|
|
915
|
+
if (name)
|
|
916
|
+
attachmentNames.set(resourceId, name);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const render = (presentation, replaces) => renderCommentArchive(inventory, destination.kind, {
|
|
920
|
+
sourceUrl: page.url,
|
|
921
|
+
...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
|
|
922
|
+
...(replaces ? { archiveReplaces: replaces } : {}),
|
|
923
|
+
attachmentNames,
|
|
924
|
+
pages: input.destinationPages,
|
|
925
|
+
sourcePages: input.sourcePages,
|
|
926
|
+
presentation,
|
|
927
|
+
});
|
|
928
|
+
const archiveState = zip ? {
|
|
929
|
+
...(archiveResource ? { archiveResourceId: archiveResource.id } : {}),
|
|
930
|
+
archiveName: archiveResource?.name ?? zip.name,
|
|
931
|
+
archivePath: zip.path,
|
|
932
|
+
archiveSha256: zip.sha256,
|
|
933
|
+
} : {};
|
|
934
|
+
const replacement = destination.capabilities.updateComments ? undefined : current?.id;
|
|
935
|
+
let expectedBody = render('native', replacement);
|
|
936
|
+
let fallbackReason;
|
|
937
|
+
let fullArchiveState = {};
|
|
938
|
+
const maximumInlineBytes = 1_000_000;
|
|
939
|
+
const prepareFullArchive = async (reason) => {
|
|
940
|
+
if (fullArchiveState.fullArchiveName)
|
|
941
|
+
return;
|
|
942
|
+
const fullBody = renderCommentArchive(inventory, 'markdown', {
|
|
943
|
+
sourceUrl: page.url,
|
|
944
|
+
...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
|
|
945
|
+
...(replacement ? { archiveReplaces: replacement } : {}),
|
|
946
|
+
attachmentNames,
|
|
947
|
+
pages: input.destinationPages,
|
|
948
|
+
sourcePages: input.sourcePages,
|
|
949
|
+
});
|
|
950
|
+
const fullArchiveName = `ywtc-comments-${safeFilename(page.id)}-${fingerprint.slice(0, 12)}.md`;
|
|
951
|
+
const fullArchivePath = path.join(input.cacheDirectory, 'comments', sha256(page.canonicalId).slice(0, 16), fullArchiveName);
|
|
952
|
+
await mkdir(path.dirname(fullArchivePath), { recursive: true, mode: 0o700 });
|
|
953
|
+
await writeFile(fullArchivePath, `${fullBody}\n`, { mode: 0o600 });
|
|
954
|
+
const fullArchiveSha256 = sha256(`${fullBody}\n`);
|
|
955
|
+
const fullArchiveResource = await uploadArchive({
|
|
956
|
+
path: fullArchivePath,
|
|
957
|
+
name: fullArchiveName,
|
|
958
|
+
size: Buffer.byteLength(`${fullBody}\n`),
|
|
959
|
+
sha256: fullArchiveSha256,
|
|
960
|
+
}, previous?.fullArchiveResourceId, 'text/markdown');
|
|
961
|
+
const destinationFullArchiveName = fullArchiveResource.name;
|
|
962
|
+
expectedBody = renderCommentArchiveIndex(inventory, destination.kind, {
|
|
963
|
+
sourceUrl: page.url,
|
|
964
|
+
fullArchiveName: destinationFullArchiveName,
|
|
965
|
+
fullArchiveHref: fullArchiveResource.url ?? destinationFullArchiveName,
|
|
966
|
+
...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
|
|
967
|
+
});
|
|
968
|
+
fullArchiveState = {
|
|
969
|
+
fullArchiveResourceId: fullArchiveResource.id,
|
|
970
|
+
fullArchiveName: destinationFullArchiveName,
|
|
971
|
+
fullArchivePath,
|
|
972
|
+
fullArchiveSha256,
|
|
973
|
+
};
|
|
974
|
+
fallbackReason = reason;
|
|
975
|
+
};
|
|
976
|
+
if (Buffer.byteLength(expectedBody) > maximumInlineBytes) {
|
|
977
|
+
await prepareFullArchive(`Full comment archive exceeded ${maximumInlineBytes} inline bytes and was attached as Markdown.`);
|
|
978
|
+
}
|
|
979
|
+
let result = current;
|
|
980
|
+
const requiresWrite = !result || previous?.fingerprint !== fingerprint;
|
|
981
|
+
if (requiresWrite) {
|
|
982
|
+
const write = async (body) => {
|
|
983
|
+
if (input.signal?.aborted)
|
|
984
|
+
throw input.signal.reason ?? new Error('Transfer cancelled');
|
|
985
|
+
if (!result || !destination.capabilities.updateComments || !destination.updateArchiveComment) {
|
|
986
|
+
return destination.createArchiveComment({ page: destinationPage, body });
|
|
987
|
+
}
|
|
988
|
+
return destination.updateArchiveComment({ comment: result, body });
|
|
989
|
+
};
|
|
990
|
+
const presentations = fallbackReason ? [] : ['native', 'quote', 'code'];
|
|
991
|
+
let lastError;
|
|
992
|
+
if (fallbackReason) {
|
|
993
|
+
result = await write(expectedBody);
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
for (const presentation of presentations) {
|
|
997
|
+
try {
|
|
998
|
+
expectedBody = render(presentation, replacement);
|
|
999
|
+
result = await write(expectedBody);
|
|
1000
|
+
if (presentation !== 'native')
|
|
1001
|
+
fallbackReason = `Destination rejected the native collapsible archive; ${presentation} fallback was used.`;
|
|
1002
|
+
lastError = undefined;
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
lastError = error;
|
|
1007
|
+
const status = error.status;
|
|
1008
|
+
if (status === 413 || /(?:status[=: ]+413|too large|payload too large)/i.test(error instanceof Error ? error.message : String(error))) {
|
|
1009
|
+
try {
|
|
1010
|
+
await prepareFullArchive('Destination rejected the inline archive size; the full archive was attached as Markdown.');
|
|
1011
|
+
result = await write(expectedBody);
|
|
1012
|
+
lastError = undefined;
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
catch (fallbackError) {
|
|
1016
|
+
lastError = fallbackError;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const reconciled = await destination.findArchiveComment?.(destinationPage, fingerprint);
|
|
1020
|
+
if (reconciled) {
|
|
1021
|
+
result = reconciled;
|
|
1022
|
+
expectedBody = reconciled.body;
|
|
1023
|
+
fallbackReason = presentation === 'native' ? undefined : `Destination accepted the ${presentation} fallback after an ambiguous response.`;
|
|
1024
|
+
lastError = undefined;
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (lastError)
|
|
1030
|
+
throw lastError;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (!result)
|
|
1034
|
+
throw new Error('Destination comment archive was not created');
|
|
1035
|
+
const verified = await destination.readArchiveComment({ page: destinationPage, commentId: result.id });
|
|
1036
|
+
const verification = verifyCommentArchive(fullArchiveState.fullArchiveName ? fingerprint : inventory, verified.body, archiveResource?.name);
|
|
1037
|
+
if (fullArchiveState.fullArchiveName && !verified.body.includes(fullArchiveState.fullArchiveName)) {
|
|
1038
|
+
verification.ok = false;
|
|
1039
|
+
verification.differences.push('full Markdown comment archive reference is missing');
|
|
1040
|
+
}
|
|
1041
|
+
if (!verification.ok)
|
|
1042
|
+
throw new Error(`Comment verification failed: ${verification.differences.join('; ')}`);
|
|
1043
|
+
const previousIds = result.id !== current?.id && current
|
|
1044
|
+
? [...new Set([...(previous?.previousDestinationIds ?? []), current.id])]
|
|
1045
|
+
: previous?.previousDestinationIds;
|
|
1046
|
+
const state = {
|
|
1047
|
+
fingerprint,
|
|
1048
|
+
destinationId: verified.id,
|
|
1049
|
+
destinationFingerprint: verified.fingerprint,
|
|
1050
|
+
...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
|
|
1051
|
+
...archiveState,
|
|
1052
|
+
...fullArchiveState,
|
|
1053
|
+
status: inventory.warnings.length > 0 || fallbackReason ? 'partial' : 'complete',
|
|
1054
|
+
...counts,
|
|
1055
|
+
preview: verified.body,
|
|
1056
|
+
...(fallbackReason ? { lastError: fallbackReason } : {}),
|
|
1057
|
+
updatedAt: now(),
|
|
1058
|
+
};
|
|
1059
|
+
pageState.comments = state;
|
|
1060
|
+
await store.setPage({ ...pageState, comments: state, updatedAt: now() });
|
|
1061
|
+
return state;
|
|
1062
|
+
}
|
|
1063
|
+
catch (error) {
|
|
1064
|
+
if (input.signal?.aborted)
|
|
1065
|
+
throw input.signal.reason ?? error;
|
|
1066
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1067
|
+
return {
|
|
1068
|
+
fingerprint,
|
|
1069
|
+
...counts,
|
|
1070
|
+
...(pageState.comments?.destinationId ? { destinationId: pageState.comments.destinationId } : {}),
|
|
1071
|
+
...(pageState.comments?.destinationFingerprint ? { destinationFingerprint: pageState.comments.destinationFingerprint } : {}),
|
|
1072
|
+
status: /conflict/i.test(message) ? 'conflict' : 'failed',
|
|
1073
|
+
lastError: message,
|
|
1074
|
+
updatedAt: now(),
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
516
1078
|
async transferRemote(input) {
|
|
517
|
-
const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
|
|
1079
|
+
const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
|
|
518
1080
|
const destinationPages = new Map();
|
|
519
1081
|
const stagingFailures = new Map();
|
|
520
1082
|
const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
|
|
521
1083
|
const bootstrapBodies = new Map();
|
|
522
1084
|
const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
|
|
523
|
-
const
|
|
524
|
-
const
|
|
1085
|
+
const totalCommentArchives = (options.comments ?? true) ? graph.pages.length : 0;
|
|
1086
|
+
const progress = { staged: 0, pages: 0, resources: 0, comments: 0 };
|
|
1087
|
+
const totalUnits = Math.max(1, graph.pages.length * 2 + totalResources + totalCommentArchives);
|
|
525
1088
|
const updateProgress = (active) => {
|
|
526
|
-
const completed = progress.staged + progress.pages + progress.resources;
|
|
527
|
-
options.onStatus?.(`Progress ${Math.min(100, Math.floor((completed / totalUnits) * 100))}% · pages ${progress.pages}/${graph.pages.length} · files ${progress.resources}/${totalResources} · ${active}`);
|
|
1089
|
+
const completed = progress.staged + progress.pages + progress.resources + progress.comments;
|
|
1090
|
+
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}`);
|
|
528
1091
|
};
|
|
529
1092
|
for (const page of graph.pages) {
|
|
530
1093
|
const [document, cachedPage] = await Promise.all([
|
|
@@ -551,15 +1114,47 @@ export class Migrator {
|
|
|
551
1114
|
const saved = store.value.pages[page.canonicalId];
|
|
552
1115
|
const parent = page.projectedParentId ? destinationPages.get(page.projectedParentId) : container;
|
|
553
1116
|
if (!parent) {
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
1117
|
+
const upstream = page.projectedParentId ? stagingFailures.get(page.projectedParentId) : undefined;
|
|
1118
|
+
const blockedByPageId = upstream?.blockedByPageId ?? page.projectedParentId ?? page.canonicalId;
|
|
1119
|
+
const blockedByCode = upstream?.blockedByCode ?? 'destination-parent-unavailable';
|
|
1120
|
+
const message = `Destination parent is unavailable for ${page.title}; blocked by ${blockedByPageId} (${blockedByCode})`;
|
|
1121
|
+
const cause = { blockedByPageId, blockedByCode, blockedReason: message };
|
|
1122
|
+
stagingFailures.set(page.canonicalId, { status: 'blocked', message, blockedByPageId, blockedByCode });
|
|
1123
|
+
const previous = store.value.pages[page.canonicalId];
|
|
1124
|
+
const resources = resourcesByPage.get(page.canonicalId) ?? [];
|
|
1125
|
+
const comments = (options.comments ?? true)
|
|
1126
|
+
? blockedCommentState(commentsByPage.get(page.canonicalId), previous?.comments, cause)
|
|
1127
|
+
: undefined;
|
|
1128
|
+
await store.setPage({
|
|
1129
|
+
sourceId: page.id,
|
|
1130
|
+
sourceCanonicalId: page.canonicalId,
|
|
1131
|
+
sourceLocator: page.locator,
|
|
1132
|
+
sourceTitle: page.title,
|
|
1133
|
+
...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
|
|
1134
|
+
destinationTitle: previous?.destinationTitle ?? page.title,
|
|
1135
|
+
...(previous?.destinationId ? { destinationId: previous.destinationId } : {}),
|
|
1136
|
+
...(previous?.destinationCanonicalId ? { destinationCanonicalId: previous.destinationCanonicalId } : {}),
|
|
1137
|
+
...(previous?.destinationLocator ? { destinationLocator: previous.destinationLocator } : {}),
|
|
1138
|
+
...(previous?.previousDestinationIds ? { previousDestinationIds: previous.previousDestinationIds } : {}),
|
|
1139
|
+
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
1140
|
+
...(previous?.destinationFingerprint ? { destinationFingerprint: previous.destinationFingerprint } : {}),
|
|
1141
|
+
status: 'blocked',
|
|
1142
|
+
resources: blockedResourceStates(resources, previous?.resources ?? {}, cause),
|
|
1143
|
+
...(comments ? { comments } : {}),
|
|
1144
|
+
degradations: previous?.degradations ?? [],
|
|
1145
|
+
...cause,
|
|
1146
|
+
lastError: message,
|
|
1147
|
+
updatedAt: new Date().toISOString(),
|
|
1148
|
+
});
|
|
1149
|
+
report.updatePage(page.canonicalId, { status: 'blocked', error: message, ...cause });
|
|
557
1150
|
report.issue({
|
|
558
1151
|
code: 'destination-parent-unavailable',
|
|
559
|
-
severity: '
|
|
1152
|
+
severity: 'blocked',
|
|
560
1153
|
phase: 'page',
|
|
561
1154
|
pageId: page.canonicalId,
|
|
562
|
-
retryable:
|
|
1155
|
+
retryable: false,
|
|
1156
|
+
blockedByPageId,
|
|
1157
|
+
blockedByCode,
|
|
563
1158
|
message,
|
|
564
1159
|
});
|
|
565
1160
|
progress.staged += 1;
|
|
@@ -570,13 +1165,20 @@ export class Migrator {
|
|
|
570
1165
|
const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
|
|
571
1166
|
const bootstrap = bootstrapBodies.get(page.canonicalId);
|
|
572
1167
|
let created;
|
|
573
|
-
|
|
1168
|
+
const title = saved?.destinationTitle ?? page.title;
|
|
574
1169
|
let resourcesForState = saved?.resources ?? {};
|
|
1170
|
+
let commentsForState = saved?.comments;
|
|
575
1171
|
let previousDestinationIds = saved?.previousDestinationIds ?? [];
|
|
576
1172
|
let mappingRecovered = false;
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
1173
|
+
const allocatePage = (preferredTitle) => createPageWithAllocatedTitle({
|
|
1174
|
+
destination,
|
|
1175
|
+
parent,
|
|
1176
|
+
sourcePage: cachedPage,
|
|
1177
|
+
bootstrap,
|
|
1178
|
+
...(preferredTitle ? { preferredTitle } : {}),
|
|
1179
|
+
consistencyOptions: this.consistencyOptions,
|
|
1180
|
+
report,
|
|
1181
|
+
});
|
|
580
1182
|
if (saved?.destinationId) {
|
|
581
1183
|
const mapped = destinationFromState(saved, destination);
|
|
582
1184
|
try {
|
|
@@ -598,79 +1200,11 @@ export class Migrator {
|
|
|
598
1200
|
if (page.projectedParentId) {
|
|
599
1201
|
await verifyConfirmedDestinationPage(destination, parent, this.consistencyOptions);
|
|
600
1202
|
}
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
: destination.findChild(parent, candidateTitle);
|
|
604
|
-
let candidate;
|
|
605
|
-
let recoveryLookupFailed = false;
|
|
606
|
-
try {
|
|
607
|
-
candidate = await findRecoveryChild(title);
|
|
608
|
-
}
|
|
609
|
-
catch (lookupError) {
|
|
610
|
-
recoveryLookupFailed = true;
|
|
611
|
-
report.issue({
|
|
612
|
-
code: 'destination-recovery-lookup-failed',
|
|
613
|
-
severity: 'warning',
|
|
614
|
-
phase: 'page',
|
|
615
|
-
pageId: page.canonicalId,
|
|
616
|
-
retryable: true,
|
|
617
|
-
message: `${page.title}: recovery lookup failed; bootstrap recreation will be attempted: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
if (candidate && sourceDerivedTextMatches(bootstrap.body, candidate.content, destination.kind, cachedPage)) {
|
|
621
|
-
created = candidate;
|
|
622
|
-
}
|
|
623
|
-
else {
|
|
624
|
-
let recoveryTitle = title;
|
|
625
|
-
let titleConflict = candidate;
|
|
626
|
-
if (!titleConflict && !recoveryLookupFailed) {
|
|
627
|
-
try {
|
|
628
|
-
titleConflict = await findTitleConflict(recoveryTitle);
|
|
629
|
-
}
|
|
630
|
-
catch (lookupError) {
|
|
631
|
-
recoveryLookupFailed = true;
|
|
632
|
-
report.issue({
|
|
633
|
-
code: 'destination-title-lookup-failed',
|
|
634
|
-
severity: 'warning',
|
|
635
|
-
phase: 'page',
|
|
636
|
-
pageId: page.canonicalId,
|
|
637
|
-
retryable: true,
|
|
638
|
-
message: `${page.title}: destination title lookup failed; bootstrap recreation will be attempted with a stable suffix: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
if (titleConflict || recoveryLookupFailed) {
|
|
643
|
-
const base = collisionTitle(page);
|
|
644
|
-
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
645
|
-
recoveryTitle = base;
|
|
646
|
-
let sequence = 0;
|
|
647
|
-
while (!recoveryLookupFailed) {
|
|
648
|
-
try {
|
|
649
|
-
if (!await findTitleConflict(recoveryTitle))
|
|
650
|
-
break;
|
|
651
|
-
sequence += 1;
|
|
652
|
-
recoveryTitle = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
653
|
-
}
|
|
654
|
-
catch (lookupError) {
|
|
655
|
-
recoveryLookupFailed = true;
|
|
656
|
-
recoveryTitle = `${base} [${hash}]`;
|
|
657
|
-
report.issue({
|
|
658
|
-
code: 'destination-recovery-lookup-failed',
|
|
659
|
-
severity: 'warning',
|
|
660
|
-
phase: 'page',
|
|
661
|
-
pageId: page.canonicalId,
|
|
662
|
-
retryable: true,
|
|
663
|
-
message: `${page.title}: suffixed-title lookup failed; bootstrap recreation will be attempted: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
created = await destination.createPage({ container: parent, title: recoveryTitle, body: bootstrap.body });
|
|
669
|
-
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
670
|
-
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
671
|
-
}
|
|
1203
|
+
const allocation = await allocatePage(title);
|
|
1204
|
+
created = allocation.page;
|
|
672
1205
|
previousDestinationIds = [...new Set([...previousDestinationIds, mapped.id])];
|
|
673
1206
|
resourcesForState = resetResourceDestinations(saved.resources);
|
|
1207
|
+
commentsForState = undefined;
|
|
674
1208
|
mappingRecovered = true;
|
|
675
1209
|
report.issue({
|
|
676
1210
|
code: 'destination-mapping-recovered',
|
|
@@ -738,29 +1272,10 @@ export class Migrator {
|
|
|
738
1272
|
});
|
|
739
1273
|
}
|
|
740
1274
|
else {
|
|
741
|
-
|
|
742
|
-
const replacementMatches = replacement
|
|
743
|
-
&& sourceDerivedTextMatches(bootstrap.body, replacement.content, destination.kind, cachedPage);
|
|
744
|
-
const titleConflict = replacementMatches ? undefined : replacement ?? await findTitleConflict(title);
|
|
745
|
-
if (titleConflict) {
|
|
746
|
-
const base = collisionTitle(page);
|
|
747
|
-
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
748
|
-
title = base;
|
|
749
|
-
let sequence = 0;
|
|
750
|
-
while (await findTitleConflict(title)) {
|
|
751
|
-
sequence += 1;
|
|
752
|
-
title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
753
|
-
}
|
|
754
|
-
replacement = undefined;
|
|
755
|
-
}
|
|
756
|
-
created = replacement ?? await destination.createPage({ container: parent, title, body: bootstrap.body });
|
|
757
|
-
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
758
|
-
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
759
|
-
if (created.parentId !== parent.id) {
|
|
760
|
-
throw new Error(`Destination hierarchy verification failed for recreated page ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
761
|
-
}
|
|
1275
|
+
created = (await allocatePage(title)).page;
|
|
762
1276
|
previousDestinationIds = [...new Set([...previousDestinationIds, oldPage.id])];
|
|
763
1277
|
resourcesForState = resetResourceDestinations(saved.resources);
|
|
1278
|
+
commentsForState = undefined;
|
|
764
1279
|
mappingRecovered = true;
|
|
765
1280
|
report.addHierarchyRepair({
|
|
766
1281
|
sourceCanonicalId: page.canonicalId,
|
|
@@ -798,9 +1313,9 @@ export class Migrator {
|
|
|
798
1313
|
};
|
|
799
1314
|
}
|
|
800
1315
|
else {
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
1316
|
+
const allocation = await allocatePage(title);
|
|
1317
|
+
created = allocation.page;
|
|
1318
|
+
if (allocation.reused) {
|
|
804
1319
|
mappingRecovered = true;
|
|
805
1320
|
report.issue({
|
|
806
1321
|
code: 'destination-unmapped-page-recovered',
|
|
@@ -811,22 +1326,6 @@ export class Migrator {
|
|
|
811
1326
|
message: `${page.title}: adopted an existing source-derived page below the expected parent`,
|
|
812
1327
|
});
|
|
813
1328
|
}
|
|
814
|
-
else {
|
|
815
|
-
const titleConflict = existingChild ?? await findTitleConflict(title);
|
|
816
|
-
if (titleConflict) {
|
|
817
|
-
const base = collisionTitle(page);
|
|
818
|
-
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
819
|
-
title = base;
|
|
820
|
-
let sequence = 0;
|
|
821
|
-
while (await findTitleConflict(title)) {
|
|
822
|
-
sequence += 1;
|
|
823
|
-
title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
created = await destination.createPage({ container: parent, title, body: bootstrap.body });
|
|
827
|
-
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
828
|
-
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
829
|
-
}
|
|
830
1329
|
}
|
|
831
1330
|
if (!options.dryRun && created.parentId !== parent.id) {
|
|
832
1331
|
throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
@@ -847,6 +1346,7 @@ export class Migrator {
|
|
|
847
1346
|
...(created.fingerprint ? { destinationFingerprint: created.fingerprint } : {}),
|
|
848
1347
|
status: options.dryRun ? 'planned' : saved?.status ?? 'skeleton-created',
|
|
849
1348
|
resources: resourcesForState,
|
|
1349
|
+
...(commentsForState ? { comments: commentsForState } : {}),
|
|
850
1350
|
degradations: saved?.degradations ?? [],
|
|
851
1351
|
updatedAt: new Date().toISOString(),
|
|
852
1352
|
};
|
|
@@ -854,12 +1354,20 @@ export class Migrator {
|
|
|
854
1354
|
options.onStatus?.(`${options.dryRun ? 'Planned' : 'Staged content for'} ${page.title}`);
|
|
855
1355
|
}
|
|
856
1356
|
catch (error) {
|
|
1357
|
+
if (options.signal?.aborted)
|
|
1358
|
+
throw options.signal.reason ?? error;
|
|
857
1359
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
858
1360
|
const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
|
|
859
|
-
|
|
1361
|
+
const failureCode = failureStatus === 'conflict' ? 'destination-conflict' : 'destination-page-staging-failed';
|
|
1362
|
+
stagingFailures.set(page.canonicalId, {
|
|
1363
|
+
status: failureStatus,
|
|
1364
|
+
message,
|
|
1365
|
+
blockedByPageId: page.canonicalId,
|
|
1366
|
+
blockedByCode: failureCode,
|
|
1367
|
+
});
|
|
860
1368
|
report.updatePage(page.canonicalId, { status: failureStatus, error: message });
|
|
861
1369
|
report.issue({
|
|
862
|
-
code:
|
|
1370
|
+
code: failureCode,
|
|
863
1371
|
severity: 'error',
|
|
864
1372
|
phase: 'page',
|
|
865
1373
|
pageId: page.canonicalId,
|
|
@@ -867,8 +1375,29 @@ export class Migrator {
|
|
|
867
1375
|
message,
|
|
868
1376
|
});
|
|
869
1377
|
const savedState = store.value.pages[page.canonicalId];
|
|
870
|
-
if (
|
|
871
|
-
|
|
1378
|
+
if (!options.dryRun) {
|
|
1379
|
+
const cause = { blockedByPageId: page.canonicalId, blockedByCode: failureCode, blockedReason: message };
|
|
1380
|
+
const resources = resourcesByPage.get(page.canonicalId) ?? [];
|
|
1381
|
+
const comments = (options.comments ?? true)
|
|
1382
|
+
? blockedCommentState(commentsByPage.get(page.canonicalId), savedState?.comments, cause)
|
|
1383
|
+
: undefined;
|
|
1384
|
+
await store.setPage({
|
|
1385
|
+
...(savedState ?? {}),
|
|
1386
|
+
sourceId: page.id,
|
|
1387
|
+
sourceCanonicalId: page.canonicalId,
|
|
1388
|
+
sourceLocator: page.locator,
|
|
1389
|
+
sourceTitle: page.title,
|
|
1390
|
+
...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
|
|
1391
|
+
destinationTitle: savedState?.destinationTitle ?? page.title,
|
|
1392
|
+
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
1393
|
+
status: failureStatus,
|
|
1394
|
+
resources: blockedResourceStates(resources, savedState?.resources ?? {}, cause),
|
|
1395
|
+
...(comments ? { comments } : {}),
|
|
1396
|
+
degradations: savedState?.degradations ?? [],
|
|
1397
|
+
lastError: message,
|
|
1398
|
+
updatedAt: new Date().toISOString(),
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
872
1401
|
}
|
|
873
1402
|
finally {
|
|
874
1403
|
progress.staged += 1;
|
|
@@ -886,13 +1415,25 @@ export class Migrator {
|
|
|
886
1415
|
if (!destinationPage) {
|
|
887
1416
|
const currentFailure = stagingFailures.get(page.canonicalId);
|
|
888
1417
|
const failedState = store.value.pages[page.canonicalId];
|
|
1418
|
+
const blockedBy = {
|
|
1419
|
+
blockedByPageId: currentFailure?.blockedByPageId ?? page.canonicalId,
|
|
1420
|
+
blockedByCode: currentFailure?.blockedByCode ?? 'destination-page-unavailable',
|
|
1421
|
+
blockedReason: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
|
|
1422
|
+
};
|
|
1423
|
+
const commentState = failedState?.comments ?? ((options.comments ?? true)
|
|
1424
|
+
? blockedCommentState(commentsByPage.get(page.canonicalId), undefined, blockedBy)
|
|
1425
|
+
: undefined);
|
|
889
1426
|
report.addPage(reportPage({
|
|
890
1427
|
page,
|
|
891
1428
|
status: currentFailure?.status ?? 'blocked',
|
|
892
1429
|
resources,
|
|
893
1430
|
document,
|
|
894
1431
|
...(failedState?.resources ? { resourceStates: failedState.resources } : {}),
|
|
895
|
-
|
|
1432
|
+
resourceStatusOverride: options.dryRun ? 'planned' : 'blocked',
|
|
1433
|
+
commentsEnabled: options.comments ?? true,
|
|
1434
|
+
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
1435
|
+
...(commentState ? { commentState } : {}),
|
|
1436
|
+
blockedBy,
|
|
896
1437
|
error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
|
|
897
1438
|
}));
|
|
898
1439
|
progress.pages += 1;
|
|
@@ -1243,7 +1784,51 @@ export class Migrator {
|
|
|
1243
1784
|
const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
|
|
1244
1785
|
verificationDegradations = verification.status === 'partial' ? verification.differences : [];
|
|
1245
1786
|
}
|
|
1246
|
-
const
|
|
1787
|
+
const commentInventory = commentsByPage.get(page.canonicalId);
|
|
1788
|
+
const commentState = (options.comments ?? true)
|
|
1789
|
+
? await this.transferRemoteComments({
|
|
1790
|
+
source,
|
|
1791
|
+
destination,
|
|
1792
|
+
page: cachedPage,
|
|
1793
|
+
destinationPage: verified,
|
|
1794
|
+
inventory: commentInventory,
|
|
1795
|
+
resources,
|
|
1796
|
+
pageState: state,
|
|
1797
|
+
cacheDirectory: options.cacheDirectory,
|
|
1798
|
+
dryRun: options.dryRun,
|
|
1799
|
+
destinationPages,
|
|
1800
|
+
sourcePages,
|
|
1801
|
+
report,
|
|
1802
|
+
store,
|
|
1803
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1804
|
+
})
|
|
1805
|
+
: {
|
|
1806
|
+
status: 'skipped',
|
|
1807
|
+
threads: 0,
|
|
1808
|
+
comments: 0,
|
|
1809
|
+
participants: 0,
|
|
1810
|
+
attachments: 0,
|
|
1811
|
+
bytes: 0,
|
|
1812
|
+
updatedAt: new Date().toISOString(),
|
|
1813
|
+
};
|
|
1814
|
+
if (options.comments ?? true) {
|
|
1815
|
+
progress.comments += 1;
|
|
1816
|
+
updateProgress(`comments ${commentState.status} → ${page.title}`);
|
|
1817
|
+
}
|
|
1818
|
+
if (['failed', 'conflict', 'stale', 'partial'].includes(commentState.status)
|
|
1819
|
+
&& !(commentState.status === 'failed' && commentInventory?.status === 'failed')
|
|
1820
|
+
&& !(commentState.status === 'partial' && (commentInventory?.warnings.length ?? 0) > 0 && !commentState.lastError)) {
|
|
1821
|
+
report.issue({
|
|
1822
|
+
code: `comment-${commentState.status}`,
|
|
1823
|
+
severity: commentState.status === 'partial' || commentState.status === 'stale' ? 'degradation' : 'error',
|
|
1824
|
+
phase: 'comment',
|
|
1825
|
+
pageId: page.canonicalId,
|
|
1826
|
+
retryable: commentState.status === 'failed',
|
|
1827
|
+
message: `${page.title}: ${commentState.lastError ?? `comment archive is ${commentState.status}`}`,
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
const commentPartial = !['complete', 'skipped', 'planned'].includes(commentState.status);
|
|
1831
|
+
const partial = rendered.degraded || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
|
|
1247
1832
|
const lastResourceProblem = resourceProblems.at(-1);
|
|
1248
1833
|
const clearedState = { ...state };
|
|
1249
1834
|
delete clearedState.lastError;
|
|
@@ -1255,6 +1840,7 @@ export class Migrator {
|
|
|
1255
1840
|
destinationTitle: verified.title,
|
|
1256
1841
|
...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
|
|
1257
1842
|
status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
|
|
1843
|
+
comments: commentState,
|
|
1258
1844
|
degradations: [...new Set([...rendered.warnings, ...resourceProblems, ...verificationDegradations])],
|
|
1259
1845
|
...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
|
|
1260
1846
|
updatedAt: new Date().toISOString(),
|
|
@@ -1277,6 +1863,9 @@ export class Migrator {
|
|
|
1277
1863
|
resourceFallbacks: fallbackDetails,
|
|
1278
1864
|
warnings: [...rendered.warnings, ...verificationDegradations],
|
|
1279
1865
|
normalizations: rendered.normalizations,
|
|
1866
|
+
commentsEnabled: options.comments ?? true,
|
|
1867
|
+
...(commentInventory ? { commentInventory } : {}),
|
|
1868
|
+
commentState,
|
|
1280
1869
|
}), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(verified.content ?? rendered.body, destination.kind) });
|
|
1281
1870
|
}
|
|
1282
1871
|
catch (error) {
|
|
@@ -1324,6 +1913,9 @@ export class Migrator {
|
|
|
1324
1913
|
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
1325
1914
|
resourceFallbacks: fallbackDetails,
|
|
1326
1915
|
error: message,
|
|
1916
|
+
commentsEnabled: options.comments ?? true,
|
|
1917
|
+
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
1918
|
+
...(state.comments ? { commentState: state.comments } : {}),
|
|
1327
1919
|
}), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
|
|
1328
1920
|
}
|
|
1329
1921
|
finally {
|