diff --git a/actual/rules.py b/actual/rules.py index bb7c611..0de8980 100644 --- a/actual/rules.py +++ b/actual/rules.py @@ -40,12 +40,15 @@ class ConditionType(enum.Enum): NOT_ONE_OF = "notOneOf" IS_BETWEEN = "isbetween" MATCHES = "matches" + HAS_TAGS = "hasTags" class ActionType(enum.Enum): SET = "set" SET_SPLIT_AMOUNT = "set-split-amount" LINK_SCHEDULE = "link-schedule" + PREPEND_NOTES = "prepend-notes" + APPEND_NOTES = "append-notes" class BetweenValue(pydantic.BaseModel): @@ -83,7 +86,16 @@ def is_valid(self, operation: ConditionType) -> bool: if self == ValueType.DATE: return operation.value in ("is", "isapprox", "gt", "gte", "lt", "lte") elif self in (ValueType.STRING, ValueType.IMPORTED_PAYEE): - return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf", "matches") + return operation.value in ( + "is", + "contains", + "oneOf", + "isNot", + "doesNotContain", + "notOneOf", + "matches", + "hasTags", + ) elif self == ValueType.ID: return operation.value in ("is", "isNot", "oneOf", "notOneOf") elif self == ValueType.NUMBER: @@ -211,6 +223,11 @@ def condition_evaluation( return self_value >= true_value elif op == ConditionType.IS_BETWEEN: return self_value.num_1 <= true_value <= self_value.num_2 + elif op == ConditionType.HAS_TAGS: + # this regex is not correct, but is good enough according to testing + # taken from https://stackoverflow.com/a/26740753/12681470 + tags = re.findall(r"\#[\U00002600-\U000027BF\U0001f300-\U0001f64F\U0001f680-\U0001f6FF\w-]+", self_value) + return any(tag in true_value for tag in tags) else: raise ActualError(f"Operation {op} not supported") @@ -346,6 +363,12 @@ def __str__(self) -> str: method = self.options.get("method") or "" split_index = self.options.get("splitIndex") or "" return f"allocate a {method} at Split {split_index}: {self.value}" + elif self.op in (ActionType.APPEND_NOTES, ActionType.PREPEND_NOTES): + return ( + f"append to notes '{self.value}'" + if self.op == ActionType.APPEND_NOTES + else f"prepend to notes '{self.value}'" + ) def as_dict(self): """Returns valid dict for database insertion.""" @@ -372,6 +395,9 @@ def check_operation_type(self): self.type = ValueType.ID elif self.op == ActionType.SET_SPLIT_AMOUNT: self.type = ValueType.NUMBER + # questionable choice from the developers to set it to ID, I hope they fix it at some point, but we change it + if self.op in (ActionType.APPEND_NOTES, ActionType.PREPEND_NOTES): + self.type = ValueType.STRING # if a pydantic object is provided and id is expected, extract the id if isinstance(self.value, pydantic.BaseModel) and hasattr(self.value, "id"): self.value = str(self.value.id) @@ -395,6 +421,16 @@ def run(self, transaction: Transactions) -> None: setattr(transaction, attr, value) elif self.op == ActionType.LINK_SCHEDULE: transaction.schedule_id = self.value + # for the notes rule, check if the rule was already applied since actual does not do that. + # this should ensure the prefix or suffix is not applied multiple times + elif self.op == ActionType.APPEND_NOTES: + notes = transaction.notes or "" + if not notes.endswith(self.value): + transaction.notes = f"{notes}{self.value}" + elif self.op == ActionType.PREPEND_NOTES: + notes = transaction.notes or "" + if not notes.startswith(self.value): + transaction.notes = f"{self.value}{notes}" else: raise ActualError(f"Operation {self.op} not supported") diff --git a/actual/version.py b/actual/version.py index 163ce2b..f29c654 100644 --- a/actual/version.py +++ b/actual/version.py @@ -1,2 +1,2 @@ -__version_info__ = ("0", "4", "1") +__version_info__ = ("0", "5", "0") __version__ = ".".join(__version_info__) diff --git a/tests/test_rules.py b/tests/test_rules.py index 9c1bab6..273fcde 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -87,7 +87,7 @@ def test_string_condition(): 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 + # case insensitive entries 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 @@ -98,6 +98,18 @@ def test_string_condition(): assert Condition(field="notes", op="doesNotContain", value="FOOBAR").run(t) is True +def test_has_tags(): + mock = MagicMock() + acct = create_account(mock, "Bank") + t = create_transaction(mock, datetime.date(2024, 1, 1), acct, "", "foo #bar #✨ #🙂‍↔️") + assert Condition(field="notes", op="hasTags", value="#bar").run(t) is True + assert Condition(field="notes", op="hasTags", value="#foo").run(t) is False + # test other unicode entries + assert Condition(field="notes", op="hasTags", value="#emoji #✨").run(t) is True + assert Condition(field="notes", op="hasTags", value="#🙂‍↔️").run(t) is True # new emojis should be supported + assert Condition(field="notes", op="hasTags", value="bar").run(t) is False # individual string will not match + + @pytest.mark.parametrize( "op,condition_value,value,expected_result", [ @@ -353,3 +365,23 @@ def test_set_split_amount_exception(session, mocker): session.flush() with pytest.raises(ActualSplitTransactionError): rs.run(t) + + +@pytest.mark.parametrize( + "operation,value,note,expected", + [ + ("append-notes", "bar", "foo", "foobar"), + ("prepend-notes", "bar", "foo", "barfoo"), + ("append-notes", "bar", None, "bar"), + ("prepend-notes", "bar", None, "bar"), + ], +) +def test_preppend_append_notes(operation, value, note, expected): + mock = MagicMock() + t = create_transaction(mock, datetime.date(2024, 1, 1), "Bank", "", notes=note) + action = Action(field="description", op=operation, value=value) + action.run(t) + assert t.notes == expected + action.run(t) # second iteration should not update the result + assert t.notes == expected + assert f"{operation.split('-')[0]} to notes '{value}'" in str(action) diff --git a/tests/test_schedules.py b/tests/test_schedules.py index fb51f47..d7dee96 100644 --- a/tests/test_schedules.py +++ b/tests/test_schedules.py @@ -70,6 +70,25 @@ def test_complex_schedules(): assert str(s) == "Every month on the last Sunday, 2nd Saturday, 10th, 31st, 5th (before weekend)" +def test_skip_weekend_schedule(): + s = Schedule.model_validate( + { + "start": "2024-09-14", + "interval": 1, + "frequency": "monthly", + "patterns": [], + "skipWeekend": True, + "weekendSolveMode": "after", + "endMode": "on_date", + "endOccurrences": 1, + "endDate": "2024-09-14", + } + ) + after = s.xafter(date(2024, 8, 14), count=2) + # we should ensure that dates that fall outside the endDate are not covered, even though actual will accept it + assert after == [] + + def test_is_approx(): # create schedule for every 1st and last day of the month (30th or 31st) s = Schedule.model_validate(