From 75ea4f75871c73f67b38d6250a7e28177d11841b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 10 Jun 2026 19:10:53 +0200 Subject: [PATCH] Complete selection value in domain parser using contains matcher Closes #14886 --- sao/src/common.js | 25 +++++++++++-------- sao/tests/index.html | 1 + sao/tests/sao.js | 23 +++++++++++++++-- tryton/tryton/common/domain_parser.py | 17 ++++++++++--- .../tryton/tests/test_common_domain_parser.py | 24 +++++++++++++++--- 5 files changed, 70 insertions(+), 20 deletions(-) diff --git a/sao/src/common.js b/sao/src/common.js index fa4975f1d7..a10c3ed841 100644 --- a/sao/src/common.js +++ b/sao/src/common.js @@ -1588,6 +1588,15 @@ } return results; }, + contains_without_diacritics: function(main, sub) { + main = main.normalize('NFD').toLowerCase(); + sub = sub.normalize('NFD').toLowerCase(); + + main = main.replace(/[\u0300-\u036f]/g, ''); + sub = sub.replace(/[\u0300-\u036f]/g, ''); + + return main.includes(sub); + }, complete_value: function(field, value) { var complete_boolean = function() { if ((value === null) || (value === undefined)) { @@ -1599,18 +1608,15 @@ } }; - var complete_selection = function() { + var complete_selection = () => { var results = []; var test_value = value !== null ? value : ''; if (value instanceof Array) { test_value = value[value.length - 1] || ''; } test_value = test_value.replace(/^%*|%*$/g, ''); - var i, len, svalue, test; - for (i=0, len=field.selection.length; i + Sao Test Suite diff --git a/sao/tests/sao.js b/sao/tests/sao.js index 19ff51cba4..08a10e6186 100644 --- a/sao/tests/sao.js +++ b/sao/tests/sao.js @@ -2427,6 +2427,25 @@ }); }); + QUnit.test('DomainParser.contains_without_diacritics', function() { + let parser = new Sao.common.DomainParser(); + for (let [main, sub, expected] of [ + ["Café au lait", "cafe", true], + ["Café au lait", "café", true], + ["Café au lait", "lait", true], + ["Café au lait", "the", false], + ["Héllò Wörld", "hello", true], + ["Héllò Wörld", "world", true], + ["Héllò Wörld", "hallo", false], + ]) { + QUnit.assert.strictEqual( + parser.contains_without_diacritics(main, sub), expected, + 'contains_without_diacritics(' + + JSON.stringify(main) + ',' + JSON.stringify(sub) + ')'); + } + + }); + QUnit.test('DomainParser.complete_value', function () { var parser = new Sao.common.DomainParser(); var field; @@ -2456,7 +2475,7 @@ ], }; [ - ['m', ['male']], + ['f', ['female']], ['test', []], ['', ['male', 'female']], [null, ['male', 'female']], @@ -2473,7 +2492,7 @@ ], }; [ - ['m', ['male']], + ['f', ['female']], ['test', []], ['', ['male', 'female', '']], [null, ['male', 'female', '']], diff --git a/tryton/tryton/common/domain_parser.py b/tryton/tryton/common/domain_parser.py index c0099123c1..42efab82c2 100644 --- a/tryton/tryton/common/domain_parser.py +++ b/tryton/tryton/common/domain_parser.py @@ -6,6 +6,7 @@ import gettext import io import locale import math +import unicodedata from collections import OrderedDict from decimal import Decimal from shlex import shlex @@ -426,6 +427,16 @@ def format_value(field, value, target=None, context=None, _quote_empty=False): lambda: value if value is not None else '')(), empty=_quote_empty) +def contains_without_diacritics(main, sub): + main = unicodedata.normalize('NFD', main.lower()) + sub = unicodedata.normalize('NFD', sub.lower()) + + main = ''.join(c for c in main if not unicodedata.combining(c)) + sub = ''.join(c for c in sub if not unicodedata.combining(c)) + + return sub in main + + def complete_value(field, value): "Complete value for field" @@ -444,7 +455,7 @@ def complete_value(field, value): test_value = value[-1] or '' test_value = test_value.strip('%') for svalue, test in field['selection']: - if test.lower().startswith(test_value.lower()): + if contains_without_diacritics(test, test_value): if isinstance(value, list): yield value[:-1] + [svalue] else: @@ -454,9 +465,9 @@ def complete_value(field, value): test_value = value if value is not None else '' if isinstance(value, list): test_value = value[-1] - test_value = test_value.strip('%') + test_value = test_value.strip('%').lower() for svalue, test in field['selection']: - if test.lower().startswith(test_value.lower()): + if contains_without_diacritics(test, test_value): if isinstance(value, list): yield value[:-1] + [svalue] else: diff --git a/tryton/tryton/tests/test_common_domain_parser.py b/tryton/tryton/tests/test_common_domain_parser.py index 4f250d6786..583c5c60c3 100644 --- a/tryton/tryton/tests/test_common_domain_parser.py +++ b/tryton/tryton/tests/test_common_domain_parser.py @@ -6,8 +6,9 @@ from unittest import TestCase from tryton.common import untimezoned_date from tryton.common.domain_parser import ( - DomainParser, complete_value, convert_value, format_value, group_operator, - likify, operatorize, parenthesize, quote, rlist, split_target_value, udlex) + DomainParser, complete_value, contains_without_diacritics, convert_value, + format_value, group_operator, likify, operatorize, parenthesize, quote, + rlist, split_target_value, udlex) class DomainParserTestCase(TestCase): @@ -433,6 +434,21 @@ class DomainParserTestCase(TestCase): format_value(field, value), result, msg="format_value(%r, %r)" % (field, value)) + def test_contains_without_diacritics(self): + "Test contains without diacritics" + for main, sub, expected in [ + ("Café au lait", "cafe", True), + ("Café au lait", "café", True), + ("Café au lait", "lait", True), + ("Café au lait", "the", False), + ("Héllò Wörld", "hello", True), + ("Héllò Wörld", "world", True), + ("Héllò Wörld", "hallo", False), + ]: + with self.subTest(main=main, sub=sub): + self.assertEqual( + contains_without_diacritics(main, sub), expected) + def test_complete_boolean(self): "Test complete boolean" field = { @@ -457,7 +473,7 @@ class DomainParserTestCase(TestCase): ], } for value, result in ( - ('m', ['male']), + ('f', ['female']), ('test', []), ('', ['male', 'female']), (None, ['male', 'female']), @@ -472,7 +488,7 @@ class DomainParserTestCase(TestCase): field_with_empty['selection'] = (field_with_empty['selection'] + [('', '')]) for value, result in ( - ('m', ['male']), + ('f', ['female']), ('test', []), ('', ['male', 'female', '']), (None, ['male', 'female', '']),