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

229 lines
11 KiB
JavaScript

#!/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, "\\$&");
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
? `(?<![\\p{L}\\p{N}])${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) {
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;
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 = [];
let skipped = 0;
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.isDirectory()) {
if (excluded.has(entry.name)) { skipped += 1; continue; }
await walk(path);
} else if (entry.isFile()) {
if (entry.name === ".aishield.json") continue;
if (entry.name === ".env" || entry.name.startsWith(".env.") ||
[".pem", ".key", ".p12", ".pfx"].includes(extname(entry.name).toLowerCase())) {
skipped += 1;
continue;
}
files.push({ abs: path, rel: relative(root, path).split(sep).join("/") });
}
}
}
await walk(root);
return { files, get skipped() { return skipped; } };
}
function convertedPath(rel, convert) {
return safeRelative(rel.split("/").map(convert).join("/"));
}
export async function migrate({ project, manifestFile, targetSupport, apply = false }) {
const root = resolve(project);
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();
let skipped = scan.skipped;
for (const file of scan.files) {
if ([".docx", ".xlsx", ".pptx", ".pdf"].includes(extname(file.rel).toLowerCase())) {
skipped += 1;
continue;
}
const bytes = await fs.readFile(file.abs);
if (bytes.includes(0)) { skipped += 1; continue; }
let text;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
catch { skipped += 1; continue; }
const output = convertedPath(file.rel, converter.convert);
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" });
}
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, replacements: converter.replacements,
placeholders: Object.keys(converter.mapping).length };
if (!apply) return { summary, applied: false };
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");
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");
process.exitCode = 2;
} else {
try {
const result = await migrate({ project, manifestFile, targetSupport, 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`);
} catch (error) {
process.stderr.write(`Migration fehlgeschlagen: ${error.message}\n`);
process.exitCode = 1;
}
}
}