diff --git a/changedetectionio/api/Watch.py b/changedetectionio/api/Watch.py index 82173842e50..77721a1a806 100644 --- a/changedetectionio/api/Watch.py +++ b/changedetectionio/api/Watch.py @@ -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 @@ -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): @@ -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/) 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('
\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 diff --git a/changedetectionio/api/__init__.py b/changedetectionio/api/__init__.py index d8f9d8fdb65..088da8a1f0b 100644 --- a/changedetectionio/api/__init__.py +++ b/changedetectionio/api/__init__.py @@ -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': @@ -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)) @@ -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 diff --git a/changedetectionio/blueprint/ui/notification.py b/changedetectionio/blueprint/ui/notification.py index e73569fad15..e691532c1b7 100644 --- a/changedetectionio/blueprint/ui/notification.py +++ b/changedetectionio/blueprint/ui/notification.py @@ -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)) diff --git a/changedetectionio/blueprint/ui/templates/diff-offscreen-options.html b/changedetectionio/blueprint/ui/templates/diff-offscreen-options.html new file mode 100644 index 00000000000..7d738464f38 --- /dev/null +++ b/changedetectionio/blueprint/ui/templates/diff-offscreen-options.html @@ -0,0 +1,14 @@ + + + + diff --git a/changedetectionio/templates/diff.html b/changedetectionio/blueprint/ui/templates/diff.html similarity index 50% rename from changedetectionio/templates/diff.html rename to changedetectionio/blueprint/ui/templates/diff.html index f7fdf868602..827ece36b35 100644 --- a/changedetectionio/templates/diff.html +++ b/changedetectionio/blueprint/ui/templates/diff.html @@ -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') }}; + + + + +
-
+
{% if versions|length >= 1 %} - Compare - from - + {%- for version in versions|reverse -%} - {% endfor %} + {%- endfor -%} - to - + {%- for version in versions|reverse -%} - {% endfor %} + {%- endfor -%} - + + {##} {% endif %}
-
- Style - - - - - - - +
+ + + + + + + + + + + + + + + - - - + + + + +
+ {%- if versions|length >= 2 -%} +
+ Keyboard: + ← Previous +   → Next +
+ {%- endif -%} -
@@ -76,7 +104,7 @@
-
{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago
+
{{watch_a.error_text_ctime|format_seconds_ago}} seconds ago.
             {{ last_error_text }}
         
@@ -87,28 +115,21 @@ Current error-ing screenshot from most recent request
-
- {% if password_enabled_and_share_is_off %} -
Pro-tip: You can enable "share access when password is enabled" from settings
- {% endif %} +
+ {% if password_enabled_and_share_is_off %} +
Pro-tip: You can enable "share access when password is enabled" from settings. +
+ {% endif %} +
+ {%- for cell in diff_cell_grid -%} +
+ {%- endfor -%} +
+
{{ from_version|format_timestamp_timeago }} {%- if note -%}{{ note }}{%- endif -%} Goto single snapshot
+
{{ content| diff_unescape_difference_spans }}
+
-
{{watch_a.snapshot_text_ctime|format_timestamp_timeago}}
- - - - - - - - - - -
- -
- Diff algorithm from the amazing github.com/kpdecker/jsdiff -
-
+
For now, Differences are performed on text, not graphically, only the latest screenshot is available.
@@ -159,8 +180,6 @@ - - diff --git a/changedetectionio/blueprint/ui/templates/edit.html b/changedetectionio/blueprint/ui/templates/edit.html index bb4c50a0d36..eb5ae4f3448 100644 --- a/changedetectionio/blueprint/ui/templates/edit.html +++ b/changedetectionio/blueprint/ui/templates/edit.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, render_conditions_fieldlist_of_formfields_as_table, render_ternary_field %} +{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, highlight_trigger_ignored_explainer, render_conditions_fieldlist_of_formfields_as_table, render_ternary_field %} {% from '_common_fields.html' import render_common_settings_form %} @@ -351,21 +351,22 @@

Text filtering

diff --git a/changedetectionio/templates/preview.html b/changedetectionio/blueprint/ui/templates/preview.html similarity index 88% rename from changedetectionio/templates/preview.html rename to changedetectionio/blueprint/ui/templates/preview.html index 826fc041a66..f000fa2f796 100644 --- a/changedetectionio/templates/preview.html +++ b/changedetectionio/blueprint/ui/templates/preview.html @@ -1,9 +1,11 @@ {% extends 'base.html' %} - +{% from '_helpers.html' import highlight_trigger_ignored_explainer %} {% block content %} {% if versions|length >= 2 %} -
+

Production server

https://yourdomain.com/api/v1/watch/{uuid}/history

Custom server

-
{protocol}://{host}/api/v1/watch/{uuid}/history

Request samples

curl -X GET "http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/history" \
+
{protocol}://{host}/api/v1/watch/{uuid}/history

Request samples

curl -X GET "http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/history" \
   -H "x-api-key: YOUR_API_KEY"
 

Response samples

Content type
application/json
{
  • "1640995200": "/path/to/snapshot1.txt",
  • "1640998800": "/path/to/snapshot2.txt"
}

Snapshots