From 9b080bc860e3221dd4276cce34d16dcb4e6b93f8 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 3 Aug 2026 21:17:32 +0000 Subject: [PATCH] =?UTF-8?q?Normalization=20of=20=C3=A4,=20=C3=B6,=C3=BC=20?= =?UTF-8?q?and=20=C3=9F=20in=20filters=20and=20search=20of=20duplicates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- VERSION | 2 +- app/main.py | 44 +++++++++++++++++++++---- app/static/js/asset-duplicates.js | 9 ++++- app/static/js/asset-inventory.js | 6 ++-- app/static/js/table-tools.js | 32 ++++++++++++++++-- app/version.py | 2 +- docs/version-history/README.md | 3 +- docs/version-history/UPDATE-0.5.5.30.md | 10 ++++++ 9 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 docs/version-history/UPDATE-0.5.5.30.md diff --git a/README.md b/README.md index 9e174ae..89c654b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/VERSION b/VERSION index c8d277e..daf6f6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.29 +0.5.5.30 diff --git a/app/main.py b/app/main.py index 20dfd9c..56b2bb6 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/static/js/asset-duplicates.js b/app/static/js/asset-duplicates.js index 5dbbcbc..44cdc78 100644 --- a/app/static/js/asset-duplicates.js +++ b/app/static/js/asset-duplicates.js @@ -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; } diff --git a/app/static/js/asset-inventory.js b/app/static/js/asset-inventory.js index 974a2aa..39c7bbb 100644 --- a/app/static/js/asset-inventory.js +++ b/app/static/js/asset-inventory.js @@ -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); }); }); } diff --git a/app/static/js/table-tools.js b/app/static/js/table-tools.js index 03370eb..1f475a5 100644 --- a/app/static/js/table-tools.js +++ b/app/static/js/table-tools.js @@ -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; diff --git a/app/version.py b/app/version.py index 0c67d80..a800bd1 100644 --- a/app/version.py +++ b/app/version.py @@ -1,2 +1,2 @@ -APP_VERSION = "0.5.5.29" +APP_VERSION = "0.5.5.30" __version__ = APP_VERSION diff --git a/docs/version-history/README.md b/docs/version-history/README.md index e373df6..5981bc6 100644 --- a/docs/version-history/README.md +++ b/docs/version-history/README.md @@ -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) diff --git a/docs/version-history/UPDATE-0.5.5.30.md b/docs/version-history/UPDATE-0.5.5.30.md new file mode 100644 index 0000000..5840e43 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.30.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.