@iwonz/ywtc 3.2.1 → 3.3.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +15 -7
  3. package/dist/adapters/confluence.d.ts +23 -2
  4. package/dist/adapters/confluence.js +64 -9
  5. package/dist/adapters/confluence.js.map +1 -1
  6. package/dist/adapters/yandex.d.ts +16 -2
  7. package/dist/adapters/yandex.js +49 -4
  8. package/dist/adapters/yandex.js.map +1 -1
  9. package/dist/cli-arguments.d.ts +3 -1
  10. package/dist/cli-arguments.js +6 -2
  11. package/dist/cli-arguments.js.map +1 -1
  12. package/dist/cli.js +31 -19
  13. package/dist/cli.js.map +1 -1
  14. package/dist/clients/confluence-cloud.d.ts +1 -0
  15. package/dist/clients/confluence-cloud.js +7 -0
  16. package/dist/clients/confluence-cloud.js.map +1 -1
  17. package/dist/clients/confluence.d.ts +2 -0
  18. package/dist/clients/confluence.js +21 -0
  19. package/dist/clients/confluence.js.map +1 -1
  20. package/dist/clients/yandex-wiki.d.ts +3 -1
  21. package/dist/clients/yandex-wiki.js +23 -5
  22. package/dist/clients/yandex-wiki.js.map +1 -1
  23. package/dist/content-model.js +3 -2
  24. package/dist/content-model.js.map +1 -1
  25. package/dist/doctor.d.ts +7 -2
  26. package/dist/doctor.js +8 -3
  27. package/dist/doctor.js.map +1 -1
  28. package/dist/domain.d.ts +48 -2
  29. package/dist/domain.js +5 -1
  30. package/dist/domain.js.map +1 -1
  31. package/dist/graph.d.ts +2 -1
  32. package/dist/graph.js +105 -15
  33. package/dist/graph.js.map +1 -1
  34. package/dist/migrator.d.ts +4 -14
  35. package/dist/migrator.js +349 -68
  36. package/dist/migrator.js.map +1 -1
  37. package/dist/report-catalog.d.ts +47 -2
  38. package/dist/report-catalog.js +71 -9
  39. package/dist/report-catalog.js.map +1 -1
  40. package/dist/report.d.ts +96 -24
  41. package/dist/report.js +303 -36
  42. package/dist/report.js.map +1 -1
  43. package/dist/state.d.ts +8 -2
  44. package/dist/state.js +25 -1
  45. package/dist/state.js.map +1 -1
  46. package/dist/types.d.ts +5 -1
  47. package/package.json +1 -1
package/dist/report.d.ts CHANGED
@@ -1,6 +1,19 @@
1
- import type { GraphEdgeStatus, ProviderKind, TransferScope } from './domain.js';
1
+ import type { DiscoveredPage, GraphEdgeStatus, ProviderKind, SourceResource, SourceSelection, SourceStatus, TransferScope } from './domain.js';
2
2
  import { type IssueInput, type MigrationIssue } from './issues.js';
