Normalization of ä, ö,ü and ß in filters and search of duplicates
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+38
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = "0.5.5.29"
|
||||
APP_VERSION = "0.5.5.30"
|
||||
__version__ = APP_VERSION
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
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.
|
||||
|
||||
## Releases
|
||||
|
||||
- [0.5.5.30](UPDATE-0.5.5.30.md)
|
||||
- [0.5.5.29](UPDATE-0.5.5.29.md)
|
||||
- [0.5.5.28](UPDATE-0.5.5.28.md)
|
||||
- [0.5.5.27](UPDATE-0.5.5.27.md)
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user