Repository: bvanelli/actualpy

Issue:
Rules 'contains' condition is case sensitive
### Checks

- [X] I have checked that this issue has not already been reported.
- [X] I have confirmed this bug exists on the latest version of actualpy.

### Reproducible example

```python
from actual import Actual

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
    actual.run_rules()
    actual.commit()
```


### Log output

_No response_

### Issue description

Hello,

It seems that when running the actual.run_rules() function, some rules that use the 'contains' comparator are not applied via the API because the condition evaluation is case sensitive (compared to a case insensitive comparison done by Actual in the GUI).

My guess is there is an issue inside the rules.py file, maybe the following part: (as `in` is not case insensitive)

```python
 elif op == ConditionType.CONTAINS:
        return self_value in true_value
```

### Expected behavior

```
Rule If all of these conditions match 'notes' contains 'LIDL' then set 'description' to '906286fc-88fb-4ec4-94c6-5e12a71126b5' matches for transferred_id=None category_id='541836f1-e756-4473-a5d0-6c1d3f06c7fa' sort_order=1720699638709.0 reconciled=1 amount=-7112 financial_id=None tombstone=0 acct='bfc9afd8-8787-4107-81cd-1a4cbc91d07c' payee_id='906286fc-88fb-4ec4-94c6-5e12a71126b5' type=None cleared=1 is_parent=0 location=None pending=0 schedule_id=None id='d2c01c7d-d8ae-4d4c-bc13-265cf10ac7d2' is_child=0 error=None parent_id=None notes='LIDL xxxxxxxxxx' imported_description='LIDL xxxxxxxxxxxxx' date=20240130 starting_balance_flag=0
```

The rule above only matches if i set the keyword to `LIDL` (capital letters). It should match in lowercase as well (it does this in Actual)

### Installed versions

- actualpy version: v0.2.0
- Actual Server version: v24.7.0


Discussion:

Patch:
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 @@ from __future__ import annotations
 
 import datetime
 import enum
+import re
 import typing
+import unicodedata
 
 import pydantic
 
@@ -12,6 +14,14 @@ from actual.database import BaseModel, Transactions, get_attribute_by_table_name
 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 @@ class ValueType(enum.Enum):
         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:


Test patch:
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():