3
- import type { RunStatus, StateLoadSource } from './state.js';
3
+ import type { PageTransferStatus, ResourceTransferState, RunStatus, StateLoadSource } from './state.js';
4
+ export type ReportInventoryStatus = 'pending' | 'complete' | 'failed';
5
+ export type ReportResourceStatus = ResourceTransferState['status'];
6
+ export interface TransferReportResource {
7
+ sourceId: string;
8
+ sourceName: string;
9
+ kind: Exclude<SourceResource['kind'], 'grid'>;
10
+ status: ReportResourceStatus;
11
+ sourceBytes?: number;
12
+ transferredBytes?: number;
13
+ destinationId?: string;
14
+ destinationName?: string;
15
+ error?: string;
16
+ }
4
17
  export interface TransferReportPage {
5
18
  sourceCanonicalId: string;
6
19
  sourceId: string;
@@ -11,13 +24,17 @@ export interface TransferReportPage {
11
24
  destinationUrl?: string;
12
25
  parentId?: string;
13
26
  depth: number;
14
- status: string;
27
+ status: PageTransferStatus;
28
+ inventoryStatus: ReportInventoryStatus;
15
29
  resources: number;
16
- bytes?: number;
17
- bytesUnknown?: number;
30
+ resourceItems: TransferReportResource[];
31
+ sourceBytes?: number;
32
+ transferredBytes: number;
33
+ bytesUnknown: number;
18
34
  links: number;
19
35
  images: number;
20
36
  tables: number;
37
+ grids: number;
21
38
  codeBlocks: number;
22
39
  degraded: boolean;
23
40
  resourceFallbacks: Array<{
@@ -35,6 +52,58 @@ export interface TransferReportEdge {
35
52
  status: GraphEdgeStatus;
36
53
  message?: string;
37
54
  }
55
+ export interface TransferReportIssue extends MigrationIssue {
56
+ pageTitle?: string;
57
+ pageAnchor?: string;
58
+ resourceName?: string;
59
+ action: string;
60
+ }
61
+ type PageStatusCounts = Record<PageTransferStatus, number>;
62
+ type ResourceStatusCounts = Record<ReportResourceStatus, number>;
63
+ type EdgeStatusCounts = Record<GraphEdgeStatus, number>;
64
+ export interface TransferReportSummary {
65
+ pages: number;
66
+ returnedDescendants: number;
67
+ selectedDescendants: number;
68
+ excludedPages: number;
69
+ maximumDepth: number;
70
+ hierarchyGaps: number;
71
+ hierarchyRepairs: number;
72
+ complete: number;
73
+ partial: number;
74
+ failed: number;
75
+ blocked: number;
76
+ conflicts: number;
77
+ unprocessedPages: number;
78
+ resources: number;
79
+ attachments: number;
80
+ resourceImages: number;
81
+ resourcesComplete: number;
82
+ resourcesPartial: number;
83
+ resourcesFailed: number;
84
+ resourcesPlanned: number;
85
+ bytes: number;
86
+ sourceBytes: number;
87
+ bytesUnknown: number;
88
+ links: number;
89
+ images: number;
90
+ tables: number;
91
+ grids: number;
92
+ codeBlocks: number;
93
+ cycles: number;
94
+ normalizations: number;
95
+ degradations: number;
96
+ warnings: number;
97
+ errors: number;
98
+ retryableFailures: number;
99
+ inventoryComplete: boolean;
100
+ inventoriedPages: number;
101
+ inventoryFailedPages: number;
102
+ durationMs?: number;
103
+ pageStatuses: PageStatusCounts;
104
+ resourceStatuses: ResourceStatusCounts;
105
+ traversalStatuses: EdgeStatusCounts;
106
+ }
38
107
  export interface TransferReportData {
39
108
  runId: string;
40
109
  startedAt: string;
@@ -42,6 +111,8 @@ export interface TransferReportData {
42
111
  status: RunStatus;
43
112
  dryRun: boolean;
44
113
  scope: TransferScope;
114
+ sourceStatus: SourceStatus;
115
+ selection: SourceSelection;
45
116
  direction: string;
46
117
  source: {
47
118
  provider: ProviderKind;
@@ -66,31 +137,26 @@ export interface TransferReportData {
66
137
  };
67
138
  pages: TransferReportPage[];
68
139
  edges: TransferReportEdge[];
140
+ hierarchyRepairs: Array<{
141
+ sourceCanonicalId: string;
142
+ mode: 'moved' | 'recreated';
143
+ oldParentId?: string;
144
+ newParentId: string;
145
+ oldDestinationId: string;
146
+ newDestinationId: string;
147
+ verified: boolean;
148
+ }>;
69
149
  warnings: string[];
70
150
  errors: string[];
71
- issues: MigrationIssue[];
151
+ issues: TransferReportIssue[];
72
152
  outputs: Array<{
73
153
  label: string;
74
154
  value: string;
75
155
  }>;
76
- summary: {
77
- pages: number;
78
- complete: number;
79
- partial: number;
80
- failed: number;
81
- resources: number;
82
- bytes: number;
83
- bytesUnknown: number;
84
- links: number;
85
- cycles: number;
86
- normalizations: number;
87
- degradations: number;
88
- warnings: number;
89
- errors: number;
90
- retryableFailures: number;
91
- };
156
+ summary: TransferReportSummary;
92
157
  }
93
158
  export interface MigrationReportPaths {
159
+ runId: string;
94
160
  directory: string;
95
161
  html: string;
96
162
  json: string;
@@ -103,9 +169,15 @@ export declare class TransferReport {
103
169
  readonly data: TransferReportData;
104
170
  private readonly previews;
105
171
  private readonly issueCollector;
106
- constructor(input: Omit<TransferReportData, 'runId' | 'startedAt' | 'status' | 'pages' | 'edges' | 'warnings' | 'errors' | 'issues' | 'outputs' | 'summary'>);
107
- addPage(page: TransferReportPage, previews?: PreviewPair): void;
172
+ constructor(input: Omit<TransferReportData, 'runId' | 'startedAt' | 'status' | 'sourceStatus' | 'selection' | 'pages' | 'edges' | 'hierarchyRepairs' | 'warnings' | 'errors' | 'issues' | 'outputs' | 'summary'> & {
173
+ sourceStatus?: SourceStatus;
174
+ });
175
+ registerPages(pages: DiscoveredPage[]): void;
176
+ updatePage(sourceCanonicalId: string, update: Partial<TransferReportPage>): void;
177
+ addPage(page: TransferReportPage, previews?: PreviewPair, collectIssues?: boolean): void;
108
178
  setEdges(edges: TransferReportEdge[]): void;
179
+ setSelection(selection: SourceSelection): void;
180
+ addHierarchyRepair(repair: TransferReportData['hierarchyRepairs'][number]): void;
109
181
  issue(input: IssueInput): void;
110
182
  warning(message: string): void;
111
183
  error(message: string): void;
package/dist/report.js CHANGED
@@ -1,7 +1,66 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, rename, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { IssueCollector } from './issues.js';
5
+ const pageStatuses = [
6
+ 'discovered',
7
+ 'planned',
8
+ 'skeleton-created',
9
+ 'complete',
10
+ 'partial',
11
+ 'conflict',
12
+ 'failed',
13
+ 'blocked',
14
+ ];
15
+ const resourceStatuses = ['planned', 'complete', 'partial', 'failed'];
16
+ const edgeStatuses = ['resolved', 'external', 'inaccessible', 'broken', 'cycle', 'unsupported'];
17
+ function countRecord(values) {
18
+ return Object.fromEntries(values.map((value) => [value, 0]));
19
+ }
20
+ function emptySummary() {
21
+ return {
22
+ pages: 0,
23
+ returnedDescendants: 0,
24
+ selectedDescendants: 0,
25
+ excludedPages: 0,
26
+ maximumDepth: 0,
27
+ hierarchyGaps: 0,
28
+ hierarchyRepairs: 0,
29
+ complete: 0,
30
+ partial: 0,
31
+ failed: 0,
32
+ blocked: 0,
33
+ conflicts: 0,
34
+ unprocessedPages: 0,
35
+ resources: 0,
36
+ attachments: 0,
37
+ resourceImages: 0,
38
+ resourcesComplete: 0,
39
+ resourcesPartial: 0,
40
+ resourcesFailed: 0,
41
+ resourcesPlanned: 0,
42
+ bytes: 0,
43
+ sourceBytes: 0,
44
+ bytesUnknown: 0,
45
+ links: 0,
46
+ images: 0,
47
+ tables: 0,
48
+ grids: 0,
49
+ codeBlocks: 0,
50
+ cycles: 0,
51
+ normalizations: 0,
52
+ degradations: 0,
53
+ warnings: 0,
54
+ errors: 0,
55
+ retryableFailures: 0,
56
+ inventoryComplete: false,
57
+ inventoriedPages: 0,
58
+ inventoryFailedPages: 0,
59
+ pageStatuses: countRecord(pageStatuses),
60
+ resourceStatuses: countRecord(resourceStatuses),
61
+ traversalStatuses: countRecord(edgeStatuses),
62
+ };
63
+ }
5
64
  function escapeHtml(value) {
6
65
  return String(value ?? '')
7
66
  .replaceAll('&', '&amp;')
@@ -22,74 +81,229 @@ function safeHttpUrl(value) {
22
81
  }
23
82
  }
24
83
  function statusClass(value) {
25
- return /complete|created|updated|planned/.test(value) ? 'ok' : /partial|warning|conflict/.test(value) ? 'warn' : 'bad';
84
+ return value === 'complete' || value === 'completed' ? 'ok'
85
+ : /partial|warning|conflict|planned|discovered|skeleton|interrupted|cancelled/.test(value) ? 'warn'
86
+ : /failed|blocked|error/.test(value) ? 'bad'
87
+ : 'muted';
88
+ }
89
+ function pageAnchor(sourceCanonicalId) {
90
+ return `page-${createHash('sha256').update(sourceCanonicalId).digest('hex').slice(0, 12)}`;
91
+ }
92
+ function formatBytes(value) {
93
+ if (value < 1024)
94
+ return `${value} B`;
95
+ const units = ['KiB', 'MiB', 'GiB', 'TiB'];
96
+ let amount = value;
97
+ let unit = -1;
98
+ do {
99
+ amount /= 1024;
100
+ unit += 1;
101
+ } while (amount >= 1024 && unit < units.length - 1);
102
+ return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${units[unit]}`;
103
+ }
104
+ function formatDuration(durationMs) {
105
+ if (durationMs === undefined)
106
+ return 'in progress';
107
+ if (durationMs < 1000)
108
+ return `${durationMs} ms`;
109
+ const seconds = Math.round(durationMs / 1000);
110
+ if (seconds < 60)
111
+ return `${seconds} s`;
112
+ const minutes = Math.floor(seconds / 60);
113
+ return `${minutes} min ${seconds % 60} s`;
114
+ }
115
+ function issueAction(issue) {
116
+ if (issue.retryable)
117
+ return 'Repeat the same migration to retry this object.';
118
+ if (issue.severity === 'degradation')
119
+ return 'Data remains represented; compare the source and destination previews.';
120
+ if (issue.severity === 'error')
121
+ return 'Review the reason and affected object before resuming the migration.';
122
+ if (issue.severity === 'normalization')
123
+ return 'No action is required.';
124
+ return 'Review the affected object and the page details below.';
125
+ }
126
+ function statCard(label, value, detail) {
127
+ return `<div class="stat"><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong>${detail ? `<span>${escapeHtml(detail)}</span>` : ''}</div>`;
128
+ }
129
+ function statusCells(counts, statuses) {
130
+ return statuses.map((status) => `<td><span class="status ${statusClass(status)}">${escapeHtml(status)}</span><strong>${escapeHtml(counts[status] ?? 0)}</strong></td>`).join('');
26
131
  }
27
132
  function renderReport(data, previews) {
28
- const cards = [
29
- ['Pages', data.summary.pages], ['Complete', data.summary.complete], ['Partial', data.summary.partial],
30
- ['Failed', data.summary.failed], ['Resources', data.summary.resources], ['Bytes', data.summary.bytes],
31
- ['Unknown sizes', data.summary.bytesUnknown], ['Links', data.summary.links], ['Cycles', data.summary.cycles],
32
- ['Warnings', data.summary.warnings], ['Errors', data.summary.errors],
33
- ].map(([label, value]) => `<div class="stat"><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong></div>`).join('');
133
+ const sourceUrl = safeHttpUrl(data.source.url);
134
+ const destinationUrl = safeHttpUrl(data.destination.url);
135
+ const inventoryState = data.summary.inventoryComplete
136
+ ? `${data.summary.inventoriedPages}/${data.summary.pages} pages inspected`
137
+ : `${data.summary.inventoriedPages}/${data.summary.pages} pages inspected · ${data.summary.inventoryFailedPages} failed`;
138
+ const sourceCards = [
139
+ statCard('Selected pages', data.summary.pages, 'accessible in selected scope'),
140
+ statCard('Returned descendants', data.summary.returnedDescendants, `${data.summary.excludedPages} excluded by status`),
141
+ statCard('Tree depth', data.summary.maximumDepth, `${data.summary.hierarchyGaps} hierarchy gaps · ${data.summary.hierarchyRepairs} repaired`),
142
+ statCard('Files', data.summary.resources, `${data.summary.attachments} attachments · ${data.summary.resourceImages} images`),
143
+ statCard('Source bytes', formatBytes(data.summary.sourceBytes), data.summary.bytesUnknown ? `${data.summary.bytesUnknown} sizes unknown` : 'all sizes known'),
144
+ statCard('Transferred bytes', formatBytes(data.summary.bytes), 'confirmed complete files'),
145
+ statCard('Links', data.summary.links, `${data.summary.images} embedded images`),
146
+ statCard('Structured content', data.summary.tables + data.summary.codeBlocks, `${data.summary.tables} tables · ${data.summary.grids} grids · ${data.summary.codeBlocks} code blocks`),
147
+ ].join('');
148
+ const excludedRows = data.selection.excluded.map((page) => `<tr><td>${escapeHtml(page.title ?? page.locator)}</td><td><code>${escapeHtml(page.id)}</code></td><td>${escapeHtml(page.status ?? 'non-current')}</td><td>${escapeHtml(page.reason)}</td></tr>`).join('');
149
+ const gapRows = data.selection.hierarchyGaps.map((gap) => `<tr><td><code>${escapeHtml(gap.pageId)}</code></td><td><code>${escapeHtml(gap.missingParentId ?? 'unknown')}</code></td><td><code>${escapeHtml(gap.selectedParentId)}</code></td><td>${escapeHtml(gap.reason)}</td></tr>`).join('');
150
+ const repairRows = data.hierarchyRepairs.map((repair) => `<tr><td><code>${escapeHtml(repair.sourceCanonicalId)}</code></td><td>${escapeHtml(repair.mode)}</td><td><code>${escapeHtml(repair.oldParentId ?? 'none')}</code></td><td><code>${escapeHtml(repair.newParentId)}</code></td><td><code>${escapeHtml(repair.oldDestinationId)} → ${escapeHtml(repair.newDestinationId)}</code></td><td>${repair.verified ? 'verified' : 'planned'}</td></tr>`).join('');
151
+ const selectionDetails = excludedRows || gapRows || repairRows || data.selection.notes.length
152
+ ? `<section class="panel" aria-labelledby="selection-title"><div class="section-head"><div><h2 id="selection-title">Selection and hierarchy</h2><p>Every source-status exclusion, projected hierarchy gap, and destination repair recorded for this run.</p></div></div>${excludedRows ? `<div class="table-wrap"><h3>Excluded pages</h3><table><thead><tr><th>Page</th><th>Source ID</th><th>Status</th><th>Reason</th></tr></thead><tbody>${excludedRows}</tbody></table></div>` : ''}${gapRows ? `<div class="table-wrap"><h3>Hierarchy gaps</h3><table><thead><tr><th>Page</th><th>Missing parent</th><th>Projected parent</th><th>Reason</th></tr></thead><tbody>${gapRows}</tbody></table></div>` : ''}${repairRows ? `<div class="table-wrap"><h3>Destination repairs</h3><table><thead><tr><th>Source page</th><th>Mode</th><th>Old parent</th><th>New parent</th><th>Destination mapping</th><th>Outcome</th></tr></thead><tbody>${repairRows}</tbody></table></div>` : ''}${data.selection.notes.length ? `<div class="callout"><h3>Provider notes</h3><ul>${data.selection.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join('')}</ul></div>` : ''}</section>`
153
+ : '';
154
+ const pageOutcomeStatuses = ['complete', 'partial', 'failed', 'blocked', 'conflict', 'planned', 'discovered', 'skeleton-created'];
155
+ const resourceOutcomeStatuses = ['complete', 'partial', 'failed', 'planned'];
156
+ const attentionIssues = data.issues.filter((issue) => issue.severity !== 'normalization');
157
+ const normalizationIssues = data.issues.filter((issue) => issue.severity === 'normalization');
158
+ const attentionSummary = [
159
+ `${data.summary.errors} errors`,
160
+ `${data.summary.warnings} warnings`,
161
+ `${data.summary.degradations} degradations`,
162
+ `${data.summary.retryableFailures} retryable`,
163
+ ].map((item) => `<span>${escapeHtml(item)}</span>`).join('');
164
+ const issueRow = (issue) => {
165
+ const target = issue.pageAnchor ? `<a href="#${escapeHtml(issue.pageAnchor)}">${escapeHtml(issue.pageTitle ?? issue.pageId)}</a>` : '<span>run-level</span>';
166
+ return `<li class="issue ${escapeHtml(issue.severity)}"><div><span class="status ${statusClass(issue.severity)}">${escapeHtml(issue.severity)}</span><strong>${escapeHtml(issue.code)}</strong><span>${escapeHtml(issue.phase)}</span>${issue.retryable ? '<span class="retry">retryable</span>' : ''}${issue.occurrences > 1 ? `<span>${issue.occurrences} occurrences</span>` : ''}</div><p>${escapeHtml(issue.message)}</p><p class="issue-context">Affected: ${target}${issue.resourceName ? ` · resource <strong>${escapeHtml(issue.resourceName)}</strong>` : ''}</p><p class="action">${escapeHtml(issue.action)}</p></li>`;
167
+ };
168
+ const attention = `<section class="panel attention" aria-labelledby="attention-title"><div class="section-head"><div><h2 id="attention-title">Needs attention</h2><p>Problems and fidelity changes that may require review or a rerun.</p></div><span class="count ${attentionIssues.length ? 'warn' : 'ok'}">${attentionIssues.length}</span></div><div class="mini-stats">${attentionSummary}</div>${attentionIssues.length ? `<ul class="issue-list">${attentionIssues.map(issueRow).join('')}</ul>` : '<p class="empty ok">No errors, warnings, conflicts, or degradations were recorded.</p>'}</section>`;
34
169
  const pageRows = data.pages.map((page) => {
35
170
  const source = safeHttpUrl(page.sourceUrl);
36
171
  const destination = safeHttpUrl(page.destinationUrl);
37
172
  const preview = previews.get(page.sourceCanonicalId);
38
- return `<details class="page" style="margin-left:${Math.min(page.depth, 8) * 14}px"><summary><span class="status ${statusClass(page.status)}">${escapeHtml(page.status)}</span><strong>${escapeHtml(page.sourceTitle)}</strong><span>${page.resources} files · ${page.links} links · depth ${page.depth}</span></summary>
39
- <div class="meta">${source ? `<a href="${escapeHtml(source)}" target="_blank" rel="noreferrer">source</a>` : ''}${destination ? `<a href="${escapeHtml(destination)}" target="_blank" rel="noreferrer">destination</a>` : ''}<span>${escapeHtml(page.sourceCanonicalId)}</span>${page.bytes === undefined ? '<span>size unknown</span>' : `<span>${page.bytes} bytes${page.bytesUnknown ? ` + ${page.bytesUnknown} unknown` : ''}</span>`}</div>
40
- ${preview ? `<div class="compare"><section><h3>Source</h3><iframe sandbox srcdoc="${escapeHtml(preview.source)}"></iframe></section><section><h3>Destination</h3><iframe sandbox srcdoc="${escapeHtml(preview.destination)}"></iframe></section></div>` : ''}
41
- ${page.resourceFallbacks.length ? `<div class="fallbacks"><h3>Resource fallbacks</h3><ul>${page.resourceFallbacks.map((item) => `<li><strong>${escapeHtml(item.name)}</strong> — ${escapeHtml(item.error)}</li>`).join('')}</ul></div>` : ''}
173
+ const pageIssues = data.issues.filter((issue) => issue.pageId === page.sourceCanonicalId);
174
+ const resourceRows = page.resourceItems.map((resource) => `<tr><td><strong>${escapeHtml(resource.sourceName)}</strong><small>${escapeHtml(resource.kind)}</small></td><td><span class="status ${statusClass(resource.status)}">${escapeHtml(resource.status)}</span></td><td>${resource.sourceBytes === undefined ? 'unknown' : escapeHtml(formatBytes(resource.sourceBytes))}</td><td>${resource.transferredBytes === undefined ? '—' : escapeHtml(formatBytes(resource.transferredBytes))}</td><td>${escapeHtml(resource.destinationName ?? '—')}</td><td>${resource.error ? `<span class="bad">${escapeHtml(resource.error)}</span>` : '—'}</td></tr>`).join('');
175
+ const metrics = `${page.resources} files · ${page.links} links · ${page.images} images · ${page.tables} tables · ${page.codeBlocks} code blocks`;
176
+ return `<details id="${escapeHtml(pageAnchor(page.sourceCanonicalId))}" class="page" style="--depth:${Math.min(page.depth, 8)}"${page.status === 'complete' ? '' : ' open'}><summary><span class="status ${statusClass(page.status)}">${escapeHtml(page.status)}</span><strong>${escapeHtml(page.sourceTitle)}</strong><span>${escapeHtml(metrics)}</span></summary>
177
+ <div class="meta"><span>inventory: <strong>${escapeHtml(page.inventoryStatus)}</strong></span><span>depth ${page.depth}</span>${page.parentId ? `<span>parent ${escapeHtml(page.parentId)}</span>` : ''}${source ? `<a href="${escapeHtml(source)}" target="_blank" rel="noreferrer">source</a>` : ''}${destination ? `<a href="${escapeHtml(destination)}" target="_blank" rel="noreferrer">destination</a>` : ''}<span>${escapeHtml(page.sourceCanonicalId)}</span><span>source ${page.sourceBytes === undefined ? 'size unknown' : escapeHtml(formatBytes(page.sourceBytes))} · transferred ${escapeHtml(formatBytes(page.transferredBytes))}${page.bytesUnknown ? ` · ${page.bytesUnknown} unknown` : ''}</span></div>
178
+ ${page.error ? `<div class="callout bad"><strong>Page error</strong><p>${escapeHtml(page.error)}</p></div>` : ''}
179
+ ${pageIssues.length ? `<div><h3>Page issues</h3><ul class="issue-list compact">${pageIssues.map(issueRow).join('')}</ul></div>` : ''}
180
+ ${page.resourceItems.length ? `<div class="table-wrap"><h3>Resources</h3><table><thead><tr><th>Source resource</th><th>Outcome</th><th>Source size</th><th>Transferred</th><th>Destination name</th><th>Problem</th></tr></thead><tbody>${resourceRows}</tbody></table></div>` : '<p class="muted">No transferable resources on this page.</p>'}
181
+ ${preview ? `<div class="compare"><section><h3>Source preview</h3><p class="muted">Normalized source content</p><iframe title="Source preview: ${escapeHtml(page.sourceTitle)}" sandbox srcdoc="${escapeHtml(preview.source)}"></iframe></section><section><h3>Destination preview</h3><p class="muted">Body read back from destination</p><iframe title="Destination preview: ${escapeHtml(page.sourceTitle)}" sandbox srcdoc="${escapeHtml(preview.destination)}"></iframe></section></div>` : '<p class="muted">Destination preview is unavailable because no body was read back.</p>'}
182
+ ${page.resourceFallbacks.length ? `<div class="callout warn"><h3>Resource fallbacks</h3><ul>${page.resourceFallbacks.map((item) => `<li><strong>${escapeHtml(item.name)}</strong> — ${escapeHtml(item.error)}</li>`).join('')}</ul></div>` : ''}
42
183
  ${(page.normalizations ?? []).length ? `<div><h3>Safe normalizations</h3><ul>${(page.normalizations ?? []).map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div>` : ''}
43
- ${page.warnings.length ? `<ul class="warnings">${page.warnings.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : ''}${page.error ? `<p class="error">${escapeHtml(page.error)}</p>` : ''}</details>`;
184
+ </details>`;
44
185
  }).join('');
45
- const edgeRows = data.edges.map((edge) => `<tr><td>${escapeHtml(edge.status)}</td><td>${escapeHtml(edge.from)}</td><td>${escapeHtml(edge.to ?? '—')}</td><td>${escapeHtml(edge.raw)}</td></tr>`).join('');
186
+ const edgeRows = data.edges.map((edge) => {
187
+ const from = data.pages.find((page) => page.sourceCanonicalId === edge.from)?.sourceTitle ?? edge.from;
188
+ const to = edge.to ? data.pages.find((page) => page.sourceCanonicalId === edge.to)?.sourceTitle ?? edge.to : '—';
189
+ return `<tr><td><span class="status ${statusClass(edge.status)}">${escapeHtml(edge.status)}</span></td><td>${escapeHtml(from)}</td><td>${escapeHtml(to)}</td><td><code>${escapeHtml(edge.raw)}</code></td><td>${escapeHtml(edge.message ?? '—')}</td></tr>`;
190
+ }).join('');
191
+ const traversalSummary = edgeStatuses.filter((status) => data.summary.traversalStatuses[status] > 0).map((status) => `<span><strong>${data.summary.traversalStatuses[status]}</strong> ${escapeHtml(status)}</span>`).join('');
192
+ const traversalBody = data.edges.length
193
+ ? `<div class="mini-stats">${traversalSummary}</div><div class="table-wrap"><table><thead><tr><th>Status</th><th>From</th><th>To</th><th>Reference</th><th>Reason</th></tr></thead><tbody>${edgeRows}</tbody></table></div>`
194
+ : data.scope === 'linked'
195
+ ? '<p class="empty">No traversal edges were discovered in linked scope.</p>'
196
+ : `<p class="empty">Traversal edges are collected only for <code>linked</code> scope. This ${escapeHtml(data.scope)} run still inventoried ${data.summary.links} content links and verified them as part of each page body.</p>`;
46
197
  const outputRows = data.outputs.map((output) => `<li><strong>${escapeHtml(output.label)}</strong> <code>${escapeHtml(output.value)}</code></li>`).join('');
47
- const issueSection = (title, issues, className = '') => issues.length
48
- ? `<section class="panel"><h2>${escapeHtml(title)}</h2><ul class="${className}">${issues.map((issue) => `<li><strong>${escapeHtml(issue.code)}</strong> · ${escapeHtml(issue.phase)}${issue.occurrences > 1 ? ` · ${issue.occurrences} attempts` : ''} — ${escapeHtml(issue.message)}</li>`).join('')}</ul></section>`
198
+ const normalizations = normalizationIssues.length
199
+ ? `<section class="panel"><div class="section-head"><div><h2>Safe normalizations</h2><p>Expected provider changes that did not lose meaningful data.</p></div><span class="count">${normalizationIssues.length}</span></div><ul class="issue-list compact">${normalizationIssues.map(issueRow).join('')}</ul></section>`
49
200
  : '';
50
- const normalizations = issueSection('Safe normalizations', data.issues.filter((issue) => issue.severity === 'normalization'));
51
- const degradations = issueSection('Degradations', data.issues.filter((issue) => issue.severity === 'degradation'), 'warnings');
52
- const retryable = issueSection('Retryable failures', data.issues.filter((issue) => issue.retryable), 'warnings');
53
- const finalErrors = issueSection('Final errors', data.issues.filter((issue) => issue.severity === 'error'), 'error');
54
201
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ywtc transfer ${escapeHtml(data.runId)}</title><style>
55
- :root{font:15px/1.5 system-ui,sans-serif;color:#182033;background:#f3f5f9}body{margin:0}main{max-width:1280px;margin:auto;padding:32px}.head{display:flex;justify-content:space-between;gap:20px;align-items:end}.muted{color:#697386}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin:24px 0}.stat,.page,.panel{background:#fff;border:1px solid #dfe4ed;border-radius:10px}.stat{padding:14px}.stat small,.stat strong{display:block}.stat strong{font-size:24px}.page{margin:10px 0;padding:12px}.page summary{display:grid;grid-template-columns:100px minmax(180px,1fr) auto;gap:12px;cursor:pointer}.status{font-size:12px;font-weight:700;text-transform:uppercase}.ok{color:#087443}.warn{color:#9a6500}.bad{color:#b42318}.meta{display:flex;gap:14px;margin:12px 0;color:#697386}.compare{display:grid;grid-template-columns:1fr 1fr;gap:12px}.compare section{min-width:0}.compare iframe{width:100%;height:420px;border:1px solid #dfe4ed;border-radius:8px;background:#fff}.panel{padding:18px;margin-top:24px;overflow:auto}table{border-collapse:collapse;width:100%}th,td{padding:8px;border-bottom:1px solid #e6e9ef;text-align:left}.warnings{color:#805800}.error{color:#b42318}code{overflow-wrap:anywhere}@media(max-width:760px){main{padding:18px}.page summary{grid-template-columns:1fr}.compare{grid-template-columns:1fr}.head{display:block}}
56
- </style></head><body><main><header class="head"><div><h1>ywtc transfer report</h1><p>${escapeHtml(data.direction)} · ${escapeHtml(data.scope)} · ${data.dryRun ? 'dry-run' : 'live'}</p></div><div><span class="status ${statusClass(data.status)}">${escapeHtml(data.status)}</span><p class="muted">${escapeHtml(data.runId)}</p></div></header><div class="stats">${cards}</div><section><h2>Pages</h2>${pageRows || '<p>No pages.</p>'}</section><section class="panel"><h2>Link graph</h2><table><thead><tr><th>Status</th><th>From</th><th>To</th><th>Reference</th></tr></thead><tbody>${edgeRows}</tbody></table></section><section class="panel"><h2>Outputs</h2><ul>${outputRows}</ul><a download="report.json" href="report.json">Download report JSON</a></section>${normalizations}${degradations}${retryable}${finalErrors}</main></body></html>`;
202
+ :root{font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f4f6fa;color-scheme:light}*{box-sizing:border-box}body{margin:0}a{color:#175cd3}main{max-width:1360px;margin:auto;padding:32px}.head,.section-head{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}.head h1{margin:0}.head p,.section-head p{margin:.25rem 0}.muted{color:#667085}.run-meta,.meta,.mini-stats{display:flex;flex-wrap:wrap;gap:10px 18px}.run-meta{margin-top:18px;padding:14px 0;border-top:1px solid #dfe4ed}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin:18px 0}.stat,.page,.panel{background:#fff;border:1px solid #dfe4ed;border-radius:12px;box-shadow:0 1px 2px rgba(16,24,40,.04)}.stat{padding:14px}.stat small,.stat strong,.stat span{display:block}.stat strong{font-size:24px}.stat span{font-size:12px;color:#667085;margin-top:2px}.panel{padding:20px;margin-top:20px;overflow:hidden}.panel h2{margin:0}.count{display:grid;place-items:center;min-width:38px;height:38px;border-radius:20px;background:#eef2f8;font-weight:800}.page{margin:10px 0 10px calc(var(--depth) * 12px);padding:14px;scroll-margin-top:18px}.page:target{outline:3px solid #84adff}.page summary{display:grid;grid-template-columns:120px minmax(180px,1fr) auto;gap:12px;cursor:pointer;align-items:center}.status{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.03em}.ok{color:#087443}.warn{color:#9a6500}.bad{color:#b42318}.meta{margin:14px 0;color:#667085;font-size:13px}.compare{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px}.compare section{min-width:0}.compare h3{margin-bottom:0}.compare iframe{width:100%;height:420px;border:1px solid #dfe4ed;border-radius:8px;background:#fff}.table-wrap{overflow:auto;margin-top:14px}table{border-collapse:collapse;width:100%;min-width:680px}th,td{padding:9px;border-bottom:1px solid #e6e9ef;text-align:left;vertical-align:top}th{font-size:12px;text-transform:uppercase;color:#667085}td strong,td small{display:block}.outcomes td strong{font-size:18px;margin-top:3px}.issue-list{list-style:none;padding:0;margin:14px 0 0}.issue{border-left:4px solid #f79009;background:#fffaeb;padding:12px 14px;margin:8px 0;border-radius:6px}.issue.error{border-color:#f04438;background:#fef3f2}.issue.degradation{border-color:#f79009}.issue.normalization{border-color:#98a2b3;background:#f9fafb}.issue>div{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.issue p{margin:.35rem 0}.issue-context,.action{font-size:13px}.retry{font-size:11px;background:#fff0c2;border-radius:12px;padding:2px 7px}.compact .issue{padding:9px 11px}.callout{padding:12px 14px;border-radius:8px;background:#f9fafb;margin:12px 0}.callout.warn{background:#fffaeb}.callout.bad{background:#fef3f2}.empty{padding:14px;border-radius:8px;background:#f9fafb}.attention{border-top:4px solid #f79009}.attention:has(.empty.ok){border-top-color:#12b76a}.context-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}.context-grid dl{margin:0}.context-grid dt{font-size:12px;text-transform:uppercase;color:#667085}.context-grid dd{margin:2px 0 12px;overflow-wrap:anywhere}code{overflow-wrap:anywhere}@media(max-width:760px){main{padding:16px}.page{margin-left:0}.page summary{grid-template-columns:1fr}.compare{grid-template-columns:1fr}.head,.section-head{display:block}.count{margin-top:10px}.stats{grid-template-columns:1fr 1fr}.panel{padding:14px}}@media(max-width:420px){.stats{grid-template-columns:1fr}}
203
+ </style></head><body><main><header class="head"><div><h1>ywtc transfer report</h1><p>${escapeHtml(data.direction)} · ${escapeHtml(data.scope)} · source status ${escapeHtml(data.sourceStatus)} · ${data.dryRun ? 'dry-run' : 'live transfer'}</p></div><div><span class="status ${statusClass(data.status)}">${escapeHtml(data.status)}</span><p class="muted">${escapeHtml(data.runId)}</p></div></header>
204
+ <div class="run-meta"><span>Started <strong>${escapeHtml(data.startedAt)}</strong></span>${data.finishedAt ? `<span>Finished <strong>${escapeHtml(data.finishedAt)}</strong></span>` : ''}<span>Duration <strong>${escapeHtml(formatDuration(data.summary.durationMs))}</strong></span><span>Inventory <strong class="${data.summary.inventoryComplete ? 'ok' : 'warn'}">${escapeHtml(inventoryState)}</strong></span></div>
205
+ <section class="panel" aria-labelledby="source-title"><div class="section-head"><div><h2 id="source-title">Source scope inventory</h2><p>Root plus ${escapeHtml(data.summary.returnedDescendants)} accessible descendants; ${escapeHtml(data.summary.selectedDescendants)} selected and ${escapeHtml(data.summary.excludedPages)} excluded with source status <code>${escapeHtml(data.sourceStatus)}</code>.</p></div><span class="status ${data.summary.inventoryComplete ? 'ok' : 'warn'}">${data.summary.inventoryComplete ? 'complete' : 'incomplete'}</span></div><div class="stats">${sourceCards}</div></section>
206
+ ${selectionDetails}
207
+ <section class="panel" aria-labelledby="outcome-title"><div class="section-head"><div><h2 id="outcome-title">Transfer outcomes</h2><p>Every selected page and discovered file is counted in exactly one status.</p></div><span class="count">${data.summary.pages}</span></div><div class="table-wrap"><table class="outcomes"><thead><tr><th>Object</th>${pageOutcomeStatuses.map((status) => `<th>${escapeHtml(status)}</th>`).join('')}<th>Total</th></tr></thead><tbody><tr><th>Pages</th>${statusCells(data.summary.pageStatuses, pageOutcomeStatuses)}<td><strong>${data.summary.pages}</strong></td></tr></tbody></table></div><div class="table-wrap"><table class="outcomes"><thead><tr><th>Object</th>${resourceOutcomeStatuses.map((status) => `<th>${escapeHtml(status)}</th>`).join('')}<th>Total</th></tr></thead><tbody><tr><th>Files</th>${statusCells(data.summary.resourceStatuses, resourceOutcomeStatuses)}<td><strong>${data.summary.resources}</strong></td></tr></tbody></table></div></section>
208
+ ${attention}
209
+ <section aria-labelledby="pages-title"><div class="section-head"><div><h2 id="pages-title">Pages</h2><p>Projected hierarchy with exact outcomes, source metrics, resources, issues, and verified previews.</p></div></div>${pageRows || '<p class="empty">No source page was resolved before the run stopped.</p>'}</section>
210
+ <section class="panel" aria-labelledby="links-title"><div class="section-head"><div><h2 id="links-title">Link traversal</h2><p>Traversal evidence used to select pages in linked scope; content link totals are shown in source inventory.</p></div><span class="count">${data.edges.length}</span></div>${traversalBody}</section>
211
+ ${normalizations}
212
+ <section class="panel" aria-labelledby="context-title"><h2 id="context-title">Run context and outputs</h2><div class="context-grid"><dl><dt>Source</dt><dd>${sourceUrl ? `<a href="${escapeHtml(sourceUrl)}">${escapeHtml(data.source.title)}</a>` : escapeHtml(data.source.title)}<br>${escapeHtml(data.source.provider)} · profile ${escapeHtml(data.source.profile)} · root ${escapeHtml(data.source.rootId)}</dd><dt>Destination</dt><dd>${destinationUrl ? `<a href="${escapeHtml(destinationUrl)}">${escapeHtml(data.destination.provider)}</a>` : escapeHtml(data.destination.provider)}${data.destination.profile ? ` · profile ${escapeHtml(data.destination.profile)}` : ''}<br>${escapeHtml(data.destination.id)}</dd></dl><dl><dt>State</dt><dd>${escapeHtml(data.state.id)}<br><code>${escapeHtml(data.state.path)}</code><br>${escapeHtml(data.state.mode)} · ${escapeHtml(data.state.source)}</dd>${data.state.backupPath ? `<dt>State backup</dt><dd><code>${escapeHtml(data.state.backupPath)}</code></dd>` : ''}<dt>Outputs</dt><dd>${outputRows ? `<ul>${outputRows}</ul>` : 'No result output was created.'}</dd></dl></div><p><a class="download" download="report.json" href="report.json">Download report JSON</a></p></section>
213
+ </main></body></html>`;
57
214
  }
58
215
  export class TransferReport {
59
216
  data;
60
217
  previews = new Map();
61
218
  issueCollector = new IssueCollector();
62
219
  constructor(input) {
220
+ const sourceStatus = input.sourceStatus ?? 'all';
63
221
  this.data = {
64
222
  runId: randomUUID(),
65
223
  startedAt: new Date().toISOString(),
66
224
  status: 'running',
225
+ sourceStatus,
226
+ selection: {
227
+ requestedStatus: sourceStatus,
228
+ effectiveStatus: sourceStatus,
229
+ returnedDescendants: 0,
230
+ selectedDescendants: 0,
231
+ excluded: [],
232
+ maximumDepth: 0,
233
+ hierarchyGaps: [],
234
+ notes: [],
235
+ },
67
236
  pages: [],
68
237
  edges: [],
238
+ hierarchyRepairs: [],
69
239
  warnings: [],
70
240
  errors: [],
71
241
  issues: [],
72
242
  outputs: [],
73
- summary: { pages: 0, complete: 0, partial: 0, failed: 0, resources: 0, bytes: 0, bytesUnknown: 0, links: 0, cycles: 0, normalizations: 0, degradations: 0, warnings: 0, errors: 0, retryableFailures: 0 },
243
+ summary: emptySummary(),
74
244
  ...input,
75
245
  };
76
246
  }
77
- addPage(page, previews) {
247
+ registerPages(pages) {
248
+ for (const page of pages) {
249
+ if (this.data.pages.some((item) => item.sourceCanonicalId === page.canonicalId))
250
+ continue;
251
+ this.data.pages.push({
252
+ sourceCanonicalId: page.canonicalId,
253
+ sourceId: page.id,
254
+ sourceTitle: page.title,
255
+ sourceUrl: page.url,
256
+ ...(page.projectedParentId ? { parentId: page.projectedParentId } : {}),
257
+ depth: page.depth,
258
+ status: 'discovered',
259
+ inventoryStatus: 'pending',
260
+ resources: 0,
261
+ resourceItems: [],
262
+ transferredBytes: 0,
263
+ bytesUnknown: 0,
264
+ links: 0,
265
+ images: 0,
266
+ tables: 0,
267
+ grids: 0,
268
+ codeBlocks: 0,
269
+ degraded: false,
270
+ resourceFallbacks: [],
271
+ warnings: [],
272
+ normalizations: [],
273
+ });
274
+ }
275
+ }
276
+ updatePage(sourceCanonicalId, update) {
277
+ const index = this.data.pages.findIndex((page) => page.sourceCanonicalId === sourceCanonicalId);
278
+ if (index < 0)
279
+ throw new Error(`Report page ${sourceCanonicalId} is not registered`);
280
+ this.data.pages[index] = { ...this.data.pages[index], ...update };
281
+ }
282
+ addPage(page, previews, collectIssues = true) {
78
283
  page.warnings = [...new Set(page.warnings)];
79
284
  page.normalizations = [...new Set(page.normalizations ?? [])];
80
285
  const index = this.data.pages.findIndex((item) => item.sourceCanonicalId === page.sourceCanonicalId);
81
286
  if (index >= 0)
82
- this.data.pages[index] = page;
287
+ this.data.pages[index] = { ...this.data.pages[index], ...page };
83
288
  else
84
289
  this.data.pages.push(page);
85
290
  if (previews)
86
291
  this.previews.set(page.sourceCanonicalId, previews);
292
+ if (!collectIssues)
293
+ return;
87
294
  for (const message of page.normalizations)
88
295
  this.issue({ code: `normalization:${message}`, severity: 'normalization', phase: 'page', pageId: page.sourceCanonicalId, retryable: false, message });
89
296
  for (const message of page.warnings)
90
297
  this.issue({ code: `warning:${message}`, severity: page.degraded ? 'degradation' : 'warning', phase: 'page', pageId: page.sourceCanonicalId, retryable: false, message });
91
298
  }
92
299
  setEdges(edges) { this.data.edges = edges; }
300
+ setSelection(selection) {
301
+ this.data.selection = selection;
302
+ this.data.sourceStatus = selection.effectiveStatus;
303
+ }
304
+ addHierarchyRepair(repair) {
305
+ this.data.hierarchyRepairs.push(repair);
306
+ }
93
307
  issue(input) { this.issueCollector.add(input); }
94
308
  warning(message) { this.issue({ code: `warning:${message}`, severity: 'warning', phase: 'report', retryable: false, message }); }
95
309
  error(message) { this.issue({ code: `error:${message}`, severity: 'error', phase: 'report', retryable: false, message }); }
@@ -97,24 +311,77 @@ export class TransferReport {
97
311
  finish(status) {
98
312
  this.data.status = status;
99
313
  this.data.finishedAt = new Date().toISOString();
100
- this.data.issues = this.issueCollector.list();
101
- this.data.warnings = this.data.issues.filter((issue) => issue.severity === 'warning' || issue.severity === 'degradation').map((issue) => issue.message);
314
+ const pageById = new Map(this.data.pages.map((page) => [page.sourceCanonicalId, page]));
315
+ this.data.issues = this.issueCollector.list().map((issue) => {
316
+ const page = issue.pageId ? pageById.get(issue.pageId) : undefined;
317
+ const resource = issue.resourceId ? page?.resourceItems.find((item) => item.sourceId === issue.resourceId) : undefined;
318
+ return {
319
+ ...issue,
320
+ ...(page ? { pageTitle: page.sourceTitle, pageAnchor: pageAnchor(page.sourceCanonicalId) } : {}),
321
+ ...(resource ? { resourceName: resource.sourceName } : {}),
322
+ action: issueAction(issue),
323
+ };
324
+ });
325
+ this.data.warnings = this.data.issues.filter((issue) => issue.severity === 'warning').map((issue) => issue.message);
102
326
  this.data.errors = this.data.issues.filter((issue) => issue.severity === 'error').map((issue) => issue.message);
327
+ const pageStatusCounts = countRecord(pageStatuses);
328
+ const resourceStatusCounts = countRecord(resourceStatuses);
329
+ const traversalStatusCounts = countRecord(edgeStatuses);
330
+ for (const page of this.data.pages)
331
+ pageStatusCounts[page.status] += 1;
332
+ const resources = this.data.pages.flatMap((page) => page.resourceItems);
333
+ for (const resource of resources)
334
+ resourceStatusCounts[resource.status] += 1;
335
+ for (const edge of this.data.edges)
336
+ traversalStatusCounts[edge.status] += 1;
337
+ const started = Date.parse(this.data.startedAt);
338
+ const finished = Date.parse(this.data.finishedAt);
339
+ const sourceBytes = resources.reduce((total, resource) => total + (resource.sourceBytes ?? 0), 0);
340
+ const transferredBytes = resources.reduce((total, resource) => total + (resource.status === 'complete' ? resource.transferredBytes ?? 0 : 0), 0);
341
+ const inventoriedPages = this.data.pages.filter((page) => page.inventoryStatus === 'complete').length;
342
+ const inventoryFailedPages = this.data.pages.filter((page) => page.inventoryStatus === 'failed').length;
103
343
  this.data.summary = {
104
344
  pages: this.data.pages.length,
105
- complete: this.data.pages.filter((page) => /complete|created|updated|planned/.test(page.status)).length,
106
- partial: this.data.pages.filter((page) => /partial|conflict/.test(page.status)).length,
107
- failed: this.data.pages.filter((page) => /failed|blocked/.test(page.status)).length,
108
- resources: this.data.pages.reduce((total, page) => total + page.resources, 0),
109
- bytes: this.data.pages.reduce((total, page) => total + (page.bytes ?? 0), 0),
110
- bytesUnknown: this.data.pages.reduce((total, page) => total + (page.bytesUnknown ?? 0), 0),
345
+ returnedDescendants: this.data.selection.returnedDescendants,
346
+ selectedDescendants: this.data.selection.selectedDescendants,
347
+ excludedPages: this.data.selection.excluded.length,
348
+ maximumDepth: this.data.selection.maximumDepth,
349
+ hierarchyGaps: this.data.selection.hierarchyGaps.length,
350
+ hierarchyRepairs: this.data.hierarchyRepairs.length,
351
+ complete: pageStatusCounts.complete,
352
+ partial: pageStatusCounts.partial,
353
+ failed: pageStatusCounts.failed,
354
+ blocked: pageStatusCounts.blocked,
355
+ conflicts: pageStatusCounts.conflict,
356
+ unprocessedPages: pageStatusCounts.discovered + pageStatusCounts.planned + pageStatusCounts['skeleton-created'],
357
+ resources: resources.length,
358
+ attachments: resources.filter((resource) => resource.kind === 'attachment').length,
359
+ resourceImages: resources.filter((resource) => resource.kind === 'image').length,
360
+ resourcesComplete: resourceStatusCounts.complete,
361
+ resourcesPartial: resourceStatusCounts.partial,
362
+ resourcesFailed: resourceStatusCounts.failed,
363
+ resourcesPlanned: resourceStatusCounts.planned,
364
+ bytes: transferredBytes,
365
+ sourceBytes,
366
+ bytesUnknown: resources.filter((resource) => resource.sourceBytes === undefined).length,
111
367
  links: this.data.pages.reduce((total, page) => total + page.links, 0),
112
- cycles: this.data.edges.filter((edge) => edge.status === 'cycle').length,
368
+ images: this.data.pages.reduce((total, page) => total + page.images, 0),
369
+ tables: this.data.pages.reduce((total, page) => total + page.tables, 0),
370
+ grids: this.data.pages.reduce((total, page) => total + page.grids, 0),
371
+ codeBlocks: this.data.pages.reduce((total, page) => total + page.codeBlocks, 0),
372
+ cycles: traversalStatusCounts.cycle,
113
373
  normalizations: this.issueCollector.count('normalization'),
114
374
  degradations: this.issueCollector.count('degradation'),
115
- warnings: this.issueCollector.count('warning') + this.issueCollector.count('degradation'),
375
+ warnings: this.issueCollector.count('warning'),
116
376
  errors: this.issueCollector.count('error'),
117
377
  retryableFailures: this.issueCollector.countRetryableFailures(),
378
+ inventoryComplete: this.data.pages.length > 0 && inventoriedPages === this.data.pages.length,
379
+ inventoriedPages,
380
+ inventoryFailedPages,
381
+ ...(Number.isFinite(started) && Number.isFinite(finished) ? { durationMs: Math.max(0, finished - started) } : {}),
382
+ pageStatuses: pageStatusCounts,
383
+ resourceStatuses: resourceStatusCounts,
384
+ traversalStatuses: traversalStatusCounts,
118
385
  };
119
386
  }
120
387
  async write(reportsDirectory) {
@@ -128,7 +395,7 @@ export class TransferReport {
128
395
  await writeFile(htmlTemporary, renderReport(this.data, this.previews), { mode: 0o600 });
129
396
  await rename(jsonTemporary, json);
130
397
  await rename(htmlTemporary, html);
131
- return { directory, html, json };
398
+ return { runId: this.data.runId, directory, html, json };
132
399
  }
133
400
  }
134
401
  //# sourceMappingURL=report.js.map