diff --git a/changedetectionio/processors/difference_base.py b/changedetectionio/processors/difference_base.py new file mode 100644 index 00000000..662006be --- /dev/null +++ b/changedetectionio/processors/difference_base.py @@ -0,0 +1,97 @@ +""" +Base contract + shared helpers for a processor's history/diff ("difference") page. + +Each processor can provide a custom history/diff view by shipping a +``processors//difference.py`` module that exposes a module-level ``render(...)`` +function. The route ``blueprint/ui/diff.py::diff_history_page`` resolves it via +``get_processor_submodule(processor_name, 'difference')`` and calls ``render(...)`` with the +keyword contract below; processors without one fall back to ``text_json_diff``. + +This module formalises that previously-implicit contract: + * ``DifferenceRenderer`` documents the ``render(...)`` signature (for typing/static checks). + * ``resolve_diff_versions()`` centralises the from/to version selection that was duplicated + between ``text_json_diff/difference.py`` and the ``/diff/.../llm-summary`` route, so every + processor resolves "which two snapshots am I comparing" identically. +""" +from dataclasses import dataclass +from typing import Any, List, Protocol, runtime_checkable + +from loguru import logger + + +@runtime_checkable +class DifferenceRenderer(Protocol): + """The contract a processor's ``difference.py`` module must satisfy. + + Implemented as a module-level function (not a class method), so a conforming module looks + like:: + + def render(watch, datastore, request, url_for, render_template, flash, redirect, + extract_form=None): + ... + return render_template(...) # a Flask response + + Returns a Flask response (rendered template / redirect / make_response). + """ + + def render(self, watch, datastore, request, url_for, render_template, flash, redirect, + extract_form: Any = None) -> Any: + ... + + +@dataclass +class DiffVersions: + """The resolved comparison window for a difference page.""" + dates: List[str] # all history timestamps (string keys), oldest -> newest + from_version: str # older snapshot timestamp being compared + to_version: str # newer snapshot timestamp being compared + from_contents: str # raw snapshot text for from_version + to_contents: str # raw snapshot text for to_version + viewing_latest: bool # True when to_version is the newest snapshot + note: str # human note when not viewing the latest changes ('' otherwise) + + +def resolve_diff_versions(watch, request) -> DiffVersions: + """Resolve which two snapshots a difference page should compare. + + Default ``from_version`` is the snapshot closest to the user's last view (so the page opens + on "what changed since I last looked"), falling back to the second-newest snapshot. Default + ``to_version`` is the newest snapshot. Both are overridable via ``?from_version=`` / + ``?to_version=`` query args. The caller is responsible for guaranteeing at least two + snapshots exist (the route already redirects otherwise). + + Note: read the versions BEFORE calling ``datastore.set_last_viewed()`` — the default + ``from_version`` depends on the pre-update last-viewed timestamp. + """ + dates = list(watch.history.keys()) + + best_last_viewed = watch.get_from_version_based_on_last_viewed + from_default = best_last_viewed if best_last_viewed else dates[-2] + from_version = request.args.get('from_version', from_default) + to_version = request.args.get('to_version', dates[-1]) + + try: + to_contents = watch.get_history_snapshot(timestamp=to_version) + except Exception as e: + logger.error(f"Unable to read watch history to-version for version {to_version}: {str(e)}") + to_contents = f"Unable to read to-version at {to_version}.\n" + + try: + from_contents = watch.get_history_snapshot(timestamp=from_version) + except Exception as e: + logger.error(f"Unable to read watch history from-version for version {from_version}: {str(e)}") + from_contents = f"Unable to read from-version {from_version}.\n" + + note = '' + if str(from_version) != str(dates[-2]) or str(to_version) != str(dates[-1]): + note = 'Note: You are not viewing the latest changes.' + + return DiffVersions( + dates=dates, + from_version=str(from_version), + to_version=str(to_version), + from_contents=from_contents, + to_contents=to_contents, + viewing_latest=str(to_version) == str(dates[-1]), + note=note, + ) diff --git a/changedetectionio/processors/text_json_diff/difference.py b/changedetectionio/processors/text_json_diff/difference.py index 625400ce..f27c5651 100644 --- a/changedetectionio/processors/text_json_diff/difference.py +++ b/changedetectionio/processors/text_json_diff/difference.py @@ -129,30 +129,14 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect, extract_form = forms.extractDataForm(formdata=request.form, data={'extract_regex': request.form.get('extract_regex', '')} ) - history = watch.history - dates = list(history.keys()) - - # If a "from_version" was requested, then find it (or the closest one) - # Also set "from version" to be the closest version to the one that was last viewed. - - best_last_viewed_timestamp = watch.get_from_version_based_on_last_viewed - from_version_timestamp = best_last_viewed_timestamp if best_last_viewed_timestamp else dates[-2] - from_version = request.args.get('from_version', from_version_timestamp ) - - # Use the current one if nothing was specified - to_version = request.args.get('to_version', str(dates[-1])) - - try: - to_version_file_contents = watch.get_history_snapshot(timestamp=to_version) - except Exception as e: - logger.error(f"Unable to read watch history to-version for version {to_version}: {str(e)}") - to_version_file_contents = f"Unable to read to-version at {to_version}.\n" - - try: - from_version_file_contents = watch.get_history_snapshot(timestamp=from_version) - except Exception as e: - logger.error(f"Unable to read watch history from-version for version {from_version}: {str(e)}") - from_version_file_contents = f"Unable to read to-version {from_version}.\n" + # Resolve which two snapshots we're comparing (shared with all processors' diff pages). + from changedetectionio.processors.difference_base import resolve_diff_versions + diff_versions = resolve_diff_versions(watch, request) + dates = diff_versions.dates + from_version = diff_versions.from_version + to_version = diff_versions.to_version + from_version_file_contents = diff_versions.from_contents + to_version_file_contents = diff_versions.to_contents screenshot_url = watch.get_screenshot() @@ -196,16 +180,14 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect, content = apply_html_color_to_body(n_body=content) offscreen_content = render_template("diff-offscreen-options.html") - note = '' - if str(from_version) != str(dates[-2]) or str(to_version) != str(dates[-1]): - note = 'Note: You are not viewing the latest changes.' + note = diff_versions.note llm_configured = bool( datastore.data.get('settings', {}).get('application', {}).get('llm', {}).get('model') ) # Load cached AI diff summary for this exact from→to + prompt combination - viewing_latest = str(to_version) == str(dates[-1]) + viewing_latest = diff_versions.viewing_latest llm_diff_summary = '' llm_summary_prompt = '' if llm_configured: