229 lines
11 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|