#!/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 ? `(? { 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 --manifest --target-support [--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; } } }