Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
105 commits
Select commit Hold shift + click to select a range
7ca3373
Use server side "history" rendering
dgtlmoon Sep 23, 2025
a5faab6
remove diff min
dgtlmoon Sep 23, 2025
12a1c20
tweaking for test
dgtlmoon Sep 23, 2025
9eb4af1
Ignore text - adding test
dgtlmoon Sep 25, 2025
f36a979
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Sep 29, 2025
d87e170
WIP
dgtlmoon Sep 29, 2025
35c22c5
Remove debug
dgtlmoon Sep 29, 2025
6b03150
Update message
dgtlmoon Sep 29, 2025
7c8bbe6
remove debug
dgtlmoon Sep 29, 2025
25cb637
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Sep 29, 2025
5cfe758
Adding simple blocked text highlight test
dgtlmoon Sep 29, 2025
ff9f09b
Adding helper text
dgtlmoon Oct 1, 2025
2a69365
Adding "Strip ignored lines"
dgtlmoon Oct 1, 2025
2598eb7
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 1, 2025
ea5ae13
Improving diff
dgtlmoon Oct 3, 2025
363e822
Correctly connect case_insensitive option
dgtlmoon Oct 3, 2025
98745bb
fix test
dgtlmoon Oct 3, 2025
a57d046
Option to ignore junk/whitespace etc
dgtlmoon Oct 3, 2025
50958ee
text_json_diff/processor.py should also obey ignore_junk when special…
dgtlmoon Oct 3, 2025
4c764bd
fix for output
dgtlmoon Oct 3, 2025
c55b8f2
Adding LINE_SIMILARITY_THRESHOLD_FOR_WORD_DIFF
dgtlmoon Oct 3, 2025
be1b9ed
Add more content to test
dgtlmoon Oct 3, 2025
12e5f36
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 3, 2025
40418b2
use redlines library for better line-level word differences
dgtlmoon Oct 6, 2025
76951ef
remove spaces from around diff
dgtlmoon Oct 6, 2025
ef437e1
Small hack to make it act like the previous implementation (whole lin…
dgtlmoon Oct 6, 2025
0fbd9b2
tweaks
dgtlmoon Oct 6, 2025
10ff851
Adding custom formats
dgtlmoon Oct 6, 2025
ddeb907
WIP
dgtlmoon Oct 6, 2025
8a254ed
unit test fixes
dgtlmoon Oct 6, 2025
d7aac2f
Unify testing with actual defined labels
dgtlmoon Oct 6, 2025
824a1ce
WIP
dgtlmoon Oct 6, 2025
2e1e301
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 6, 2025
bd3e2dc
WIP
dgtlmoon Oct 6, 2025
1d3cadc
back on
dgtlmoon Oct 6, 2025
ea45c70
redlines hacks not needed
dgtlmoon Oct 6, 2025
0f6f2a9
WIP
dgtlmoon Oct 8, 2025
ab1b8e9
adding cookie preferences for form defaults
dgtlmoon Oct 8, 2025
5bbc33f
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 8, 2025
82b2bf5
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 9, 2025
2709ba6
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 13, 2025
961994a
refactor
dgtlmoon Oct 13, 2025
a389084
Lets go with line highlighting with sub words
dgtlmoon Oct 13, 2025
97b0e12
WIP
dgtlmoon Oct 13, 2025
a172d00
WIP
dgtlmoon Oct 13, 2025
cb31e6e
WIP
dgtlmoon Oct 13, 2025
6aba434
WIP
dgtlmoon Oct 13, 2025
6a28a6a
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 14, 2025
f750fa1
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 21, 2025
060fdcf
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 23, 2025
e66229d
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 25, 2025
10e3db5
WIP
dgtlmoon Oct 25, 2025
892ea6b
Fix markup
dgtlmoon Oct 25, 2025
ea7f2b1
oops
dgtlmoon Oct 25, 2025
650b179
Removing old vars, fixing tests
dgtlmoon Oct 25, 2025
a3a93d2
Adding API endpoint, rebuild docs
dgtlmoon Oct 25, 2025
5b5449e
Tidy tests and word_diff handling
dgtlmoon Oct 25, 2025
bce3b00
Needed some delay?
dgtlmoon Oct 25, 2025
2ad7d46
bump docs
dgtlmoon Oct 25, 2025
c407226
Rebuild docs
dgtlmoon Oct 25, 2025
63dfb39
tweak to API
dgtlmoon Oct 25, 2025
339106c
tweak docs again
dgtlmoon Oct 25, 2025
a3b3497
Fix test
dgtlmoon Oct 26, 2025
6c166ba
dont use word mode in text diff mode
dgtlmoon Oct 26, 2025
a8d06e9
No need to define line feed sep
dgtlmoon Oct 26, 2025
8be6b91
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 27, 2025
ea77845
Fixing patch check
dgtlmoon Oct 27, 2025
c227c43
and the rest of unit test
dgtlmoon Oct 27, 2025
95380cb
unused
dgtlmoon Oct 27, 2025
6f7d3b6
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 27, 2025
ea66231
Ensure linefeed is present on diff view
dgtlmoon Oct 27, 2025
21bf382
WIP
dgtlmoon Oct 27, 2025
a8192f6
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 28, 2025
08169c2
fix import
dgtlmoon Oct 28, 2025
0d03688
oops
dgtlmoon Oct 28, 2025
bf070e6
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 28, 2025
5070089
WIP
dgtlmoon Oct 29, 2025
d4a7f6f
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 29, 2025
3e48201
WIP
dgtlmoon Oct 29, 2025
3897653
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 30, 2025
c792453
misc improvements
dgtlmoon Oct 30, 2025
0cc98af
wip
dgtlmoon Oct 30, 2025
61f6bec
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Oct 31, 2025
843659d
Merging
dgtlmoon Oct 31, 2025
76c6a60
merge fix
dgtlmoon Oct 31, 2025
0e29b74
WIP
dgtlmoon Nov 1, 2025
168b4d4
Set the notification body at process time
dgtlmoon Nov 3, 2025
e0e7918
Only render/diff used diff* tokens
dgtlmoon Nov 3, 2025
be4f0e0
label fix
dgtlmoon Nov 3, 2025
7c3241d
WIP
dgtlmoon Nov 3, 2025
bdb2102
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Nov 3, 2025
4168696
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Nov 4, 2025
a7fd101
WIP
dgtlmoon Nov 4, 2025
c315021
refactor form widgets
dgtlmoon Nov 4, 2025
3cfc976
Label fixes etc
dgtlmoon Nov 4, 2025
cfa9c19
optimise vars
dgtlmoon Nov 4, 2025
b22ed7e
Adding keyboard nav
dgtlmoon Nov 4, 2025
f7a9c29
UI tweaks
dgtlmoon Nov 4, 2025
2c0b5b6
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Nov 7, 2025
59e6be3
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Nov 13, 2025
ebd7f7c
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Nov 13, 2025
a8d1bf4
separate out the difference renderer to the processor
dgtlmoon Nov 13, 2025
5510cbc
test fix
dgtlmoon Nov 13, 2025
6dc1f55
Merge branch 'master' into history-preview-ignore-text-highlighting
dgtlmoon Nov 14, 2025
c84171a
UI tweaks
dgtlmoon Nov 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 111 additions & 1 deletion changedetectionio/api/Watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from changedetectionio.validate_url import is_safe_valid_url

from flask_expects_json import expects_json
from changedetectionio import queuedWatchMetaData
from changedetectionio import queuedWatchMetaData, strtobool
from changedetectionio import worker_handler
from flask_restful import abort, Resource
from flask import request, make_response, send_from_directory
Expand All @@ -12,6 +12,8 @@

# Import schemas from __init__.py
from . import schema, schema_create_watch, schema_update_watch, validate_openapi_request
from ..notification import valid_notification_formats
from ..notification.handler import newline_re


def validate_time_between_check_required(json_data):
Expand Down Expand Up @@ -181,6 +183,114 @@ def get(self, uuid, timestamp):

return response

class WatchHistoryDiff(Resource):
"""
Generate diff between two historical snapshots.

Note: This API endpoint currently returns text-based diffs and works best
with the text_json_diff processor. Future processor types (like image_diff,
restock_diff) may want to implement their own specialized API endpoints
for returning processor-specific data (e.g., price charts, image comparisons).

The web UI diff page (/diff/<uuid>) is processor-aware and delegates rendering
to processors/{type}/difference.py::render() for processor-specific visualizations.
"""
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']

@auth.check_token
@validate_openapi_request('getWatchHistoryDiff')
def get(self, uuid, from_timestamp, to_timestamp):
"""Generate diff between two historical snapshots."""
from changedetectionio import diff
from changedetectionio.notification.handler import apply_service_tweaks

watch = self.datastore.data['watching'].get(uuid)
if not watch:
abort(404, message=f"No watch exists with the UUID of {uuid}")

if not len(watch.history):
abort(404, message=f"Watch found but no history exists for the UUID {uuid}")

history_keys = list(watch.history.keys())

# Handle 'latest' keyword for to_timestamp
if to_timestamp == 'latest':
to_timestamp = history_keys[-1]

# Handle 'previous' keyword for from_timestamp (second-most-recent)
if from_timestamp == 'previous':
if len(history_keys) < 2:
abort(404, message=f"Not enough history entries. Need at least 2 snapshots for 'previous'")
from_timestamp = history_keys[-2]

# Validate timestamps exist
if from_timestamp not in watch.history:
abort(404, message=f"From timestamp {from_timestamp} not found in watch history")
if to_timestamp not in watch.history:
abort(404, message=f"To timestamp {to_timestamp} not found in watch history")

# Get the format parameter (default to 'text')
output_format = request.args.get('format', 'text').lower()

# Validate format
if output_format not in valid_notification_formats.keys():
abort(400, message=f"Invalid format. Must be one of: {', '.join(valid_notification_formats.keys())}")

# Get the word_diff parameter (default to False - line-level mode)
word_diff = strtobool(request.args.get('word_diff', 'false'))

# Get the no_markup parameter (default to False)
no_markup = strtobool(request.args.get('no_markup', 'false'))

# Retrieve snapshot contents
from_version_file_contents = watch.get_history_snapshot(from_timestamp)
to_version_file_contents = watch.get_history_snapshot(to_timestamp)

# Get diff preferences (using defaults similar to the existing code)
diff_prefs = {
'diff_ignoreWhitespace': False,
'diff_changesOnly': True
}

# Generate the diff
content = diff.render_diff(
previous_version_file_contents=from_version_file_contents,
newest_version_file_contents=to_version_file_contents,
ignore_junk=diff_prefs.get('diff_ignoreWhitespace'),
include_equal=not diff_prefs.get('diff_changesOnly'),
word_diff=word_diff,
)

# Skip formatting if no_markup is set
if no_markup:
mimetype = "text/plain"
else:
# Apply formatting based on the requested format
if output_format == 'htmlcolor':
from changedetectionio.notification.handler import apply_html_color_to_body
content = apply_html_color_to_body(n_body=content)
mimetype = "text/html"
else:
# Apply service tweaks for text/html formats
# Pass empty URL and title as they're not used for the placeholder replacement we need
_, content, _ = apply_service_tweaks(
url='',
n_body=content,
n_title='',
requested_output_format=output_format
)
mimetype = "text/html" if output_format == 'html' else "text/plain"

if 'html' in output_format:
content = newline_re.sub('<br>\r\n', content)

response = make_response(content, 200)
response.mimetype = mimetype
return response


class WatchFavicon(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
Expand Down
4 changes: 2 additions & 2 deletions changedetectionio/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def validate_openapi_request(operation_id):
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
from werkzeug.exceptions import BadRequest
try:
# Skip OpenAPI validation for GET requests since they don't have request bodies
if request.method.upper() != 'GET':
Expand All @@ -61,7 +62,6 @@ def wrapper(*args, **kwargs):
openapi_request = FlaskOpenAPIRequest(request)
result = spec.unmarshal_request(openapi_request)
if result.errors:
from werkzeug.exceptions import BadRequest
error_details = []
for error in result.errors:
error_details.append(str(error))
Expand All @@ -78,7 +78,7 @@ def wrapper(*args, **kwargs):
return decorator

# Import all API resources
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon
from .Watch import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, WatchFavicon
from .Tags import Tags, Tag
from .Import import Import
from .SystemInfo import SystemInfo
Expand Down
3 changes: 1 addition & 2 deletions changedetectionio/blueprint/ui/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,7 @@ def ajax_callback_send_notification_test(watch_uuid=None):
prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2])
current_snapshot = watch.get_history_snapshot(timestamp=dates[-1])

