diff --git a/changedetectionio/diff/__init__.py b/changedetectionio/diff/__init__.py index 044604b7c..ba7dac9c0 100644 --- a/changedetectionio/diff/__init__.py +++ b/changedetectionio/diff/__init__.py @@ -45,6 +45,36 @@ CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED' # Compiled regex patterns for performance WHITESPACE_NORMALIZE_RE = re.compile(r'\s+') +# Regexes built from the constants above — no brittle hardcoded strings +_EXTRACT_REMOVED_RE = re.compile( + re.escape(REMOVED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(REMOVED_PLACEMARKER_CLOSED) + + r'|' + + re.escape(CHANGED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_PLACEMARKER_CLOSED) +) +_EXTRACT_ADDED_RE = re.compile( + re.escape(ADDED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(ADDED_PLACEMARKER_CLOSED) + + r'|' + + re.escape(CHANGED_INTO_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_INTO_PLACEMARKER_CLOSED) +) + + +def extract_changed_from(raw_diff: str) -> str: + """Extract only the removed/changed-from fragments from a raw diff string. + + Useful for {{diff_changed_from}} — gives just the old value (e.g. old price), + not the full surrounding line. Multiple fragments joined with newlines. + """ + return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_REMOVED_RE.finditer(raw_diff)) + + +def extract_changed_to(raw_diff: str) -> str: + """Extract only the added/changed-into fragments from a raw diff string. + + Useful for {{diff_changed_to}} — gives just the new value (e.g. new price), + not the full surrounding line. Multiple fragments joined with newlines. + """ + return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_ADDED_RE.finditer(raw_diff)) + def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]: """ diff --git a/changedetectionio/notification_service.py b/changedetectionio/notification_service.py index 3290ff7b5..b136d1056 100644 --- a/changedetectionio/notification_service.py +++ b/changedetectionio/notification_service.py @@ -88,6 +88,28 @@ class FormattableTimestamp(str): return self._dt.isoformat() +class FormattableExtract(str): + """ + A str subclass that holds only the extracted changed fragments from a diff. + Used for {{diff_changed_from}} and {{diff_changed_to}} tokens. + + {{ diff_changed_from }} → old value(s) only, e.g. "$99.99" + {{ diff_changed_to }} → new value(s) only, e.g. "$109.99" + + Multiple changed fragments are joined with newlines. + Being a str subclass means it is natively JSON serializable. + """ + def __new__(cls, prev_snapshot, current_snapshot, extract_fn): + if prev_snapshot or current_snapshot: + from changedetectionio import diff as diff_module + raw = diff_module.render_diff(prev_snapshot, current_snapshot, word_diff=True) + extracted = extract_fn(raw) + else: + extracted = '' + instance = super().__new__(cls, extracted) + return instance + + class FormattableDiff(str): """ A str subclass representing a rendered diff. As a plain string it renders @@ -161,6 +183,8 @@ class NotificationContextData(dict): '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_changed_from': FormattableExtract('', '', extract_fn=lambda x: x), + 'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x), 'diff_url': None, 'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen 'notification_timestamp': time.time(), @@ -244,16 +268,27 @@ 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}, } + from changedetectionio.diff import extract_changed_from, extract_changed_to + extract_specs = { + 'diff_changed_from': extract_changed_from, + 'diff_changed_to': extract_changed_to, + } + ret = {} rendered_count = 0 - # Only create FormattableDiff objects for diff keys actually used in the notification text + # Only create FormattableDiff/FormattableExtract 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_patch}}' }} {{ _('The diff output - patch in unified format') }} + + {{ '{{diff_changed_from}}' }} + {{ _('Only the changed fragments from the previous version — e.g. the old price. Multiple changes joined by newline.') }} + + + {{ '{{diff_changed_to}}' }} + {{ _('Only the changed fragments from the new version — e.g. the new price. Multiple changes joined by newline.') }} + {{ '{{current_snapshot}}' }} {{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }} diff --git a/changedetectionio/tests/smtp/test_notification_smtp.py b/changedetectionio/tests/smtp/test_notification_smtp.py index 01ee33902..ec646c8c8 100644 --- a/changedetectionio/tests/smtp/test_notification_smtp.py +++ b/changedetectionio/tests/smtp/test_notification_smtp.py @@ -11,10 +11,10 @@ from changedetectionio.tests.util import set_original_response, set_modified_res set_longer_modified_response, delete_all_watches import logging - +import os # NOTE - RELIES ON mailserver as hostname running, see github build recipes -smtp_test_server = 'mailserver' +smtp_test_server = os.getenv('SMTP_TEST_MAILSERVER', 'mailserver') ALL_MARKUP_TOKENS = ''.join(f"TOKEN: '{t}'\n{{{{{t}}}}}\n" for t in NotificationContextData().keys()) diff --git a/changedetectionio/tests/unit/test_notification_diff.py b/changedetectionio/tests/unit/test_notification_diff.py index a03002172..fa9fbfc94 100755 --- a/changedetectionio/tests/unit/test_notification_diff.py +++ b/changedetectionio/tests/unit/test_notification_diff.py @@ -15,7 +15,9 @@ from changedetectionio.diff import ( CHANGED_PLACEMARKER_OPEN, CHANGED_PLACEMARKER_CLOSED, CHANGED_INTO_PLACEMARKER_OPEN, - CHANGED_INTO_PLACEMARKER_CLOSED + CHANGED_INTO_PLACEMARKER_CLOSED, + extract_changed_from, + extract_changed_to, ) @@ -381,5 +383,72 @@ Line 3 with tabs and spaces""" self.assertNotIn('[-Line 2-]', output) self.assertNotIn('[+Line 2+]', output) + def test_diff_changed_from_to_word_level(self): + """Primary use case: extract just the old/new value from a changed line (e.g. price monitoring)""" + before = "Widget costs $99.99 per month" + after = "Widget costs $109.99 per month" + + raw = diff.render_diff(before, after, word_diff=True) + + self.assertEqual(extract_changed_from(raw), "$99.99") + self.assertEqual(extract_changed_to(raw), "$109.99") + + def test_diff_changed_from_to_multiple_changes(self): + """Multiple changed fragments on different lines are joined with newline. + An unchanged line between the two changes ensures each is a 1-to-1 replace, + so word_diff fires per line rather than falling back to multi-line block mode.""" + before = "Price $99\nunchanged\nTax $5" + after = "Price $149\nunchanged\nTax $12" + + raw = diff.render_diff(before, after, word_diff=True) + + self.assertEqual(extract_changed_from(raw), "$99\n$5") + self.assertEqual(extract_changed_to(raw), "$149\n$12") + + def test_diff_changed_from_to_pure_insert_delete(self): + """Pure line additions/deletions (no inline word diff) are also captured""" + before = "old line" + after = "new line" + + # word_diff=False forces line-level CHANGED markers + raw = diff.render_diff(before, after, word_diff=False) + + self.assertEqual(extract_changed_from(raw), "old line") + self.assertEqual(extract_changed_to(raw), "new line") + + def test_diff_changed_from_to_similar_numbers(self): + """$90.00 → $9.00 must not produce a partial match like '0.00'. + The tokenizer splits on whitespace only, so '$90.00' and '$9.00' are + each a single atomic token — diff never sees their internal characters.""" + before = "for sale $90.00" + after = "for sale $9.00" + + raw = diff.render_diff(before, after, word_diff=True) + + self.assertEqual(extract_changed_from(raw), "$90.00") + self.assertEqual(extract_changed_to(raw), "$9.00") + + def test_diff_changed_from_to_whole_line_replaced(self): + """When every token on the line changed (no common tokens), render_inline_word_diff + takes the whole_line_replaced path using CHANGED/CHANGED_INTO markers instead of + REMOVED/ADDED. Extraction must still work via the alternation in the regex.""" + before = "$99" + after = "$109" + + raw = diff.render_diff(before, after, word_diff=True) + + self.assertEqual(extract_changed_from(raw), "$99") + self.assertEqual(extract_changed_to(raw), "$109") + + def test_diff_changed_from_to_no_change(self): + """No changes → empty string""" + content = "nothing changed here" + + raw = diff.render_diff(content, content, word_diff=True) + + self.assertEqual(extract_changed_from(raw), "") + self.assertEqual(extract_changed_to(raw), "") + + if __name__ == '__main__': unittest.main()