""" History/diff rendering for text_json_diff processor. This module handles the visualization of text/HTML/JSON changes by rendering a side-by-side or unified diff view with syntax highlighting and change markers. """ import os import time from loguru import logger from markupsafe import Markup from changedetectionio import diff, strtobool from changedetectionio.diff import ( REMOVED_STYLE, ADDED_STYLE, REMOVED_INNER_STYLE, ADDED_INNER_STYLE, REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED, ADDED_PLACEMARKER_OPEN, ADDED_PLACEMARKER_CLOSED, CHANGED_PLACEMARKER_OPEN, CHANGED_PLACEMARKER_CLOSED, CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED ) from changedetectionio.notification.handler import apply_html_color_to_body # Diff display preferences configuration - single source of truth DIFF_PREFERENCES_CONFIG = { 'changesOnly': {'default': True, 'type': 'bool'}, 'ignoreWhitespace': {'default': False, 'type': 'bool'}, 'removed': {'default': True, 'type': 'bool'}, 'added': {'default': True, 'type': 'bool'}, 'replaced': {'default': True, 'type': 'bool'}, 'type': {'default': 'diffLines', 'type': 'value'}, } def build_diff_cell_visualizer(content, resolution=100): """ Build a visual cell grid for the diff visualizer. Analyzes the content for placemarkers indicating changes and creates a grid of cells representing the document, with each cell marked as: - 'deletion' for removed content - 'insertion' for added content - 'mixed' for cells containing both deletions and insertions - empty string for cells with no changes Args: content: The diff content with placemarkers resolution: Number of cells to create (default 100) Returns: List of dicts with 'class' key for each cell's CSS class """ if not content: return [{'class': ''} for _ in range(resolution)] now = time.time() # Work with character positions for better accuracy content_length = len(content) if content_length == 0: return [{'class': ''} for _ in range(resolution)] chars_per_cell = max(1, content_length / resolution) # Track change type for each cell cell_data = {} # Placemarkers to detect change_markers = { REMOVED_PLACEMARKER_OPEN: 'deletion', ADDED_PLACEMARKER_OPEN: 'insertion', CHANGED_PLACEMARKER_OPEN: 'deletion', CHANGED_INTO_PLACEMARKER_OPEN: 'insertion', } # Find all occurrences of each marker for marker, change_type in change_markers.items(): pos = 0 while True: pos = content.find(marker, pos) if pos == -1: break # Calculate which cell this marker falls into cell_index = min(int(pos / chars_per_cell), resolution - 1) if cell_index not in cell_data: cell_data[cell_index] = change_type elif cell_data[cell_index] != change_type: # Mixed changes in this cell cell_data[cell_index] = 'mixed' pos += len(marker) # Build the cell list cells = [] for i in range(resolution): change_type = cell_data.get(i, '') cells.append({'class': change_type}) logger.debug(f"Built diff cell visualizer: {len([c for c in cells if c['class']])} cells with changes out of {resolution} in {time.time() - now:.2f}s") return cells def render(watch, datastore, request, url_for, render_template, flash, redirect, extract_form=None): """ Render the history/diff view for text/JSON/HTML changes. Args: watch: The watch object datastore: The ChangeDetectionStore instance request: Flask request object url_for: Flask url_for function render_template: Flask render_template function flash: Flask flash function redirect: Flask redirect function extract_form: Optional pre-built extract form (for error cases) Returns: Rendered HTML response """ from changedetectionio import forms uuid = watch.get('uuid') extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] # Use provided form or create a new one if extract_form is None: 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" screenshot_url = watch.get_screenshot() system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' is_html_webdriver = False if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'): is_html_webdriver = True password_enabled_and_share_is_off = False if datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False): password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access') datastore.set_last_viewed(uuid, time.time()) # Parse diff preferences from request using config as single source of truth # Check if this is a user submission (any diff pref param exists in query string) user_submitted = any(key in request.args for key in DIFF_PREFERENCES_CONFIG.keys()) diff_prefs = {} for key, config in DIFF_PREFERENCES_CONFIG.items(): if user_submitted: # User submitted form - missing checkboxes are explicitly OFF if config['type'] == 'bool': diff_prefs[key] = strtobool(request.args.get(key, 'off')) else: diff_prefs[key] = request.args.get(key, config['default']) else: # Initial load - use defaults from config diff_prefs[key] = config['default'] content = diff.render_diff(previous_version_file_contents=from_version_file_contents, newest_version_file_contents=to_version_file_contents, include_replaced=diff_prefs['replaced'], include_added=diff_prefs['added'], include_removed=diff_prefs['removed'], include_equal=diff_prefs['changesOnly'], ignore_junk=diff_prefs['ignoreWhitespace'], word_diff=diff_prefs['type'] == 'diffWords', ) # Build cell grid visualizer before applying HTML color (so we can detect placemarkers) diff_cell_grid = build_diff_cell_visualizer(content) 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.' output = render_template("diff.html", #initial_scroll_line_number=100, bottom_horizontal_offscreen_contents=offscreen_content, content=content, current_diff_url=watch['url'], diff_cell_grid=diff_cell_grid, diff_prefs=diff_prefs, extra_classes='difference-page', extra_stylesheets=extra_stylesheets, extra_title=f" - {watch.label} - History", extract_form=extract_form, from_version=str(from_version), is_html_webdriver=is_html_webdriver, last_error=watch['last_error'], last_error_screenshot=watch.get_error_snapshot(), last_error_text=watch.get_error_text(), newest=to_version_file_contents, newest_version_timestamp=dates[-1], note=note, password_enabled_and_share_is_off=password_enabled_and_share_is_off, pure_menu_fixed=False, screenshot=screenshot_url, to_version=str(to_version), uuid=uuid, versions=dates, # All except current/last watch_a=watch, ) return output