/** * M2 Strukturanalyse — Dedup-Erkennung & Autovervollständigung für (primäre) * Informations-Assets (IMPL-tisax-wizard-neustruktur.md §2.1). * * `normalizeAssetName` erzeugt den Dedup-Schlüssel `Asset.normalizedName`, auf dem * die `@@unique([tenantId, normalizedName])` Exakt-Duplikate verhindert. Bei einem * Treffer wird das bestehende Asset VERKNÜPFT (ProcessAsset) statt neu angelegt. * * Die Normalisierung ist bewusst reine, DB-agnostische Logik (kein Prisma) — dieselbe * Funktion läuft im Server (Schreibpfad + searchAssets) und im Client (Combobox), damit * Vorschau und Persistenz denselben Schlüssel sehen. * * Reihenfolge (fix, §2.1): * trim → Mehrfach-Whitespace kollabieren → Unicode-NFC → toLowerCase("de") → * Umlaut-Faltung (ä→ae, ö→oe, ü→ue, ß→ss) → Satzzeichen entfernen. */ /** Umlaut-/ß-Faltung nach der Kleinschreibung (Reihenfolge relevant). */ function foldUmlauts(s: string): string { return s .replace(/ä/g, "ae") .replace(/ö/g, "oe") .replace(/ü/g, "ue") .replace(/ß/g, "ss"); } /** * Erzeugt den normalisierten Dedup-Schlüssel eines Asset-Namens. * Beispiel: „Kundendaten", „kundendaten", „ Kunden­daten " → alle `kundendaten`. * Liefert `""` für leere/nur-Satzzeichen-Eingaben (Aufrufer behandelt das als „kein Schlüssel"). */ export function normalizeAssetName(input: string): string { if (!input) return ""; let s = input.trim(); // Weiches Trennzeichen / Zero-Width entfernen, bevor Whitespace kollabiert wird. s = s.replace(/[­​-‍]/g, ""); s = s.replace(/\s+/g, " "); s = s.normalize("NFC"); s = s.toLocaleLowerCase("de"); s = foldUmlauts(s); // Satzzeichen/Sonderzeichen entfernen — nur Wortzeichen und Leerzeichen behalten, // dann erneut Whitespace normalisieren und trimmen. s = s.replace(/[^\p{L}\p{N} ]+/gu, ""); s = s.replace(/\s+/g, " ").trim(); return s; } /** * Levenshtein-Distanz (DB-agnostische Fuzzy-Nähe, §2.1). Wird für die WEICHE Warnung * („Meintest du …?") auf der Kandidatenliste genutzt — als Alternative zu Postgres * `pg_trgm.similarity()` (siehe Integrationsbericht). Iterativ, O(n·m) Zeit, O(min)-Speicher. */ export function levenshtein(a: string, b: string): number { if (a === b) return 0; if (a.length === 0) return b.length; if (b.length === 0) return a.length; // Kürzere Zeichenkette als Spaltenachse (weniger Speicher). if (a.length > b.length) [a, b] = [b, a]; let prev = Array.from({ length: a.length + 1 }, (_, i) => i); let curr = new Array(a.length + 1); for (let j = 1; j <= b.length; j++) { curr[0] = j; for (let i = 1; i <= a.length; i++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; curr[i] = Math.min(prev[i] + 1, curr[i - 1] + 1, prev[i - 1] + cost); } [prev, curr] = [curr, prev]; } return prev[a.length]; } /** * Ähnlichkeit 0..1 auf Basis der normalisierten Levenshtein-Distanz. * 1 = identisch. Für die weiche Fuzzy-Warnung ab Schwelle (Default 0.82). */ export function nameSimilarity(a: string, b: string): number { const na = normalizeAssetName(a); const nb = normalizeAssetName(b); if (!na && !nb) return 1; const max = Math.max(na.length, nb.length); if (max === 0) return 1; return 1 - levenshtein(na, nb) / max; } /** Schwelle, ab der ein Kandidat als Nah-Duplikat gewarnt wird (§2.1). */ export const FUZZY_SIMILARITY_THRESHOLD = 0.82;