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
+1 -1
View File
@@ -1,4 +1,4 @@
# AssetManager 0.5.5.29 # AssetManager 0.5.5.30
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.29 0.5.5.30
+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): 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 = ( escaped = (
str(value or "") _normalize_filter_text(value)
.casefold()
.replace("\\", "\\\\") .replace("\\", "\\\\")
.replace("%", "\\%") .replace("%", "\\%")
.replace("_", "\\_") .replace("_", "\\_")
) )
return func.lower(func.coalesce(cast(column, String), "")).like( return _sql_normalized_filter_text(column).like(
f"%{escaped}%", f"%{escaped}%",
escape="\\", escape="\\",
) )
@@ -4894,7 +4926,7 @@ def _translated_filter_codes(
The translation dictionaries are cached on the request so one filter The translation dictionaries are cached on the request so one filter
request does not open a database session for every possible status value. 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: if not needle:
return [] return []
@@ -4920,7 +4952,7 @@ def _translated_filter_codes(
for code in codes: for code in codes:
key = f"{prefix}.{code}" key = f"{prefix}.{code}"
label = cache["own"].get(key) or cache["en"].get(key) or 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) matches.append(code)
return matches return matches
+8 -1
View File
@@ -47,7 +47,14 @@
} }
function normalize(value) { 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(); if (caseInsensitive.checked) result = result.toLocaleLowerCase();
return result; return result;
} }
+4 -2
View File
@@ -11,9 +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 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) => { 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', { const collator = new Intl.Collator(document.documentElement.lang || 'de', {
numeric: true, numeric: true,
sensitivity: 'base' sensitivity: 'base'
@@ -322,7 +350,7 @@
const active = filterDefinitions const active = filterDefinitions
.map(definition => ({ .map(definition => ({
definition, definition,
term: definition.input?.value.trim().toLocaleLowerCase() || '' term: normalizeSearchText(definition.input?.value.trim() || '')
})) }))
.filter(item => item.term); .filter(item => item.term);
@@ -350,7 +378,7 @@
if (definition.numericFilter) { if (definition.numericFilter) {
return !numericFilterMatches(value, term); return !numericFilterMatches(value, term);
} }
return !value.toLocaleLowerCase().includes(term); return !normalizeSearchText(value).includes(term);
}); });
row.hidden = hidden; 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 __version__ = APP_VERSION
+2 -1
View File
@@ -2,12 +2,13 @@
Release notes are stored outside the project root to keep the repository overview compact. Release notes are stored outside the project root to keep the repository overview compact.
The current release is **0.5.5.29**. The current release is **0.5.5.30**.
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details. Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
## Releases ## Releases
- [0.5.5.30](UPDATE-0.5.5.30.md)
- [0.5.5.29](UPDATE-0.5.5.29.md) - [0.5.5.29](UPDATE-0.5.5.29.md)
- [0.5.5.28](UPDATE-0.5.5.28.md) - [0.5.5.28](UPDATE-0.5.5.28.md)
- [0.5.5.27](UPDATE-0.5.5.27.md) - [0.5.5.27](UPDATE-0.5.5.27.md)
+10
View File
@@ -0,0 +1,10 @@
# Version 0.5.5.30
## Umlaut-aware table filtering and duplicate detection
- Normalizes German umlauts in all generic browser-side table filters.
- Treats `ä/ae`, `ö/oe`, `ü/ue`, and `ß/ss` as equivalent during filtering.
- Applies the same normalization to duplicate detection while preserving the existing case-sensitivity option.
- Applies the same behavior to the standalone software inventory filter.
- Applies equivalent normalization to the server-side recent-job filters so historical searches behave like browser-side tables.
- Keeps original values, display text, sorting, and stored data unchanged; normalization is used only for comparisons.