diff --git a/actual/rules.py b/actual/rules.py index 6147b85..de1fd65 100644 --- a/actual/rules.py +++ b/actual/rules.py @@ -2,7 +2,9 @@ import datetime import enum +import re import typing +import unicodedata import pydantic @@ -12,6 +14,14 @@ from actual.schedules import Schedule +def get_normalized_string(value: str) -> str: + """Normalization of string for comparison. Uses lowercase and Canonical Decomposition. + + See https://github.com/actualbudget/actual/blob/a22160579d6e1f7a17213561cec79c321a14525b/packages/loot-core/src/shared/normalisation.ts + """ + return unicodedata.normalize("NFD", value.lower()) + + class ConditionType(enum.Enum): IS = "is" IS_APPROX = "isapprox" @@ -25,6 +35,7 @@ class ConditionType(enum.Enum): DOES_NOT_CONTAIN = "doesNotContain" NOT_ONE_OF = "notOneOf" IS_BETWEEN = "isbetween" + MATCHES = "matches" class ActionType(enum.Enum): @@ -67,7 +78,7 @@ def is_valid(self, operation: ConditionType) -> bool: if self == ValueType.DATE: return operation.value in ("is", "isapprox", "gt", "gte", "lt", "lte") elif self == ValueType.STRING: - return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf") + return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf", "matches") elif self == ValueType.ID: return operation.value in ("is", "isNot", "oneOf", "notOneOf") elif self == ValueType.NUMBER: @@ -130,6 +141,11 @@ def get_value( return datetime.datetime.strptime(str(value), "%Y%m%d").date() elif value_type is ValueType.BOOLEAN: return int(value) # database accepts 0 or 1 + elif value_type is ValueType.STRING: + if isinstance(value, list): + return [get_value(v, value_type) for v in value] + else: + return get_normalized_string(value) return value @@ -173,7 +189,9 @@ def condition_evaluation( elif op == ConditionType.ONE_OF: return true_value in self_value elif op == ConditionType.CONTAINS: - return self_value in true_value + return get_normalized_string(self_value) in get_normalized_string(true_value) + elif op == ConditionType.MATCHES: + return bool(re.match(self_value, true_value, re.IGNORECASE)) elif op == ConditionType.NOT_ONE_OF: return true_value not in self_value elif op == ConditionType.DOES_NOT_CONTAIN: diff --git a/tests/test_rules.py b/tests/test_rules.py index fce1352..bc28ec9 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -81,8 +81,19 @@ def test_string_condition(): assert Condition(field="notes", op="notOneOf", value=["foo", "bar"]).run(t) is False assert Condition(field="notes", op="contains", value="fo").run(t) is True assert Condition(field="notes", op="contains", value="foobar").run(t) is False + assert Condition(field="notes", op="matches", value="f.*").run(t) is True + assert Condition(field="notes", op="matches", value="g.*").run(t) is False assert Condition(field="notes", op="doesNotContain", value="foo").run(t) is False assert Condition(field="notes", op="doesNotContain", value="foobar").run(t) is True + # test the cases where the case do not match + assert Condition(field="notes", op="oneOf", value=["FOO", "BAR"]).run(t) is True + assert Condition(field="notes", op="notOneOf", value=["FOO", "BAR"]).run(t) is False + assert Condition(field="notes", op="contains", value="FO").run(t) is True + assert Condition(field="notes", op="contains", value="FOOBAR").run(t) is False + assert Condition(field="notes", op="matches", value="F.*").run(t) is True + assert Condition(field="notes", op="matches", value="G.*").run(t) is False + assert Condition(field="notes", op="doesNotContain", value="FOO").run(t) is False + assert Condition(field="notes", op="doesNotContain", value="FOOBAR").run(t) is True def test_numeric_condition():