Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
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
98 changes: 98 additions & 0 deletions changedetectionio/blueprint/cookie_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from typing import Dict, Any
from flask import request


class PreferenceManager:
"""
Manages user preferences with cookie persistence.

Handles reading from cookies, overriding with URL query parameters,
and setting cookies when preferences are updated.
"""

def __init__(self, preferences_config: Dict[str, Dict[str, Any]], cookie_scope: str = 'path'):
"""
Initialize the preference manager.

Args:
preferences_config: Dict defining preferences with their defaults and types
e.g., {'diff_type': {'default': 'diffLines', 'type': 'value'}}
cookie_scope: 'path' for current path only, 'global' for entire application
"""
self.config = preferences_config
self.cookie_scope = cookie_scope
self.preferences = {}
self.cookies_updated = False

def load_preferences(self) -> Dict[str, Any]:
"""
Load preferences from cookies and override with URL query parameters.

URL query parameters act as temporary overrides but don't update cookies.

Returns:
Dict containing current preference values
"""
for key, config in self.config.items():
# Read from cookie first (or use default)
if config['type'] == 'bool':
if key in request.cookies:
# Cookie exists, use its value
self.preferences[key] = request.cookies.get(key) == 'on'
else:
# No cookie, use configured default
self.preferences[key] = config['default']
else:
self.preferences[key] = request.cookies.get(key, config['default'])

# URL query parameters override (but don't update cookies)
if key in request.args:
if config['type'] == 'bool':
self.preferences[key] = request.args.get(key) == 'on'
else:
self.preferences[key] = request.args.get(key, config['default'])

return self.preferences

def load_from_form(self) -> Dict[str, Any]:
"""
Load preferences from POST form data and mark for cookie updates.

For checkboxes: absence in form.data means unchecked = False.

Returns:
Dict containing preference values from form
"""
self.cookies_updated = True

for key, config in self.config.items():
if config['type'] == 'bool':
# Checkbox: present = on, absent = off
self.preferences[key] = key in request.form and request.form.get(key) == 'on'
else:
# Value field: get from form or use default
self.preferences[key] = request.form.get(key, config['default'])

return self.preferences

def apply_cookies_to_response(self, response, max_age: int = 365 * 24 * 60 * 60):
"""
Apply cookies to the response if preferences were updated.

Args:
response: Flask response object
max_age: Cookie expiration time in seconds (default: 1 year)

Returns:
Modified response object
"""
if not self.cookies_updated:
return response

cookie_path = request.path if self.cookie_scope == 'path' else '/'

for key, value in self.preferences.items():
cookie_value = 'on' if value is True else ('off' if value is False else value)
response.set_cookie(key, cookie_value, max_age=max_age, path=cookie_path)

return response
3 changes: 2 additions & 1 deletion changedetectionio/blueprint/rss/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ def feed():
newest_version_file_contents=watch.get_history_snapshot(dates[-1]),
include_equal=False,
line_feed_sep="<br>",
html_colour=html_colour_enable
html_colour=html_colour_enable,
word_diff=False
)
except FileNotFoundError as e:
html_diff = f"History snapshot file for watch {watch.get('uuid')}@{watch.last_changed} - '{watch.get('title')} not found."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@
{% endif %}

const highlight_submit_ignore_url="{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}";

