From a6ce04039745fd1a6310fd6543ae13de26e8c8a4 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Thu, 15 Jan 2026 10:28:16 +0100 Subject: [PATCH] Notifications - Word difference on HTML style notifications should be configurable, add tests. Re #3740 --- .../settings/templates/settings.html | 4 ++ changedetectionio/forms.py | 1 + changedetectionio/model/App.py | 1 + changedetectionio/notification/handler.py | 12 +++-- changedetectionio/notification_service.py | 1 + .../templates/_common_fields.html | 54 +++++++++---------- changedetectionio/tests/test_notification.py | 35 +++++++----- changedetectionio/tests/util.py | 16 ++++-- 8 files changed, 73 insertions(+), 51 deletions(-) diff --git a/changedetectionio/blueprint/settings/templates/settings.html b/changedetectionio/blueprint/settings/templates/settings.html index ff47edeea..6b73309e0 100644 --- a/changedetectionio/blueprint/settings/templates/settings.html +++ b/changedetectionio/blueprint/settings/templates/settings.html @@ -86,6 +86,10 @@
{{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} +
+
+ {{ render_checkbox_field(form.application.form.notification_html_word_diff_enabled) }} + HTML notifications - Use "word by word" difference where possible.
{{ render_field(form.application.form.base_url, class="m-d") }} diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index f8eff6a78..7c0af7e2e 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -991,6 +991,7 @@ class globalSettingsApplicationForm(commonSettingsForm): render_kw={"placeholder": os.getenv('BASE_URL', 'Not set')} ) empty_pages_are_a_change = BooleanField(_l('Treat empty pages as a change?'), default=False) + notification_html_word_diff_enabled = BooleanField(_l('Notification HTML as word-by-word difference'), default=True, validators=[validators.Optional()]) fetch_backend = RadioField(_l('Fetch Method'), default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) global_ignore_text = StringListField(_l('Ignore Text'), [ValidateListRegex()]) global_subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)]) diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py index 59421af20..c4f177b84 100644 --- a/changedetectionio/model/App.py +++ b/changedetectionio/model/App.py @@ -49,6 +49,7 @@ class model(dict): 'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison 'notification_body': default_notification_body, 'notification_format': default_notification_format, + 'notification_html_word_diff': True, 'notification_title': default_notification_title, 'notification_urls': [], # Apprise URL list 'pager_size': 50, diff --git a/changedetectionio/notification/handler.py b/changedetectionio/notification/handler.py index fbc068467..6c4e4776f 100644 --- a/changedetectionio/notification/handler.py +++ b/changedetectionio/notification/handler.py @@ -309,6 +309,9 @@ def process_notification(n_object: NotificationContextData, datastore): if not isinstance(n_object, NotificationContextData): raise TypeError(f"Expected NotificationContextData, got {type(n_object)}") + if not n_object.get('notification_urls'): + return None + now = time.time() if n_object.get('notification_timestamp'): logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s") @@ -348,16 +351,15 @@ def process_notification(n_object: NotificationContextData, datastore): apprise.plugins.N_MGR.remove('discord') apprise.plugins.N_MGR.add(NotifyDiscordCustom, schemas='discord') - if not n_object.get('notification_urls'): - return None + # Should always be false for 'text' mode or its too hard to read, otherwise it's a setting (for html style). + word_diff_enable = requested_output_format_original == 'text' or ( + n_object.get('notification_html_word_diff_enabled', True) and requested_output_format_original.startswith('html')) n_object.update(add_rendered_diff_to_notification_vars( notification_scan_text=n_object.get('notification_body', '')+n_object.get('notification_title', ''), current_snapshot=n_object.get('current_snapshot'), prev_snapshot=n_object.get('prev_snapshot'), - # Should always be false for 'text' mode or its too hard to read - # But otherwise, this could be some setting - word_diff=False if requested_output_format_original == 'text' else True, + word_diff=word_diff_enable ) ) diff --git a/changedetectionio/notification_service.py b/changedetectionio/notification_service.py index eac9902e8..63e0ffb1c 100644 --- a/changedetectionio/notification_service.py +++ b/changedetectionio/notification_service.py @@ -250,6 +250,7 @@ class NotificationService: if n_object.get('notification_format') == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH: n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format') + n_object['notification_html_word_diff_enabled'] = self.datastore.data['settings']['application'].get('notification_html_word_diff_enabled', True) triggered_text = '' if len(trigger_text): diff --git a/changedetectionio/templates/_common_fields.html b/changedetectionio/templates/_common_fields.html index 36cc1b6b9..537028e18 100644 --- a/changedetectionio/templates/_common_fields.html +++ b/changedetectionio/templates/_common_fields.html @@ -1,5 +1,5 @@ -{% from '_helpers.html' import render_field %} +{% from '_helpers.html' import render_field, render_checkbox_field %} {% macro show_token_placeholders(extra_notification_token_placeholder_info, suffix="") %} @@ -8,9 +8,7 @@ Body for all notifications ‐ You can use Jinja2 templating in the notification title, body and URL, and tokens from below.
-
Show - token/placeholders -
+
Show extra help and tokens
{% endmacro %} @@ -151,28 +168,11 @@ {{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }} Title for all notifications
-
+
{{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }} {{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info) }} -
-
    -
  • - For JSON payloads, use |tojson without quotes for automatic escaping, for example - { "name": {{ '{{ watch_title|tojson }}' }} } -
  • -
  • - URL encoding, use |urlencode, for example - gets://hook-website.com/test.php?title={{ '{{ watch_title|urlencode }}' }} -
  • -
  • - Regular-expression replace, use |regex_replace, for example - {{ "{{ \"hello world 123\" | regex_replace('[0-9]+', 'no-more-numbers') }}" }} -
  • -
  • - For a complete reference of all Jinja2 built-in filters, users can refer to the https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters -
  • -
-
-
-
+
{{ render_field(form.notification_format , class="notification-format") }} Format for all notifications
diff --git a/changedetectionio/tests/test_notification.py b/changedetectionio/tests/test_notification.py index be1b5316e..48e27cd08 100644 --- a/changedetectionio/tests/test_notification.py +++ b/changedetectionio/tests/test_notification.py @@ -532,7 +532,7 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem assert 'Current snapshot: Example text: example test' in x os.unlink(os.path.join(datastore_path, "notification.txt")) -def _test_color_notifications(client, notification_body_token, datastore_path): +def _test_color_notifications(client, notification_body_token, datastore_path, word_diff_enabled = True): set_original_response(datastore_path=datastore_path) @@ -551,6 +551,7 @@ def _test_color_notifications(client, notification_body_token, datastore_path): "application-minutes_between_check": 180, "application-notification_body": notification_body_token, "application-notification_format": "htmlcolor", + "application-notification_html_word_diff_enabled": 'y' if word_diff_enabled else '', "application-notification_urls": test_notification_url, "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", }, @@ -559,17 +560,13 @@ def _test_color_notifications(client, notification_body_token, datastore_path): assert b'Settings updated' in res.data test_url = url_for('test_endpoint', _external=True) - res = client.post( - url_for("ui.ui_views.form_quick_watch_add"), - data={"url": test_url, "tags": 'nice one'}, - follow_redirects=True - ) - - assert b"Watch added" in res.data + uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data wait_for_all_checks(client) - - set_modified_response(datastore_path=datastore_path) + extras='XXX ' if word_diff_enabled else '' + set_modified_response(datastore_path=datastore_path, extras=extras) res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) @@ -579,9 +576,13 @@ def _test_color_notifications(client, notification_body_token, datastore_path): wait_for_notification_endpoint_output(datastore_path=datastore_path) with open(os.path.join(datastore_path, "notification.txt"), 'r') as f: - x = f.read() + contents = f.read() s = f'Which is across multiple lines
' - assert s in x + assert s in contents + if word_diff_enabled: + assert '>XXX' in contents + else: + assert '>XXX' not in contents client.get( url_for("ui.form_delete", uuid="all"), @@ -590,6 +591,12 @@ def _test_color_notifications(client, notification_body_token, datastore_path): # Just checks the format of the colour notifications was correct def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path): - _test_color_notifications(client, '{{diff}}',datastore_path=datastore_path) - _test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path) + # Word-level diff only triggers when difflib.SequenceMatcher identifies a single-line to single-line replacement. + # If you have multiple changed lines close together, you need at least 1 unchanged content line (not empty) between them to + # prevent them from being grouped into a multi-line replacement that falls back to line-level diff. + _test_color_notifications(client, '{{diff}}',datastore_path=datastore_path, word_diff_enabled = True) + _test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path, word_diff_enabled = True) + + _test_color_notifications(client, '{{diff}}',datastore_path=datastore_path, word_diff_enabled = False) + _test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path, word_diff_enabled = False) \ No newline at end of file diff --git a/changedetectionio/tests/util.py b/changedetectionio/tests/util.py index 2b8dda989..b9a975134 100644 --- a/changedetectionio/tests/util.py +++ b/changedetectionio/tests/util.py @@ -7,7 +7,7 @@ import logging import time import os -def set_original_response(datastore_path, extra_title=''): +def set_original_response(datastore_path, extra_title='', extras=''): test_return_data = f""" head title{extra_title} @@ -15,6 +15,9 @@ def set_original_response(datastore_path, extra_title=''):

Which is across multiple lines


So let's see what happens.
+ with more text that helps word-diff if needed
+ and more text that helps word-diff if needed
+ and even more text {extras}that helps word-diff if needed
@@ -24,14 +27,17 @@ def set_original_response(datastore_path, extra_title=''): f.write(test_return_data) return None -def set_modified_response(datastore_path): - test_return_data = """ +def set_modified_response(datastore_path, extras=''): + test_return_data =f""" modified head title Some initial text

which has this one new line


So let's see what happens.
+ with more text that helps word-diff if needed
+ and more text that helps word-diff if needed
+ and even more text {extras}that helps word-diff if needed
""" @@ -92,8 +98,8 @@ def wait_for_notification_endpoint_output(datastore_path): #@todo - could check the apprise object directly instead of looking for this file from os.path import isfile notification_file = os.path.join(datastore_path, "notification.txt") - for i in range(1, 20): - time.sleep(1) + for i in range(1, 100): + time.sleep(0.3) if isfile(notification_file): return True