n_object.update(set_basic_notification_vars(snapshot_contents=snapshot_contents,
current_snapshot=current_snapshot,
n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text=trigger_text))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<ul id="highlightSnippetActions">
<li>
<button class="pure-button pure-button-primary" onclick="diffToJpeg()" title="Share diff as image">Share as Image</button>
</li>
<li>
<a class="pure-button pure-button-primary" data-mode="exact" href="javascript:void(0);">Ignore any lines matching</a>
</li>
<li>
<a class="pure-button pure-button-primary" data-mode="digit-regex" href="javascript:void(0);" >Ignore any lines matching excluding digits</a>
</li>
</ul>

<!-- if (/\d/.test(window.getSelection().toString())) { -->

Original file line number Diff line number Diff line change
Expand Up @@ -8,55 +8,83 @@
{% endif %}

const highlight_submit_ignore_url="{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}";
const watch_url= {{watch_a.link|tojson}};

// Initial scroll position: if set, scroll to this line number in #difference on page load
const initialScrollToLineNumber = {{ initial_scroll_line_number|default('null') }};
</script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/html2canvas.min.js"></script>
<script src="{{url_for('static_content', group='js', filename='plugins.js')}}"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/piexif.min.js"></script>
<script src="{{url_for('static_content', group='js', filename='snippet-to-image.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script>


<div id="settings">
<form class="pure-form " action="" method="GET" id="diff-form">
<form class="pure-form " action="{{ url_for("ui.ui_views.diff_history_page", uuid=uuid) }}" method="GET" id="diff-form">
<fieldset class="diff-fieldset">
{% if versions|length >= 1 %}
<strong>Compare</strong>
<del class="change"><span>from</span></del>
<select id="diff-version" name="from_version" class="needs-localtime">
{% for version in versions|reverse %}
<span style="white-space: nowrap;">
<label id="change-from" for="diff-from-version" class="from-to-label">From</label>
<select id="diff-from-version" name="from_version" class="needs-localtime">
{%- for version in versions|reverse -%}
<option value="{{ version }}" {% if version== from_version %} selected="" {% endif %}>
{{ version }}
{{ version }}{#{% if loop.index == 2 %} (Previous){% endif %}#}
</option>
{% endfor %}
{%- endfor -%}
</select>
<ins class="change"><span>to</span></ins>
<select id="current-version" name="to_version" class="needs-localtime">
{% for version in versions|reverse %}
</span>
<span style="white-space: nowrap;">
<label id="change-to" for="diff-to-version" class="from-to-label">To</label>
<select id="diff-to-version" name="to_version" class="needs-localtime">
{%- for version in versions|reverse -%}
<option value="{{ version }}" {% if version== to_version %} selected="" {% endif %}>
{{ version }}
{{ version }}{#{% if loop.first %} (Current){% endif %}#}
</option>
{% endfor %}
{%- endfor -%}
</select>
<button type="submit" class="pure-button pure-button-primary reset-margin">Go</button>
</span>
{#<button type="submit" class="pure-button pure-button-primary reset-margin">Go</button>#}
{% endif %}
</fieldset>
<fieldset>
<strong>Style</strong>
<label for="diffWords" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffWords" value="diffWords"> Words</label>
<label for="diffLines" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffLines" value="diffLines" checked=""> Lines</label>

<label for="diffChars" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffChars" value="diffChars"> Chars</label>
<!-- @todo - when mimetype is JSON, select this by default? -->
<label for="diffJson" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffJson" value="diffJson"> JSON</label>

<fieldset id="diff-style">
<span>
<label for="diffWords" class="pure-checkbox">
<input type="radio" name="type" id="diffWords" value="diffWords" {% if diff_prefs.type == 'diffWords' %}checked=""{% endif %}> Words</label>
</span>
<span>
<label for="diffLines" class="pure-checkbox">
<input type="radio" name="type" id="diffLines" value="diffLines" {% if diff_prefs.type == 'diffLines' %}checked=""{% endif %}> Lines</label>
</span>
<span>
<label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace">
<input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace" {% if diff_prefs.ignoreWhitespace %}checked=""{% endif %}> Ignore Whitespace</label>
</span>
<span>
<label for="changesOnly" class="pure-checkbox" id="label-diff-changes">
<input type="checkbox" id="changesOnly" name="changesOnly" {% if diff_prefs.changesOnly %}checked=""{% endif %}> Same/non-changed</label>
</span>
<span>
<label for="removed" class="pure-checkbox" id="label-diff-removed">
<input type="checkbox" id="removed" name="removed" {% if diff_prefs.removed %}checked=""{% endif %}> Removed</label>
</span>
<span>
<!-- https://github.com/kpdecker/jsdiff/issues/389 ? -->
<label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace">
<input type="checkbox" id="ignoreWhitespace" name="ignoreWhitespace"> Ignore Whitespace</label>
</span>
<label for="added" class="pure-checkbox" id="label-diff-added">
<input type="checkbox" id="added" name="added" {% if diff_prefs.added %}checked=""{% endif %}> Added</label>
</span>
<span>
<label for="replaced" class="pure-checkbox" id="label-diff-replaced">
<input type="checkbox" id="replaced" name="replaced" {% if diff_prefs.replaced %}checked=""{% endif %}> Replaced</label>
</span>
</fieldset>
{%- if versions|length >= 2 -%}
<div id="keyboard-nav">
<strong>Keyboard: </strong>
<a href="" class="pure-button pure-button-primary" id="btn-previous"> &larr; Previous</a>
&nbsp; <a class="pure-button pure-button-primary" id="btn-next" href=""> &rarr; Next</a>
</div>
{%- endif -%}
</form>

</div>

<div id="diff-jump">
Expand All @@ -76,7 +104,7 @@

<div id="diff-ui">
<div class="tab-pane-inner" id="error-text">
<div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago</div>
<div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago.</div>
<pre>
{{ last_error_text }}
</pre>
Expand All @@ -87,28 +115,21 @@
<img id="error-screenshot-img" style="max-width: 80%" alt="Current error-ing screenshot from most recent request" >
</div>

<div class="tab-pane-inner" id="text">
{% if password_enabled_and_share_is_off %}
<div class="tip">Pro-tip: You can enable <strong>"share access when password is enabled"</strong> from settings</div>
{% endif %}
<div class="tab-pane-inner" id="text">
{% if password_enabled_and_share_is_off %}
<div class="tip">Pro-tip: You can enable <strong>"share access when password is enabled"</strong> from settings.
</div>
{% endif %}
<div id="cell-diff-jump-visualiser">
{%- for cell in diff_cell_grid -%}
<div{% if cell.class %} class="{{ cell.class }}"{% endif %}></div>
{%- endfor -%}
</div>
<div class="snapshot-age">{{ from_version|format_timestamp_timeago }} {%- if note -%}<span class="note"><strong>{{ note }}</strong></span>{%- endif -%} <a href="{{ url_for("ui.ui_views.preview_page", uuid=uuid) }}">Goto single snapshot</a></div>
<pre id="difference" style="border-left: 2px solid #ddd;">{{ content| diff_unescape_difference_spans }}</pre>
</div>

<div class="snapshot-age">{{watch_a.snapshot_text_ctime|format_timestamp_timeago}}</div>

<table>
<tbody>
<tr>
<!-- just proof of concept copied straight from github.com/kpdecker/jsdiff -->
<td id="a" style="display: none;">{{from_version_file_contents}}</td>
<td id="b" style="display: none;">{{to_version_file_contents}}</td>
<td id="diff-col">
<span id="result" class="highlightable-filter"></span>
</td>
</tr>
</tbody>
</table>
Diff algorithm from the amazing <a href="https://github.com/kpdecker/jsdiff">github.com/kpdecker/jsdiff</a>
</div>
<div class="tab-pane-inner" id="screenshot">
<div class="tab-pane-inner" id="screenshot">
<div class="tip">
For now, Differences are performed on text, not graphically, only the latest screenshot is available.
</div>
Expand Down Expand Up @@ -159,8 +180,6 @@
<script>
const newest_version_timestamp = {{newest_version_timestamp}};
</script>
<script src="{{url_for('static_content', group='js', filename='diff.min.js')}}"></script>

<script src="{{url_for('static_content', group='js', filename='diff-render.js')}}"></script>


Expand Down
Loading
Loading