Normalization of ä, ö,ü and ß in filters and search of duplicates

This commit is contained in:
2026-08-03 21:17:32 +00:00
parent b54b180115
commit 9b080bc860
9 changed files with 95 additions and 15 deletions
+38 -6
View File
@@ -4868,16 +4868,48 @@ def _software_job_filter_values(request: Request) -> dict[str, str]:
}
def _normalize_filter_text(value: Any) -> str:
"""Normalize user-facing text for accent- and German umlaut-aware search."""
import unicodedata
text = str(value or "").translate(
str.maketrans(
{
"Ä": "Ae",
"Ö": "Oe",
"Ü": "Ue",
"ä": "ae",
"ö": "oe",
"ü": "ue",
"": "SS",
"ß": "ss",
}
)
)
return "".join(
character
for character in unicodedata.normalize("NFKD", text.casefold())
if not unicodedata.combining(character)
)
def _sql_normalized_filter_text(column: Any):
"""Return the SQL equivalent of :func:`_normalize_filter_text`."""
expression = func.lower(func.coalesce(cast(column, String), ""))
for source, target in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
expression = func.replace(expression, source, target)
return expression
def _sql_text_contains(column: Any, value: str):
"""Case-insensitive literal substring search for SQL text expressions."""
"""Literal substring search with umlaut and ASCII spelling equivalence."""
escaped = (
str(value or "")
.casefold()
_normalize_filter_text(value)
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
return func.lower(func.coalesce(cast(column, String), "")).like(
return _sql_normalized_filter_text(column).like(
f"%{escaped}%",
escape="\\",
)
@@ -4894,7 +4926,7 @@ def _translated_filter_codes(
The translation dictionaries are cached on the request so one filter
request does not open a database session for every possible status value.
"""
needle = str(search_value or "").strip().casefold()
needle = _normalize_filter_text(str(search_value or "").strip())
if not needle:
return []
@@ -4920,7 +4952,7 @@ def _translated_filter_codes(
for code in codes:
key = f"{prefix}.{code}"
label = cache["own"].get(key) or cache["en"].get(key) or code
if needle in code.casefold() or needle in str(label).casefold():
if needle in _normalize_filter_text(code) or needle in _normalize_filter_text(label):
matches.append(code)
return matches
+8 -1
View File
@@ -47,7 +47,14 @@
}
function normalize(value) {
let result = value.trim().replace(/\s+/g, ' ');
const sharedNormalize = window.AssetManagerNormalizeSearchText;
if (typeof sharedNormalize === 'function') {
return sharedNormalize(value, {
caseSensitive: !caseInsensitive.checked,
collapseWhitespace: true
});
}
let result = String(value || '').trim().replace(/\s+/g, ' ');
if (caseInsensitive.checked) result = result.toLocaleLowerCase();
return result;
}
+4 -2
View File
@@ -11,9 +11,11 @@
const table = document.getElementById('software-inventory-table');
if (filter && table) {
filter.addEventListener('input', () => {
const needle = filter.value.trim().toLocaleLowerCase();
const normalize = window.AssetManagerNormalizeSearchText ||
((value) => String(value || '').toLocaleLowerCase());
const needle = normalize(filter.value.trim());
table.querySelectorAll('tbody tr').forEach((row) => {
row.hidden = needle && !row.textContent.toLocaleLowerCase().includes(needle);
row.hidden = Boolean(needle) && !normalize(row.textContent).includes(needle);
});
});
}
+30 -2
View File
@@ -1,4 +1,32 @@
(() => {
function normalizeSearchText(value, options = {}) {
const caseSensitive = Boolean(options.caseSensitive);
const collapseWhitespace = Boolean(options.collapseWhitespace);
let text = String(value ?? '');
// Preserve the common German transliterations before Unicode accent
// folding so searches treat umlauts and their ASCII spellings equally.
text = text
.replace(/Ä/g, 'Ae')
.replace(/Ö/g, 'Oe')
.replace(/Ü/g, 'Ue')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ẞ/g, 'SS')
.replace(/ß/g, 'ss')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '');
if (collapseWhitespace) text = text.trim().replace(/\s+/g, ' ');
if (!caseSensitive) text = text.toLocaleLowerCase();
return text;
}
// Standalone filters and duplicate detection use the exact same comparison
// rules as the generic table filters.
window.AssetManagerNormalizeSearchText = normalizeSearchText;
const collator = new Intl.Collator(document.documentElement.lang || 'de', {
numeric: true,
sensitivity: 'base'
@@ -322,7 +350,7 @@
const active = filterDefinitions
.map(definition => ({
definition,
term: definition.input?.value.trim().toLocaleLowerCase() || ''
term: normalizeSearchText(definition.input?.value.trim() || '')
}))
.filter(item => item.term);
@@ -350,7 +378,7 @@
if (definition.numericFilter) {
return !numericFilterMatches(value, term);
}
return !value.toLocaleLowerCase().includes(term);
return !normalizeSearchText(value).includes(term);
});
row.hidden = hidden;
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.29"
APP_VERSION = "0.5.5.30"
__version__ = APP_VERSION