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.
|
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):
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = "0.5.5.29"
|
APP_VERSION = "0.5.5.30"
|
||||||
__version__ = APP_VERSION
|
__version__ = APP_VERSION
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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