normalization optimized

This commit is contained in:
2026-08-03 21:51:54 +00:00
parent bfc6128353
commit bd23858537
8 changed files with 95 additions and 46 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# AssetManager 0.5.5.31 # AssetManager 0.5.5.32
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. The project is released under the **Apache License 2.0** and may be used, modified, and redistributed for private and commercial purposes. AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. The project is released under the **Apache License 2.0** and may be used, modified, and redistributed for private and commercial purposes.
+1 -1
View File
@@ -1 +1 @@
0.5.5.31 0.5.5.32
+36 -19
View File
@@ -4869,40 +4869,57 @@ def _software_job_filter_values(request: Request) -> dict[str, str]:
def _normalize_filter_text(value: Any) -> str: def _normalize_filter_text(value: Any) -> str:
"""Normalize user-facing text for accent- and German umlaut-aware search.""" """Normalize text while preserving natural German vowel combinations."""
import re
import unicodedata import unicodedata
text = str(value or "").translate( text = str(value or "").casefold()
str.maketrans( protected = (
{ text.replace("ä", "\ue000")
"Ä": "Ae", .replace("ö", "\ue001")
"Ö": "Oe", .replace("ü", "\ue002")
"Ü": "Ue", .replace("ß", "\ue003")
"ä": "ae",
"ö": "oe",
"ü": "ue",
"": "SS",
"ß": "ss",
}
) )
) text = "".join(
return "".join(
character character
for character in unicodedata.normalize("NFKD", text.casefold()) for character in unicodedata.normalize("NFKD", protected)
if not unicodedata.combining(character) if not unicodedata.combining(character)
) )
text = (
text.replace("\ue000", "ä")
.replace("\ue001", "ö")
.replace("\ue002", "ü")
.replace("\ue003", "ß")
)
# Mark only plausible ASCII umlaut spellings. This prevents natural
# combinations in words such as "Bauer", "teuer" and "euer" from being
# treated as an umlaut while keeping Mueller/Müller and Hyaene/Hyäne equal.
text = re.sub(r"(^|[^aeiouäöü])([aou])e", r"\1\2~e", text)
return (
text.replace("ä", "a~e")
.replace("ö", "o~e")
.replace("ü", "u~e")
.replace("ß", "s~s")
)
def _sql_normalized_filter_text(column: Any): def _sql_normalized_filter_text(column: Any):
"""Return the SQL equivalent of :func:`_normalize_filter_text`.""" """Return the PostgreSQL equivalent of :func:`_normalize_filter_text`."""
expression = func.lower(func.coalesce(cast(column, String), "")) expression = func.lower(func.coalesce(cast(column, String), ""))
for source, target in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): expression = func.regexp_replace(
expression,
r"(^|[^aeiouäöü])([aou])e",
r"\1\2~e",
"g",
)
for source, target in (("ä", "a~e"), ("ö", "o~e"), ("ü", "u~e"), ("ß", "s~s")):
expression = func.replace(expression, source, target) expression = func.replace(expression, source, target)
return expression return expression
def _sql_text_contains(column: Any, value: str): def _sql_text_contains(column: Any, value: str):
"""Literal substring search with umlaut and ASCII spelling equivalence.""" """Literal substring search with conservative German umlaut equivalence."""
escaped = ( escaped = (
_normalize_filter_text(value) _normalize_filter_text(value)
.replace("\\", "\\\\") .replace("\\", "\\\\")
+3 -3
View File
@@ -47,9 +47,9 @@
} }
function normalize(value) { function normalize(value) {
const sharedNormalize = window.AssetManagerNormalizeSearchText; const sharedKey = window.AssetManagerGermanSearchKey;
if (typeof sharedNormalize === 'function') { if (typeof sharedKey === 'function') {
return sharedNormalize(value, { return sharedKey(value, {
caseSensitive: !caseInsensitive.checked, caseSensitive: !caseInsensitive.checked,
collapseWhitespace: true collapseWhitespace: true
}); });
+4 -4
View File
@@ -11,11 +11,11 @@
const table = document.getElementById('software-inventory-table'); const table = document.getElementById('software-inventory-table');
if (filter && table) { if (filter && table) {
filter.addEventListener('input', () => { filter.addEventListener('input', () => {
const normalize = window.AssetManagerNormalizeSearchText || const matches = window.AssetManagerSearchTextMatches ||
((value) => String(value || '').toLocaleLowerCase()); ((value, term) => String(value || '').toLocaleLowerCase().includes(String(term || '').toLocaleLowerCase()));
const needle = normalize(filter.value.trim()); const needle = filter.value.trim();
table.querySelectorAll('tbody tr').forEach((row) => { table.querySelectorAll('tbody tr').forEach((row) => {
row.hidden = Boolean(needle) && !normalize(row.textContent).includes(needle); row.hidden = Boolean(needle) && !matches(row.textContent, needle);
}); });
}); });
} }
+41 -17
View File
@@ -4,28 +4,52 @@
const collapseWhitespace = Boolean(options.collapseWhitespace); const collapseWhitespace = Boolean(options.collapseWhitespace);
let text = String(value ?? ''); 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 (collapseWhitespace) text = text.trim().replace(/\s+/g, ' ');
if (!caseSensitive) text = text.toLocaleLowerCase(); if (!caseSensitive) text = text.toLocaleLowerCase();
return text;
const protectedGerman = text
.replace(/ä/g, '\uE000')
.replace(/ö/g, '\uE001')
.replace(/ü/g, '\uE002')
.replace(/ß/g, '\uE003');
return protectedGerman
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\uE000/g, 'ä')
.replace(/\uE001/g, 'ö')
.replace(/\uE002/g, 'ü')
.replace(/\uE003/g, 'ß');
}
function germanSearchKey(value, options = {}) {
let text = normalizeSearchText(value, options);
// Mark only plausible ASCII umlaut spellings. The marker keeps a real or
// transliterated umlaut distinct from natural letter pairs in words such
// as "Bauer", "teuer" and "euer".
text = text.replace(/(^|[^aeiouäöü])([aou])e/g, '$1$2~e');
return text
.replace(/ä/g, 'a~e')
.replace(/ö/g, 'o~e')
.replace(/ü/g, 'u~e')
.replace(/ß/g, 's~s');
}
function searchTextMatches(value, term, options = {}) {
const directValue = normalizeSearchText(value, options);
const directTerm = normalizeSearchText(term, options);
if (!directTerm) return true;
if (directValue.includes(directTerm)) return true;
return germanSearchKey(value, options).includes(germanSearchKey(term, options));
} }
// Standalone filters and duplicate detection use the exact same comparison // Standalone filters and duplicate detection use the exact same comparison
// rules as the generic table filters. // rules as the generic table filters.
window.AssetManagerNormalizeSearchText = normalizeSearchText; window.AssetManagerNormalizeSearchText = normalizeSearchText;
window.AssetManagerGermanSearchKey = germanSearchKey;
window.AssetManagerSearchTextMatches = searchTextMatches;
const collator = new Intl.Collator(document.documentElement.lang || 'de', { const collator = new Intl.Collator(document.documentElement.lang || 'de', {
numeric: true, numeric: true,
@@ -387,7 +411,7 @@
const active = filterDefinitions const active = filterDefinitions
.map(definition => ({ .map(definition => ({
definition, definition,
term: normalizeSearchText(definition.input?.value.trim() || '') term: definition.input?.value.trim() || ''
})) }))
.filter(item => item.term); .filter(item => item.term);
@@ -415,7 +439,7 @@
if (definition.numericFilter) { if (definition.numericFilter) {
return !numericFilterMatches(value, term); return !numericFilterMatches(value, term);
} }
return !normalizeSearchText(value).includes(term); return !searchTextMatches(value, term);
}); });
row.hidden = hidden; row.hidden = hidden;
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.31" APP_VERSION = "0.5.5.32"
__version__ = APP_VERSION __version__ = APP_VERSION
+8
View File
@@ -0,0 +1,8 @@
# Version 0.5.5.32
## German search normalization refinement
- Distinguishes real or transliterated umlauts from natural vowel combinations.
- Keeps searches such as `Müller`/`Mueller` and `Hyäne`/`Hyaene` equivalent.
- Prevents short umlaut filters from matching words such as `Bauer`, `teuer` or `euer` merely because they contain `ue`.
- Applies the same comparison rules to generic table filters, the server-side job history filters, asset software filtering and duplicate detection.