diff --git a/changedetectionio/conditions.py b/changedetectionio/conditions.py new file mode 100644 index 000000000..9a28a9f9d --- /dev/null +++ b/changedetectionio/conditions.py @@ -0,0 +1,95 @@ +from json_logic import jsonLogic +from json_logic.builtins import BUILTINS +import re + +# List of all supported JSON Logic operators +operator_choices = [ + (">", "Greater Than"), + ("<", "Less Than"), + (">=", "Greater Than or Equal To"), + ("<=", "Less Than or Equal To"), + ("==", "Equals"), + ("!=", "Not Equals"), + ("in", "Contains"), + ("!in", "Does Not Contain"), + ("contains_regex", "Text Matches Regex"), + ("!contains_regex", "Text Does NOT Match Regex"), + ("changed > minutes", "Changed more than X minutes ago"), +# ("watch_uuid_changed", "Watch UUID had unviewed change"), +# ("watch_uuid_not_changed", "Watch UUID did NOT have unviewed change"), +# ("!!", "Is Truthy"), +# ("!", "Is Falsy"), +# ("and", "All Conditions Must Be True"), +# ("or", "At Least One Condition Must Be True"), +# ("max", "Maximum of Values"), +# ("min", "Minimum of Values"), +# ("+", "Addition"), +# ("-", "Subtraction"), +# ("*", "Multiplication"), +# ("/", "Division"), +# ("%", "Modulo"), +# ("log", "Logarithm"), +# ("if", "Conditional If-Else") +] + +# Fields available in the rules +field_choices = [ + ("extracted_number", "Extracted Number"), + ("diff_removed", "Diff Removed"), + ("diff_added", "Diff Added"), + ("page_text", "Page Text"), + ("page_title", "Page Title"), + ("watch_uuid", "Watch UUID"), + ("watch_history_length", "History Length"), + ("watch_history", "All Watch Text History"), + ("watch_check_count", "Watch Check Count") +] + + +# ✅ Custom function for case-insensitive regex matching +def contains_regex(_, text, pattern): + """Returns True if `text` contains `pattern` (case-insensitive regex match).""" + return bool(re.search(pattern, text, re.IGNORECASE)) + +# ✅ Custom function for NOT matching case-insensitive regex +def not_contains_regex(_, text, pattern): + """Returns True if `text` does NOT contain `pattern` (case-insensitive regex match).""" + return not bool(re.search(pattern, text, re.IGNORECASE)) + + +# ✅ Custom function to check if "watch_uuid" has changed +def watch_uuid_changed(_, previous_uuid, current_uuid): + """Returns True if the watch UUID has changed.""" + return previous_uuid != current_uuid + +# ✅ Custom function to check if "watch_uuid" has NOT changed +def watch_uuid_not_changed(_, previous_uuid, current_uuid): + """Returns True if the watch UUID has NOT changed.""" + return previous_uuid == current_uuid + +# Define the extended operations dictionary +CUSTOM_OPERATIONS = { + **BUILTINS, # Include all standard operators + "watch_uuid_changed": watch_uuid_changed, + "watch_uuid_not_changed": watch_uuid_not_changed, + "contains_regex": contains_regex, + "!contains_regex": not_contains_regex +} + + + +def run(ruleset, data): + """ + Execute a JSON Logic rule against given data. + + :param ruleset: JSON Logic rule dictionary. + :param data: Dictionary containing the facts. + :return: Boolean result of rule evaluation. + """ + + + try: + return jsonLogic(ruleset, data, CUSTOM_OPERATIONS) + except Exception as e: + print(f"❌ Error evaluating JSON Logic: {e}") + return False diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index f603e0121..713163fb6 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -758,6 +758,25 @@ def changedetection_app(config=None, datastore_o=None): for p in datastore.proxy_list: form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) + # Example JSON Rule + DEFAULT_RULE = { + "and": [ + {">": [{"var": "extracted_number"}, 5000]}, + {"<": [{"var": "extracted_number"}, 80000]}, + {"in": ["rock", {"var": "page_text"}]} + ] + } + form.conditions.pop_entry() # Remove the default empty row + for condition in DEFAULT_RULE["and"]: + operator, values = list(condition.items())[0] + field = values[0]["var"] if isinstance(values[0], dict) else values[1]["var"] + value = values[1] if isinstance(values[1], (str, int)) else values[0] + + form.conditions.append_entry({ + "operator": operator, + "field": field, + "value": value + }) if request.method == 'POST' and form.validate(): @@ -793,6 +812,19 @@ def changedetection_app(config=None, datastore_o=None): extra_update_obj['filter_text_replaced'] = True extra_update_obj['filter_text_removed'] = True + # Convert form input into JSON Logic format + extra_update_obj["conditions"] = { + "and": [ + { + form.conditions[i].operator.data: [ + {"var": form.conditions[i].field.data}, + form.conditions[i].value.data + ] + } + for i in range(len(form.conditions)) + ] + } + # Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs tag_uuids = [] if form.data.get('tags'): diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 11792d622..0a099e4ee 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -4,6 +4,10 @@ from loguru import logger from wtforms.widgets.core import TimeInput from changedetectionio.strtobool import strtobool +from flask_wtf import FlaskForm +from wtforms import SelectField, StringField, SubmitField, FieldList, FormField +from wtforms.validators import DataRequired, URL +from flask_wtf.file import FileField from wtforms import ( BooleanField, @@ -509,6 +513,23 @@ class quickWatchForm(Form): edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) + +# Condition Rule Form (for each rule row) +class ConditionForm(FlaskForm): + from .conditions import operator_choices, field_choices + + operator = SelectField( + "Operator", + choices=operator_choices, + validators=[DataRequired()] + ) + field = SelectField( + "Field", + choices=field_choices, + validators=[DataRequired()] + ) + value = StringField("Value", validators=[DataRequired()]) + # Common to a single watch and the global settings class commonSettingsForm(Form): from . import processors @@ -596,6 +617,9 @@ class processor_text_json_diff_form(commonSettingsForm): notification_muted = BooleanField('Notifications Muted / Off', default=False) notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False) + conditions = FieldList(FormField(ConditionForm), min_entries=1) # Add rule logic here + + def extra_tab_content(self): return None diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html index f970e608c..6ffe2a44a 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -6,6 +6,39 @@ + + + + +