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

@@ -12,16 +12,18 @@ Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max
### Einmalige Übernahme aus KI Datenschutz
Velum enthält **keinen** Laufzeit-Zugriff auf die frühere Extension. Das eigenständige Skript `tools/migrate-ki-datenschutz.mjs` wandelt einen bereits anonymisierten KI-Datenschutz-Projektordner samt seiner eingefrorenen Variante-Tabelle in einen Velum-Projektstand um. Es verändert weder den alten Ordner noch die alte Tabelle. Der neue Stand erscheint anschließend unter `Projektdateien rückübersetzen`; Begriffe nur für dieses Projekt werden als Velum-Projektregeln übernommen. Alle Inhalte bleiben während der Migration lokal und werden nicht an eine API gesendet. Das Skript protokolliert nur Zahlen, keine Werte oder Dateinamen.
Velum enthält **keinen** Laufzeit-Zugriff auf die frühere Extension. Das eigenständige Skript `tools/migrate-ki-datenschutz.mjs` wandelt einen bereits anonymisierten KI-Datenschutz-Projektordner samt seiner eingefrorenen Variante-Tabelle in einen Velum-Projektstand um. Es verändert weder den alten Ordner noch die alte Tabelle. Der neue Stand erscheint anschließend unter `Projektdateien rückübersetzen`; Begriffe nur für dieses Projekt werden als Velum-Projektregeln übernommen. Alle Inhalte bleiben während der Migration lokal und werden nicht an eine API gesendet. Im Terminal protokolliert das Skript nur Zahlen, keine Werte oder Dateinamen.
1. In Raycast `Migrationsziel anzeigen` öffnen und den Velum-Datenordner kopieren.
2. Den alten Projektordner und die zugehörige Datei `projects/<Projekt-ID>.json` aus dem Datenordner von KI Datenschutz angeben. Zuerst ohne `--apply` prüfen:
2. Den alten Projektordner und die zugehörige Datei `projects/<Projekt-ID>.json` aus dem Datenordner von KI Datenschutz angeben. Mit `--preview-file` eine prüfbare Vorschau erstellen:
```bash
node tools/migrate-ki-datenschutz.mjs --project "/Pfad/zum/Projekt" --manifest "/Pfad/zu/projects/abcdef0123456789.json" --target-support "/kopierter/Velum-Datenordner"
node tools/migrate-ki-datenschutz.mjs --project "/Pfad/zum/Projekt" --manifest "/Pfad/zu/projects/abcdef0123456789.json" --target-support "/kopierter/Velum-Datenordner" --preview-file "$HOME/Downloads/Migrationsvorschau.md"
```
3. Denselben Befehl mit `--apply` ausführen. Danach den neuen Stand in Velum öffnen und den Klartext-Export mit einem Testdokument prüfen.
Die Datei zeigt Quell- und Zielnamen, alte und neue Platzhalter samt Häufigkeit pro Datei sowie übersprungene Pfade mit Grund. Sie enthält keine Klartextwerte aus der Zuordnungstabelle. Da Dateinamen trotzdem vertraulich sein können, wird sie nur lokal mit eingeschränkten Dateirechten erstellt und eine vorhandene Datei nie überschrieben. Der Vorschaupfad muss außerhalb des Projekts liegen. Ohne `--preview-file` bleibt es bei der Zahlenausgabe im Terminal.
3. Nach Prüfung denselben Befehl **ohne** `--preview-file`, dafür mit `--apply` ausführen. Danach den neuen Stand in Velum öffnen und den Klartext-Export mit einem Testdokument prüfen.
Das Skript übernimmt Text- und Codedateien. Binäre Dateien und bisher nicht anonymisierte Office/PDF-Dateien werden gezählt und ausgelassen; sie müssen im neuen Projektdateien-Befehl geprüft werden. Platzhalter, die in technischen Bezeichnern kleben, behalten ein eigenes Rückübersetzungsverhalten. Wenn die alte Variante-Tabelle mehrere Klartext-Schreibweisen für denselben Platzhalter enthält, ist die exakte frühere Schreibweise nicht mehr rekonstruierbar; das Skript verwendet dieselbe kontextabhängige Kanonisierung wie der alte Klartext-Export. Die alte Extension kann nach erfolgreicher Prüfung entfernt werden.

View File

@@ -16,11 +16,14 @@ it("migriert einen künstlichen Altbestand verlustarm ohne den Quellordner zu ä
const project = join(root, "Projekt");
const targetSupport = join(root, "VelumSupport");
const oldSupport = join(root, "OldSupport");
const previewFile = join(root, "Migrationsvorschau.md");
await fs.mkdir(project);
await fs.mkdir(join(oldSupport, "projects"), { recursive: true });
const legacyName = "KUNDE ALPHA.txt";
const legacyContent = "kundeAlphaService und KUNDE ALPHA";
await fs.writeFile(join(project, legacyName), legacyContent);
await fs.writeFile(join(project, "Handbuch.docx"), "office");
await fs.writeFile(join(project, "leer.txt"), "Ohne Platzhalter");
const projectId = createHash("sha1").update(project).digest("hex").slice(0, 16);
const manifestFile = join(oldSupport, "projects", `${projectId}.json`);
await fs.writeFile(manifestFile, JSON.stringify({
@@ -30,11 +33,24 @@ it("migriert einen künstlichen Altbestand verlustarm ohne den Quellordner zu ä
{ termId: "t1", termVariant: "muellerGmbh", placeholderVariant: "kundeAlpha", style: "camel", boundary: "identifier" },
],
}));
const preview = await migrate({ project, manifestFile, targetSupport });
const preview = await migrate({ project, manifestFile, targetSupport, previewFile });
expect(preview.applied).toBe(false);
expect(preview.summary.files).toBe(1);
expect(preview.summary.files).toBe(2);
expect(preview.summary.skipped).toBe(1);
const report = await fs.readFile(previewFile, "utf8");
expect(report).toContain('"KUNDE ALPHA.txt"');
expect(report).toContain('"KUNDE_100001.txt"');
expect(report).toContain('"kundeAlpha" → "KUNDE_100002" (1×)');
expect(report).toContain('"Handbuch.docx": Office- oder PDF-Datei');
expect(report).toContain("Keine Änderung.");
expect(report).not.toContain("Müller GmbH");
expect((await fs.stat(previewFile)).mode & 0o777).toBe(0o600);
expect(await fs.readFile(join(project, legacyName), "utf8")).toBe(legacyContent);
expect(await fs.readdir(root)).not.toContain("VelumSupport");
await expect(migrate({ project, manifestFile, targetSupport, previewFile })).rejects.toMatchObject({ code: "EEXIST" });
await expect(migrate({ project, manifestFile, targetSupport, previewFile, apply: true })).rejects.toThrow();
await expect(migrate({ project, manifestFile, targetSupport,
previewFile: join(project, "vorschau.md") })).rejects.toThrow();
const result = await migrate({ project, manifestFile, targetSupport, apply: true });
expect(result.applied).toBe(true);

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;