mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-06-09 02:11:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fac3c9d71b |
@@ -45,6 +45,36 @@ CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'
|
|||||||
# Compiled regex patterns for performance
|
# Compiled regex patterns for performance
|
||||||
WHITESPACE_NORMALIZE_RE = re.compile(r'\s+')
|
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]:
|
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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -88,6 +88,28 @@ class FormattableTimestamp(str):
|
|||||||
return self._dt.isoformat()
|
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):
|
class FormattableDiff(str):
|
||||||
"""
|
"""
|
||||||
A str subclass representing a rendered diff. As a plain string it renders
|
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_patch': FormattableDiff('', '', patch_format=True),
|
||||||
'diff_removed': FormattableDiff('', '', include_added=False),
|
'diff_removed': FormattableDiff('', '', include_added=False),
|
||||||
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=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,
|
'diff_url': None,
|
||||||
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
||||||
'notification_timestamp': time.time(),
|
'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},
|
'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 = {}
|
ret = {}
|
||||||
rendered_count = 0
|
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():
|
for key in NotificationContextData().keys():
|
||||||
if key.startswith('diff') and key in diff_specs:
|
if not key.startswith('diff'):
|
||||||
# Check if this placeholder is actually used in the notification text
|
continue
|
||||||
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
|
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
|
||||||
if re.search(pattern, notification_scan_text, re.IGNORECASE):
|
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
|
||||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
continue
|
||||||
rendered_count += 1
|
if key in diff_specs:
|
||||||
|
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
||||||
|
rendered_count += 1
|
||||||
|
elif key in extract_specs:
|
||||||
|
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
|
||||||
|
rendered_count += 1
|
||||||
|
|
||||||
if rendered_count:
|
if rendered_count:
|
||||||
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")
|
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")
|
||||||
|
|||||||
@@ -98,6 +98,14 @@
|
|||||||
<td><code>{{ '{{diff_patch}}' }}</code></td>
|
<td><code>{{ '{{diff_patch}}' }}</code></td>
|
||||||
<td>{{ _('The diff output - patch in unified format') }}</td>
|
<td>{{ _('The diff output - patch in unified format') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ '{{diff_changed_from}}' }}</code></td>
|
||||||
|
<td>{{ _('Only the changed fragments from the previous version — e.g. the old price. Multiple changes joined by newline.') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ '{{diff_changed_to}}' }}</code></td>
|
||||||
|
<td>{{ _('Only the changed fragments from the new version — e.g. the new price. Multiple changes joined by newline.') }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{ '{{current_snapshot}}' }}</code></td>
|
<td><code>{{ '{{current_snapshot}}' }}</code></td>
|
||||||
<td>{{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }}
|
<td>{{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ from changedetectionio.tests.util import set_original_response, set_modified_res
|
|||||||
set_longer_modified_response, delete_all_watches
|
set_longer_modified_response, delete_all_watches
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
# NOTE - RELIES ON mailserver as hostname running, see github build recipes
|
# 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())
|
ALL_MARKUP_TOKENS = ''.join(f"TOKEN: '{t}'\n{{{{{t}}}}}\n" for t in NotificationContextData().keys())
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from changedetectionio.diff import (
|
|||||||
CHANGED_PLACEMARKER_OPEN,
|
CHANGED_PLACEMARKER_OPEN,
|
||||||
CHANGED_PLACEMARKER_CLOSED,
|
CHANGED_PLACEMARKER_CLOSED,
|
||||||
CHANGED_INTO_PLACEMARKER_OPEN,
|
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)
|
||||||
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user