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}}' }}
{{ '{{diff_changed_from}}' }}{{ '{{diff_changed_to}}' }}{{ '{{current_snapshot}}' }}