normalization optimized
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+36
-19
@@ -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("\\", "\\\\")
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = "0.5.5.31"
|
APP_VERSION = "0.5.5.32"
|
||||||
__version__ = APP_VERSION
|
__version__ = APP_VERSION
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user