From 8c2621080447a86f759d8ceabc9a7ca0219af706 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sun, 9 Feb 2025 00:03:01 +0100 Subject: [PATCH 01/36] Initial WIP for adding support for CONDITIONS --- changedetectionio/conditions.py | 95 +++++++++++++++++++++++++++ changedetectionio/flask_app.py | 32 +++++++++ changedetectionio/forms.py | 24 +++++++ changedetectionio/templates/edit.html | 65 ++++++++++++++++++ requirements.txt | 2 + 5 files changed, 218 insertions(+) create mode 100644 changedetectionio/conditions.py diff --git a/changedetectionio/conditions.py b/changedetectionio/conditions.py new file mode 100644 index 00000000000..9a28a9f9dd5 --- /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 f603e012171..713163fb606 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -758,6 +758,25 @@ def edit_page(uuid): 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 edit_page(uuid): 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 11792d6225c..0a099e4eec0 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -4,6 +4,10 @@ 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 f970e608c98..6ffe2a44a82 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -6,6 +6,39 @@ + + + + + @@ -323,28 +323,8 @@

Click here to Start

{{ render_field(form.conditions_match_logic) }} - - - - - - - - - - - {% for rule in form.conditions %} - - - - - - - {% endfor %} - -
In ValueOperatorValue
{{ rule.field() }}{{ rule.operator() }}{{ rule.value() }} - -
+ {{ render_fieldlist_of_formfields_as_table(form.conditions) }} +
From f67d98b8392a760dd44efbd8131d037d94f52815 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Fri, 14 Mar 2025 17:41:48 +0100 Subject: [PATCH 14/36] WIP --- changedetectionio/static/js/conditions.js | 68 +++++++++++++++++++++++ changedetectionio/templates/_helpers.html | 7 ++- changedetectionio/templates/edit.html | 32 +---------- 3 files changed, 73 insertions(+), 34 deletions(-) create mode 100644 changedetectionio/static/js/conditions.js diff --git a/changedetectionio/static/js/conditions.js b/changedetectionio/static/js/conditions.js new file mode 100644 index 00000000000..1b983b51473 --- /dev/null +++ b/changedetectionio/static/js/conditions.js @@ -0,0 +1,68 @@ +$(document).ready(function () { + // Function to set up button event handlers + function setupButtonHandlers() { + // Unbind existing handlers first to prevent duplicates + $(".addRuleRow, .removeRuleRow").off("click"); + + // Add row button handler + $(".addRuleRow").on("click", function(e) { + e.preventDefault(); + + let currentRow = $(this).closest("tr"); + + // Clone without events + let newRow = currentRow.clone(false); + + // Reset input values in the cloned row + newRow.find("input").val(""); + newRow.find("select").prop("selectedIndex", 0); + + // Insert the new row after the current one + currentRow.after(newRow); + + // Reindex all rows + reindexRules(); + }); + + // Remove row button handler + $(".removeRuleRow").on("click", function(e) { + e.preventDefault(); + + // Only remove if there's more than one row + if ($("#rulesTable tbody tr").length > 1) { + $(this).closest("tr").remove(); + reindexRules(); + } + }); + } + + // Function to reindex form elements and re-setup event handlers + function reindexRules() { + // Unbind all button handlers first + $(".addRuleRow, .removeRuleRow").off("click"); + + // Reindex all form elements + $("#rulesTable tbody tr").each(function(index) { + $(this).find("select, input").each(function() { + let oldName = $(this).attr("name"); + let oldId = $(this).attr("id"); + + if (oldName) { + let newName = oldName.replace(/\d+/, index); + $(this).attr("name", newName); + } + + if (oldId) { + let newId = oldId.replace(/\d+/, index); + $(this).attr("id", newId); + } + }); + }); + + // Reattach event handlers after reindexing + setupButtonHandlers(); + } + + // Initial setup of button handlers + setupButtonHandlers(); +}); diff --git a/changedetectionio/templates/_helpers.html b/changedetectionio/templates/_helpers.html index ab66f63321c..d0eec6dfe8c 100644 --- a/changedetectionio/templates/_helpers.html +++ b/changedetectionio/templates/_helpers.html @@ -61,8 +61,8 @@ {{ field(**kwargs)|safe }} {% endmacro %} -{% macro render_fieldlist_of_formfields_as_table(fieldlist) %} - +{% macro render_fieldlist_of_formfields_as_table(fieldlist, table_id="rulesTable") %} +
{% for subfield in fieldlist[0] %} @@ -87,7 +87,8 @@ {% endfor %} {% endfor %} diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html index c7bae704879..ae82c9df864 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -6,37 +6,7 @@ - - - +
- + +