feat(katalog): dublettenpruefung beim anlegen eines sets
Vor dem Speichern eines neuen Sets sucht die App nach Eintraegen, die dasselbe sein koennten: gleiche Setnummer beim gleichen Hersteller, sehr aehnlicher Name, oder dieselbe Nummer bei einem anderen Hersteller - letzteres meist ein Zeichen dafuer, dass der falsche Hersteller ausgewaehlt wurde. Die Kandidaten stehen als Liste mit Link im Dialog; "Trotzdem anlegen" bleibt einen Klick entfernt. Die Aehnlichkeit ist bewusst konservativ eingestellt: Tippfehler und abweichende Schreibweisen schlagen an, zwei echte Sets desselben Themas nicht. Die Schwelle und die Sonderfaelle (ein Name steckt im anderen, sehr kurze Namen) sind mit Beispielen aus dem echten Katalog getestet. Beim Bearbeiten laeuft die Pruefung nicht - dort waere der Eintrag, vor dem gewarnt wuerde, der eigene. Refs Phase 3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Name comparison for the duplicate check when a set is created.
|
||||
*
|
||||
* Pure and free of server imports so it can be unit tested - and it needs to
|
||||
* be, because "similar enough" is a judgement call that is easy to get wrong
|
||||
* in both directions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lowercases, strips punctuation and collapses whitespace, so that
|
||||
* "Feuerwehr-Auto" and "feuerwehr auto" are the same string.
|
||||
*/
|
||||
export function normalizeName(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Classic Levenshtein distance, iterative with a single row. */
|
||||
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;
|
||||
|
||||
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
||||
|
||||
for (let i = 1; i <= a.length; i += 1) {
|
||||
const current = [i];
|
||||
for (let j = 1; j <= b.length; j += 1) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
current[j] = Math.min(
|
||||
current[j - 1]! + 1, // insertion
|
||||
previous[j]! + 1, // deletion
|
||||
previous[j - 1]! + cost, // substitution
|
||||
);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
|
||||
return previous[b.length]!;
|
||||
}
|
||||
|
||||
/** 1 means identical, 0 means nothing in common. */
|
||||
export function similarity(a: string, b: string): number {
|
||||
const left = normalizeName(a);
|
||||
const right = normalizeName(b);
|
||||
if (left === right) return 1;
|
||||
if (left.length === 0 || right.length === 0) return 0;
|
||||
|
||||
const longest = Math.max(left.length, right.length);
|
||||
return 1 - levenshtein(left, right) / longest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Above this, two names are close enough to be worth asking about. Chosen so
|
||||
* that a typo or a different spelling triggers, while two genuinely different
|
||||
* sets of the same theme do not.
|
||||
*/
|
||||
export const SIMILAR_NAME_THRESHOLD = 0.82;
|
||||
|
||||
export function isSimilarName(a: string, b: string): boolean {
|
||||
const left = normalizeName(a);
|
||||
const right = normalizeName(b);
|
||||
|
||||
// One name fully contained in the other ("Feuerwehrauto" vs "Feuerwehrauto
|
||||
// mit Leiter") is a strong hint even when the length differs a lot.
|
||||
if (left.length >= 6 && right.length >= 6 && (left.includes(right) || right.includes(left))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return similarity(a, b) >= SIMILAR_NAME_THRESHOLD;
|
||||
}
|
||||
Reference in New Issue
Block a user