Files
velum-raycast/tools/migrate-ki-datenschutz.mjs

293 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/** One-time, standalone converter. It is not imported by the Raycast extension. */
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
const styles = ["typed", "title", "lower", "upper", "camel", "pascal", "kebab",
"kebabUpper", "snake", "snakeUpper", "flat", "flatUpper", "camelKeep", "pascalKeep"];
const excluded = new Set([".git", "node_modules", "dist", "build", "out", ".next", ".nuxt",
".venv", "venv", "__pycache__", "target", "vendor", "coverage", ".cache", ".turbo",
".idea", ".vscode", "Pods", "DerivedData"]);
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// In URL-kodierten Dateinamen steht nach %2f unmittelbar ein "f" vor dem Platzhalter.
// Das ist ein Pfadtrenner, kein angrenzender Buchstabe des Bezeichners.
const strictPrefix = "(?:(?<![\\p{L}\\p{N}])|(?<=%2[fF]))";
const hasSeparator = (value) => /[^\p{L}\p{N}]/u.test(value);
const shape = (value) => !/\p{L}/u.test(value) ? "mixed"
: value === value.toUpperCase() ? "upper" : value === value.toLowerCase() ? "lower" : "mixed";
function canonical(pairs, attached) {
const sameShape = pairs.filter((pair) => hasSeparator(pair.termVariant) === hasSeparator(pairs[0].placeholderVariant));
const candidates = sameShape.length ? sameShape : pairs;
const wantedShape = shape(pairs[0].placeholderVariant);
return [...candidates].sort((a, b) => {
const preferred = Number((b.style === "typed") === !attached) - Number((a.style === "typed") === !attached);
if (preferred) return preferred;
const casing = Number(shape(b.termVariant) === wantedShape) - Number(shape(a.termVariant) === wantedShape);
return casing || styles.indexOf(a.style) - styles.indexOf(b.style);
})[0];
}
function category(type) {
return ({ Person: "PERSON", Firma: "FIRMA", Kunde: "KUNDE", System: "BEGRIFF",
Sonstiges: "BEGRIFF" })[type] ?? "BEGRIFF";
}
export function createConverter(manifest, globalTerms = []) {
if (manifest.version !== 1 || !Array.isArray(manifest.variantTable)) {
throw new Error("Unbekanntes Tabellenformat.");
}
const terms = new Map([...globalTerms, ...(manifest.projectTerms ?? [])].map((term) => [term.id, term]));
const groups = new Map();
for (const pair of manifest.variantTable) {
if (typeof pair.placeholderVariant !== "string" || typeof pair.termVariant !== "string"
|| !pair.placeholderVariant || !pair.termVariant || typeof pair.termId !== "string") {
throw new Error("Ungültiger Eintrag in der alten Variante-Tabelle.");
}
const bucket = groups.get(pair.placeholderVariant) ?? [];
bucket.push(pair);
groups.set(pair.placeholderVariant, bucket);
}
const needles = [...groups].map(([needle, pairs]) => ({
needle, pairs, strict: pairs.every((pair) => pair.boundary === "strict"),
})).sort((a, b) => b.needle.length - a.needle.length || a.needle.localeCompare(b.needle));
const lookup = new Map(needles.map((item) => [item.needle, item]));
const pattern = needles.length ? new RegExp(needles.map((item) => item.strict
? `${strictPrefix}${escapeRegex(item.needle)}(?![\\p{L}\\p{N}])`
: escapeRegex(item.needle)).join("|"), "gu") : null;
const mapping = {};
const matchModes = {};
const allocated = new Map();
const counters = new Map();
let replacements = 0;
function convert(input, onReplacement) {
if (!pattern) return input;
pattern.lastIndex = 0;
return input.replace(pattern, (matched, offset, whole) => {
const item = lookup.get(matched);
if (!item) return matched;
const before = offset > 0 ? whole[offset - 1] : "";
const after = whole[offset + matched.length] ?? "";
const attached = /[\p{L}\p{N}_-]/u.test(before) || /[\p{L}\p{N}_-]/u.test(after);
const chosen = canonical(item.pairs, attached);
const type = category(terms.get(chosen.termId)?.category);
const key = `${matched}\0${attached ? "attached" : "standalone"}`;
let placeholder = allocated.get(key);
if (!placeholder) {
// Keep imported identifiers clear of ordinary low-numbered Velum IDs.
let number = counters.get(type) ?? 100000;
do { number += 1; placeholder = `${type}_${String(number).padStart(2, "0")}`; }
while (groups.has(placeholder) || mapping[placeholder]);
counters.set(type, number);
if (Object.keys(mapping).length >= 5000) throw new Error("Mehr als 5.000 neue Platzhalter.");
mapping[placeholder] = { original: chosen.termVariant, type };
matchModes[placeholder] = attached ? "anywhere" : "token";
allocated.set(key, placeholder);
}
replacements += 1;
onReplacement?.(matched, placeholder);
return placeholder;
});
}
return { convert, mapping, matchModes, get replacements() { return replacements; } };
}
function safeRelative(path) {
const value = path.split("\\").join("/");
if (!value || value.startsWith("/") || value.split("/").some((part) => !part || part === "." || part === "..")) {
throw new Error("Ungültiger relativer Pfad.");
}
return value;
}
async function filesUnder(root) {
const files = [];
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()) { skip(path, "Symbolischer Link"); continue; }
if (entry.isDirectory()) {
if (excluded.has(entry.name)) { skip(path, "Ausgeschlossener Ordner"); continue; }
await walk(path);
} else if (entry.isFile()) {
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())) {
skip(path, "Vertrauliche Konfigurations- oder Schlüsseldatei");
continue;
}
files.push({ abs: path, rel: relative(root, path).split(sep).join("/") });
}
}
}
await walk(root);
return { files, skippedFiles };
}
function convertedPath(rel, convert) {
return safeRelative(rel.split("/").map(convert).join("/"));
}
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.");
}
const manifest = JSON.parse(await fs.readFile(resolve(manifestFile), "utf8"));
if (resolve(manifest.projectPath) !== root) throw new Error("Tabelle und Projektordner gehören nicht zusammen.");
let globalTerms = [];
try {
const termFile = join(dirname(dirname(resolve(manifestFile))), "terms.json");
globalTerms = JSON.parse(await fs.readFile(termFile, "utf8")).terms ?? [];
} catch (error) {
if ((error).code !== "ENOENT") throw error;
}
const converter = createConverter(manifest, globalTerms);
const scan = await filesUnder(root);
const converted = [];
const seen = new Set();
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())) {
skip(file, "Office- oder PDF-Datei");
continue;
}
const bytes = await fs.readFile(file.abs);
if (bytes.includes(0)) { skip(file, "Binärdatei"); continue; }
let text;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
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, 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);
const projectData = join(resolve(targetSupport), "velum-projects", projectId);
const runDir = join(projectData, "runs", id);
const run = {
version: 2, origin: "imported", id, projectPath: root, projectName: basename(root), createdAt: new Date().toISOString(),
mapping: converter.mapping, matchModes: converter.matchModes,
files: converted.map(({ source, output, kind }) => ({ source, output, kind })),
};
const summary = { files: converted.length, skipped: skippedFiles.length, replacements: converter.replacements,
placeholders: Object.keys(converter.mapping).length };
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;
try {
for (const file of converted) {
const dest = resolve(stage, "files", file.output);
if (!dest.startsWith(resolve(stage, "files") + sep)) throw new Error("Zielpfad außerhalb des Projektstands.");
await fs.mkdir(dirname(dest), { recursive: true, mode: 0o700 });
await fs.writeFile(dest, file.content, { encoding: "utf8", mode: 0o600 });
}
await fs.writeFile(join(stage, "run.json"), JSON.stringify(run), { encoding: "utf8", mode: 0o600 });
await fs.mkdir(dirname(runDir), { recursive: true, mode: 0o700 });
await fs.rename(stage, runDir);
committed = true;
const projectRules = (manifest.projectTerms ?? []).filter((term) => term.enabled).flatMap((term) => {
const type = category(term.category);
return [term.term, ...(term.aliases ?? []).map((alias) => alias.text)].map((value) => ({ type, value }));
});
const rulesFile = join(projectData, "rules.json");
try { await fs.writeFile(rulesFile, JSON.stringify(projectRules), { encoding: "utf8", flag: "wx", mode: 0o600 }); }
catch (error) { if (error.code !== "EEXIST") throw error; }
} catch (error) {
await fs.rm(stage, { recursive: true, force: true });
if (committed) await fs.rm(runDir, { recursive: true, force: true });
throw error;
}
return { summary, applied: true, runDir };
}
function option(args, name) {
const index = args.indexOf(name);
return index < 0 ? undefined : args[index + 1];
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const project = option(args, "--project");
const manifestFile = option(args, "--manifest");
const targetSupport = option(args, "--target-support");
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, 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;
}
}
}