@iwonz/ywtc 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +31 -2
- package/dist/adapters/confluence.d.ts +8 -2
- package/dist/adapters/confluence.js +44 -6
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.d.ts +18 -4
- package/dist/adapters/yandex.js +34 -6
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/cli.js +6 -4
- package/dist/cli.js.map +1 -1
- 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/domain.d.ts +12 -0
- package/dist/domain.js +10 -0
- package/dist/domain.js.map +1 -1
- package/dist/graph.js +11 -2
- package/dist/graph.js.map +1 -1
- package/dist/issues.d.ts +10 -1
- package/dist/issues.js +9 -1
- package/dist/issues.js.map +1 -1
- package/dist/migrator.d.ts +1 -1
- package/dist/migrator.js +344 -155
- package/dist/migrator.js.map +1 -1
- package/dist/report-catalog.d.ts +14 -0
- package/dist/report-catalog.js +14 -0
- package/dist/report-catalog.js.map +1 -1
- package/dist/report-contract.d.ts +259 -0
- package/dist/report-contract.js +15 -0
- package/dist/report-contract.js.map +1 -0
- package/dist/report-ui/index.html +92 -0
- package/dist/report.d.ts +13 -192
- package/dist/report.js +273 -176
- package/dist/report.js.map +1 -1
- package/dist/state.d.ts +24 -6
- package/dist/state.js +54 -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/docs/assets/report-header-transparent.png +0 -0
- package/package.json +24 -3
package/dist/report.js
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
const commentStatuses = ['planned', 'complete', 'partial', 'failed', 'conflict', 'stale', 'skipped'];
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { redact } from './logger.js';
|
|
6
|
+
import { IssueCollector, } from './issues.js';
|
|
7
|
+
import { commentStatuses, edgeStatuses, pageStatuses, resourceStatuses, } from './report-contract.js';
|
|
8
|
+
export const REPORT_DATA_MARKER = '{"__ywtc_report_payload__":true}';
|
|
18
9
|
function countRecord(values) {
|
|
19
10
|
return Object.fromEntries(values.map((value) => [value, 0]));
|
|
20
11
|
}
|
|
@@ -32,6 +23,7 @@ function emptySummary() {
|
|
|
32
23
|
failed: 0,
|
|
33
24
|
blocked: 0,
|
|
34
25
|
conflicts: 0,
|
|
26
|
+
notStartedPages: 0,
|
|
35
27
|
unprocessedPages: 0,
|
|
36
28
|
resources: 0,
|
|
37
29
|
attachments: 0,
|
|
@@ -39,6 +31,8 @@ function emptySummary() {
|
|
|
39
31
|
resourcesComplete: 0,
|
|
40
32
|
resourcesPartial: 0,
|
|
41
33
|
resourcesFailed: 0,
|
|
34
|
+
resourcesBlocked: 0,
|
|
35
|
+
resourcesNotStarted: 0,
|
|
42
36
|
resourcesPlanned: 0,
|
|
43
37
|
bytes: 0,
|
|
44
38
|
sourceBytes: 0,
|
|
@@ -53,6 +47,10 @@ function emptySummary() {
|
|
|
53
47
|
degradations: 0,
|
|
54
48
|
warnings: 0,
|
|
55
49
|
errors: 0,
|
|
50
|
+
rootErrors: 0,
|
|
51
|
+
dependentPages: 0,
|
|
52
|
+
dependentFiles: 0,
|
|
53
|
+
dependentComments: 0,
|
|
56
54
|
retryableFailures: 0,
|
|
57
55
|
inventoryComplete: false,
|
|
58
56
|
inventoriedPages: 0,
|
|
@@ -68,167 +66,174 @@ function emptySummary() {
|
|
|
68
66
|
traversalStatuses: countRecord(edgeStatuses),
|
|
69
67
|
};
|
|
70
68
|
}
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
function issueCategory(issue) {
|
|
70
|
+
if (issue.category)
|
|
71
|
+
return issue.category;
|
|
72
|
+
const value = `${issue.code} ${issue.message}`.toLocaleLowerCase();
|
|
73
|
+
if (issue.severity === 'blocked' || issue.blockedByPageId)
|
|
74
|
+
return 'dependency';
|
|
75
|
+
if (issue.phase === 'comment' || /comment/.test(value))
|
|
76
|
+
return 'comment';
|
|
77
|
+
if (issue.phase === 'resource' || /attachment|resource|image|upload|download/.test(value))
|
|
78
|
+
return 'attachment-image';
|
|
79
|
+
if (issue.phase === 'verification' || /verif|fingerprint|differs|mismatch/.test(value))
|
|
80
|
+
return 'verification';
|
|
81
|
+
if (/rate.?limit|\b429\b|retry-after/.test(value))
|
|
82
|
+
return 'rate-limit';
|
|
83
|
+
if (/\b401\b|auth|token|credential/.test(value))
|
|
84
|
+
return 'authorization';
|
|
85
|
+
if (/\b403\b|forbidden|permission|access denied/.test(value))
|
|
86
|
+
return 'permissions';
|
|
87
|
+
if (/timeout|timed out|dns|tls|socket|network|econn|enotfound/.test(value))
|
|
88
|
+
return 'network';
|
|
89
|
+
if (/parse|schema|invalid (?:html|xml|yfm|storage)|malformed/.test(value))
|
|
90
|
+
return 'parsing';
|
|
91
|
+
if (/render|markup|layout|format|fallback/.test(value))
|
|
92
|
+
return 'rendering-layout';
|
|
93
|
+
if (/hierarchy|parent|ancestor|reparent|subtree/.test(value))
|
|
94
|
+
return 'hierarchy';
|
|
95
|
+
if (/conflict|duplicate|title|occupied/.test(value))
|
|
96
|
+
return 'conflict';
|
|
97
|
+
if (/source-status|excluded|filter/.test(value))
|
|
98
|
+
return 'filtering';
|
|
99
|
+
if (/http|api|\b4\d\d\b|\b5\d\d\b|not found/.test(value))
|
|
100
|
+
return 'provider-api';
|
|
101
|
+
return 'unknown';
|
|
78
102
|
}
|
|
79
|
-
function
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
function statusClass(value) {
|
|
91
|
-
return value === 'complete' || value === 'completed' ? 'ok'
|
|
92
|
-
: /partial|warning|conflict|planned|discovered|skeleton|interrupted|cancelled/.test(value) ? 'warn'
|
|
93
|
-
: /failed|blocked|error/.test(value) ? 'bad'
|
|
94
|
-
: 'muted';
|
|
103
|
+
function issueObjectType(issue) {
|
|
104
|
+
if (issue.objectType)
|
|
105
|
+
return issue.objectType;
|
|
106
|
+
if (issue.resourceId)
|
|
107
|
+
return 'resource';
|
|
108
|
+
if (issue.commentId || issue.phase === 'comment')
|
|
109
|
+
return 'comment';
|
|
110
|
+
if (issue.pageId)
|
|
111
|
+
return 'page';
|
|
112
|
+
return 'run';
|
|
95
113
|
}
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
function formatBytes(value) {
|
|
100
|
-
if (value < 1024)
|
|
101
|
-
return `${value} B`;
|
|
102
|
-
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
|
|
103
|
-
let amount = value;
|
|
104
|
-
let unit = -1;
|
|
105
|
-
do {
|
|
106
|
-
amount /= 1024;
|
|
107
|
-
unit += 1;
|
|
108
|
-
} while (amount >= 1024 && unit < units.length - 1);
|
|
109
|
-
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${units[unit]}`;
|
|
110
|
-
}
|
|
111
|
-
function formatDuration(durationMs) {
|
|
112
|
-
if (durationMs === undefined)
|
|
113
|
-
return 'in progress';
|
|
114
|
-
if (durationMs < 1000)
|
|
115
|
-
return `${durationMs} ms`;
|
|
116
|
-
const seconds = Math.round(durationMs / 1000);
|
|
117
|
-
if (seconds < 60)
|
|
118
|
-
return `${seconds} s`;
|
|
119
|
-
const minutes = Math.floor(seconds / 60);
|
|
120
|
-
return `${minutes} min ${seconds % 60} s`;
|
|
121
|
-
}
|
|
122
|
-
function issueAction(issue) {
|
|
114
|
+
function issueAction(issue, category) {
|
|
115
|
+
if (issue.severity === 'blocked')
|
|
116
|
+
return 'Resolve the upstream page error, then repeat the same migration; this object will resume automatically.';
|
|
123
117
|
if (issue.retryable)
|
|
124
118
|
return 'Repeat the same migration to retry this object.';
|
|
125
119
|
if (issue.severity === 'degradation')
|
|
126
120
|
return 'Data remains represented; compare the source and destination previews.';
|
|
127
|
-
if (issue.severity === 'error')
|
|
128
|
-
return 'Review the reason and affected object before resuming the migration.';
|
|
129
121
|
if (issue.severity === 'normalization')
|
|
130
122
|
return 'No action is required.';
|
|
131
|
-
|
|
123
|
+
if (category === 'authorization')
|
|
124
|
+
return 'Check the selected connection token, then run doctor and repeat the migration.';
|
|
125
|
+
if (category === 'permissions')
|
|
126
|
+
return 'Grant the token user access to the affected object, then repeat the migration.';
|
|
127
|
+
if (category === 'parsing' || category === 'rendering-layout')
|
|
128
|
+
return 'Inspect the comparison and fallback, then report the unsupported construct if the source data is not represented.';
|
|
129
|
+
if (category === 'conflict')
|
|
130
|
+
return 'Review the destination change or title conflict before repeating the migration.';
|
|
131
|
+
if (issue.severity === 'error')
|
|
132
|
+
return 'Review the reason and affected object before resuming the migration.';
|
|
133
|
+
return 'Review the affected object and its page details.';
|
|
132
134
|
}
|
|
133
|
-
function
|
|
134
|
-
return
|
|
135
|
+
function serializePayload(payload) {
|
|
136
|
+
return JSON.stringify(payload)
|
|
137
|
+
.replaceAll('&', '\\u0026')
|
|
138
|
+
.replaceAll('<', '\\u003c')
|
|
139
|
+
.replaceAll('>', '\\u003e')
|
|
140
|
+
.replaceAll('\u2028', '\\u2028')
|
|
141
|
+
.replaceAll('\u2029', '\\u2029');
|
|
135
142
|
}
|
|
136
|
-
function
|
|
137
|
-
|
|
143
|
+
function sanitizedString(value, secrets) {
|
|
144
|
+
const redacted = redact(value, [...secrets]);
|
|
145
|
+
try {
|
|
146
|
+
const url = new URL(redacted);
|
|
147
|
+
if (!['http:', 'https:', 'file:'].includes(url.protocol))
|
|
148
|
+
return redacted;
|
|
149
|
+
url.username = '';
|
|
150
|
+
url.password = '';
|
|
151
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
152
|
+
if (/token|secret|auth|credential|signature|(^|[-_])key$|^x-amz-/i.test(key))
|
|
153
|
+
url.searchParams.set(key, '[REDACTED]');
|
|
154
|
+
}
|
|
155
|
+
return url.toString();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return redacted;
|
|
159
|
+
}
|
|
138
160
|
}
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
: data.scope === 'linked'
|
|
206
|
-
? '<p class="empty">No traversal edges were discovered in linked scope.</p>'
|
|
207
|
-
: `<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>`;
|
|
208
|
-
const outputRows = data.outputs.map((output) => `<li><strong>${escapeHtml(output.label)}</strong> <code>${escapeHtml(output.value)}</code></li>`).join('');
|
|
209
|
-
const normalizations = normalizationIssues.length
|
|
210
|
-
? `<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>`
|
|
211
|
-
: '';
|
|
212
|
-
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>
|
|
213
|
-
: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}}
|
|
214
|
-
</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>
|
|
215
|
-
<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>
|
|
216
|
-
<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>
|
|
217
|
-
${selectionDetails}
|
|
218
|
-
<section class="panel" aria-labelledby="outcome-title"><div class="section-head"><div><h2 id="outcome-title">Transfer outcomes</h2><p>Every selected page, discovered file, and page comment archive 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><div class="table-wrap"><table class="outcomes"><thead><tr><th>Object</th>${commentOutcomeStatuses.map((status) => `<th>${escapeHtml(status)}</th>`).join('')}<th>Total</th></tr></thead><tbody><tr><th>Page comment archives</th>${statusCells(data.summary.commentStatuses, commentOutcomeStatuses)}<td><strong>${data.summary.pages}</strong></td></tr></tbody></table></div></section>
|
|
219
|
-
${attention}
|
|
220
|
-
<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>
|
|
221
|
-
<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>
|
|
222
|
-
${normalizations}
|
|
223
|
-
<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>
|
|
224
|
-
</main></body></html>`;
|
|
161
|
+
function sanitizedValue(value, secrets) {
|
|
162
|
+
if (typeof value === 'string')
|
|
163
|
+
return sanitizedString(value, secrets);
|
|
164
|
+
if (Array.isArray(value))
|
|
165
|
+
return value.map((item) => sanitizedValue(item, secrets));
|
|
166
|
+
if (value && typeof value === 'object') {
|
|
167
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizedValue(item, secrets)]));
|
|
168
|
+
}
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
export function injectReportPayload(shell, payload) {
|
|
172
|
+
const occurrences = shell.split(REPORT_DATA_MARKER).length - 1;
|
|
173
|
+
if (occurrences !== 1)
|
|
174
|
+
throw new Error(`Compiled report shell must contain exactly one data marker; found ${occurrences}`);
|
|
175
|
+
return shell.replace(REPORT_DATA_MARKER, serializePayload(payload));
|
|
176
|
+
}
|
|
177
|
+
let shellPromise;
|
|
178
|
+
async function loadReportShell() {
|
|
179
|
+
shellPromise ??= (async () => {
|
|
180
|
+
const candidates = [
|
|
181
|
+
fileURLToPath(new URL('./report-ui/index.html', import.meta.url)),
|
|
182
|
+
fileURLToPath(new URL('../dist/report-ui/index.html', import.meta.url)),
|
|
183
|
+
];
|
|
184
|
+
const errors = [];
|
|
185
|
+
for (const filename of [...new Set(candidates)]) {
|
|
186
|
+
try {
|
|
187
|
+
return await readFile(filename, 'utf8');
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
errors.push(`${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`Compiled report shell is unavailable. Run "npm run build:report". ${errors.join('; ')}`);
|
|
194
|
+
})();
|
|
195
|
+
return shellPromise;
|
|
196
|
+
}
|
|
197
|
+
function reportExcludedPages(selection, data) {
|
|
198
|
+
const selectedByProviderId = new Map(data.pages.map((page) => [page.sourceId, page]));
|
|
199
|
+
const excludedCanonicalByProviderId = new Map(selection.excluded.map((page) => [
|
|
200
|
+
page.id,
|
|
201
|
+
page.canonicalId ?? `${data.source.profile}:${data.source.provider}:${page.id}`,
|
|
202
|
+
]));
|
|
203
|
+
return selection.excluded.map((page) => {
|
|
204
|
+
const sourceCanonicalId = excludedCanonicalByProviderId.get(page.id);
|
|
205
|
+
const ancestors = page.ancestorIds ?? [];
|
|
206
|
+
const projectedParentId = [page.parentId, ...ancestors.slice().reverse(), page.discoveredFromId]
|
|
207
|
+
.find((candidate) => candidate && (selectedByProviderId.has(candidate) || excludedCanonicalByProviderId.has(candidate)));
|
|
208
|
+
const canonicalParent = projectedParentId
|
|
209
|
+
? selectedByProviderId.get(projectedParentId)?.sourceCanonicalId ?? excludedCanonicalByProviderId.get(projectedParentId)
|
|
210
|
+
: undefined;
|
|
211
|
+
return {
|
|
212
|
+
sourceCanonicalId,
|
|
213
|
+
sourceId: page.id,
|
|
214
|
+
sourceTitle: page.title ?? page.locator,
|
|
215
|
+
...(page.url ? { sourceUrl: page.url } : {}),
|
|
216
|
+
...(page.parentId ? { sourceParentId: page.parentId } : {}),
|
|
217
|
+
...(ancestors.length > 0 ? { sourceAncestorIds: ancestors } : {}),
|
|
218
|
+
...(canonicalParent ? { projectedParentId: canonicalParent } : {}),
|
|
219
|
+
...(page.discoveredFromId ? { discoveredFromId: page.discoveredFromId } : {}),
|
|
220
|
+
...(page.depth !== undefined ? { depth: page.depth } : {}),
|
|
221
|
+
status: 'excluded',
|
|
222
|
+
...(page.status ? { sourceStatus: page.status } : {}),
|
|
223
|
+
reason: page.reason,
|
|
224
|
+
metadataComplete: page.metadataComplete ?? Boolean(page.title && page.url && (page.parentId || ancestors.length > 0)),
|
|
225
|
+
};
|
|
226
|
+
});
|
|
225
227
|
}
|
|
226
228
|
export class TransferReport {
|
|
227
229
|
data;
|
|
228
230
|
previews = new Map();
|
|
229
231
|
issueCollector = new IssueCollector();
|
|
232
|
+
secrets;
|
|
230
233
|
constructor(input) {
|
|
231
|
-
const
|
|
234
|
+
const { secrets = [], ...reportInput } = input;
|
|
235
|
+
this.secrets = [...secrets];
|
|
236
|
+
const sourceStatus = reportInput.sourceStatus ?? 'all';
|
|
232
237
|
this.data = {
|
|
233
238
|
runId: randomUUID(),
|
|
234
239
|
startedAt: new Date().toISOString(),
|
|
@@ -245,6 +250,7 @@ export class TransferReport {
|
|
|
245
250
|
notes: [],
|
|
246
251
|
},
|
|
247
252
|
pages: [],
|
|
253
|
+
excludedPages: [],
|
|
248
254
|
edges: [],
|
|
249
255
|
hierarchyRepairs: [],
|
|
250
256
|
warnings: [],
|
|
@@ -252,8 +258,8 @@ export class TransferReport {
|
|
|
252
258
|
issues: [],
|
|
253
259
|
outputs: [],
|
|
254
260
|
summary: emptySummary(),
|
|
255
|
-
...
|
|
256
|
-
commentsEnabled:
|
|
261
|
+
...reportInput,
|
|
262
|
+
commentsEnabled: reportInput.commentsEnabled ?? true,
|
|
257
263
|
};
|
|
258
264
|
}
|
|
259
265
|
registerPages(pages) {
|
|
@@ -265,7 +271,9 @@ export class TransferReport {
|
|
|
265
271
|
sourceId: page.id,
|
|
266
272
|
sourceTitle: page.title,
|
|
267
273
|
sourceUrl: page.url,
|
|
268
|
-
...(page.
|
|
274
|
+
...(page.parentId ? { sourceParentId: page.parentId } : {}),
|
|
275
|
+
...(page.ancestorIds?.length ? { sourceAncestorIds: page.ancestorIds } : {}),
|
|
276
|
+
...(page.projectedParentId ? { parentId: page.projectedParentId, projectedParentId: page.projectedParentId } : {}),
|
|
269
277
|
depth: page.depth,
|
|
270
278
|
status: 'discovered',
|
|
271
279
|
inventoryStatus: 'pending',
|
|
@@ -285,6 +293,8 @@ export class TransferReport {
|
|
|
285
293
|
comments: { status: this.data.commentsEnabled ? 'planned' : 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0 },
|
|
286
294
|
});
|
|
287
295
|
}
|
|
296
|
+
if (this.data.selection.excluded.length > 0)
|
|
297
|
+
this.data.excludedPages = reportExcludedPages(this.data.selection, this.data);
|
|
288
298
|
}
|
|
289
299
|
updatePage(sourceCanonicalId, update) {
|
|
290
300
|
const index = this.data.pages.findIndex((page) => page.sourceCanonicalId === sourceCanonicalId);
|
|
@@ -302,37 +312,108 @@ export class TransferReport {
|
|
|
302
312
|
this.data.pages.push(page);
|
|
303
313
|
if (previews)
|
|
304
314
|
this.previews.set(page.sourceCanonicalId, previews);
|
|
315
|
+
if (this.data.selection.excluded.length > 0)
|
|
316
|
+
this.data.excludedPages = reportExcludedPages(this.data.selection, this.data);
|
|
305
317
|
if (!collectIssues)
|
|
306
318
|
return;
|
|
307
|
-
for (const message of page.normalizations)
|
|
319
|
+
for (const message of page.normalizations) {
|
|
308
320
|
this.issue({ code: `normalization:${message}`, severity: 'normalization', phase: 'page', pageId: page.sourceCanonicalId, retryable: false, message });
|
|
309
|
-
|
|
321
|
+
}
|
|
322
|
+
for (const message of page.warnings) {
|
|
310
323
|
this.issue({ code: `warning:${message}`, severity: page.degraded ? 'degradation' : 'warning', phase: 'page', pageId: page.sourceCanonicalId, retryable: false, message });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
setEdges(edges) {
|
|
327
|
+
this.data.edges = edges;
|
|
311
328
|
}
|
|
312
|
-
setEdges(edges) { this.data.edges = edges; }
|
|
313
329
|
setSelection(selection) {
|
|
314
330
|
this.data.selection = selection;
|
|
315
331
|
this.data.sourceStatus = selection.effectiveStatus;
|
|
332
|
+
this.data.excludedPages = reportExcludedPages(selection, this.data);
|
|
333
|
+
}
|
|
334
|
+
setDestinationUrl(url) {
|
|
335
|
+
this.data.destination.url = url;
|
|
316
336
|
}
|
|
317
337
|
addHierarchyRepair(repair) {
|
|
318
338
|
this.data.hierarchyRepairs.push(repair);
|
|
319
339
|
}
|
|
320
|
-
issue(input) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
340
|
+
issue(input) {
|
|
341
|
+
this.issueCollector.add({ ...input, message: sanitizedString(input.message, this.secrets) });
|
|
342
|
+
}
|
|
343
|
+
warning(message) {
|
|
344
|
+
this.issue({ code: `warning:${message}`, severity: 'warning', phase: 'report', retryable: false, message });
|
|
345
|
+
}
|
|
346
|
+
error(message) {
|
|
347
|
+
this.issue({ code: `error:${message}`, severity: 'error', phase: 'report', retryable: false, message });
|
|
348
|
+
}
|
|
349
|
+
output(label, value) {
|
|
350
|
+
this.data.outputs.push({ label, value });
|
|
351
|
+
}
|
|
352
|
+
finalizeOutcomes(status) {
|
|
353
|
+
if (this.data.dryRun)
|
|
354
|
+
return;
|
|
355
|
+
const interrupted = status === 'cancelled' || status === 'interrupted';
|
|
356
|
+
for (const page of this.data.pages) {
|
|
357
|
+
if (['discovered', 'planned', 'skeleton-created'].includes(page.status))
|
|
358
|
+
page.status = 'not-started';
|
|
359
|
+
const dependencyBlocked = page.status === 'blocked' || page.status === 'failed' || page.status === 'conflict';
|
|
360
|
+
const blockedByPageId = page.blockedByPageId ?? page.sourceCanonicalId;
|
|
361
|
+
const blockedByCode = page.blockedByCode ?? (page.status === 'conflict' ? 'destination-conflict' : 'destination-page-unavailable');
|
|
362
|
+
for (const resource of page.resourceItems) {
|
|
363
|
+
if (resource.status !== 'planned')
|
|
364
|
+
continue;
|
|
365
|
+
if (dependencyBlocked) {
|
|
366
|
+
resource.status = 'blocked';
|
|
367
|
+
resource.blockedByPageId = blockedByPageId;
|
|
368
|
+
resource.blockedByCode = blockedByCode;
|
|
369
|
+
resource.error ??= `Not attempted because destination page ${blockedByPageId} is unavailable`;
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
resource.status = 'not-started';
|
|
373
|
+
if (!interrupted)
|
|
374
|
+
resource.error ??= 'Live transfer ended before this file operation started';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (page.comments.status !== 'planned')
|
|
378
|
+
continue;
|
|
379
|
+
if (dependencyBlocked) {
|
|
380
|
+
page.comments.status = 'blocked';
|
|
381
|
+
page.comments.blockedByPageId = blockedByPageId;
|
|
382
|
+
page.comments.blockedByCode = blockedByCode;
|
|
383
|
+
page.comments.error ??= `Not attempted because destination page ${blockedByPageId} is unavailable`;
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
page.comments.status = 'not-started';
|
|
387
|
+
if (!interrupted)
|
|
388
|
+
page.comments.error ??= 'Live transfer ended before comment archival started';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
324
392
|
finish(status) {
|
|
325
393
|
this.data.status = status;
|
|
326
394
|
this.data.finishedAt = new Date().toISOString();
|
|
395
|
+
this.finalizeOutcomes(status);
|
|
327
396
|
const pageById = new Map(this.data.pages.map((page) => [page.sourceCanonicalId, page]));
|
|
328
397
|
this.data.issues = this.issueCollector.list().map((issue) => {
|
|
329
398
|
const page = issue.pageId ? pageById.get(issue.pageId) : undefined;
|
|
330
399
|
const resource = issue.resourceId ? page?.resourceItems.find((item) => item.sourceId === issue.resourceId) : undefined;
|
|
400
|
+
const category = issueCategory(issue);
|
|
401
|
+
const objectType = issueObjectType(issue);
|
|
402
|
+
const objectId = issue.objectId ?? issue.resourceId ?? issue.commentId ?? issue.pageId;
|
|
331
403
|
return {
|
|
332
404
|
...issue,
|
|
333
|
-
|
|
405
|
+
category,
|
|
406
|
+
objectType,
|
|
407
|
+
...(objectId ? { objectId } : {}),
|
|
408
|
+
...(!issue.rootCauseId && issue.severity === 'error'
|
|
409
|
+
? { rootCauseId: `${issue.pageId ?? 'run'}:${issue.code}` }
|
|
410
|
+
: issue.blockedByPageId && !issue.rootCauseId
|
|
411
|
+
? { rootCauseId: `${issue.blockedByPageId}:${issue.blockedByCode ?? 'dependency'}` }
|
|
412
|
+
: {}),
|
|
413
|
+
...(page ? { pageTitle: page.sourceTitle, pageAnchor: page.sourceCanonicalId } : {}),
|
|
334
414
|
...(resource ? { resourceName: resource.sourceName } : {}),
|
|
335
|
-
|
|
415
|
+
...(issue.commentId ? { commentLabel: issue.commentId } : {}),
|
|
416
|
+
action: issueAction(issue, category),
|
|
336
417
|
};
|
|
337
418
|
});
|
|
338
419
|
this.data.warnings = this.data.issues.filter((issue) => issue.severity === 'warning').map((issue) => issue.message);
|
|
@@ -360,7 +441,7 @@ export class TransferReport {
|
|
|
360
441
|
pages: this.data.pages.length,
|
|
361
442
|
returnedDescendants: this.data.selection.returnedDescendants,
|
|
362
443
|
selectedDescendants: this.data.selection.selectedDescendants,
|
|
363
|
-
excludedPages: this.data.
|
|
444
|
+
excludedPages: this.data.excludedPages.length,
|
|
364
445
|
maximumDepth: this.data.selection.maximumDepth,
|
|
365
446
|
hierarchyGaps: this.data.selection.hierarchyGaps.length,
|
|
366
447
|
hierarchyRepairs: this.data.hierarchyRepairs.length,
|
|
@@ -369,13 +450,16 @@ export class TransferReport {
|
|
|
369
450
|
failed: pageStatusCounts.failed,
|
|
370
451
|
blocked: pageStatusCounts.blocked,
|
|
371
452
|
conflicts: pageStatusCounts.conflict,
|
|
372
|
-
|
|
453
|
+
notStartedPages: pageStatusCounts['not-started'],
|
|
454
|
+
unprocessedPages: pageStatusCounts.discovered + pageStatusCounts.planned + pageStatusCounts['skeleton-created'] + pageStatusCounts['not-started'],
|
|
373
455
|
resources: resources.length,
|
|
374
456
|
attachments: resources.filter((resource) => resource.kind === 'attachment').length,
|
|
375
457
|
resourceImages: resources.filter((resource) => resource.kind === 'image').length,
|
|
376
458
|
resourcesComplete: resourceStatusCounts.complete,
|
|
377
459
|
resourcesPartial: resourceStatusCounts.partial,
|
|
378
460
|
resourcesFailed: resourceStatusCounts.failed,
|
|
461
|
+
resourcesBlocked: resourceStatusCounts.blocked,
|
|
462
|
+
resourcesNotStarted: resourceStatusCounts['not-started'],
|
|
379
463
|
resourcesPlanned: resourceStatusCounts.planned,
|
|
380
464
|
bytes: transferredBytes,
|
|
381
465
|
sourceBytes,
|
|
@@ -390,6 +474,10 @@ export class TransferReport {
|
|
|
390
474
|
degradations: this.issueCollector.count('degradation'),
|
|
391
475
|
warnings: this.issueCollector.count('warning'),
|
|
392
476
|
errors: this.issueCollector.count('error'),
|
|
477
|
+
rootErrors: this.issueCollector.count('error'),
|
|
478
|
+
dependentPages: pageStatusCounts.blocked,
|
|
479
|
+
dependentFiles: resourceStatusCounts.blocked,
|
|
480
|
+
dependentComments: commentStatusCounts.blocked,
|
|
393
481
|
retryableFailures: this.issueCollector.countRetryableFailures(),
|
|
394
482
|
inventoryComplete: this.data.pages.length > 0 && inventoriedPages === this.data.pages.length,
|
|
395
483
|
inventoriedPages,
|
|
@@ -405,6 +493,11 @@ export class TransferReport {
|
|
|
405
493
|
resourceStatuses: resourceStatusCounts,
|
|
406
494
|
traversalStatuses: traversalStatusCounts,
|
|
407
495
|
};
|
|
496
|
+
const pageOutcomes = Object.values(pageStatusCounts).reduce((total, value) => total + value, 0);
|
|
497
|
+
const resourceOutcomes = Object.values(resourceStatusCounts).reduce((total, value) => total + value, 0);
|
|
498
|
+
if (pageOutcomes !== this.data.summary.pages || resourceOutcomes !== this.data.summary.resources) {
|
|
499
|
+
throw new Error('Report outcome invariant failed: source inventory does not reconcile with exclusive outcomes');
|
|
500
|
+
}
|
|
408
501
|
}
|
|
409
502
|
async write(reportsDirectory) {
|
|
410
503
|
const directory = path.join(reportsDirectory, this.data.runId);
|
|
@@ -413,8 +506,12 @@ export class TransferReport {
|
|
|
413
506
|
const html = path.join(directory, 'index.html');
|
|
414
507
|
const jsonTemporary = `${json}.${process.pid}.tmp`;
|
|
415
508
|
const htmlTemporary = `${html}.${process.pid}.tmp`;
|
|
416
|
-
|
|
417
|
-
|
|
509
|
+
const shell = await loadReportShell();
|
|
510
|
+
const safeData = sanitizedValue(this.data, this.secrets);
|
|
511
|
+
const safePreviews = sanitizedValue(Object.fromEntries(this.previews), this.secrets);
|
|
512
|
+
const payload = { report: safeData, previews: safePreviews };
|
|
513
|
+
await writeFile(jsonTemporary, `${JSON.stringify(safeData, null, 2)}\n`, { mode: 0o600 });
|
|
514
|
+
await writeFile(htmlTemporary, injectReportPayload(shell, payload), { mode: 0o600 });
|
|
418
515
|
await rename(jsonTemporary, json);
|
|
419
516
|
await rename(htmlTemporary, html);
|
|
420
517
|
return { runId: this.data.runId, directory, html, json };
|