const watch_url= {{watch_a.link|tojson}};
</script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/html2canvas.min.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">
<fieldset class="diff-fieldset">
{% if versions|length >= 1 %}
<form class="pure-form " action="" method="GET" id="diff-form">
<strong>Compare</strong>
<del class="change"><span>from</span></del>
<select id="diff-version" name="from_version" class="needs-localtime">
Expand All @@ -34,29 +37,35 @@
{% endfor %}
</select>
<button type="submit" class="pure-button pure-button-primary reset-margin">Go</button>
</form>
{% 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>

<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>
</fieldset>
</form>

<form class="pure-form" action="{{ url_for("ui.ui_views.diff_history_page_set_preferences", uuid=uuid) }}" method="POST" id="diff-text-options">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<strong>Style</strong>

<label for="diffWords" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffWords" value="diffWords" {% if diff_prefs.diff_type == 'diffWords' %}checked=""{% endif %}> Words</label>
<label for="diffLines" class="pure-checkbox">
<input type="radio" name="diff_type" id="diffLines" value="diffLines" {% if diff_prefs.diff_type == 'diffLines' %}checked=""{% endif %}> Lines</label>

<label for="ignoreWhitespace" class="pure-checkbox" id="label-diff-ignorewhitespace">
<input type="checkbox" name="ignoreWhitespace" {% if diff_prefs.ignoreWhitespace %}checked=""{% endif %}> Ignore Whitespace</label>

<label for="changesOnly" class="pure-checkbox" id="label-diff-changes">
<input type="checkbox" name="diff_changesOnly" {% if diff_prefs.diff_changesOnly %}checked=""{% endif %}> Changes only</label>

<label for="changesOnly" class="pure-checkbox" id="label-diff-removed">
<input type="checkbox" name="diff_removed" {% if diff_prefs.diff_removed %}checked=""{% endif %}> Removed</label>
<label for="changesOnly" class="pure-checkbox" id="label-diff-added">
<input type="checkbox" name="diff_added" {% if diff_prefs.diff_added %}checked=""{% endif %}> Added</label>
<label for="changesOnly" class="pure-checkbox" id="label-diff-replaced">
<input type="checkbox" name="diff_replaced" {% if diff_prefs.diff_replaced %}checked=""{% endif %}> Replaced</label>
<input type="submit">
</form>
</fieldset>
</div>

<div id="diff-jump">
Expand Down Expand Up @@ -88,26 +97,30 @@
</div>

<div class="tab-pane-inner" id="text">
<button id="share-as-image-btn" onclick="diffToJpeg()" title="Share diff as image" style="float: right; margin: 10px; padding: 8px 12px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
Share as Image
</button>
{% 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="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>
<table>
<tbody>
<tr>
<td id="diff-col" class="highlightable-filter">
<pre id="difference" style="border-left: 2px solid #ddd;">{{ content| diff_unescape_difference_spans }}</pre>
</td>
</tr>
</tbody>
</table>
</div>

<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.
Expand Down Expand Up @@ -159,8 +172,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
31 changes: 16 additions & 15 deletions changedetectionio/blueprint/ui/templates/edit.html
Original file line number Diff line number Diff line change
@@ -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 %}
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
Expand Down Expand Up @@ -351,21 +351,22 @@ <h3>Text filtering</h3>
</div>
</div>
<div id="text-preview" style="display: none;" >
<script>
const preview_text_edit_filters_url="{{url_for('ui.ui_edit.watch_get_preview_rendered', uuid=uuid)}}";
</script>
<br>
{#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#}
<div class="minitabs-wrapper">
<div class="minitabs-content">
<div id="text-preview-inner" class="monospace-preview">
<p>Loading...</p>
</div>
<div id="text-preview-before-inner" style="display: none;" class="monospace-preview">
<p>Loading...</p>
</div>
<script>
const preview_text_edit_filters_url="{{url_for('ui.ui_edit.watch_get_preview_rendered', uuid=uuid)}}";
</script>
<br>
{#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#}
<div class="minitabs-wrapper">
<div class="minitabs-content">
<div id="text-preview-inner" class="monospace-preview">
<p>Loading...</p>
</div>
</div>
<div id="text-preview-before-inner" style="display: none;" class="monospace-preview">
<p>Loading...</p>
</div>
</div>
</div>
{{ highlight_trigger_ignored_explainer() }}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{% extends 'base.html' %}

{% from '_helpers.html' import highlight_trigger_ignored_explainer %}
{% block content %}
<script>
const screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid)}}";
const triggered_line_numbers = {{ triggered_line_numbers|tojson }};
const triggered_line_numbers = {{ highlight_triggered_line_numbers|tojson }};
const ignored_line_numbers = {{ highlight_ignored_line_numbers|tojson }};
const blocked_line_numbers = {{ highlight_blocked_line_numbers|tojson }};
{% if last_error_screenshot %}
const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
{% endif %}
Expand Down Expand Up @@ -82,6 +84,7 @@
</tr>
</tbody>
</table>
{{ highlight_trigger_ignored_explainer() }}
</div>

<div class="tab-pane-inner" id="screenshot">
Expand Down
Loading
Loading