#!/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}]/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 --manifest --target-support [--preview-file | --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; } } }