293 lines
15 KiB
JavaScript
293 lines
15 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, "\\$&");
|
||
// 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;
|
||
}
|
||
}
|
||
}
|