diff --git a/changedetectionio/notification_service.py b/changedetectionio/notification_service.py index eac9902e8..7046e652a 100644 --- a/changedetectionio/notification_service.py +++ b/changedetectionio/notification_service.py @@ -54,21 +54,110 @@ def _check_cascading_vars(datastore, var_name, watch): return None +class FormattableTimestamp(str): + """ + A str subclass representing a formatted datetime. As a plain string it renders + with the default format, but can also be called with a custom format argument + in Jinja2 templates: + + {{ change_datetime }} → '2024-01-15 10:30:00 UTC' + {{ change_datetime(format='%Y') }} → '2024' + {{ change_datetime(format='%A') }} → 'Monday' + + Being a str subclass means it is natively JSON serializable. + """ + _DEFAULT_FORMAT = '%Y-%m-%d %H:%M:%S %Z' + + def __new__(cls, timestamp): + dt = datetime.datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + local_tz = datetime.datetime.now().astimezone().tzinfo + dt_local = dt.astimezone(local_tz) + try: + formatted = dt_local.strftime(cls._DEFAULT_FORMAT) + except Exception: + formatted = dt_local.isoformat() + instance = super().__new__(cls, formatted) + instance._dt = dt_local + return instance + + def __call__(self, format=_DEFAULT_FORMAT): + try: + return self._dt.strftime(format) + except Exception: + return self._dt.isoformat() + + +class FormattableDiff(str): + """ + A str subclass representing a rendered diff. As a plain string it renders + with the default options for that variant, but can be called with custom + arguments in Jinja2 templates: + + {{ diff }} → default diff output + {{ diff(lines=5) }} → truncate to 5 lines + {{ diff(added_only=true) }} → only show added lines + {{ diff(removed_only=true) }} → only show removed lines + {{ diff(context=3) }} → 3 lines of context around changes + {{ diff(word_diff=false) }} → line-level diff instead of word-level + {{ diff(lines=10, added_only=true) }} → combine args + {{ diff_added(lines=5) }} → works on any diff_* variant too + + Being a str subclass means it is natively JSON serializable. + """ + def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs): + if prev_snapshot or current_snapshot: + from changedetectionio import diff as diff_module + rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs) + else: + rendered = '' + instance = super().__new__(cls, rendered) + instance._prev = prev_snapshot + instance._current = current_snapshot + instance._base_kwargs = base_kwargs + return instance + + def __call__(self, lines=None, added_only=False, removed_only=False, context=0, + word_diff=None, case_insensitive=False, ignore_junk=False): + from changedetectionio import diff as diff_module + kwargs = dict(self._base_kwargs) + + if added_only: + kwargs['include_removed'] = False + if removed_only: + kwargs['include_added'] = False + if context: + kwargs['context_lines'] = int(context) + if word_diff is not None: + kwargs['word_diff'] = bool(word_diff) + if case_insensitive: + kwargs['case_insensitive'] = True + if ignore_junk: + kwargs['ignore_junk'] = True + + result = diff_module.render_diff(self._prev or '', self._current or '', **kwargs) + + if lines is not None: + result = '\n'.join(result.splitlines()[:int(lines)]) + + return result + + # What is passed around as notification context, also used as the complete list of valid {{ tokens }} class NotificationContextData(dict): def __init__(self, initial_data=None, **kwargs): super().__init__({ 'base_url': None, + 'change_datetime': FormattableTimestamp(time.time()), 'current_snapshot': None, - 'diff': None, - 'diff_clean': None, - 'diff_added': None, - 'diff_added_clean': None, - 'diff_full': None, - 'diff_full_clean': None, - 'diff_patch': None, - 'diff_removed': None, - 'diff_removed_clean': None, + 'diff': FormattableDiff('', ''), + 'diff_clean': FormattableDiff('', '', include_change_type_prefix=False), + 'diff_added': FormattableDiff('', '', include_removed=False), + 'diff_added_clean': FormattableDiff('', '', include_removed=False, include_change_type_prefix=False), + 'diff_full': FormattableDiff('', '', include_equal=True), + 'diff_full_clean': FormattableDiff('', '', include_equal=True, include_change_type_prefix=False), + 'diff_patch': FormattableDiff('', '', patch_format=True), + 'diff_removed': FormattableDiff('', '', include_added=False), + 'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False), 'diff_url': None, 'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen 'notification_timestamp': time.time(), @@ -103,7 +192,7 @@ class NotificationContextData(dict): So we can test the output in the notification body """ for key in self.keys(): - if key in ['uuid', 'time', 'watch_uuid']: + if key in ['uuid', 'time', 'watch_uuid', 'change_datetime'] or key.startswith('diff'): continue rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12)) self[key] = rand_str @@ -150,13 +239,12 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap Returns: dict: Only the diff placeholders that were found in notification_scan_text, with rendered content """ - from changedetectionio import diff import re - from functools import lru_cache now = time.time() - # Define specifications for each diff variant + # Define base kwargs for each diff variant — these become the stored defaults + # on the FormattableDiff object, so {{ diff(lines=5) }} overrides on top of them diff_specs = { 'diff': {'word_diff': word_diff}, 'diff_clean': {'word_diff': word_diff, 'include_change_type_prefix': False}, @@ -169,22 +257,15 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap 'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False}, } - # Memoize render_diff to avoid duplicate renders with same kwargs - @lru_cache(maxsize=4) - def cached_render(kwargs_tuple): - return diff.render_diff(prev_snapshot, current_snapshot, **dict(kwargs_tuple)) - ret = {} rendered_count = 0 - # Only check and render diff keys that exist in NotificationContextData + # Only create FormattableDiff objects for diff keys actually used in the notification text for key in NotificationContextData().keys(): if key.startswith('diff') and key in diff_specs: # Check if this placeholder is actually used in the notification text pattern = rf"(? {{ '{{diff}}' }} - {{ _('The diff output - only changes, additions, and removals') }} + {{ _('The diff output - only changes, additions, and removals') }}
+ + {{ _('All diff variants accept') }} lines=, context=, word_diff=, ignore_junk= {{ _('args, e.g.') }} + {{ '{{diff(lines=10)}}' }}, {{ '{{diff_added(lines=5, context=2)}}' }} + + {{ '{{diff_clean}}' }} diff --git a/changedetectionio/tests/test_notification.py b/changedetectionio/tests/test_notification.py index 568727e8f..d06ad62db 100644 --- a/changedetectionio/tests/test_notification.py +++ b/changedetectionio/tests/test_notification.py @@ -107,6 +107,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore "Diff Added: {{diff_added}}\n" "Diff Removed: {{diff_removed}}\n" "Diff Full: {{diff_full}}\n" + "Diff with args: {{diff(context=3)}}" "Diff as Patch: {{diff_patch}}\n" ":-)", "notification_screenshot": True,