feat: add migration preview report

This commit is contained in:
muena
2026-09-24 19:21:07 +02:00
parent c1229e1d14
commit ae62c6f95c
3 changed files with 104 additions and 25 deletions

View File

@@ -60,7 +60,7 @@ export function createConverter(manifest, globalTerms = []) {
const counters = new Map();
let replacements = 0;
function convert(input) {
function convert(input, onReplacement) {
if (!pattern) return input;
pattern.lastIndex = 0;
return input.replace(pattern, (matched, offset, whole) => {
@@ -85,6 +85,7 @@ export function createConverter(manifest, globalTerms = []) {
allocated.set(key, placeholder);
}
replacements += 1;
onReplacement?.(matched, placeholder);
return placeholder;
});
}
@@ -101,19 +102,20 @@ function safeRelative(path) {
async function filesUnder(root) {
const files = [];
let skipped = 0;
const skippedFiles = [];
const skip = (path, reason) => skippedFiles.push({ path: relative(root, path).split(sep).join("/"), reason });
async function walk(dir) {
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
const path = join(dir, entry.name);
if (entry.isSymbolicLink()) { skipped += 1; continue; }
if (entry.isSymbolicLink()) { skip(path, "Symbolischer Link"); continue; }
if (entry.isDirectory()) {
if (excluded.has(entry.name)) { skipped += 1; continue; }
if (excluded.has(entry.name)) { skip(path, "Ausgeschlossener Ordner"); continue; }
await walk(path);
} else if (entry.isFile()) {
if (entry.name === ".aishield.json") continue;
if (entry.name === ".aishield.json") { skip(path, "Alte Projektmetadaten"); continue; }
if (entry.name === ".env" || entry.name.startsWith(".env.") ||
[".pem", ".key", ".p12", ".pfx"].includes(extname(entry.name).toLowerCase())) {
skipped += 1;
skip(path, "Vertrauliche Konfigurations- oder Schlüsseldatei");
continue;
}
files.push({ abs: path, rel: relative(root, path).split(sep).join("/") });
@@ -121,15 +123,55 @@ async function filesUnder(root) {
}
}
await walk(root);
return { files, get skipped() { return skipped; } };
return { files, skippedFiles };
}
function convertedPath(rel, convert) {
return safeRelative(rel.split("/").map(convert).join("/"));
}
export async function migrate({ project, manifestFile, targetSupport, apply = false }) {
function replacementList(counts) {
return [...counts.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
}
function reportText(summary, converted, skippedFiles) {
const quoted = (value) => JSON.stringify(value);
const lines = [
"# Migrationsvorschau für Velum", "",
"Diese Vorschau wurde ohne Übernahme erstellt. Sie enthält alte und neue Platzhalter, aber keine Klartextwerte aus der Zuordnungstabelle.", "",
`Dateien: ${summary.files} · Ausgelassen: ${summary.skipped} · Ersetzungen: ${summary.replacements} · Neue Platzhalter: ${summary.placeholders}`, "",
"## Dateien", "",
];
for (const file of converted) {
lines.push(`- Quelle: ${quoted(file.source)}`);
if (file.source !== file.output) lines.push(` Zielname: ${quoted(file.output)}`);
const pathReplacements = replacementList(file.pathReplacements);
const contentReplacements = replacementList(file.contentReplacements);
if (!pathReplacements.length && !contentReplacements.length) lines.push(" Keine Änderung.");
for (const [label, entries] of [["Name", pathReplacements], ["Inhalt", contentReplacements]]) {
for (const entry of entries) lines.push(` ${label}: ${quoted(entry.from)} → ${quoted(entry.to)} (${entry.count}×)`);
}
}
if (!converted.length) lines.push("Keine übernehmbaren Dateien.");
lines.push("", "## Ausgelassen", "");
for (const item of skippedFiles) lines.push(`- ${quoted(item.path)}: ${item.reason}`);
if (!skippedFiles.length) lines.push("Keine.");
return lines.join("\n") + "\n";
}
export async function migrate({ project, manifestFile, targetSupport, apply = false, previewFile }) {
const root = resolve(project);
if (apply && previewFile) throw new Error("Vorschau-Datei und --apply bitte in getrennten Aufrufen verwenden.");
if (previewFile && (resolve(previewFile) === root || resolve(previewFile).startsWith(root + sep))) {
throw new Error("Die Vorschau-Datei muss außerhalb des Projektordners liegen.");
}
if (previewFile) {
const realRoot = await fs.realpath(root);
const realPreviewParent = await fs.realpath(dirname(resolve(previewFile)));
if (realPreviewParent === realRoot || realPreviewParent.startsWith(realRoot + sep)) {
throw new Error("Die Vorschau-Datei muss außerhalb des Projektordners liegen.");
}
}
if (resolve(targetSupport) === root || resolve(targetSupport).startsWith(root + sep)) {
throw new Error("Der Velum-Datenordner muss außerhalb des Projektordners liegen.");
}
@@ -146,21 +188,31 @@ export async function migrate({ project, manifestFile, targetSupport, apply = fa
const scan = await filesUnder(root);
const converted = [];
const seen = new Set();
let skipped = scan.skipped;
const skippedFiles = [...scan.skippedFiles];
const skip = (file, reason) => skippedFiles.push({ path: file.rel, reason });
const track = (counts) => (from, to) => {
const key = `${from}\0${to}`;
const entry = counts.get(key) ?? { from, to, count: 0 };
entry.count += 1;
counts.set(key, entry);
};
for (const file of scan.files) {
if ([".docx", ".xlsx", ".pptx", ".pdf"].includes(extname(file.rel).toLowerCase())) {
skipped += 1;
skip(file, "Office- oder PDF-Datei");
continue;
}
const bytes = await fs.readFile(file.abs);
if (bytes.includes(0)) { skipped += 1; continue; }
if (bytes.includes(0)) { skip(file, "Binärdatei"); continue; }
let text;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
catch { skipped += 1; continue; }
const output = convertedPath(file.rel, converter.convert);
catch { skip(file, "Kein gültiger UTF-8-Text"); continue; }
const pathReplacements = new Map();
const contentReplacements = new Map();
const output = convertedPath(file.rel, (part) => converter.convert(part, track(pathReplacements)));
if (seen.has(output)) throw new Error("Zwei Dateien hätten denselben Zielnamen.");
seen.add(output);
converted.push({ source: file.rel, output, content: converter.convert(text), kind: "text" });
converted.push({ source: file.rel, output, content: converter.convert(text, track(contentReplacements)),
kind: "text", pathReplacements, contentReplacements });
}
const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
const projectId = createHash("sha1").update(root.replace(/\/+$/, "")).digest("hex").slice(0, 16);
@@ -171,9 +223,15 @@ export async function migrate({ project, manifestFile, targetSupport, apply = fa
mapping: converter.mapping, matchModes: converter.matchModes,
files: converted.map(({ source, output, kind }) => ({ source, output, kind })),
};
const summary = { files: converted.length, skipped, replacements: converter.replacements,
const summary = { files: converted.length, skipped: skippedFiles.length, replacements: converter.replacements,
placeholders: Object.keys(converter.mapping).length };
if (!apply) return { summary, applied: false };
if (!apply) {
if (previewFile) {
await fs.writeFile(resolve(previewFile), reportText(summary, converted, skippedFiles),
{ encoding: "utf8", flag: "wx", mode: 0o600 });
}
return { summary, applied: false, previewFile: previewFile && resolve(previewFile) };
}
const stage = join(projectData, `.${id}.staging`);
await fs.mkdir(join(stage, "files"), { recursive: true, mode: 0o700 });
let committed = false;
@@ -213,13 +271,16 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
const project = option(args, "--project");
const manifestFile = option(args, "--manifest");
const targetSupport = option(args, "--target-support");
if (!project || !manifestFile || !targetSupport) {
process.stderr.write("Aufruf: node tools/migrate-ki-datenschutz.mjs --project <Ordner> --manifest <alte Tabelle.json> --target-support <Velum-Datenordner> [--apply]\n");
const previewFile = option(args, "--preview-file");
if (!project || !manifestFile || !targetSupport ||
(args.includes("--preview-file") && (!previewFile || previewFile.startsWith("--")))) {
process.stderr.write("Aufruf: node tools/migrate-ki-datenschutz.mjs --project <Ordner> --manifest <alte Tabelle.json> --target-support <Velum-Datenordner> [--preview-file <Bericht.md> | --apply]\n");
process.exitCode = 2;
} else {
try {
const result = await migrate({ project, manifestFile, targetSupport, apply: args.includes("--apply") });
const result = await migrate({ project, manifestFile, targetSupport, previewFile, apply: args.includes("--apply") });
process.stdout.write(`${result.applied ? "Import erstellt" : "Vorschau"}: ${result.summary.files} Dateien, ${result.summary.skipped} übersprungen, ${result.summary.replacements} Ersetzungen, ${result.summary.placeholders} neue Platzhalter.\n`);
if (result.previewFile) process.stdout.write(`Bericht: ${result.previewFile}\n`);
} catch (error) {
process.stderr.write(`Migration fehlgeschlagen: ${error.message}\n`);
process.exitCode = 1;