Compare commits

..

1 Commits

Author SHA1 Message Date
dgtlmoon
fe45bfc27a more resilient same UUID being processed 2026-01-02 17:25:58 +01:00
66 changed files with 1154 additions and 9272 deletions

View File

@@ -183,9 +183,6 @@ docker compose pull && docker compose up -d
See the wiki for more information https://github.com/dgtlmoon/changedetection.io/wiki
## Different browser viewport sizes (mobile, desktop etc)
If you are using the recommended `sockpuppetbrowser` (which is in the docker-compose.yml as a setting to be uncommented) you can easily set different viewport sizes for your web page change detection, [see more information here about setting up different viewport sizes](https://github.com/dgtlmoon/sockpuppetbrowser?tab=readme-ov-file#setting-viewport-size).
## Filters

View File

@@ -64,17 +64,8 @@ class Watch(Resource):
@validate_openapi_request('getWatch')
def get(self, uuid):
"""Get information about a single watch, recheck, pause, or mute."""
import time
from copy import deepcopy
watch = None
for _ in range(20):
try:
watch = deepcopy(self.datastore.data['watching'].get(uuid))
break
except RuntimeError:
# Incase dict changed, try again
time.sleep(0.01)
watch = deepcopy(self.datastore.data['watching'].get(uuid))
if not watch:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
@@ -311,28 +302,18 @@ class WatchHistoryDiff(Resource):
from_version_file_contents = watch.get_history_snapshot(from_timestamp)
to_version_file_contents = watch.get_history_snapshot(to_timestamp)
# Get diff preferences from query parameters (matching UI preferences in DIFF_PREFERENCES_CONFIG)
# Support both 'type' (UI parameter) and 'word_diff' (API parameter) for backward compatibility
diff_type = request.args.get('type', 'diffLines')
if diff_type == 'diffWords':
word_diff = True
# Get diff preferences (using defaults similar to the existing code)
diff_prefs = {
'diff_ignoreWhitespace': False,
'diff_changesOnly': True
}
# Get boolean diff preferences with defaults from DIFF_PREFERENCES_CONFIG
changes_only = strtobool(request.args.get('changesOnly', 'true'))
ignore_whitespace = strtobool(request.args.get('ignoreWhitespace', 'false'))
include_removed = strtobool(request.args.get('removed', 'true'))
include_added = strtobool(request.args.get('added', 'true'))
include_replaced = strtobool(request.args.get('replaced', 'true'))
# Generate the diff with all preferences
# 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=ignore_whitespace,
include_equal=changes_only,
include_removed=include_removed,
include_added=include_added,
include_replaced=include_replaced,
ignore_junk=diff_prefs.get('diff_ignoreWhitespace'),
include_equal=not diff_prefs.get('diff_changesOnly'),
word_diff=word_diff,
)

View File

@@ -85,7 +85,9 @@
<div class="tab-pane-inner" id="notifications">
<fieldset>
{{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
<div class="field-group">
{{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
</div>
</fieldset>
<div class="pure-control-group" id="notification-base-url">
{{ render_field(form.application.form.base_url, class="m-d") }}
@@ -126,7 +128,7 @@
</div>
<div class="pure-control-group">
{{ render_field(form.requests.form.timeout) }}
<span class="pure-form-message-inline">For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.</span><br>
<span class="pure-form-message-inline">For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.<br>
</div>
<div class="pure-control-group inline-radio">
{{ render_field(form.requests.form.default_ua) }}
@@ -217,7 +219,7 @@ nav
<a id="chrome-extension-link"
title="Try our new Chrome Extension!"
href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop">
<img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" >
<img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" alt="Chrome">
Chrome Webstore
</a>
</p>
@@ -258,14 +260,14 @@ nav
Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.
</div>
<div class="pure-control-group">
<p><strong>UTC Time &amp; Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p>
<p><strong>Local Time &amp; Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p>
<div>
<p><strong>UTC Time &amp Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p>
<p><strong>Local Time &amp Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p>
<p>
{{ render_field(form.application.form.scheduler_timezone_default) }}
<datalist id="timezones" style="display: none;">
{%- for timezone in available_timezones -%}<option value="{{ timezone }}">{{ timezone }}</option>{%- endfor -%}
</datalist>
</div>
</p>
</div>
</div>
<div class="tab-pane-inner" id="ui-options">
@@ -334,7 +336,7 @@ nav
</div>
</div>
<p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.</p>
<p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.
<div class="pure-control-group" id="extra-proxies-setting">
{{ render_fieldlist_with_inline_errors(form.requests.form.extra_proxies) }}

View File

@@ -118,7 +118,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
sent_obj = process_notification(n_object, datastore)
except Exception as e:
logger.error(e)
e_str = str(e)
# Remove this text which is not important and floods the container
e_str = e_str.replace(

View File

@@ -87,7 +87,7 @@
</form>
</div>
<div id="diff-jump" style="display:none;"><!-- disabled for now -->
<div id="diff-jump">
<a id="jump-next-diff" title="{{ _('Jump to next difference') }}">{{ _('Jump') }}</a>
</div>

View File

@@ -86,7 +86,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
datastore=datastore,
errored_count=errored_count,
form=form,
generate_tag_colors=processors.generate_processor_badge_colors,
guid=datastore.data['app_guid'],
has_proxies=datastore.proxy_list,
hosted_sticky=os.getenv("SALTED_PASS", False) == False,

View File

@@ -22,33 +22,6 @@ document.addEventListener('DOMContentLoaded', function() {
/* Auto-generated processor badge colors */
{{ processor_badge_css|safe }}
/* Auto-generated tag colors */
{%- for uuid, tag in tags -%}
{%- if tag and tag.title -%}
{%- set class_name = tag.title|sanitize_tag_class -%}
{%- set colors = generate_tag_colors(tag.title) -%}
.button-tag.tag-{{ class_name }} {
background-color: {{ colors['light']['bg'] }};
color: {{ colors['light']['color'] }};
}
.watch-tag-list.tag-{{ class_name }} {
background-color: {{ colors['light']['bg'] }};
color: {{ colors['light']['color'] }};
}
html[data-darkmode="true"] .button-tag.tag-{{ class_name }} {
background-color: {{ colors['dark']['bg'] }};
color: {{ colors['dark']['color'] }};
}
html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
background-color: {{ colors['dark']['bg'] }};
color: {{ colors['dark']['color'] }};
}
{%- endif -%}
{%- endfor -%}
</style>
<div class="box" id="form-quick-watch-add">
@@ -109,7 +82,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<!-- tag list -->
{%- for uuid, tag in tags -%}
{%- if tag != "" -%}
<a href="{{url_for('watchlist.index', tag=uuid) }}" class="pure-button button-tag tag-{{ tag.title|sanitize_tag_class }} {{'active' if active_tag_uuid == uuid }}">{{ tag.title }}</a>
<a href="{{url_for('watchlist.index', tag=uuid) }}" class="pure-button button-tag {{'active' if active_tag_uuid == uuid }}">{{ tag.title }}</a>
{%- endif -%}
{%- endfor -%}
</div>
@@ -196,7 +169,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<div class="flex-wrapper">
{% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
<div>{# A page might have hundreds of these images, set IMG options for lazy loading, don't set SRC if we dont have it so it doesnt fetch the placeholder' #}
<img alt="Favicon thumbnail" class="favicon" loading="lazy" decoding="async" fetchpriority="low" {% if favicon %} src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}" {% else %} src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E' {% endif %} >
<img alt="Favicon thumbnail" class="favicon" loading="lazy" decoding="async" fetchpriority="low" {% if favicon %} src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}" {% else %} src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E' {% endif %} />
</div>
{% endif %}
<div>
@@ -218,7 +191,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<span class="processor-badge processor-badge-{{ watch['processor'] }}" title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</span>
{%- endif -%}
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
<span class="watch-tag-list tag-{{ watch_tag.title|sanitize_tag_class }}">{{ watch_tag.title }}</span>
<span class="watch-tag-list">{{ watch_tag.title }}</span>
{%- endfor -%}
</div>
<div class="status-icons">

View File

@@ -204,7 +204,7 @@ class fetcher(Fetcher):
import re
self.delete_browser_steps_screenshots()
n = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 12)) + self.render_extract_delay
n = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
extra_wait = min(n, 15)
logger.debug(f"Extra wait set to {extra_wait}s, requested was {n}s.")
@@ -288,27 +288,28 @@ class fetcher(Fetcher):
# Enable Network domain to detect when first bytes arrive
await self.page._client.send('Network.enable')
# Now set up the frame navigation handlers
async def handle_frame_navigation(event=None):
# Wait n seconds after the frameStartedLoading, not from any frameStartedLoading/frameStartedNavigating
logger.debug(f"Frame navigated: {event}")
w = extra_wait - 2 if extra_wait > 4 else 2
logger.debug(f"Waiting {w} seconds before calling Page.stopLoading...")
await asyncio.sleep(w)
logger.debug("Issuing stopLoading command...")
await self.page._client.send('Page.stopLoading')
logger.debug("stopLoading command sent!")
async def setup_frame_handlers_on_first_response(event):
# Only trigger for the main document response
if event.get('type') == 'Document':
logger.debug("First response received, setting up frame handlers for forced page stop load.")
# De-register this listener - we only need it once
self.page._client.remove_listener('Network.responseReceived', setup_frame_handlers_on_first_response)
# Now set up the frame navigation handlers
async def handle_frame_navigation(event):
# Wait n seconds after the frameStartedLoading, not from any frameStartedLoading/frameStartedNavigating
logger.debug(f"Frame navigated: {event}")
w = extra_wait - 2 if extra_wait > 4 else 2
logger.debug(f"Waiting {w} seconds before calling Page.stopLoading...")
await asyncio.sleep(w)
logger.debug("Issuing stopLoading command...")
await self.page._client.send('Page.stopLoading')
logger.debug("stopLoading command sent!")
self.page._client.on('Page.frameStartedNavigating', lambda e: asyncio.create_task(handle_frame_navigation(e)))
self.page._client.on('Page.frameStartedLoading', lambda e: asyncio.create_task(handle_frame_navigation(e)))
self.page._client.on('Page.frameStoppedLoading', lambda e: logger.debug(f"Frame stopped loading: {e}"))
logger.debug("First response received, setting up frame handlers for forced page stop load DONE SETUP")
# De-register this listener - we only need it once
self.page._client.remove_listener('Network.responseReceived', setup_frame_handlers_on_first_response)
# Listen for first response to trigger frame handler setup
self.page._client.on('Network.responseReceived', setup_frame_handlers_on_first_response)
@@ -317,11 +318,8 @@ class fetcher(Fetcher):
attempt=0
while not response:
logger.debug(f"Attempting page fetch {url} attempt {attempt}")
asyncio.create_task(handle_frame_navigation())
response = await self.page.goto(url, timeout=0)
await asyncio.sleep(1 + extra_wait)
await self.page._client.send('Page.stopLoading')
if response:
break
if not response:

View File

@@ -26,7 +26,6 @@ from flask import (
session,
url_for,
)
from urllib.parse import urlparse
from flask_compress import Compress as FlaskCompress
from flask_login import current_user
from flask_restful import abort, Api
@@ -297,25 +296,6 @@ def _jinja2_filter_fetcher_status_icons(fetcher_name):
return ''
@app.template_filter('sanitize_tag_class')
def _jinja2_filter_sanitize_tag_class(tag_title):
"""Sanitize a tag title to create a valid CSS class name.
Removes all non-alphanumeric characters and converts to lowercase.
Args:
tag_title: The tag title string
Returns:
str: A sanitized string suitable for use as a CSS class name
"""
import re
# Remove all non-alphanumeric characters and convert to lowercase
sanitized = re.sub(r'[^a-zA-Z0-9]', '', tag_title).lower()
# Ensure it starts with a letter (CSS requirement)
if sanitized and not sanitized[0].isalpha():
sanitized = 'tag' + sanitized
return sanitized if sanitized else 'tag'
# Import login_optionally_required from auth_decorator
from changedetectionio.auth_decorator import login_optionally_required
@@ -370,13 +350,6 @@ def changedetection_app(config=None, datastore_o=None):
global datastore, socketio_server
datastore = datastore_o
# Import and create a wrapper for is_safe_url that has access to app
from changedetectionio.is_safe_url import is_safe_url as _is_safe_url
def is_safe_url(target):
"""Wrapper for is_safe_url that passes the app instance"""
return _is_safe_url(target, app)
# so far just for read-only via tests, but this will be moved eventually to be the main source
# (instead of the global var)
app.config['DATASTORE'] = datastore_o
@@ -498,21 +471,11 @@ def changedetection_app(config=None, datastore_o=None):
@login_manager.unauthorized_handler
def unauthorized_handler():
# Pass the current request path so users are redirected back after login
return redirect(url_for('login', redirect=request.path))
return redirect(url_for('login', next=url_for('watchlist.index')))
@app.route('/logout')
def logout():
flask_login.logout_user()
# Check if there's a redirect parameter to return to after re-login
redirect_url = request.args.get('redirect')
# If redirect is provided and safe, pass it to login page
if redirect_url and is_safe_url(redirect_url):
return redirect(url_for('login', redirect=redirect_url))
# Otherwise just go to watchlist
return redirect(url_for('watchlist.index'))
@app.route('/set-language/<locale>')
@@ -524,36 +487,20 @@ def changedetection_app(config=None, datastore_o=None):
else:
logger.error(f"Invalid locale {locale}, available: {language_codes}")
# Check if there's a redirect parameter to return to the same page
redirect_url = request.args.get('redirect')
# If redirect is provided and safe, use it
if redirect_url and is_safe_url(redirect_url):
return redirect(redirect_url)
# Otherwise redirect to watchlist
# Redirect back to the page they came from, or home
return redirect(url_for('watchlist.index'))
# https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39
# You can divide up the stuff like this
@app.route('/login', methods=['GET', 'POST'])
def login():
# Extract and validate the redirect parameter
redirect_url = request.args.get('redirect') or request.form.get('redirect')
# Validate the redirect URL - default to watchlist if invalid
if redirect_url and is_safe_url(redirect_url):
validated_redirect = redirect_url
else:
validated_redirect = url_for('watchlist.index')
if request.method == 'GET':
if flask_login.current_user.is_authenticated:
# Already logged in - redirect immediately to the target
flash(gettext("Already logged in"))
return redirect(validated_redirect)
return redirect(url_for("watchlist.index"))
flash(gettext("You must be logged in, please log in."), 'error')
output = render_template("login.html", redirect_url=validated_redirect)
output = render_template("login.html")
return output
user = User()
@@ -563,13 +510,23 @@ def changedetection_app(config=None, datastore_o=None):
if (user.check_password(password)):
flask_login.login_user(user, remember=True)
# Redirect to the validated URL after successful login
return redirect(validated_redirect)
# For now there's nothing else interesting here other than the index/list page
# It's more reliable and safe to ignore the 'next' redirect
# When we used...
# next = request.args.get('next')
# return redirect(next or url_for('watchlist.index'))
# We would sometimes get login loop errors on sites hosted in sub-paths
# note for the future:
# if not is_safe_valid_url(next):
# return flask.abort(400)
return redirect(url_for('watchlist.index'))
else:
flash(gettext('Incorrect password'), 'error')
return redirect(url_for('login', redirect=redirect_url if redirect_url else None))
return redirect(url_for('login'))
@app.before_request
def before_request_handle_cookie_x_settings():
@@ -914,7 +871,7 @@ def notification_runner():
# At the moment only one thread runs (single runner)
n_object = notification_q.get(block=False)
except queue.Empty:
app.config.exit.wait(1)
time.sleep(1)
else:
@@ -951,7 +908,7 @@ def notification_runner():
app.config['watch_check_update_SIGNAL'].send(app_context=app, watch_uuid=n_object.get('uuid'))
# Process notifications
notification_debug_log+= ["{} - SENDING - {}".format(now.strftime("%c"), json.dumps(sent_obj))]
notification_debug_log+= ["{} - SENDING - {}".format(now.strftime("%Y/%m/%d %H:%M:%S,000"), json.dumps(sent_obj))]
# Trim the log length
notification_debug_log = notification_debug_log[-100:]
@@ -1009,7 +966,7 @@ def ticker_thread_check_time_launch_checks():
# Re #438 - Don't place more watches in the queue to be checked if the queue is already large
while update_q.qsize() >= 2000:
logger.warning(f"Recheck watches queue size limit reached ({MAX_QUEUE_SIZE}), skipping adding more items")
app.config.exit.wait(10.0)
time.sleep(3)
recheck_time_system_seconds = int(datastore.threshold_seconds)
@@ -1107,5 +1064,8 @@ def ticker_thread_check_time_launch_checks():
# Reset for next time
watch.jitter_seconds = 0
# Wait before checking the list again - saves CPU
time.sleep(1)
# Should be low so we can break this out in testing
app.config.exit.wait(1)

View File

@@ -781,8 +781,8 @@ class SingleBrowserStep(Form):
class processor_text_json_diff_form(commonSettingsForm):
url = fields.URLField('Web Page URL', validators=[validateURL()])
tags = StringTagUUID('Group Tag', [validators.Optional()], default='')
url = fields.URLField('URL', validators=[validateURL()])
tags = StringTagUUID('Group tag', [validators.Optional()], default='')
time_between_check = EnhancedFormField(
TimeBetweenCheckForm,

View File

@@ -1,113 +0,0 @@
"""
URL redirect validation module for preventing open redirect vulnerabilities.
This module provides functionality to safely validate redirect URLs, ensuring they:
1. Point to internal routes only (no external redirects)
2. Are properly normalized (preventing browser parsing differences)
3. Match registered Flask routes (no fake/non-existent pages)
4. Are fully logged for security monitoring
References:
- https://flask-login.readthedocs.io/ (safe redirect patterns)
- https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-v-user-logins
- https://www.pythonkitchen.com/how-prevent-open-redirect-vulnerab-flask/
"""
from urllib.parse import urlparse, urljoin
from flask import request
from loguru import logger
def is_safe_url(target, app):
"""
Validate that a redirect URL is safe to prevent open redirect vulnerabilities.
This follows Flask/Werkzeug best practices by ensuring the redirect URL:
1. Is a relative path starting with exactly one '/'
2. Does not start with '//' (double-slash attack)
3. Has no external protocol handlers
4. Points to a valid registered route in the application
5. Is properly normalized to prevent browser parsing differences
Args:
target: The URL to validate (e.g., '/settings', '/login#top')
app: The Flask application instance (needed for route validation)
Returns:
bool: True if the URL is safe for redirection, False otherwise
Examples:
>>> is_safe_url('/settings', app)
True
>>> is_safe_url('//evil.com', app)
False
>>> is_safe_url('/settings#general', app)
True
>>> is_safe_url('/fake-page', app)
False
"""
if not target:
return False
# Normalize the URL to prevent browser parsing differences
# Strip whitespace and replace backslashes (which some browsers interpret as forward slashes)
target = target.strip()
target = target.replace('\\', '/')
# First, check if it starts with // or more (double-slash attack)
if target.startswith('//'):
logger.warning(f"Blocked redirect attempt with double-slash: {target}")
return False
# Parse the URL to check for scheme and netloc
parsed = urlparse(target)
# Block any URL with a scheme (http://, https://, javascript:, etc.)
if parsed.scheme:
logger.warning(f"Blocked redirect attempt with scheme: {target}")
return False
# Block any URL with a network location (netloc)
# This catches patterns like //evil.com, user@host, etc.
if parsed.netloc:
logger.warning(f"Blocked redirect attempt with netloc: {target}")
return False
# At this point, we have a relative URL with no scheme or netloc
# Use urljoin to resolve it and verify it points to the same host
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
# Check: ensure the resolved URL has the same netloc as current host
if not (test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc):
logger.warning(f"Blocked redirect attempt with mismatched netloc: {target}")
return False
# Additional validation: Check if the URL matches a registered route
# This prevents redirects to non-existent pages or unintended endpoints
try:
# Get the path without query string and fragment
# Fragments (like #general) are automatically stripped by urlparse
path = parsed.path
# Create a URL adapter bound to the server name
adapter = app.url_map.bind(ref_url.netloc)
# Try to match the path to a registered route
# This will raise NotFound if the route doesn't exist
endpoint, values = adapter.match(path, return_rule=False)
# Block redirects to static file endpoints - these are catch-all routes
# that would match arbitrary paths, potentially allowing unintended redirects
if endpoint in ('static_content', 'static', 'static_flags'):
logger.warning(f"Blocked redirect to static endpoint: {target}")
return False
# Successfully matched a valid route
logger.debug(f"Validated safe redirect to endpoint '{endpoint}': {target}")
return True
except Exception as e:
# Route doesn't exist or can't be matched
logger.warning(f"Blocked redirect to non-existent route: {target} (error: {e})")
return False

View File

@@ -34,16 +34,13 @@ def get_timeago_locale(flask_locale):
'no': 'nb_NO', # Norwegian Bokmål
'hi': 'in_HI', # Hindi
'cs': 'en', # Czech not supported by timeago, fallback to English
'en_GB': 'en', # British English - timeago uses 'en'
'en_US': 'en', # American English - timeago uses 'en'
}
return locale_map.get(flask_locale, flask_locale)
# Language metadata: flag icon CSS class and native name
# Using flag-icons library: https://flagicons.lipis.dev/
LANGUAGE_DATA = {
'en_GB': {'flag': 'fi fi-gb fis', 'name': 'English (UK)'},
'en_US': {'flag': 'fi fi-us fis', 'name': 'English (US)'},
'en': {'flag': 'fi fi-gb fis', 'name': 'English'},
'de': {'flag': 'fi fi-de fis', 'name': 'Deutsch'},
'fr': {'flag': 'fi fi-fr fis', 'name': 'Français'},
'ko': {'flag': 'fi fi-kr fis', 'name': '한국어'},
@@ -74,7 +71,10 @@ def get_available_languages():
"""
translations_dir = Path(__file__).parent / 'translations'
available = {}
# Always include English as base language
available = {
'en': LANGUAGE_DATA['en']
}
# Scan for translation directories
if translations_dir.exists():
@@ -85,10 +85,6 @@ def get_available_languages():
if po_file.exists():
available[lang_dir.name] = LANGUAGE_DATA[lang_dir.name]
# If no English variants found, fall back to adding en_GB as default
if 'en_GB' not in available and 'en_US' not in available:
available['en_GB'] = LANGUAGE_DATA['en_GB']
return available

View File

@@ -1,99 +0,0 @@
/**
* Flask Toast Bridge
* Automatically converts Flask flash messages to toast notifications
*
* Maps Flask message categories to toast types:
* - 'message' or 'info' -> info toast
* - 'success' -> success toast
* - 'error' or 'danger' -> error toast
* - 'warning' -> warning toast
*/
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', function() {
// Find the Flask messages container
const messagesContainer = document.querySelector('ul.messages');
if (!messagesContainer) {
return;
}
// Get all flash messages
const messages = messagesContainer.querySelectorAll('li');
if (messages.length === 0) {
return;
}
let toastIndex = 0;
// Convert each message to a toast (except errors)
messages.forEach(function(messageEl) {
const text = messageEl.textContent.trim();
const category = getMessageCategory(messageEl);
// Skip error messages - they should stay in the page
if (category === 'error') {
return;
}
const toastType = mapCategoryToToastType(category);
// Stagger toast appearance for multiple messages
setTimeout(function() {
Toast[toastType](text, {
duration: 6000 // 6 seconds for Flask messages
});
}, toastIndex * 200); // 200ms delay between each toast
toastIndex++;
// Hide this specific message element (not errors)
messageEl.style.display = 'none';
});
});
/**
* Extract message category from class names
*/
function getMessageCategory(messageEl) {
const classes = messageEl.className.split(' ');
// Common Flask flash message categories
const categoryMap = {
'success': 'success',
'error': 'error',
'danger': 'error',
'warning': 'warning',
'info': 'info',
'message': 'info',
'notice': 'info'
};
for (let className of classes) {
if (categoryMap[className]) {
return categoryMap[className];
}
}
// Default to info if no category found
return 'info';
}
/**
* Map Flask category to Toast type
*/
function mapCategoryToToastType(category) {
const typeMap = {
'success': 'success',
'error': 'error',
'warning': 'warning',
'info': 'info'
};
return typeMap[category] || 'info';
}
})();

View File

@@ -1,69 +0,0 @@
// Hamburger menu toggle functionality
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', function() {
const hamburgerToggle = document.getElementById('hamburger-toggle');
const mobileMenuDrawer = document.getElementById('mobile-menu-drawer');
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
if (!hamburgerToggle || !mobileMenuDrawer || !mobileMenuOverlay) {
return;
}
function openMenu() {
hamburgerToggle.classList.add('active');
mobileMenuDrawer.classList.add('active');
mobileMenuOverlay.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeMenu() {
hamburgerToggle.classList.remove('active');
mobileMenuDrawer.classList.remove('active');
mobileMenuOverlay.classList.remove('active');
document.body.style.overflow = '';
}
function toggleMenu() {
if (mobileMenuDrawer.classList.contains('active')) {
closeMenu();
} else {
openMenu();
}
}
// Toggle menu on hamburger click
hamburgerToggle.addEventListener('click', function(e) {
e.stopPropagation();
toggleMenu();
});
// Close menu when clicking overlay
mobileMenuOverlay.addEventListener('click', closeMenu);
// Close menu when clicking a menu item
const menuItems = mobileMenuDrawer.querySelectorAll('.mobile-menu-items a');
menuItems.forEach(function(item) {
item.addEventListener('click', closeMenu);
});
// Close menu on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && mobileMenuDrawer.classList.contains('active')) {
closeMenu();
}
});
// Close menu when window is resized above mobile breakpoint
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
if (window.innerWidth > 768 && mobileMenuDrawer.classList.contains('active')) {
closeMenu();
}
}, 250);
});
});
})();

View File

@@ -3,71 +3,53 @@
* Allows users to select their preferred language
*/
$(document).ready(function() {
const $languageButton = $('.language-selector');
const $languageModal = $('#language-modal');
const $closeButton = $('#close-language-modal');
document.addEventListener('DOMContentLoaded', function() {
const languageButton = document.getElementById('language-selector');
const languageModal = document.getElementById('language-modal');
const closeButton = document.getElementById('close-language-modal');
if (!$languageButton.length || !$languageModal.length) {
if (!languageButton || !languageModal) {
return;
}
// Open modal when language button is clicked
$languageButton.on('click', function(e) {
languageButton.addEventListener('click', function(e) {
e.preventDefault();
// Update all language links to include current hash in the redirect parameter
const currentPath = window.location.pathname;
const currentHash = window.location.hash;
if (currentHash) {
const $languageOptions = $languageModal.find('.language-option');
$languageOptions.each(function() {
const $option = $(this);
const url = new URL($option.attr('href'), window.location.origin);
// Update the redirect parameter to include the hash
const redirectPath = currentPath + currentHash;
url.searchParams.set('redirect', redirectPath);
$option.attr('href', url.pathname + url.search + url.hash);
});
}
$languageModal[0].showModal();
languageModal.showModal();
});
// Close modal when cancel button is clicked
if ($closeButton.length) {
$closeButton.on('click', function() {
$languageModal[0].close();
if (closeButton) {
closeButton.addEventListener('click', function() {
languageModal.close();
});
}
// Close modal when clicking outside (on backdrop)
$languageModal.on('click', function(e) {
const rect = this.getBoundingClientRect();
languageModal.addEventListener('click', function(e) {
const rect = languageModal.getBoundingClientRect();
if (
e.clientY < rect.top ||
e.clientY > rect.bottom ||
e.clientX < rect.left ||
e.clientX > rect.right
) {
$languageModal[0].close();
languageModal.close();
}
});
// Close modal on Escape key
$languageModal.on('cancel', function(e) {
languageModal.addEventListener('cancel', function(e) {
e.preventDefault();
$languageModal[0].close();
languageModal.close();
});
// Highlight current language
const currentLocale = $('html').attr('lang') || 'en';
const $languageOptions = $languageModal.find('.language-option');
$languageOptions.each(function() {
const $option = $(this);
if ($option.attr('data-locale') === currentLocale) {
$option.addClass('active');
const currentLocale = document.documentElement.lang || 'en';
const languageOptions = languageModal.querySelectorAll('.language-option');
languageOptions.forEach(function(option) {
if (option.dataset.locale === currentLocale) {
option.classList.add('active');
}
});
});

View File

@@ -74,9 +74,6 @@ $(document).ready(function () {
}
// Cache DOM elements for performance
const queueBubble = document.getElementById('queue-bubble');
// Only try to connect if authentication isn't required or user is authenticated
// The 'is_authenticated' variable will be set in the template
if (typeof is_authenticated !== 'undefined' ? is_authenticated : true) {
@@ -118,40 +115,7 @@ $(document).ready(function () {
socket.on('queue_size', function (data) {
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
// Update queue bubble in action sidebar
//if (queueBubble) {
if (0) {
const count = parseInt(data.q_length) || 0;
const oldCount = parseInt(queueBubble.getAttribute('data-count')) || 0;
if (count > 0) {
// Format number according to browser locale
const formatter = new Intl.NumberFormat(navigator.language);
queueBubble.textContent = formatter.format(count);
queueBubble.setAttribute('data-count', count);
queueBubble.classList.add('visible');
// Add large-number class for numbers > 999
if (count > 999) {
queueBubble.classList.add('large-number');
} else {
queueBubble.classList.remove('large-number');
}
// Pulse animation if count changed
if (count !== oldCount) {
queueBubble.classList.remove('pulse');
// Force reflow to restart animation
void queueBubble.offsetWidth;
queueBubble.classList.add('pulse');
}
} else {
// Hide bubble when queue is empty
queueBubble.classList.remove('visible', 'pulse', 'large-number');
queueBubble.setAttribute('data-count', '0');
}
}
// Update queue size display if implemented in the UI
})
// Listen for operation results

View File

@@ -1,96 +0,0 @@
// Search modal functionality
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', function() {
const searchModal = document.getElementById('search-modal');
const openSearchButton = document.getElementById('open-search-modal');
const closeSearchButton = document.getElementById('close-search-modal');
const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-modal-input');
if (!searchModal || !openSearchButton) {
return;
}
// Open modal
function openSearchModal() {
searchModal.showModal();
// Focus the input after a small delay to ensure modal is rendered
setTimeout(function() {
if (searchInput) {
searchInput.focus();
}
}, 100);
}
// Close modal
function closeSearchModal() {
searchModal.close();
if (searchInput) {
searchInput.value = '';
}
}
// Open search modal on button click
openSearchButton.addEventListener('click', openSearchModal);
// Close modal on cancel button
if (closeSearchButton) {
closeSearchButton.addEventListener('click', closeSearchModal);
}
// Close modal on escape key (native behavior for dialog)
searchModal.addEventListener('cancel', function(e) {
if (searchInput) {
searchInput.value = '';
}
});
// Close modal when clicking the backdrop
searchModal.addEventListener('click', function(e) {
const rect = searchModal.getBoundingClientRect();
const isInDialog = (
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width
);
if (!isInDialog) {
closeSearchModal();
}
});
// Handle Alt+S keyboard shortcut
document.addEventListener('keydown', function(e) {
if (e.altKey && e.key.toLowerCase() === 's') {
e.preventDefault();
openSearchModal();
}
});
// Handle form submission
if (searchForm) {
searchForm.addEventListener('submit', function(e) {
e.preventDefault();
// Get form data
const formData = new FormData(searchForm);
const searchQuery = formData.get('q');
const tags = formData.get('tags');
// Build URL
const params = new URLSearchParams();
if (searchQuery) {
params.append('q', searchQuery);
}
if (tags) {
params.append('tags', tags);
}
// Navigate to search results
window.location.href = '?' + params.toString();
});
}
});
})();

View File

@@ -1,12 +1,11 @@
// Rewrite this is a plugin.. is all this JS really 'worth it?'
window.addEventListener('hashchange', function () {
// Only remove active from tab elements, not menu items
var tabs = document.querySelectorAll('.tabs li.active');
tabs.forEach(function(tab) {
tab.classList.remove('active');
});
document.body.classList.remove('full-width');
var tabs = document.getElementsByClassName('active');
while (tabs[0]) {
tabs[0].classList.remove('active');
document.body.classList.remove('full-width');
}
set_active_tab();
}, false);
@@ -23,9 +22,9 @@ if (!has_errors.length) {
function set_active_tab() {
document.body.classList.remove('full-width');
var tab = document.querySelectorAll(".tabs a[href='" + location.hash + "']");
var tab = document.querySelectorAll("a[href='" + location.hash + "']");
if (tab.length) {
tab[0].parentElement.classList.add("active");
tab[0].parentElement.className = "active";
}
}

View File

@@ -1,275 +0,0 @@
/**
* Toast - Modern toast notification system
* Inspired by Toastify, Notyf, and React Hot Toast
*
* Usage:
* Toast.success('Operation completed!');
* Toast.error('Something went wrong');
* Toast.info('Here is some information');
* Toast.warning('Warning message');
* Toast.show('Custom message', { type: 'success', duration: 3000 });
*
* License: MIT
*/
(function(window) {
'use strict';
// Toast configuration
const defaultConfig = {
duration: 5000, // Auto-dismiss after 5 seconds (0 = no auto-dismiss)
position: 'top-center', // top-right, top-center, top-left, bottom-right, bottom-center, bottom-left
closeButton: true, // Show close button
progressBar: true, // Show progress bar
pauseOnHover: true, // Pause auto-dismiss on hover
maxToasts: 5, // Maximum toasts to show at once
offset: '20px', // Offset from edge
zIndex: 10000, // Z-index for toast container
};
let config = { ...defaultConfig };
let toastCount = 0;
let container = null;
/**
* Initialize toast system with custom config
*/
function init(userConfig = {}) {
config = { ...defaultConfig, ...userConfig };
createContainer();
}
/**
* Create toast container if it doesn't exist
*/
function createContainer() {
if (container) return;
container = document.createElement('div');
container.className = `toast-container toast-${config.position}`;
container.style.zIndex = config.zIndex;
document.body.appendChild(container);
}
/**
* Show a toast notification
*/
function show(message, options = {}) {
createContainer();
const toast = createToastElement(message, options);
// Limit number of toasts
const existingToasts = container.querySelectorAll('.toast');
if (existingToasts.length >= config.maxToasts) {
removeToast(existingToasts[0]);
}
// Add to container
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => {
toast.classList.add('toast-show');
});
// Auto-dismiss
if (options.duration !== 0 && (options.duration || config.duration) > 0) {
setupAutoDismiss(toast, options.duration || config.duration);
}
return {
dismiss: () => removeToast(toast)
};
}
/**
* Create toast DOM element
*/
function createToastElement(message, options) {
const toast = document.createElement('div');
toast.className = `toast toast-${options.type || 'default'}`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
// Icon
const icon = createIcon(options.type || 'default');
if (icon) {
toast.appendChild(icon);
}
// Message
const messageEl = document.createElement('div');
messageEl.className = 'toast-message';
messageEl.textContent = message;
toast.appendChild(messageEl);
// Close button
if (options.closeButton !== false && config.closeButton) {
const closeBtn = document.createElement('button');
closeBtn.className = 'toast-close';
closeBtn.innerHTML = '&times;';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.onclick = () => removeToast(toast);
toast.appendChild(closeBtn);
}
// Progress bar
if (options.progressBar !== false && config.progressBar && (options.duration || config.duration) > 0) {
const progressBar = document.createElement('div');
progressBar.className = 'toast-progress';
toast.appendChild(progressBar);
toast._progressBar = progressBar;
}
return toast;
}
/**
* Create icon based on toast type
*/
function createIcon(type) {
const iconEl = document.createElement('div');
iconEl.className = 'toast-icon';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
let path = '';
switch (type) {
case 'success':
path = 'M20 6L9 17l-5-5';
break;
case 'error':
path = 'M18 6L6 18M6 6l12 12';
break;
case 'warning':
path = 'M12 9v4m0 4h.01M12 2a10 10 0 100 20 10 10 0 000-20z';
svg.setAttribute('stroke-width', '1.5');
break;
case 'info':
path = 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
svg.setAttribute('stroke-width', '1.5');
break;
default:
return null;
}
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('d', path);
pathEl.setAttribute('stroke-linecap', 'round');
pathEl.setAttribute('stroke-linejoin', 'round');
svg.appendChild(pathEl);
iconEl.appendChild(svg);
return iconEl;
}
/**
* Setup auto-dismiss with progress bar
*/
function setupAutoDismiss(toast, duration) {
let startTime = Date.now();
let remainingTime = duration;
let isPaused = false;
let animationFrame;
function updateProgress() {
if (isPaused) return;
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
if (toast._progressBar) {
toast._progressBar.style.transform = `scaleX(${1 - progress})`;
}
if (progress >= 1) {
removeToast(toast);
} else {
animationFrame = requestAnimationFrame(updateProgress);
}
}
// Pause on hover
if (config.pauseOnHover) {
toast.addEventListener('mouseenter', () => {
isPaused = true;
remainingTime = duration - (Date.now() - startTime);
cancelAnimationFrame(animationFrame);
});
toast.addEventListener('mouseleave', () => {
isPaused = false;
startTime = Date.now();
duration = remainingTime;
animationFrame = requestAnimationFrame(updateProgress);
});
}
animationFrame = requestAnimationFrame(updateProgress);
}
/**
* Remove toast with animation
*/
function removeToast(toast) {
if (!toast || !toast.parentElement) return;
toast.classList.add('toast-hide');
// Remove after animation
setTimeout(() => {
if (toast.parentElement) {
toast.parentElement.removeChild(toast);
}
}, 300);
}
// Convenience methods
function success(message, options = {}) {
return show(message, { ...options, type: 'success' });
}
function error(message, options = {}) {
return show(message, { ...options, type: 'error' });
}
function warning(message, options = {}) {
return show(message, { ...options, type: 'warning' });
}
function info(message, options = {}) {
return show(message, { ...options, type: 'info' });
}
/**
* Clear all toasts
*/
function clear() {
if (!container) return;
const toasts = container.querySelectorAll('.toast');
toasts.forEach(removeToast);
}
// Public API
window.Toast = {
init,
show,
success,
error,
warning,
info,
clear,
version: '1.0.0'
};
// Auto-initialize
document.addEventListener('DOMContentLoaded', () => {
init();
});
})(window);

View File

@@ -1,15 +0,0 @@
/**
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/toastify-js@1.12.0/src/toastify.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
/*!
* Toastify js 1.12.0
* https://github.com/apvarun/toastify-js
* @license MIT licensed
*
* Copyright (C) 2018 Varun A P
*/
.toastify{padding:12px 20px;color:#fff;display:inline-block;box-shadow:0 3px 6px -1px rgba(0,0,0,.12),0 10px 36px -4px rgba(77,96,232,.3);background:-webkit-linear-gradient(315deg,#73a5ff,#5477f5);background:linear-gradient(135deg,#73a5ff,#5477f5);position:fixed;opacity:0;transition:all .4s cubic-bezier(.215, .61, .355, 1);border-radius:2px;cursor:pointer;text-decoration:none;max-width:calc(50% - 20px);z-index:2147483647}.toastify.on{opacity:1}.toast-close{background:0 0;border:0;color:#fff;cursor:pointer;font-family:inherit;font-size:1em;opacity:.4;padding:0 5px}.toastify-right{right:15px}.toastify-left{left:15px}.toastify-top{top:-150px}.toastify-bottom{bottom:-150px}.toastify-rounded{border-radius:25px}.toastify-avatar{width:1.5em;height:1.5em;margin:-7px 5px;border-radius:2px}.toastify-center{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content;max-width:-moz-fit-content}@media only screen and (max-width:360px){.toastify-left,.toastify-right{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content}}
/*# sourceMappingURL=/sm/cb4335d1b03e933ed85cb59fffa60cf51f07567ed09831438c60f59afd166464.map */

File diff suppressed because one or more lines are too long

View File

@@ -3,12 +3,14 @@
* Toggles theme between light and dark mode.
*/
$(document).ready(function () {
const button = document.getElementById("toggle-light-mode");
$(".toggle-light-mode").on("click", function () {
const isDark = $("html").attr("data-darkmode") === "true";
$("html").attr("data-darkmode", !isDark);
setCookieValue(!isDark);
});
button.onclick = () => {
const htmlElement = document.getElementsByTagName("html");
const isDarkMode = htmlElement[0].dataset.darkmode === "true";
htmlElement[0].dataset.darkmode = !isDarkMode;
setCookieValue(!isDarkMode);
};
const setCookieValue = (value) => {
document.cookie = `css_dark_mode=${value};max-age=31536000;path=/`

View File

@@ -1,7 +0,0 @@
/**
* SCSS variables (compile-time)
* These can be used in media queries and other places where CSS custom properties don't work
*/
// Breakpoints
$desktop-wide-breakpoint: 980px;

View File

@@ -1,115 +0,0 @@
// Action Sidebar - Minimal navigation icons with light grey aesthetic
.content-wrapper {
display: flex;
gap: 0;
width: 100%;
max-width: 100%;
position: relative;
@media only screen and (max-width: 900px) {
flex-direction: column;
}
}
.action-sidebar {
position: sticky;
top: 100px;
flex-shrink: 0;
width: 80px;
height: fit-content;
background: transparent;
padding: 1.5rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
z-index: 0;
@media only screen and (max-width: 900px) {
position: relative;
top: 0;
width: 100%;
flex-direction: row;
justify-content: space-around;
padding: 0;
overflow-x: auto;
}
}
.action-sidebar-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.75rem 0.5rem;
min-width: 64px;
text-decoration: none;
opacity: 0.8;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
.action-icon {
stroke: #fff;
stroke-width: 2.5;
}
.action-label {
color: #fff;
font-weight: 700;
}
}
}
.action-icon {
width: 28px;
height: 28px;
stroke: #fff;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke 0.2s ease;
}
.action-label {
font-size: 0.65rem;
font-weight: 500;
text-align: center;
line-height: 1.1;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #fff;
transition: color 0.2s ease;
max-width: 60px;
word-wrap: break-word;
}
.content-main {
flex: 0 1 auto;
width: 100%;
min-width: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
}
// Dark mode adjustments
html[data-darkmode=true] {
.action-icon {
/* stroke: #666;*/
}
.action-label {
/* color: #666;*/
}
}

View File

@@ -1,13 +1,15 @@
.toggle-light-mode {
#toggle-light-mode {
/* width: 3rem;*/
/* default */
.icon-dark {
display: none;
}
}
html[data-darkmode="true"] {
.toggle-light-mode {
#toggle-light-mode {
.icon-light {
display: none;
}

View File

@@ -1,167 +0,0 @@
// Hamburger Menu for Mobile Navigation
@use "../settings" as *;
.hamburger-menu {
display: none;
background: transparent;
border: none;
cursor: pointer;
padding: 0.5rem;
z-index: 10001;
position: relative;
@media only screen and (max-width: $desktop-wide-breakpoint) {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
}
.hamburger-icon {
width: 24px;
height: 20px;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
span {
display: block;
height: 3px;
width: 100%;
background: var(--color-text);
border-radius: 2px;
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
transform-origin: center;
}
}
.hamburger-menu.active {
.hamburger-icon span:nth-child(1) {
transform: translateY(8.5px) rotate(45deg);
}
.hamburger-icon span:nth-child(2) {
opacity: 0;
transform: translateX(-10px);
}
.hamburger-icon span:nth-child(3) {
transform: translateY(-8.5px) rotate(-45deg);
}
}
// Mobile menu overlay
.mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
opacity: 0;
transition: opacity 0.3s ease;
&.active {
display: block;
opacity: 1;
}
}
// Mobile menu drawer
.mobile-menu-drawer {
position: fixed;
top: 0;
right: -280px;
width: 280px;
height: 100%;
background: var(--color-background);
opacity: 1;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
z-index: 10000;
transition: right 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
overflow-y: auto;
padding-top: 60px;
&.active {
right: 0;
}
.mobile-menu-items {
list-style: none;
padding: 1rem 0;
margin: 0;
li {
border-bottom: 1px solid var(--color-border-table-cell);
>* {
display: block;
padding: 1rem 1.5rem;
color: var(--color-text);
text-decoration: none;
font-weight: 500;
transition: background 0.2s ease;
&:hover {
background: var(--color-background-menu-link-hover);
}
}
}
}
}
// Logo styling
.logo-cdio {
font-weight: bold;
font-size: 1.1rem;
.logo-cd {
color: var(--color-grey-500);
}
.logo-io {
color: var(--color-text);
}
}
// Always visible items container
.menu-always-visible {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
// Hide regular menu items on mobile (but not in mobile drawer)
@media only screen and (max-width: $desktop-wide-breakpoint) {
#top-right-menu .menu-collapsible {
display: none !important;
}
.pure-menu-horizontal {
overflow-x: visible !important;
}
#nav-menu {
overflow-x: visible !important;
}
}
// Desktop - hide mobile menu elements
@media only screen and (min-width: 1025px) {
.hamburger-menu,
.mobile-menu-drawer,
.mobile-menu-overlay {
display: none !important;
}
}
html[data-darkmode=true] {
.mobile-menu-drawer {
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.4);
}
}

View File

@@ -1,69 +0,0 @@
#language-selector-flag {
display: inline-block;
width: 1.2em;
height: 1.2em;
vertical-align: middle;
border-radius: 50%;
overflow: hidden;
opacity: 0.6;
&:hover {
opacity: 1.0;
}
}
// Language Selector Modal Styles
.language-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
}
.language-option {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.25rem;
border-radius: 4px;
transition: background-color 0.2s ease;
text-decoration: none;
color: var(--color-text);
border: 1px solid transparent;
&:hover {
background-color: var(--color-background-menu-link-hover);
border-color: var(--color-border-table-cell);
}
&.active {
background-color: var(--color-link);
color: var(--color-text-button);
font-weight: 600;
}
.flag {
font-size: 1.5rem;
flex-shrink: 0;
}
.language-name {
flex-grow: 1;
font-size: 1rem;
}
}
.language-modal {
.language-list {
.lang-option {
display: inline-block;
width: 1.5em;
height: 1.5em;
vertical-align: middle;
margin-right: 0.5em;
border-radius: 50%;
overflow: hidden;
}
}
}

View File

@@ -1,233 +0,0 @@
// Modern Login Form - Friendly and Welcoming Design
.login-form {
min-height: 52vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
.inner {
background: var(--color-background);
border-radius: 16px;
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
padding: 3rem 2.5rem;
width: 100%;
max-width: 420px;
position: relative;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
box-shadow:
0 15px 50px rgba(0, 0, 0, 0.12),
0 5px 15px rgba(0, 0, 0, 0.06);
}
}
form {
margin: 0;
}
fieldset {
border: none;
padding: 0;
margin: 0;
}
.pure-control-group {
margin-bottom: 1.75rem;
&:last-of-type {
margin-bottom: 0;
margin-top: 2rem;
}
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
color: var(--color-text);
letter-spacing: 0.01em;
}
input[type="password"] {
width: 100%;
padding: 0.875rem 1rem;
border: 2px solid var(--color-grey-800);
border-radius: 8px;
font-size: 1rem;
background: var(--color-background-input);
color: var(--color-text-input);
transition: all 0.2s ease;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--color-link);
box-shadow: 0 0 0 3px rgba(27, 152, 248, 0.1);
transform: translateY(-1px);
}
&::placeholder {
color: var(--color-text-input-placeholder);
}
}
button[type="submit"] {
width: 100%;
padding: 0.875rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border-radius: 8px;
border: none;
background: var(--color-background-button-primary);
color: var(--color-text-button);
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(27, 152, 248, 0.2);
&:hover {
box-shadow: 0 4px 12px rgba(27, 152, 248, 0.3);
background: #0066cc;
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(27, 152, 248, 0.2);
}
}
}
// Messages styling for login page
.content-main > ul.messages {
position: fixed;
top: 120px;
left: 50%;
transform: translateX(-50%);
list-style: none;
padding: 0;
margin: 0;
z-index: 1000;
min-width: 300px;
max-width: 500px;
li {
padding: 1rem 1.25rem;
border-radius: 8px;
font-size: 0.95rem;
line-height: 1.5;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideDown 0.3s ease-out;
border: 2px solid transparent;
&.error {
background: #fee;
border: 2px solid #ef4444;
color: #991b1b;
font-weight: 600;
}
&.success {
background: #f0fdf4;
border: 2px solid #10b981;
color: #166534;
}
&.info,
&.message {
background: #eff6ff;
border: 2px solid #3b82f6;
color: #1e40af;
}
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Dark mode adjustments
html[data-darkmode="true"] {
.login-form {
.inner {
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.2);
&:hover {
box-shadow:
0 15px 50px rgba(0, 0, 0, 0.5),
0 5px 15px rgba(0, 0, 0, 0.3);
}
}
input[type="password"] {
border-color: var(--color-grey-400);
&:focus {
border-color: var(--color-link);
}
}
}
.content-main > ul.messages {
li {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
&.error {
background: #4a1d1d;
border-color: #ef4444;
color: #fca5a5;
}
&.success {
background: #1a3a2a;
border-color: #10b981;
color: #86efac;
}
&.info,
&.message {
background: #1e3a5f;
border-color: #3b82f6;
color: #93c5fd;
}
}
}
}
// Mobile adjustments
@media only screen and (max-width: 768px) {
.login-form {
min-height: auto;
padding: 1rem 0.5rem;
padding-top: 5rem; // Space for error message
.inner {
padding: 2rem 1.5rem;
border-radius: 12px;
}
}
.content-main > ul.messages {
top: 70px; // Higher up on mobile to avoid overlap
left: 10px;
right: 10px;
transform: none;
min-width: auto;
}
}

View File

@@ -22,22 +22,4 @@
cursor: pointer;
}
}
// Active menu item styling
&.active {
.pure-menu-link {
background-color: var(--color-background-menu-link-hover);
color: var(--color-text-menu-link-hover);
}
}
}
#cdio-logo {
padding-left: 0.5em;
}
#inline-menu-extras-group {
>* {
display: inline-block;
}
}

View File

@@ -1,73 +0,0 @@
// Reusable notification bubble for action sidebar icons
.action-sidebar-item {
position: relative;
.notification-bubble {
position: absolute;
top: 8px;
left: 8px;
min-width: 18px;
height: 18px;
background: #ff4444;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 18px;
text-align: center;
border-radius: 9px;
padding: 0 2px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: all 0.2s ease;
display: none;
// Red bubble for errors/urgent
&.red-bubble {
background: #ff4444;
}
// Blue bubble for informational
&.blue-bubble {
background: #4a9eff;
color: #fff;
}
&.visible {
display: block;
}
// Pulse animation when value changes
&.pulse {
animation: bubblePulse 0.4s ease-out;
}
// Large numbers get smaller font
&.large-number {
font-size: 8px;
min-width: 20px;
height: 20px;
line-height: 20px;
border-radius: 10px;
}
}
}
@keyframes bubblePulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
// Dark mode adjustments
html[data-darkmode=true] {
.notification-bubble {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
}
}

View File

@@ -1,55 +0,0 @@
// Search Modal Styles
#search-modal {
.modal-body {
padding: 2rem 1.5rem;
.pure-control-group {
padding-bottom: 0;
label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text);
}
#search-modal-input {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.6rem 0.8rem;
font-size: 1rem;
border: 1px solid var(--color-border-input);
border-radius: 4px;
background-color: var(--color-background-input);
color: var(--color-text-input);
box-shadow: inset 0 1px 3px var(--color-shadow-input);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--color-link);
box-shadow: 0 0 0 3px rgba(27, 152, 248, 0.1);
}
&::placeholder {
color: var(--color-text-input-placeholder);
opacity: 0.7;
}
}
}
}
}
// Dark mode adjustments
html[data-darkmode=true] {
#search-modal {
#search-modal-input {
&:focus {
box-shadow: 0 0 0 3px rgba(89, 189, 251, 0.15);
}
}
}
}

View File

@@ -1,57 +0,0 @@
body.wrapped-tabs {
.tabs {
ul {
grid-template-columns: repeat(auto-fill, minmax(var(--tab-width, 180px), 1fr));
grid-auto-flow: row;
grid-auto-columns: unset;
gap: 0;
column-gap: 5px;
}
ul li {
border-radius: 0;
}
}
}
.tabs {
ul {
margin: 0px;
padding: 0px;
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: 5px;
list-style: none;
li {
white-space: nowrap;
color: var(--color-text-tab);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
background-color: var(--color-background-tab);
&:not(.active) {
&:hover {
background-color: var(--color-background-tab-hover);
}
}
&.active,
:target {
background-color: var(--color-background);
a {
color: var(--color-text-tab-active);
font-weight: bold;
}
}
a {
display: block;
padding: 0.7em;
color: var(--color-text-tab);
}
}
}
}

View File

@@ -1,231 +0,0 @@
// Toast Notification System
// Modern, animated toast notifications
.toast-container {
position: fixed;
display: flex;
flex-direction: column;
gap: 0.75rem;
pointer-events: none;
z-index: 10000;
// Positioning
&.toast-top-right {
top: 20px;
right: 20px;
}
&.toast-top-center {
top: 100px;
left: 50%;
transform: translateX(-50%);
}
&.toast-top-left {
top: 20px;
left: 20px;
}
&.toast-bottom-right {
bottom: 20px;
right: 20px;
}
&.toast-bottom-center {
bottom: 20px;
left: 50%;
transform: translateX(-50%);
}
&.toast-bottom-left {
bottom: 20px;
left: 20px;
}
}
.toast {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 300px;
max-width: 500px;
padding: 1rem 1.25rem;
background: var(--color-background);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
pointer-events: auto;
overflow: hidden;
opacity: 0;
transform: translateY(-50px);
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
font-family: inherit;
&.toast-show {
opacity: 1;
transform: translateY(0);
}
&.toast-hide {
opacity: 0;
transform: translateY(-50px) scale(0.95);
}
// Toast types
&.toast-success {
border-left: 4px solid #10b981;
.toast-icon {
color: #10b981;
}
}
&.toast-error {
border-left: 4px solid #ef4444;
.toast-icon {
color: #ef4444;
}
}
&.toast-warning {
border-left: 4px solid #f59e0b;
.toast-icon {
color: #f59e0b;
}
}
&.toast-info {
border-left: 4px solid #3b82f6;
.toast-icon {
color: #3b82f6;
}
}
&.toast-default {
border-left: 4px solid var(--color-grey-500);
}
}
.toast-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
svg {
width: 100%;
height: 100%;
}
}
.toast-message {
flex: 1;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text);
word-break: break-word;
font-family: inherit;
}
.toast-close {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
color: var(--color-grey-500);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
margin-left: 0.25rem;
&:hover {
background: var(--color-grey-800);
color: var(--color-text);
}
&:active {
transform: scale(0.95);
}
}
.toast-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: currentColor;
opacity: 0.3;
transform-origin: left;
transition: transform linear;
}
// Dark mode adjustments
html[data-darkmode=true] {
.toast {
background: var(--color-grey-300);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.toast-close:hover {
background: var(--color-grey-400);
}
}
// Mobile adjustments
@media only screen and (max-width: 768px) {
.toast-container {
left: 10px !important;
right: 10px !important;
top: 10px !important;
transform: none !important;
align-items: stretch;
&.toast-bottom-right,
&.toast-bottom-center,
&.toast-bottom-left {
top: auto !important;
bottom: 10px !important;
}
}
.toast {
min-width: auto;
max-width: none;
width: 100%;
transform: translateY(-100px);
&.toast-show {
transform: translateY(0);
}
&.toast-hide {
transform: translateY(-100px) scale(0.95);
}
}
}
// Accessibility
@media (prefers-reduced-motion: reduce) {
.toast {
transition: opacity 0.2s ease;
transform: none !important;
&.toast-show {
opacity: 1;
}
&.toast-hide {
opacity: 0;
}
}
}

View File

@@ -2,7 +2,6 @@
* -- BASE STYLES --
*/
@use "settings" as *;
@use "parts/variables";
@use "parts/arrows";
@use "parts/browser-steps";
@@ -24,14 +23,6 @@
@use "parts/widgets";
@use "parts/diff_image";
@use "parts/modal";
@use "parts/language";
@use "parts/action_sidebar";
@use "parts/hamburger_menu";
@use "parts/search_modal";
@use "parts/notification_bubble";
@use "parts/toast";
@use "parts/login_form";
@use "parts/tabs";
body {
@@ -80,6 +71,20 @@ a.github-link {
}
}
#search-q {
opacity: 0;
-webkit-transition: all .9s ease;
-moz-transition: all .9s ease;
transition: all .9s ease;
width: 0;
display: none;
&.expanded {
width: auto;
display: inline-block;
opacity: 1;
}
}
#search-result-info {
color: #fff;
}
@@ -160,13 +165,7 @@ body.spinner-active {
}
section.content {
@media only screen and (max-width: $desktop-wide-breakpoint) {
padding-top: 80px;
}
@media only screen and (min-width: $desktop-wide-breakpoint) {
padding-top: 100px;
}
padding-top: 100px;
padding-bottom: 1em;
flex-direction: column;
display: flex;
@@ -184,13 +183,13 @@ code {
border-radius: 5px;
padding: 2px 5px;
margin-right: 4px;
line-height: 1.2rem;
}
/* Processor type badges - colors auto-generated from processor names */
.processor-badge {
@extend .inline-tag;
font-weight: 900;
font-size: 0.85em;
font-weight: 500;
}
.watch-tag-list {
@@ -523,9 +522,6 @@ footer {
}
.sticky-tab {
@media only screen and (max-width: $desktop-wide-breakpoint) {
display: none;
}
position: absolute;
top: 60px;
font-size: 65%;
@@ -670,7 +666,7 @@ footer {
@media only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: $desktop-wide-breakpoint) {
(min-device-width: 768px) and (max-device-width: 1024px) {
.edit-form {
padding: 0.5em;
margin: 0;
@@ -682,10 +678,30 @@ footer {
}
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: $desktop-wide-breakpoint) {
@media only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 800px) {
div.sticky-tab#hosted-sticky {
top: 60px;
left: 0px;
right: auto;
}
section.content {
padding-top: 110px;
}
// Make the tabs easier to hit, they will be all nice and horizontal
div.tabs.collapsable ul li {
display: block;
border-radius: 0px;
margin-right: 0px;
}
input[type='text'] {
width: 100%;
}
}
.pure-table {
@@ -761,6 +777,45 @@ textarea::placeholder {
}
.tabs {
ul {
margin: 0px;
padding: 0px;
display: block;
li {
margin-right: 1px;
display: inline-block;
color: var(--color-text-tab);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
background-color: var(--color-background-tab);
&:not(.active) {
&:hover {
background-color: var(--color-background-tab-hover);
}
}
&.active,
:target {
background-color: var(--color-background);
a {
color: var(--color-text-tab-active);
font-weight: bold;
}
}
a {
display: block;
padding: 0.7em;
color: var(--color-text-tab);
}
}
}
}
$form-edge-padding: 20px;
.pure-form-stacked {
@@ -769,7 +824,14 @@ $form-edge-padding: 20px;
}
}
// Login form styles moved to parts/_login_form.scss
.login-form {
.inner {
background: var(--color-background);
;
padding: $form-edge-padding;
border-radius: 5px;
}
}
.tab-pane-inner {
@@ -1097,4 +1159,44 @@ ul#highlightSnippetActions {
}
}
// Language Selector Modal Styles
.language-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
}
.language-option {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.25rem;
border-radius: 4px;
transition: background-color 0.2s ease;
text-decoration: none;
color: var(--color-text);
border: 1px solid transparent;
&:hover {
background-color: var(--color-background-menu-link-hover);
border-color: var(--color-border-table-cell);
}
&.active {
background-color: var(--color-link);
color: var(--color-text-button);
font-weight: 600;
}
.flag {
font-size: 1.5rem;
flex-shrink: 0;
}
.language-name {
flex-grow: 1;
font-size: 1rem;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -145,7 +145,6 @@
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">Processing..</span></div>
</div>
</div>
<div class="pure-control-group grey-form-border">
<div class="pure-control-group">
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }}
@@ -170,7 +169,6 @@
</span></li>
</ul>
<br>
</div>
</div>
<div class="">
{{ render_field(form.notification_format , class="notification-format") }}

View File

@@ -22,7 +22,7 @@
{% for idx, entry_errors in field.errors|enumerate %}
{% if entry_errors is mapping and entry_errors %}
{# Only show entries that have actual errors #}
<li><strong>{{ _('Entry') }} {{ idx + 1 }}:</strong>
<li><strong>Entry {{ idx + 1 }}:</strong>
<ul>
{% for field_name, messages in entry_errors.items() %}
{% for message in messages %}
@@ -150,7 +150,7 @@
{% for subfield in fieldlist[0] %}
<div class="fieldlist-header-cell">{{ subfield.label }}</div>
{% endfor %}
<div class="fieldlist-header-cell">{{ _('Actions') }}</div>
<div class="fieldlist-header-cell">Actions</div>
</div>
<div class="fieldlist-body">
{% for form_row in fieldlist %}
@@ -169,9 +169,9 @@
</div>
{% endfor %}
<div class="fieldlist-cell fieldlist-actions">
<button type="button" class="addRuleRow" title="{{ _('Add a row/rule after') }}">+</button>
<button type="button" class="removeRuleRow" title="{{ _('Remove this row/rule') }}">-</button>
<button type="button" class="verifyRuleRow" title="{{ _('Verify this rule against current snapshot') }}"></button>
<button type="button" class="addRuleRow" title="Add a row/rule after">+</button>
<button type="button" class="removeRuleRow" title="Remove this row/rule">-</button>
<button type="button" class="verifyRuleRow" title="Verify this rule against current snapshot"></button>
</div>
</div>
{% endfor %}
@@ -181,8 +181,8 @@
{% macro playwright_warning() %}
<p><strong>{{ _('Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.') }}</strong> {{ _('Alternatively try our') }} <a href="https://changedetection.io">{{ _('very affordable subscription based service which has all this setup for you') }}</a>.</p>
<p>{{ _('You may need to') }} <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">{{ _('Enable playwright environment variable') }}</a> {{ _('and uncomment the') }} <strong>sockpuppetbrowser</strong> {{ _('in the') }} <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml">docker-compose.yml</a> {{ _('file') }}.</p>
<p><strong>Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.</strong> Alternatively try our <a href="https://changedetection.io">very affordable subscription based service which has all this setup for you</a>.</p>
<p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> in the <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml">docker-compose.yml</a> file.</p>
<br>
{% endmacro %}
@@ -237,17 +237,18 @@
<span id="scheduler-icon-label" style="">
{{ render_checkbox_field(form.time_schedule_limit.enabled) }}
<div class="pure-form-message-inline">
{{ _('Set a hourly/week day schedule') }}
Set a hourly/week day schedule
</div>
</span>
</div>
<br>
<div id="schedule-day-limits-wrapper">
<label>{{ _('Schedule time limits') }}</label><a data-template="business-hours"
class="set-schedule pure-button button-secondary button-xsmall">{{ _('Business hours') }}</a>
<a data-template="weekend" class="set-schedule pure-button button-secondary button-xsmall">{{ _('Weekends') }}</a>
<a data-template="reset" class="set-schedule pure-button button-xsmall">{{ _('Reset') }}</a><br>
<label>Schedule time limits</label><a data-template="business-hours"
class="set-schedule pure-button button-secondary button-xsmall">Business
hours</a>
<a data-template="weekend" class="set-schedule pure-button button-secondary button-xsmall">Weekends</a>
<a data-template="reset" class="set-schedule pure-button button-xsmall">Reset</a><br>
<br>
<ul id="day-wrapper">
@@ -256,8 +257,8 @@
{{ render_nolabel_field(form.time_schedule_limit[day]) }}
</li>
{% endfor %}
<li id="timespan-warning">{{ _("Warning, one or more of your 'days' has a duration that would extend into the next day.") }}<br>
{{ _('This could have unintended consequences.') }}</li>
<li id="timespan-warning">Warning, one or more of your 'days' has a duration that would extend into the next day.<br>
This could have unintended consequences.</li>
<li id="timezone-info">
{{ render_field(form.time_schedule_limit.timezone, placeholder=timezone_default_config) }} <span id="local-time-in-tz"></span>
<datalist id="timezones" style="display: none;">
@@ -267,12 +268,12 @@
</ul>
<br>
<span class="pure-form-message-inline">
<a href="https://changedetection.io/tutorials">{{ _('More help and examples about using the scheduler') }}</a>
<a href="https://changedetection.io/tutorials">More help and examples about using the scheduler</a>
</span>
</div>
{% else %}
<span class="pure-form-message-inline">
{{ _('Want to use a time schedule?') }} <a href="{{url_for('settings.settings_page')}}#timedate">{{ _('First confirm/save your Time Zone Settings') }}</a>
Want to use a time schedule? <a href="{{url_for('settings.settings_page')}}#timedate">First confirm/save your Time Zone Settings</a>
</span>
<br>
{% endif %}
@@ -281,8 +282,8 @@
{% macro highlight_trigger_ignored_explainer() %}
<p>
<span title="{{ _('Triggers a change if this text appears, AND something changed in the document.') }}" style="background-color: var(--highlight-trigger-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">{{ _('Triggered text') }}</span>
<span title="{{ _('Ignored for calculating changes, but still shown.') }}" style="background-color: var(--highlight-ignored-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">{{ _('Ignored text') }}</span>
<span title="{{ _('No change-detection will occur because this text exists.') }}" style="background-color: var(--highlight-blocked-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">{{ _('Blocked text') }}</span>
<span title="Triggers a change if this text appears, AND something changed in the document." style="background-color: var(--highlight-trigger-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Triggered text</span>
<span title="Ignored for calculating changes, but still shown." style="background-color: var(--highlight-ignored-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Ignored text</span>
<span title="No change-detection will occur because this text exists." style="background-color: var(--highlight-blocked-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;">Blocked text</span>
</p>
{% endmacro %}

View File

@@ -53,10 +53,10 @@
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
{% if has_password and not current_user.is_authenticated %}
<a id="cdio-logo" class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
<a class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
<strong>Change</strong>Detection.io</a>
{% else %}
<a id="cdio-logo" class="pure-menu-heading" href="{{url_for('watchlist.index')}}">
<a class="pure-menu-heading" href="{{url_for('watchlist.index')}}">
<strong>Change</strong>Detection.io</a>
{% endif %}
{% if current_diff_url and is_safe_valid_url(current_diff_url) %}
@@ -71,20 +71,64 @@
{% endif %}
<ul class="pure-menu-list" id="top-right-menu">
<!-- Collapsible menu items (hidden on mobile, shown in drawer) -->
{% include "menu.html" %}
{% if current_user.is_authenticated or not has_password %}
{% if not current_diff_url %}
<li class="pure-menu-item menu-collapsible">
<button class="toggle-button" id="open-search-modal" type="button" title="{{ _('Search, or Use Alt+S Key') }}">
{% include "svgs/search-icon.svg" %}
</button>
<li class="pure-menu-item">
<a href="{{ url_for('tags.tags_overview_page')}}" class="pure-menu-link">{{ _('GROUPS') }}</a>
</li>
<li class="pure-menu-item">
<a href="{{ url_for('settings.settings_page')}}" class="pure-menu-link">{{ _('SETTINGS') }}</a>
</li>
<li class="pure-menu-item">
<a href="{{ url_for('imports.import_page')}}" class="pure-menu-link">{{ _('IMPORT') }}</a>
</li>
<li class="pure-menu-item">
<a href="{{ url_for('backups.index')}}" class="pure-menu-link">{{ _('BACKUPS') }}</a>
</li>
{% else %}
<li class="pure-menu-item">
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}" class="pure-menu-link">{{ _('EDIT') }}</a>
</li>
{% endif %}
{% else %}
<li class="pure-menu-item">
<a class="pure-menu-link" href="https://changedetection.io">Website Change Detection and Notification.</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="pure-menu-item">
<a href="{{url_for('logout')}}" class="pure-menu-link">{{ _('LOG OUT') }}</a>
</li>
{% endif %}
{% if current_user.is_authenticated or not has_password %}
<li class="pure-menu-item pure-form" id="search-menu-item">
<!-- We use GET here so it offers people a chance to set bookmarks etc -->
<form name="searchForm" action="" method="GET">
<input id="search-q" class="" name="q" placeholder="URL or Title {% if active_tag_uuid %}in '{{ active_tag.title }}'{% endif %}" required="" type="text" value="">
<input name="tags" type="hidden" value="{% if active_tag_uuid %}{{active_tag_uuid}}{% endif %}">
<button class="toggle-button " id="toggle-search" type="button" title="{{ _('Search, or Use Alt+S Key') }}" >
{% include "svgs/search-icon.svg" %}
</button>
</form>
</li>
{% endif %}
<li class="pure-menu-item">
<button class="toggle-button" id ="toggle-light-mode" type="button" title="{{ _('Toggle Light/Dark Mode') }}">
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
<span class="icon-light">
{% include "svgs/light-mode-toggle-icon.svg" %}
</span>
<span class="icon-dark">
{% include "svgs/dark-mode-toggle-icon.svg" %}
</span>
</button>
</li>
<li class="pure-menu-item">
<button class="toggle-button" id="language-selector" type="button" title="{{ _('Change Language') }}">
<span class="visually-hidden">{{ _('Change language') }}</span>
<span class="{{ get_flag_for_locale(get_locale()) }}" style="display: inline-block; width: 1.2em; height: 1.2em; vertical-align: middle; border-radius: 50%; overflow: hidden;"></span>
</button>
</li>
<li class="pure-menu-item" id="heart-us">
<svg
fill="#ff0000"
@@ -94,37 +138,24 @@
id="svg-heart"
xmlns="http://www.w3.org/2000/svg"
>
<path id="heartpath" d="M 5.338316,0.50302766 C 0.71136983,0.50647126 -3.9576371,7.2707777 8.5004254,15.503028 23.833425,5.3700277 13.220206,-2.5384409 8.6762066,1.6475589 c -0.060791,0.054322 -0.11943,0.1110064 -0.1757812,0.1699219 -0.057,-0.059 -0.1157813,-0.116875 -0.1757812,-0.171875 C 7.4724566,0.86129334 6.4060729,0.50223298 5.338316,0.50302766 Z" style="fill:var(--color-background);fill-opacity:1;stroke:#ff0000;stroke-opacity:1" />
<path id="heartpath" d="M 5.338316,0.50302766 C 0.71136983,0.50647126 -3.9576371,7.2707777 8.5004254,15.503028 23.833425,5.3700277 13.220206,-2.5384409 8.6762066,1.6475589 c -0.060791,0.054322 -0.11943,0.1110064 -0.1757812,0.1699219 -0.057,-0.059 -0.1157813,-0.116875 -0.1757812,-0.171875 C 7.4724566,0.86129334 6.4060729,0.50223298 5.338316,0.50302766 Z"
style="fill:var(--color-background);fill-opacity:1;stroke:#ff0000;stroke-opacity:1" />
</svg>
</li>
<!-- Hamburger menu button (mobile only) -->
<li class="pure-menu-item">
<button class="hamburger-menu" id="hamburger-toggle" aria-label="Toggle menu">
<div class="hamburger-icon">
<span></span>
<span></span>
<span></span>
</div>
</button>
</li>
</ul>
</div>
<!-- Mobile menu drawer -->
<div class="mobile-menu-overlay" id="mobile-menu-overlay"></div>
<div class="mobile-menu-drawer" id="mobile-menu-drawer">
<ul class="mobile-menu-items">
{% include "menu.html" %}
<li class="pure-menu-item menu-collapsible">
{%- if right_sticky -%}<div>{{ right_sticky }}</div>{%- endif -%}
<a href="https://changedetection.io/?ref={{ guid }}">Let us host your instance!</a><br>
</li>
</li>
<li class="pure-menu-item">
<a class="github-link" href="https://github.com/dgtlmoon/changedetection.io">
{% include "svgs/github.svg" %}
</a>
</li>
</ul>
</div>
<div id="pure-menu-horizontal-spinner"></div>
</div>
</div>
{% if hosted_sticky %}
<div class="sticky-tab" id="hosted-sticky">
<a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>
@@ -187,62 +218,32 @@
</p>
</div>
</div>
<header>
{% block header %}{% endblock %}
</header>
<div class="content-wrapper">
{#
{% if current_user.is_authenticated or not has_password %}
<aside class="action-sidebar">
<a href="{{ url_for('watchlist.index') }}" class="action-sidebar-item {% if request.endpoint.startswith('watchlist.') or request.endpoint.startswith('ui.') %}active{% endif %}" title="{{ _('Watch List') }}">
<svg class="action-icon" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<span class="action-label">{{ _('Watches') }}</span>
</a>
<a href="{{ url_for('queue_status') }}" class="action-sidebar-item {% if request.endpoint == 'queue_status' %}active{% endif %}" id="queue-action-item" title="{{ _('Queue Status') }}">
<svg class="action-icon" viewBox="0 0 24 24">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span class="action-label">{{ _('Queue') }}</span>
<span class="notification-bubble blue-bubble" id="queue-bubble" data-count="0"></span>
</a>
</aside>
{% endif %}
#}
<div class="content-main">
<header>
{% block header %}{% endblock %}
</header>
{% with messages = get_flashed_messages(with_categories = true) %}
{% if messages %}
<ul class="messages">
{% for category, message in messages %}
<li class="{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% if session['share-link'] %}
<ul class="messages with-share-link">
<li class="message">
Share this link:
<span id="share-link">{{ session['share-link'] }}</span>
<img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}" >
</li>
</ul>
{% endif %}
{% block content %}{% endblock %}
</div>
</div>
{% with messages = get_flashed_messages(with_categories = true) %}
{% if
messages %}
<ul class="messages">
{% for category, message in messages %}
<li class="{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% if session['share-link'] %}
<ul class="messages with-share-link">
<li class="message">
Share this link:
<span id="share-link">{{ session['share-link'] }}</span>
<img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='copy.svg')}}" >
</li>
</ul>
{% endif %}
{% block content %}{% endblock %}
</section>
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='hamburger-menu.js')}}" defer></script>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span class="status-text">&nbsp;{{ _('Checking now') }}</span></div>
<div id="realtime-conn-error" style="display:none">{{ _('Real-time updates offline') }}</div>
@@ -260,8 +261,8 @@
<div class="modal-body">
<div class="language-list">
{% for locale, lang_data in available_languages.items()|sort %}
<a href="{{ url_for('set_language', locale=locale, redirect=request.path) }}" class="language-option" data-locale="{{ locale }}">
<span class="lang-option {{ lang_data.flag }}"></span> <span class="language-name">{{ lang_data.name }}</span>
<a href="{{ url_for('set_language', locale=locale) }}" class="language-option" data-locale="{{ locale }}">
<span class="{{ lang_data.flag }}" style="display: inline-block; width: 1.5em; height: 1.5em; vertical-align: middle; margin-right: 0.5em; border-radius: 50%; overflow: hidden;"></span> <span class="language-name">{{ lang_data.name }}</span>
</a>
{% endfor %}
</div>
@@ -274,103 +275,7 @@
</div>
</dialog>
<!-- Search Modal -->
{% if current_user.is_authenticated or not has_password %}
<dialog id="search-modal" class="modal-dialog" aria-labelledby="search-modal-title">
<div class="modal-header">
<h2 class="modal-title" id="search-modal-title">{{ _('Search') }}</h2>
</div>
<div class="modal-body">
<form id="search-form" method="GET">
<div class="pure-control-group">
<label for="search-modal-input">{{ _('URL or Title') }}{% if active_tag_uuid %} {{ _('in') }} '{{ active_tag.title }}'{% endif %}</label>
<input id="search-modal-input" class="m-d" name="q" placeholder="{{ _('Enter search term...') }}" required type="text" value="" autofocus>
<input name="tags" type="hidden" value="{% if active_tag_uuid %}{{active_tag_uuid}}{% endif %}">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="pure-button button-cancel" id="close-search-modal">{{ _('Cancel') }}</button>
<button type="submit" form="search-form" class="pure-button pure-button-primary">{{ _('Search') }}</button>
</div>
</dialog>
{% endif %}
<script>
(function() {
/* AUTOMATIC TAB COLUMN-IZER FOR WHEN TABS WRAP */
// Exit early if no tabs on page
if (!document.querySelector('.tab')) return;
const cache = new Map();
function checkWrapping(ul) {
const tabs = ul.querySelectorAll('.tab');
if (tabs.length < 2) return false;
// Init cache on first run
if (!cache.has(ul)) {
ul.style.setProperty('--tab-width', '');
void ul.offsetHeight;
let max = 0;
tabs.forEach(t => max = Math.max(max, t.offsetWidth));
cache.set(ul, max);
}
// Temporarily use flex wrap to check if wrapping occurs
ul.style.display = 'flex';
ul.style.flexWrap = 'wrap';
void ul.offsetHeight;
const top = tabs[0].offsetTop;
const wrapped = Array.from(tabs).some((t, i) => i > 0 && t.offsetTop !== top);
// Reset display to use CSS grid
ul.style.display = '';
ul.style.flexWrap = '';
// Set CSS variable for wrapped mode
if (wrapped) {
ul.style.setProperty('--tab-width', `${cache.get(ul) + 10}px`);
} else {
ul.style.setProperty('--tab-width', '');
}
return wrapped;
}
function check() {
let any = false;
document.querySelectorAll('ul').forEach(ul => {
if (ul.querySelector('.tab') && checkWrapping(ul)) any = true;
});
document.body.classList.toggle('wrapped-tabs', any);
}
check();
let timer;
window.addEventListener('resize', () => {
clearTimeout(timer);
timer = setTimeout(check, 100);
});
// Re-check wrapping when tabs are switched via anchors
window.addEventListener('hashchange', () => {
clearTimeout(timer);
// Use requestAnimationFrame + setTimeout to ensure DOM has settled
requestAnimationFrame(() => {
timer = setTimeout(check, 0);
});
});
})();
</script>
<script src="{{url_for('static_content', group='js', filename='language-selector.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='search-modal.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='toast.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='flask-toast-bridge.js')}}" defer></script>
</body>
</html>

View File

@@ -5,7 +5,6 @@
<div class="inner">
<form class="pure-form pure-form-stacked" action="{{url_for('login')}}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" id="redirect" name="redirect" value="{{ redirect_url }}">
<fieldset>
<div class="pure-control-group">
<label for="password">{{ _('Password') }}</label>

View File

@@ -1,54 +0,0 @@
{# Menu items template - used for both desktop and mobile menus #}
{# CSS media queries handle which version displays - no need for conditional classes #}
{% if current_user.is_authenticated or not has_password %}
{% if not current_diff_url %}
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('tags.') %}active{% endif %}">
<a href="{{ url_for('tags.tags_overview_page') }}" class="pure-menu-link">{{ _('GROUPS') }}</a>
</li>
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('settings.') %}active{% endif %}">
<a href="{{ url_for('settings.settings_page') }}" class="pure-menu-link">{{ _('SETTINGS') }}</a>
</li>
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('imports.') %}active{% endif %}">
<a href="{{ url_for('imports.import_page') }}" class="pure-menu-link">{{ _('IMPORT') }}</a>
</li>
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('backups.') %}active{% endif %}">
<a href="{{ url_for('backups.index') }}" class="pure-menu-link">{{ _('BACKUPS') }}</a>
</li>
{% else %}
<li class="pure-menu-item menu-collapsible">
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}"
class="pure-menu-link">{{ _('EDIT') }}</a>
</li>
{% endif %}
{%- if current_user.is_authenticated -%}
<li class="pure-menu-item menu-collapsible">
<a href="{{ url_for('logout', redirect=request.path) }}" class="pure-menu-link">{{ _('LOG OUT') }}</a>
</li>
{%- endif -%}
{% else %}
<li class="pure-menu-item menu-collapsible">
<a class="pure-menu-link" href="https://changedetection.io">Website Change Detection and Notification.</a>
</li>
{% endif %}
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
<button class="toggle-button toggle-light-mode " type="button" title="{{ _('Toggle Light/Dark Mode') }}">
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
<span class="icon-light">
{% include "svgs/light-mode-toggle-icon.svg" %}
</span>
<span class="icon-dark">
{% include "svgs/dark-mode-toggle-icon.svg" %}
</span>
</button>
<button class="toggle-button language-selector" type="button" title="{{ _('Change Language') }}">
<span class="visually-hidden">{{ _('Change language') }}</span>
<span class="{{ get_flag_for_locale(get_locale()) }}" id="language-selector-flag"></span>
</button>
<a class="github-link" href="https://github.com/dgtlmoon/changedetection.io">
{% include "svgs/github.svg" %}
</a>
</li>

View File

@@ -165,83 +165,18 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
assert b'<div id' in res.data
# Fetch the difference between two versions (default text format)
# Fetch the difference between two versions
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest'),
headers={'x-api-key': api_key},
)
assert b'(changed) Which is across' in res.data
# Test htmlcolor format
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?format=htmlcolor',
headers={'x-api-key': api_key},
)
assert b'aria-label="Changed text" title="Changed text">Which is across multiple lines' in res.data
# Test html format
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?format=html',
headers={'x-api-key': api_key},
)
assert res.status_code == 200
assert b'<br>' in res.data
# Test markdown format
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?format=markdown',
headers={'x-api-key': api_key},
)
assert res.status_code == 200
# Test new diff preference parameters
# Test removed=false (should hide removed content)
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?removed=false',
headers={'x-api-key': api_key},
)
# Should not contain removed content indicator
assert b'(removed)' not in res.data
# Should still contain added content
assert b'(added)' in res.data or b'which has this one new line' in res.data
# Test added=false (should hide added content)
# Note: The test data has replacements, not pure additions, so we test differently
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?added=false&replaced=false',
headers={'x-api-key': api_key},
)
# With both added and replaced disabled, should have minimal content
# Should not contain added indicators
assert b'(added)' not in res.data
# Test replaced=false (should hide replaced/changed content)
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?replaced=false',
headers={'x-api-key': api_key},
)
# Should not contain changed content indicator
assert b'(changed)' not in res.data
# Test type=diffWords for word-level diff
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?type=diffWords&format=htmlcolor',
headers={'x-api-key': api_key},
)
# Should contain HTML formatted diff
assert res.status_code == 200
assert len(res.data) > 0
# Test combined parameters: show only additions with word diff
res = client.get(
url_for("watchhistorydiff", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?removed=false&replaced=false&type=diffWords',
headers={'x-api-key': api_key},
)
assert res.status_code == 200
# Should not contain removed or changed markers
assert b'(removed)' not in res.data
assert b'(changed)' not in res.data
# Fetch the whole watch
res = client.get(

View File

@@ -110,42 +110,3 @@ def test_language_persistence_in_session(client, live_server, measure_memory_usa
assert res.status_code == 200
assert b"Annulla" in res.data, "Italian text should persist across requests"
def test_set_language_with_redirect(client, live_server, measure_memory_usage, datastore_path):
"""
Test that changing language keeps the user on the same page.
Example: User is on /settings, changes language, stays on /settings.
"""
from flask import url_for
# Set language with a redirect parameter (simulating language change from /settings)
res = client.get(
url_for("set_language", locale="de", redirect="/settings"),
follow_redirects=False
)
# Should redirect back to settings
assert res.status_code in [302, 303]
assert '/settings' in res.location
# Verify language was set in session
with client.session_transaction() as sess:
assert sess.get('locale') == 'de'
# Test with invalid locale (should still redirect safely)
res = client.get(
url_for("set_language", locale="invalid_locale", redirect="/settings"),
follow_redirects=False
)
assert res.status_code in [302, 303]
assert '/settings' in res.location
# Test with malicious redirect (should default to watchlist)
res = client.get(
url_for("set_language", locale="en", redirect="https://evil.com"),
follow_redirects=False
)
assert res.status_code in [302, 303]
# Should not redirect to evil.com
assert 'evil.com' not in res.location

View File

@@ -5,7 +5,7 @@ import re
from flask import url_for
from loguru import logger
from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output
from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks
from . util import extract_UUID_from_client
import logging
import base64
@@ -83,9 +83,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
screenshot_dir = os.path.join(datastore_path, str(uuid))
os.makedirs(screenshot_dir, exist_ok=True)
with open(os.path.join(screenshot_dir, 'last-screenshot.png'), 'wb') as f:
with open(os.path.join(datastore_path, str(uuid), 'last-screenshot.png'), 'wb') as f:
f.write(base64.b64decode(testimage_png))
# Goto the edit page, add our ignore text
@@ -144,7 +142,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
# Trigger a check
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
time.sleep(6)
# Check no errors were recorded
res = client.get(url_for("watchlist.index"))
@@ -201,7 +199,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
set_more_modified_response(datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
time.sleep(6)
# Verify what was sent as a notification, this file should exist
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
notification_submission = f.read()
@@ -242,8 +240,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
time.sleep(2)
# Verify what was sent as a notification, this file should exist
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
@@ -328,7 +325,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
time.sleep(2) # plus extra delay for notifications to fire
# Check no errors were recorded, because we asked for 204 which is slightly uncommon but is still OK
@@ -446,7 +443,7 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
assert res.status_code != 500
# Give apprise time to fire
wait_for_notification_endpoint_output(datastore_path=datastore_path)
time.sleep(4)
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
x = f.read()
@@ -503,7 +500,7 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123"
# 1995 UTF-8 content should be encoded
test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}\n\nCurrent snapshot: {{current_snapshot}}'
test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}'
######### Test global/system settings
res = client.post(
url_for("ui.ui_notification.ajax_callback_send_notification_test")+f"/{uuid}",
@@ -528,8 +525,7 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem
assert 'title="Changed into">Example text:' not in x
assert 'span' not in x
assert 'Example text:' in x
#3720 current_snapshot check, was working but lets test it exactly.
assert 'Current snapshot: Example text: example test' in x
os.unlink(os.path.join(datastore_path, "notification.txt"))
def _test_color_notifications(client, notification_body_token, datastore_path):
@@ -576,7 +572,7 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
assert b'Queued 1 watch for rechecking.' in res.data
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
time.sleep(2)
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
x = f.read()

View File

@@ -240,6 +240,7 @@ def test_restock_itemprop_with_tag(client, live_server, measure_memory_usage, da
def test_itemprop_percent_threshold(client, live_server, measure_memory_usage, datastore_path):
delete_all_watches(client)
@@ -298,26 +299,7 @@ def test_itemprop_percent_threshold(client, live_server, measure_memory_usage, d
assert b'has-unread-changes' not in res.data
# Re #2600 - Switch the mode to normal type and back, and see if the values stick..
###################################################################################
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={"restock_settings-follow_price_changes": "y",
"restock_settings-price_change_threshold_percent": 5.05,
"processor": "text_json_diff",
"url": test_url,
'fetch_backend': "html_requests",
"time_between_check_use_default": "y"
},
follow_redirects=True
)
assert b"Updated watch." in res.data
# And back again
live_server.app.config['DATASTORE'].data['watching'][uuid]['processor'] = 'restock_diff'
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
assert b'type="text" value="5.05"' in res.data
delete_all_watches(client)
@@ -461,4 +443,3 @@ def test_special_prop_examples(client, live_server, measure_memory_usage, datast
res = client.get(url_for("watchlist.index"))
assert b'ception' not in res.data
assert b'155.55' in res.data

View File

@@ -192,289 +192,3 @@ def test_xss_watch_last_error(client, live_server, measure_memory_usage, datasto
assert b'&lt;a href=&#34;https://foobar&#34;&gt;&lt;/a&gt;&lt;script&gt;alert(123);&lt;/script&gt;' in res.data
assert b"https://foobar" in res.data # this text should be there
def test_login_redirect_safe_urls(client, live_server, measure_memory_usage, datastore_path):
"""
Test that safe redirect URLs work correctly in login flow.
This verifies the fix for open redirect vulnerabilities while maintaining
legitimate redirect functionality for both authenticated and unauthenticated users.
"""
# Test 1: Accessing /login?redirect=/settings when not logged in
# Should show the login form with redirect parameter preserved
res = client.get(
url_for("login", redirect="/settings"),
follow_redirects=False
)
# Should show login form
assert res.status_code == 200
# Check that the redirect is preserved in the hidden form field
assert b'name="redirect"' in res.data
# Test 2: Valid internal redirect with query parameters
res = client.get(
url_for("login", redirect="/settings?tab=notifications"),
follow_redirects=False
)
assert res.status_code == 200
# Check that the redirect is preserved
assert b'value="/settings?tab=notifications"' in res.data
# Test 3: Malicious external URL should be blocked and default to watchlist
res = client.get(
url_for("login", redirect="https://evil.com/phishing"),
follow_redirects=False
)
# Should show login form
assert res.status_code == 200
# The redirect parameter in the form should NOT contain the evil URL
# Check the actual input value, not just anywhere in the page
assert b'value="https://evil.com' not in res.data
assert b'value="/evil.com' not in res.data
assert b'name="redirect"' in res.data
# Test 4: Double-slash attack should be blocked
res = client.get(
url_for("login", redirect="//evil.com"),
follow_redirects=False
)
assert res.status_code == 200
# Should not have the malicious URL in the redirect input value
assert b'value="//evil.com"' not in res.data
# Test 5: Protocol handler exploit should be blocked
res = client.get(
url_for("login", redirect="javascript:alert(document.domain)"),
follow_redirects=False
)
assert res.status_code == 200
# Should not have javascript: in the redirect input value
assert b'value="javascript:' not in res.data
# Test 6: At-symbol obfuscation attack should be blocked
res = client.get(
url_for("login", redirect="//@evil.com"),
follow_redirects=False
)
assert res.status_code == 200
# Should not have the malicious URL in the redirect input value
assert b'value="//@evil.com"' not in res.data
# Test 7: Multiple slashes attack should be blocked
res = client.get(
url_for("login", redirect="////evil.com"),
follow_redirects=False
)
assert res.status_code == 200
# Should not have the malicious URL in the redirect input value
assert b'value="////evil.com"' not in res.data
def test_login_redirect_with_password(client, live_server, measure_memory_usage, datastore_path):
"""
Test that redirect functionality works correctly when a password is set.
This ensures that notifications can always link to /login and users will
be redirected to the correct page after authentication.
"""
# Set a password
from changedetectionio import store
import base64
import hashlib
# Generate a test password
password = "test123"
salt = os.urandom(32)
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
salted_pass = base64.b64encode(salt + key).decode('ascii')
# Set the password in the datastore
client.application.config['DATASTORE'].data['settings']['application']['password'] = salted_pass
# Test 1: Try to access /login?redirect=/settings without being logged in
# Should show login form and preserve redirect parameter
res = client.get(
url_for("login", redirect="/settings"),
follow_redirects=False
)
assert res.status_code == 200
assert b"Password" in res.data
# Check that redirect parameter is preserved in the form
assert b'name="redirect"' in res.data
assert b'value="/settings"' in res.data
# Test 2: Submit correct password with redirect parameter
# Should redirect to /settings after successful login
res = client.post(
url_for("login"),
data={"password": password, "redirect": "/settings"},
follow_redirects=True
)
assert res.status_code == 200
# Should be on settings page
assert b"Settings" in res.data or b"settings" in res.data
# Test 3: Now that we're logged in, accessing /login?redirect=/settings
# should redirect immediately without showing login form
res = client.get(
url_for("login", redirect="/"),
follow_redirects=True
)
assert res.status_code == 200
assert b"Already logged in" in res.data
# Test 4: Malicious redirect should be blocked even with correct password
res = client.post(
url_for("login"),
data={"password": password, "redirect": "https://evil.com"},
follow_redirects=True
)
# Should redirect to watchlist index instead of evil.com
assert b"evil.com" not in res.data
# Logout for cleanup
client.get(url_for("logout"))
# Test 5: Incorrect password with redirect should stay on login page
res = client.post(
url_for("login"),
data={"password": "wrongpassword", "redirect": "/settings"},
follow_redirects=True
)
assert res.status_code == 200
assert b"Incorrect password" in res.data or b"password" in res.data
# Clear the password
del client.application.config['DATASTORE'].data['settings']['application']['password']
def test_login_redirect_from_protected_page(client, live_server, measure_memory_usage, datastore_path):
"""
Test the complete redirect flow: accessing a protected page while logged out
should redirect to login with the page URL, then redirect back after login.
This is the real-world scenario where users try to access /edit/uuid or /settings
and need to login first.
"""
import base64
import hashlib
# Add a watch first
set_original_response(datastore_path=datastore_path)
res = client.post(
url_for("imports.import_page"),
data={"urls": url_for('test_endpoint', _external=True)},
follow_redirects=True
)
assert b"1 Imported" in res.data
wait_for_all_checks(client)
# Set a password
password = "test123"
salt = os.urandom(32)
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
salted_pass = base64.b64encode(salt + key).decode('ascii')
client.application.config['DATASTORE'].data['settings']['application']['password'] = salted_pass
# Logout to ensure we're not authenticated
client.get(url_for("logout"))
# Try to access a protected page (edit page for first watch)
res = client.get(
url_for("ui.ui_edit.edit_page", uuid="first"),
follow_redirects=False
)
# Should redirect to login with the edit page as redirect parameter
assert res.status_code in [302, 303]
assert '/login' in res.location
assert 'redirect=' in res.location or 'redirect=%2F' in res.location
# Follow the redirect to login page
res = client.get(res.location, follow_redirects=False)
assert res.status_code == 200
assert b'Password' in res.data
# The redirect parameter should be preserved in the login form
# It should contain the edit page URL
assert b'name="redirect"' in res.data
assert b'value="/edit/first"' in res.data or b'value="%2Fedit%2Ffirst"' in res.data
# Now login with correct password and the redirect parameter
res = client.post(
url_for("login"),
data={"password": password, "redirect": "/edit/first"},
follow_redirects=False
)
# Should redirect to the edit page
assert res.status_code in [302, 303]
assert '/edit/first' in res.location
# Follow the redirect to verify we're on the edit page
res = client.get(res.location, follow_redirects=True)
assert res.status_code == 200
# Should see edit page content
assert b'Edit' in res.data or b'Watching' in res.data
# Cleanup
client.get(url_for("logout"))
del client.application.config['DATASTORE'].data['settings']['application']['password']
def test_logout_with_redirect(client, live_server, measure_memory_usage, datastore_path):
"""
Test that logout preserves the current page URL, so after re-login
the user returns to where they were before logging out.
Example: User is on /edit/uuid, clicks logout, then logs back in and
returns to /edit/uuid.
"""
import base64
import hashlib
# Set a password and login
password = "test123"
salt = os.urandom(32)
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
salted_pass = base64.b64encode(salt + key).decode('ascii')
client.application.config['DATASTORE'].data['settings']['application']['password'] = salted_pass
# Login
res = client.post(
url_for("login"),
data={"password": password},
follow_redirects=True
)
assert res.status_code == 200
# Now logout with a redirect parameter (simulating logout from /settings)
res = client.get(
url_for("logout", redirect="/settings"),
follow_redirects=False
)
# Should redirect to login with the redirect parameter
assert res.status_code in [302, 303]
assert '/login' in res.location
assert 'redirect=' in res.location or 'redirect=%2F' in res.location
# Follow the redirect to login page
res = client.get(res.location, follow_redirects=False)
assert res.status_code == 200
assert b'Password' in res.data
# The redirect parameter should be preserved
assert b'value="/settings"' in res.data or b'value="%2Fsettings"' in res.data
# Login again with the redirect
res = client.post(
url_for("login"),
data={"password": password, "redirect": "/settings"},
follow_redirects=False
)
# Should redirect back to settings
assert res.status_code in [302, 303]
assert '/settings' in res.location or 'settings' in res.location
# Cleanup
del client.application.config['DATASTORE'].data['settings']['application']['password']

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: it\n"
@@ -19,21 +19,21 @@ msgstr ""
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
#: changedetectionio/flask_app.py:247
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
#: changedetectionio/flask_app.py:246
#: changedetectionio/realtime/socket_server.py:171
msgid "Not yet"
msgstr "Non ancora"
#: changedetectionio/flask_app.py:534
msgid "Already logged in"
msgstr "Già autenticato"
#: changedetectionio/flask_app.py:536
#: changedetectionio/flask_app.py:468
msgid "You must be logged in, please log in."
msgstr "Devi essere autenticato, effettua l'accesso."
#: changedetectionio/flask_app.py:551
#: changedetectionio/flask_app.py:495
msgid "Already logged in"
msgstr "Già autenticato"
#: changedetectionio/flask_app.py:522
msgid "Incorrect password"
msgstr "Password errata"
@@ -175,15 +175,16 @@ msgstr "Valore non valido."
#: changedetectionio/forms.py:732
msgid "Watch"
msgstr "Monitora"
msgstr "Osserva"
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
msgid "Processor"
msgstr "Processore"
#: changedetectionio/forms.py:734
#, fuzzy
msgid "Edit > Watch"
msgstr "Modifica > Monitora"
msgstr "Modifica prima poi Monitora"
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
msgid "Fetch Method"
@@ -414,8 +415,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
#, fuzzy
msgid "Name"
msgstr "Nome"
msgstr "Riattiva audio"
#: changedetectionio/forms.py:921
msgid "Proxy URL"
@@ -438,8 +440,9 @@ msgid "Plaintext requests"
msgstr "Richieste in chiaro"
#: changedetectionio/forms.py:946
#, fuzzy
msgid "Chrome requests"
msgstr "Richieste Chrome"
msgstr "Richiesta"
#: changedetectionio/forms.py:952
msgid "Default proxy"
@@ -494,8 +497,9 @@ msgid "API access token security check enabled"
msgstr "Controllo sicurezza token API attivo"
#: changedetectionio/forms.py:989
#, fuzzy
msgid "Notification base URL override"
msgstr "URL base notifiche"
msgstr "Notifiche"
#: changedetectionio/forms.py:993
msgid "Treat empty pages as a change?"
@@ -601,8 +605,6 @@ msgid "Backups were deleted."
msgstr "I backup sono stati eliminati."
#: changedetectionio/blueprint/backups/templates/overview.html:6
#: changedetectionio/templates/base.html:282
#: changedetectionio/templates/base.html:290
msgid "Backups"
msgstr "Backup"
@@ -1022,7 +1024,7 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
msgid "# Watches"
msgstr "# Monitoraggi"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
msgid "Tag / Label name"
@@ -1269,7 +1271,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
msgid "Confirmation text"
msgstr "Testo di conferma"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
msgid "Type in the word"
@@ -1288,8 +1290,7 @@ msgid "Clear History!"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
#: changedetectionio/templates/base.html:379
#: changedetectionio/templates/base.html:399
#: changedetectionio/templates/base.html:274
msgid "Cancel"
msgstr "Annulla"
@@ -1319,11 +1320,11 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:53
msgid "Words"
msgstr "Parole"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:57
msgid "Lines"
msgstr "Righe"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:61
msgid "Ignore Whitespace"
@@ -1331,7 +1332,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:65
msgid "Same/non-changed"
msgstr "Uguale/non modificato"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:69
msgid "Removed"
@@ -1373,12 +1374,12 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:97
#: changedetectionio/blueprint/ui/templates/preview.html:45
msgid "Error Text"
msgstr "Testo dell'errore"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:98
#: changedetectionio/blueprint/ui/templates/preview.html:47
msgid "Error Screenshot"
msgstr "Screenshot dell'errore"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:99
#: changedetectionio/blueprint/ui/templates/preview.html:50
@@ -1388,7 +1389,7 @@ msgstr "Testo"
#: changedetectionio/blueprint/ui/templates/diff.html:100
#: changedetectionio/blueprint/ui/templates/preview.html:51
msgid "Current screenshot"
msgstr "Screenshot corrente"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:101
msgid "Extract Data"
@@ -1443,7 +1444,7 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:149
#: changedetectionio/blueprint/ui/templates/preview.html:86
msgid "Current screenshot from most recent request"
msgstr "Screenshot corrente dalla richiesta più recente"
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:151
#: changedetectionio/blueprint/ui/templates/preview.html:88
@@ -1911,7 +1912,7 @@ msgstr "Monitora questo URL!"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
msgid "Edit first then Watch"
msgstr "Modifica > Monitora"
msgstr "Modifica prima poi Monitora"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
msgid "Create a shareable link"
@@ -2021,7 +2022,7 @@ msgstr "Modifica"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "No website watches configured, please add a URL in the box above, or"
msgstr "Nessun monitoraggio configurato, aggiungi un URL nella casella sopra, oppure"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "import a list"
@@ -2048,7 +2049,7 @@ msgid "No information"
msgstr "Nessuna informazione"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
#: changedetectionio/templates/base.html:353
#: changedetectionio/templates/base.html:248
msgid "Checking now"
msgstr "Controllo in corso"
@@ -2115,9 +2116,7 @@ msgstr "Valore riquadro di selezione troppo lungo"
#: changedetectionio/processors/image_ssim_diff/forms.py:23
msgid "Bounding box must be in format: x,y,width,height (integers only)"
msgstr ""
"Il riquadro deve essere nel formato: x,y,larghezza,altezza (solo numeri "
"interi)"
msgstr "Il riquadro deve essere nel formato: x,y,larghezza,altezza (solo numeri interi)"
#: changedetectionio/processors/image_ssim_diff/forms.py:29
msgid "Bounding box values must be non-negative"
@@ -2170,9 +2169,7 @@ msgstr "Rilevamento modifiche screenshot visivi"
#: changedetectionio/processors/image_ssim_diff/processor.py:22
msgid "Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM"
msgstr ""
"Confronta screenshot con algoritmo OpenCV veloce, 10-100x più veloce di "
"SSIM"
msgstr "Confronta screenshot con algoritmo OpenCV veloce, 10-100x più veloce di SSIM"
#: changedetectionio/processors/restock_diff/forms.py:15
msgid "Re-stock detection"
@@ -2236,233 +2233,59 @@ msgstr "Modifiche testo/HTML, JSON e PDF"
msgid "Detects all text changes where possible"
msgstr "Rileva tutte le modifiche di testo possibili"
#: changedetectionio/templates/_helpers.html:25
msgid "Entry"
msgstr ""
#: changedetectionio/templates/_helpers.html:153
#, fuzzy
msgid "Actions"
msgstr "Condizioni"
#: changedetectionio/templates/_helpers.html:172
msgid "Add a row/rule after"
msgstr ""
#: changedetectionio/templates/_helpers.html:173
msgid "Remove this row/rule"
msgstr ""
#: changedetectionio/templates/_helpers.html:174
msgid "Verify this rule against current snapshot"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
"Chrome based fetching is not enabled."
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid "Alternatively try our"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"very affordable subscription based service which has all this setup for "
"you"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "You may need to"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "Enable playwright environment variable"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "and uncomment the"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "in the"
msgstr "Silenzia"
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "file"
msgstr "Titolo"
#: changedetectionio/templates/_helpers.html:240
msgid "Set a hourly/week day schedule"
msgstr ""
#: changedetectionio/templates/_helpers.html:247
#, fuzzy
msgid "Schedule time limits"
msgstr "Tempo di ricontrollo (minuti)"
#: changedetectionio/templates/_helpers.html:248
msgid "Business hours"
msgstr ""
#: changedetectionio/templates/_helpers.html:249
#, fuzzy
msgid "Weekends"
msgstr "Settimane"
#: changedetectionio/templates/_helpers.html:250
#, fuzzy
msgid "Reset"
msgstr "Richiesta"
#: changedetectionio/templates/_helpers.html:259
msgid ""
"Warning, one or more of your 'days' has a duration that would extend into"
" the next day."
msgstr ""
#: changedetectionio/templates/_helpers.html:260
msgid "This could have unintended consequences."
msgstr ""
#: changedetectionio/templates/_helpers.html:270
msgid "More help and examples about using the scheduler"
msgstr ""
#: changedetectionio/templates/_helpers.html:275
#, fuzzy
msgid "Want to use a time schedule?"
msgstr "Usa pianificazione oraria"
#: changedetectionio/templates/_helpers.html:275
msgid "First confirm/save your Time Zone Settings"
msgstr ""
#: changedetectionio/templates/_helpers.html:284
msgid ""
"Triggers a change if this text appears, AND something changed in the "
"document."
msgstr ""
#: changedetectionio/templates/_helpers.html:284
#, fuzzy
msgid "Triggered text"
msgstr "Ignora testo"
#: changedetectionio/templates/_helpers.html:285
msgid "Ignored for calculating changes, but still shown."
msgstr ""
#: changedetectionio/templates/_helpers.html:285
#, fuzzy
msgid "Ignored text"
msgstr "Ignora testo"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "No change-detection will occur because this text exists."
msgstr "Blocca rilevamento modifiche quando il testo corrisponde"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "Blocked text"
msgstr "Ignora testo"
#: changedetectionio/templates/base.html:78
#: changedetectionio/templates/base.html:168
#: changedetectionio/templates/base.html:77
msgid "GROUPS"
msgstr "GRUPPI"
#: changedetectionio/templates/base.html:81
#: changedetectionio/templates/base.html:169
#: changedetectionio/templates/base.html:80
msgid "SETTINGS"
msgstr "IMPOSTAZIONI"
#: changedetectionio/templates/base.html:84
#: changedetectionio/templates/base.html:170
#: changedetectionio/templates/base.html:83
msgid "IMPORT"
msgstr "IMPORTA"
#: changedetectionio/templates/base.html:87
#: changedetectionio/templates/base.html:171
#: changedetectionio/templates/base.html:86
msgid "BACKUPS"
msgstr "BACKUP"
#: changedetectionio/templates/base.html:91
#: changedetectionio/templates/base.html:173
#: changedetectionio/templates/base.html:90
msgid "EDIT"
msgstr "MODIFICA"
#: changedetectionio/templates/base.html:101
#: changedetectionio/templates/base.html:177
#: changedetectionio/templates/base.html:100
msgid "LOG OUT"
msgstr "ESCI"
#: changedetectionio/templates/base.html:108
#: changedetectionio/templates/base.html:109
msgid "Search, or Use Alt+S Key"
msgstr "Cerca, o usa il tasto Alt+S"
#: changedetectionio/templates/base.html:114
#: changedetectionio/templates/base.html:116
msgid "Toggle Light/Dark Mode"
msgstr "Cambia Modalità Chiaro/Scuro"
#: changedetectionio/templates/base.html:115
#: changedetectionio/templates/base.html:117
msgid "Toggle light/dark mode"
msgstr "Cambia modalità chiaro/scuro"
#: changedetectionio/templates/base.html:125
#: changedetectionio/templates/base.html:127
msgid "Change Language"
msgstr "Cambia Lingua"
#: changedetectionio/templates/base.html:126
#: changedetectionio/templates/base.html:128
msgid "Change language"
msgstr "Cambia lingua"
#: changedetectionio/templates/base.html:253
#, fuzzy
msgid "Watch List"
msgstr "Lista Monitoraggi"
#: changedetectionio/templates/base.html:258
#, fuzzy
msgid "Watches"
msgstr "Monitoraggi"
#: changedetectionio/templates/base.html:261
msgid "Queue Status"
msgstr ""
#: changedetectionio/templates/base.html:270
#, fuzzy
msgid "Queue"
msgstr "In coda"
#: changedetectionio/templates/base.html:274
#: changedetectionio/templates/base.html:279
#, fuzzy
msgid "Settings"
msgstr "IMPOSTAZIONI"
#: changedetectionio/templates/base.html:293
msgid "Sitemap Crawler"
msgstr ""
#: changedetectionio/templates/base.html:318
msgid "Sitemap"
msgstr ""
#: changedetectionio/templates/base.html:354
#: changedetectionio/templates/base.html:249
msgid "Real-time updates offline"
msgstr ""
#: changedetectionio/templates/base.html:364
#: changedetectionio/templates/base.html:259
msgid "Select Language"
msgstr "Seleziona Lingua"
#: changedetectionio/templates/base.html:375
#: changedetectionio/templates/base.html:270
msgid ""
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
@@ -2470,30 +2293,11 @@ msgstr ""
"Il supporto linguistico è in versione beta, aiutaci a migliorare aprendo "
"una PR su GitHub con eventuali aggiornamenti."
#: changedetectionio/templates/base.html:387
#: changedetectionio/templates/base.html:400
#, fuzzy
msgid "Search"
msgstr "Ricerca in corso"
#: changedetectionio/templates/base.html:392
msgid "URL or Title"
msgstr ""
#: changedetectionio/templates/base.html:392
#, fuzzy
msgid "in"
msgstr "Info"
#: changedetectionio/templates/base.html:393
msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/login.html:11
#: changedetectionio/templates/login.html:10
msgid "Password"
msgstr "Password"
#: changedetectionio/templates/login.html:17
#: changedetectionio/templates/login.html:16
msgid "Login"
msgstr "Accedi"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: ko\n"
@@ -19,21 +19,21 @@ msgstr ""
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
#: changedetectionio/flask_app.py:247
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
#: changedetectionio/flask_app.py:246
#: changedetectionio/realtime/socket_server.py:171
msgid "Not yet"
msgstr "아직 아님"
#: changedetectionio/flask_app.py:534
msgid "Already logged in"
msgstr ""
#: changedetectionio/flask_app.py:536
#: changedetectionio/flask_app.py:468
msgid "You must be logged in, please log in."
msgstr ""
#: changedetectionio/flask_app.py:551
#: changedetectionio/flask_app.py:495
msgid "Already logged in"
msgstr ""
#: changedetectionio/flask_app.py:522
#, fuzzy
msgid "Incorrect password"
msgstr "비밀번호"
@@ -176,16 +176,18 @@ msgid "Invalid value."
msgstr "값이 잘못되었습니다."
#: changedetectionio/forms.py:732
#, fuzzy
msgid "Watch"
msgstr "모니터"
msgstr "# 시계"
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
msgid "Processor"
msgstr "프로세서"
#: changedetectionio/forms.py:734
#, fuzzy
msgid "Edit > Watch"
msgstr "편집 > 모니터"
msgstr "먼저 편집한 다음 보기"
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
#, fuzzy
@@ -361,7 +363,7 @@ msgstr "구하다"
#: changedetectionio/forms.py:829
msgid "Proxy"
msgstr "프록시"
msgstr "대리"
#: changedetectionio/forms.py:831
msgid "Send a notification when the filter can no longer be found on the page"
@@ -372,7 +374,7 @@ msgstr "페이지에서 필터를 더 이상 찾을 수 없으면 알림 보내
#: changedetectionio/blueprint/ui/templates/edit.html:59
#: changedetectionio/forms.py:832
msgid "Notifications"
msgstr "알림"
msgstr "정보 없음"
#: changedetectionio/forms.py:832
#, fuzzy
@@ -425,8 +427,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
#, fuzzy
msgid "Name"
msgstr "이름"
msgstr "음소거 해제"
#: changedetectionio/forms.py:921
msgid "Proxy URL"
@@ -449,8 +452,9 @@ msgid "Plaintext requests"
msgstr "일반 텍스트 요청"
#: changedetectionio/forms.py:946
#, fuzzy
msgid "Chrome requests"
msgstr "Chrome 요청"
msgstr "요구"
#: changedetectionio/forms.py:952
msgid "Default proxy"
@@ -507,8 +511,9 @@ msgid "API access token security check enabled"
msgstr "API 액세스 토큰 보안 확인이 활성화되었습니다."
#: changedetectionio/forms.py:989
#, fuzzy
msgid "Notification base URL override"
msgstr "알림 기본 URL"
msgstr "알림 경고 수"
#: changedetectionio/forms.py:993
msgid "Treat empty pages as a change?"
@@ -617,8 +622,6 @@ msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:6
#: changedetectionio/templates/base.html:282
#: changedetectionio/templates/base.html:290
msgid "Backups"
msgstr "백업"
@@ -642,11 +645,11 @@ msgstr "백업을 찾을 수 없습니다."
#: changedetectionio/blueprint/backups/templates/overview.html:28
msgid "Create backup"
msgstr "백업 생성"
msgstr "백업"
#: changedetectionio/blueprint/backups/templates/overview.html:30
msgid "Remove backups"
msgstr "백업 삭제"
msgstr "백업"
#: changedetectionio/blueprint/imports/importer.py:45
msgid ""
@@ -854,7 +857,7 @@ msgstr "일반적인"
#: changedetectionio/blueprint/settings/templates/settings.html:23
msgid "Fetching"
msgstr "가져오기"
msgstr "수색"
#: changedetectionio/blueprint/settings/templates/settings.html:24
msgid "Global Filters"
@@ -882,7 +885,7 @@ msgstr "보안 문자 및 프록시"
#: changedetectionio/blueprint/settings/templates/settings.html:35
msgid "Info"
msgstr "정보"
msgstr "추가 정보"
#: changedetectionio/blueprint/settings/templates/settings.html:46
msgid "Default recheck time for all watches, current system minimum is"
@@ -910,7 +913,7 @@ msgstr "활성화된 플러그인이 없습니다."
#: changedetectionio/blueprint/settings/templates/settings.html:405
msgid "Back"
msgstr "뒤로"
msgstr "백업"
#: changedetectionio/blueprint/settings/templates/settings.html:406
msgid "Clear Snapshot History"
@@ -1033,7 +1036,7 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
msgid "# Watches"
msgstr "# 모니터"
msgstr "# 시계"
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
msgid "Tag / Label name"
@@ -1285,7 +1288,7 @@ msgstr "먼저 링크하세요."
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
msgid "Confirmation text"
msgstr "확인 텍스트"
msgstr "정보 없음"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
msgid "Type in the word"
@@ -1304,8 +1307,7 @@ msgid "Clear History!"
msgstr "기록 지우기"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
#: changedetectionio/templates/base.html:379
#: changedetectionio/templates/base.html:399
#: changedetectionio/templates/base.html:274
msgid "Cancel"
msgstr "취소"
@@ -1335,11 +1337,11 @@ msgstr "에게"
#: changedetectionio/blueprint/ui/templates/diff.html:53
msgid "Words"
msgstr "단어"
msgstr "비밀번호"
#: changedetectionio/blueprint/ui/templates/diff.html:57
msgid "Lines"
msgstr ""
msgstr "로그인"
#: changedetectionio/blueprint/ui/templates/diff.html:61
msgid "Ignore Whitespace"
@@ -1347,7 +1349,7 @@ msgstr "공백 무시"
#: changedetectionio/blueprint/ui/templates/diff.html:65
msgid "Same/non-changed"
msgstr "동일/변경되지 않음"
msgstr "변경됨"
#: changedetectionio/blueprint/ui/templates/diff.html:69
msgid "Removed"
@@ -1488,7 +1490,7 @@ msgstr "정황"
#: changedetectionio/blueprint/ui/templates/edit.html:60
msgid "Stats"
msgstr "통계"
msgstr "설정"
#: changedetectionio/blueprint/ui/templates/edit.html:73
#: changedetectionio/blueprint/ui/templates/edit.html:313
@@ -1923,11 +1925,11 @@ msgstr "새로운 웹 페이지 변경 감지 감시 추가"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
msgid "Watch this URL!"
msgstr "이 URL 모니터!"
msgstr "이 URL을 시청하세요!"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
msgid "Edit first then Watch"
msgstr "편집 후 모니터"
msgstr "먼저 편집한 다음 보기"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
msgid "Create a shareable link"
@@ -2064,7 +2066,7 @@ msgid "No information"
msgstr "정보 없음"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
#: changedetectionio/templates/base.html:353
#: changedetectionio/templates/base.html:248
msgid "Checking now"
msgstr "지금 확인 중"
@@ -2074,7 +2076,7 @@ msgstr "대기 중"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:250
msgid "History"
msgstr "기록"
msgstr "역사"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:251
msgid "Preview"
@@ -2250,264 +2252,69 @@ msgstr "웹페이지 텍스트/HTML, JSON 및 PDF 변경"
msgid "Detects all text changes where possible"
msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
#: changedetectionio/templates/_helpers.html:25
msgid "Entry"
msgstr ""
#: changedetectionio/templates/_helpers.html:153
#, fuzzy
msgid "Actions"
msgstr "정황"
#: changedetectionio/templates/_helpers.html:172
msgid "Add a row/rule after"
msgstr ""
#: changedetectionio/templates/_helpers.html:173
msgid "Remove this row/rule"
msgstr ""
#: changedetectionio/templates/_helpers.html:174
msgid "Verify this rule against current snapshot"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
"Chrome based fetching is not enabled."
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid "Alternatively try our"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"very affordable subscription based service which has all this setup for "
"you"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "You may need to"
msgstr "당신은"
#: changedetectionio/templates/_helpers.html:185
msgid "Enable playwright environment variable"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "and uncomment the"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "in the"
msgstr "그만큼"
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "file"
msgstr "제목"
#: changedetectionio/templates/_helpers.html:240
msgid "Set a hourly/week day schedule"
msgstr ""
#: changedetectionio/templates/_helpers.html:247
#, fuzzy
msgid "Schedule time limits"
msgstr "재확인 시간(분)"
#: changedetectionio/templates/_helpers.html:248
msgid "Business hours"
msgstr ""
#: changedetectionio/templates/_helpers.html:249
#, fuzzy
msgid "Weekends"
msgstr "주"
#: changedetectionio/templates/_helpers.html:250
#, fuzzy
msgid "Reset"
msgstr "요구"
#: changedetectionio/templates/_helpers.html:259
msgid ""
"Warning, one or more of your 'days' has a duration that would extend into"
" the next day."
msgstr ""
#: changedetectionio/templates/_helpers.html:260
msgid "This could have unintended consequences."
msgstr ""
#: changedetectionio/templates/_helpers.html:270
#, fuzzy
msgid "More help and examples about using the scheduler"
msgstr "여기에 더 많은 도움말과 예시가 있습니다."
#: changedetectionio/templates/_helpers.html:275
#, fuzzy
msgid "Want to use a time schedule?"
msgstr "시간 스케줄러 사용"
#: changedetectionio/templates/_helpers.html:275
msgid "First confirm/save your Time Zone Settings"
msgstr ""
#: changedetectionio/templates/_helpers.html:284
msgid ""
"Triggers a change if this text appears, AND something changed in the "
"document."
msgstr ""
#: changedetectionio/templates/_helpers.html:284
#, fuzzy
msgid "Triggered text"
msgstr "오류 텍스트"
#: changedetectionio/templates/_helpers.html:285
msgid "Ignored for calculating changes, but still shown."
msgstr ""
#: changedetectionio/templates/_helpers.html:285
#, fuzzy
msgid "Ignored text"
msgstr "오류 텍스트"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "No change-detection will occur because this text exists."
msgstr "텍스트가 일치하는 동안 변경 감지 차단"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "Blocked text"
msgstr "오류 텍스트"
#: changedetectionio/templates/base.html:78
#: changedetectionio/templates/base.html:168
#: changedetectionio/templates/base.html:77
msgid "GROUPS"
msgstr "여러 떼"
#: changedetectionio/templates/base.html:81
#: changedetectionio/templates/base.html:169
#: changedetectionio/templates/base.html:80
msgid "SETTINGS"
msgstr "설정"
#: changedetectionio/templates/base.html:84
#: changedetectionio/templates/base.html:170
#: changedetectionio/templates/base.html:83
msgid "IMPORT"
msgstr "가져오기"
msgstr "수입"
#: changedetectionio/templates/base.html:87
#: changedetectionio/templates/base.html:171
#: changedetectionio/templates/base.html:86
msgid "BACKUPS"
msgstr "백업"
#: changedetectionio/templates/base.html:91
#: changedetectionio/templates/base.html:173
#: changedetectionio/templates/base.html:90
msgid "EDIT"
msgstr "편집하다"
#: changedetectionio/templates/base.html:101
#: changedetectionio/templates/base.html:177
#: changedetectionio/templates/base.html:100
msgid "LOG OUT"
msgstr "로그아웃"
#: changedetectionio/templates/base.html:108
#: changedetectionio/templates/base.html:109
msgid "Search, or Use Alt+S Key"
msgstr "검색 또는 Alt+S 키 사용"
#: changedetectionio/templates/base.html:114
#: changedetectionio/templates/base.html:116
msgid "Toggle Light/Dark Mode"
msgstr "밝은/어두운 모드 전환"
#: changedetectionio/templates/base.html:115
#: changedetectionio/templates/base.html:117
msgid "Toggle light/dark mode"
msgstr "밝은/어두운 모드 전환"
#: changedetectionio/templates/base.html:125
#: changedetectionio/templates/base.html:127
msgid "Change Language"
msgstr "언어 변경"
#: changedetectionio/templates/base.html:126
#: changedetectionio/templates/base.html:128
msgid "Change language"
msgstr "언어 변경"
#: changedetectionio/templates/base.html:253
#, fuzzy
msgid "Watch List"
msgstr "모니터 목록"
#: changedetectionio/templates/base.html:258
#, fuzzy
msgid "Watches"
msgstr "모니터"
#: changedetectionio/templates/base.html:261
msgid "Queue Status"
msgstr ""
#: changedetectionio/templates/base.html:270
#, fuzzy
msgid "Queue"
msgstr "대기 중"
#: changedetectionio/templates/base.html:274
#: changedetectionio/templates/base.html:279
#, fuzzy
msgid "Settings"
msgstr "설정"
#: changedetectionio/templates/base.html:293
msgid "Sitemap Crawler"
msgstr ""
#: changedetectionio/templates/base.html:318
msgid "Sitemap"
msgstr ""
#: changedetectionio/templates/base.html:354
#: changedetectionio/templates/base.html:249
msgid "Real-time updates offline"
msgstr "실시간 업데이트 오프라인"
#: changedetectionio/templates/base.html:364
#: changedetectionio/templates/base.html:259
msgid "Select Language"
msgstr "언어 선택"
#: changedetectionio/templates/base.html:375
#: changedetectionio/templates/base.html:270
msgid ""
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr ""
#: changedetectionio/templates/base.html:387
#: changedetectionio/templates/base.html:400
#, fuzzy
msgid "Search"
msgstr "수색"
#: changedetectionio/templates/base.html:392
msgid "URL or Title"
msgstr ""
#: changedetectionio/templates/base.html:392
#, fuzzy
msgid "in"
msgstr "추가 정보"
#: changedetectionio/templates/base.html:393
msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/login.html:11
#: changedetectionio/templates/login.html:10
msgid "Password"
msgstr "비밀번호"
#: changedetectionio/templates/login.html:17
#: changedetectionio/templates/login.html:16
msgid "Login"
msgstr "로그인"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
"PO-Revision-Date: 2026-01-02 11:54+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh\n"
@@ -19,21 +19,21 @@ msgstr ""
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
#: changedetectionio/flask_app.py:247
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
#: changedetectionio/flask_app.py:246
#: changedetectionio/realtime/socket_server.py:171
msgid "Not yet"
msgstr "还没有"
#: changedetectionio/flask_app.py:534
msgid "Already logged in"
msgstr ""
#: changedetectionio/flask_app.py:536
#: changedetectionio/flask_app.py:468
msgid "You must be logged in, please log in."
msgstr ""
#: changedetectionio/flask_app.py:551
#: changedetectionio/flask_app.py:495
msgid "Already logged in"
msgstr ""
#: changedetectionio/flask_app.py:522
#, fuzzy
msgid "Incorrect password"
msgstr "密码"
@@ -176,8 +176,9 @@ msgid "Invalid value."
msgstr "无效值。"
#: changedetectionio/forms.py:732
#, fuzzy
msgid "Watch"
msgstr "监控"
msgstr "# 手表"
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
msgid "Processor"
@@ -186,7 +187,7 @@ msgstr "处理器"
#: changedetectionio/forms.py:734
#, fuzzy
msgid "Edit > Watch"
msgstr "编辑 > 监控"
msgstr "编辑后观看"
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
#, fuzzy
@@ -362,7 +363,7 @@ msgstr "节省"
#: changedetectionio/forms.py:829
msgid "Proxy"
msgstr "代理"
msgstr "代理"
#: changedetectionio/forms.py:831
msgid "Send a notification when the filter can no longer be found on the page"
@@ -373,7 +374,7 @@ msgstr "当页面上找不到过滤器时发送通知"
#: changedetectionio/blueprint/ui/templates/edit.html:59
#: changedetectionio/forms.py:832
msgid "Notifications"
msgstr "通知"
msgstr "暂无信息"
#: changedetectionio/forms.py:832
#, fuzzy
@@ -426,8 +427,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
#, fuzzy
msgid "Name"
msgstr "名称"
msgstr "取消静音"
#: changedetectionio/forms.py:921
msgid "Proxy URL"
@@ -450,8 +452,9 @@ msgid "Plaintext requests"
msgstr "明文请求"
#: changedetectionio/forms.py:946
#, fuzzy
msgid "Chrome requests"
msgstr "Chrome请求"
msgstr "求"
#: changedetectionio/forms.py:952
msgid "Default proxy"
@@ -508,8 +511,9 @@ msgid "API access token security check enabled"
msgstr "已启用 API 访问令牌安全检查"
#: changedetectionio/forms.py:989
#, fuzzy
msgid "Notification base URL override"
msgstr "通知基础URL"
msgstr "通知警报计数"
#: changedetectionio/forms.py:993
msgid "Treat empty pages as a change?"
@@ -618,8 +622,6 @@ msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:6
#: changedetectionio/templates/base.html:282
#: changedetectionio/templates/base.html:290
msgid "Backups"
msgstr "备份"
@@ -643,11 +645,11 @@ msgstr "未找到备份。"
#: changedetectionio/blueprint/backups/templates/overview.html:28
msgid "Create backup"
msgstr "创建备份"
msgstr "备份"
#: changedetectionio/blueprint/backups/templates/overview.html:30
msgid "Remove backups"
msgstr "删除备份"
msgstr "备份"
#: changedetectionio/blueprint/imports/importer.py:45
msgid ""
@@ -855,7 +857,7 @@ msgstr "一般的"
#: changedetectionio/blueprint/settings/templates/settings.html:23
msgid "Fetching"
msgstr "获取"
msgstr "搜寻中"
#: changedetectionio/blueprint/settings/templates/settings.html:24
msgid "Global Filters"
@@ -883,7 +885,7 @@ msgstr "验证码和代理"
#: changedetectionio/blueprint/settings/templates/settings.html:35
msgid "Info"
msgstr "信息"
msgstr "更多信息"
#: changedetectionio/blueprint/settings/templates/settings.html:46
msgid "Default recheck time for all watches, current system minimum is"
@@ -911,7 +913,7 @@ msgstr "没有激活的插件"
#: changedetectionio/blueprint/settings/templates/settings.html:405
msgid "Back"
msgstr "返回"
msgstr "备份"
#: changedetectionio/blueprint/settings/templates/settings.html:406
msgid "Clear Snapshot History"
@@ -1034,7 +1036,7 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
msgid "# Watches"
msgstr "# 监控项"
msgstr "# 手表"
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
msgid "Tag / Label name"
@@ -1286,7 +1288,7 @@ msgstr "先链接。"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:17
msgid "Confirmation text"
msgstr "确认文本"
msgstr "暂无信息"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:27
msgid "Type in the word"
@@ -1305,8 +1307,7 @@ msgid "Clear History!"
msgstr "清晰的历史记录"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
#: changedetectionio/templates/base.html:379
#: changedetectionio/templates/base.html:399
#: changedetectionio/templates/base.html:274
msgid "Cancel"
msgstr "取消"
@@ -1489,7 +1490,7 @@ msgstr "状况"
#: changedetectionio/blueprint/ui/templates/edit.html:60
msgid "Stats"
msgstr "统计"
msgstr "设置"
#: changedetectionio/blueprint/ui/templates/edit.html:73
#: changedetectionio/blueprint/ui/templates/edit.html:313
@@ -1924,11 +1925,11 @@ msgstr "添加新的网页更改检测监视"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
msgid "Watch this URL!"
msgstr "监控此URL"
msgstr "关注这个网址"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
msgid "Edit first then Watch"
msgstr "编辑后监控"
msgstr "编辑后观看"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
msgid "Create a shareable link"
@@ -2065,7 +2066,7 @@ msgid "No information"
msgstr "暂无信息"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
#: changedetectionio/templates/base.html:353
#: changedetectionio/templates/base.html:248
msgid "Checking now"
msgstr "立即检查"
@@ -2251,264 +2252,69 @@ msgstr "网页文本/HTML、JSON 和 PDF 更改"
msgid "Detects all text changes where possible"
msgstr "尽可能检测所有文本更改"
#: changedetectionio/templates/_helpers.html:25
msgid "Entry"
msgstr ""
#: changedetectionio/templates/_helpers.html:153
#, fuzzy
msgid "Actions"
msgstr "状况"
#: changedetectionio/templates/_helpers.html:172
msgid "Add a row/rule after"
msgstr ""
#: changedetectionio/templates/_helpers.html:173
msgid "Remove this row/rule"
msgstr ""
#: changedetectionio/templates/_helpers.html:174
msgid "Verify this rule against current snapshot"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
"Chrome based fetching is not enabled."
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid "Alternatively try our"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"very affordable subscription based service which has all this setup for "
"you"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "You may need to"
msgstr "你需要"
#: changedetectionio/templates/_helpers.html:185
msgid "Enable playwright environment variable"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "and uncomment the"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "in the"
msgstr "这"
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "file"
msgstr "标题"
#: changedetectionio/templates/_helpers.html:240
msgid "Set a hourly/week day schedule"
msgstr ""
#: changedetectionio/templates/_helpers.html:247
#, fuzzy
msgid "Schedule time limits"
msgstr "复检时间(分钟)"
#: changedetectionio/templates/_helpers.html:248
msgid "Business hours"
msgstr ""
#: changedetectionio/templates/_helpers.html:249
#, fuzzy
msgid "Weekends"
msgstr "周数"
#: changedetectionio/templates/_helpers.html:250
#, fuzzy
msgid "Reset"
msgstr "要求"
#: changedetectionio/templates/_helpers.html:259
msgid ""
"Warning, one or more of your 'days' has a duration that would extend into"
" the next day."
msgstr ""
#: changedetectionio/templates/_helpers.html:260
msgid "This could have unintended consequences."
msgstr ""
#: changedetectionio/templates/_helpers.html:270
#, fuzzy
msgid "More help and examples about using the scheduler"
msgstr "更多帮助和示例请参见此处"
#: changedetectionio/templates/_helpers.html:275
#, fuzzy
msgid "Want to use a time schedule?"
msgstr "使用时间调度器"
#: changedetectionio/templates/_helpers.html:275
msgid "First confirm/save your Time Zone Settings"
msgstr ""
#: changedetectionio/templates/_helpers.html:284
msgid ""
"Triggers a change if this text appears, AND something changed in the "
"document."
msgstr ""
#: changedetectionio/templates/_helpers.html:284
#, fuzzy
msgid "Triggered text"
msgstr "错误文本"
#: changedetectionio/templates/_helpers.html:285
msgid "Ignored for calculating changes, but still shown."
msgstr ""
#: changedetectionio/templates/_helpers.html:285
#, fuzzy
msgid "Ignored text"
msgstr "错误文本"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "No change-detection will occur because this text exists."
msgstr "文本匹配时阻止更改检测"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "Blocked text"
msgstr "错误文本"
#: changedetectionio/templates/base.html:78
#: changedetectionio/templates/base.html:168
#: changedetectionio/templates/base.html:77
msgid "GROUPS"
msgstr "团体"
#: changedetectionio/templates/base.html:81
#: changedetectionio/templates/base.html:169
#: changedetectionio/templates/base.html:80
msgid "SETTINGS"
msgstr "设置"
#: changedetectionio/templates/base.html:84
#: changedetectionio/templates/base.html:170
#: changedetectionio/templates/base.html:83
msgid "IMPORT"
msgstr "导入"
msgstr "进口"
#: changedetectionio/templates/base.html:87
#: changedetectionio/templates/base.html:171
#: changedetectionio/templates/base.html:86
msgid "BACKUPS"
msgstr "备份"
#: changedetectionio/templates/base.html:91
#: changedetectionio/templates/base.html:173
#: changedetectionio/templates/base.html:90
msgid "EDIT"
msgstr "编辑"
#: changedetectionio/templates/base.html:101
#: changedetectionio/templates/base.html:177
#: changedetectionio/templates/base.html:100
msgid "LOG OUT"
msgstr "退出"
#: changedetectionio/templates/base.html:108
#: changedetectionio/templates/base.html:109
msgid "Search, or Use Alt+S Key"
msgstr "搜索或使用 Alt+S 键"
#: changedetectionio/templates/base.html:114
#: changedetectionio/templates/base.html:116
msgid "Toggle Light/Dark Mode"
msgstr "切换亮/暗模式"
#: changedetectionio/templates/base.html:115
#: changedetectionio/templates/base.html:117
msgid "Toggle light/dark mode"
msgstr "切换亮/暗模式"
#: changedetectionio/templates/base.html:125
#: changedetectionio/templates/base.html:127
msgid "Change Language"
msgstr "更改语言"
#: changedetectionio/templates/base.html:126
#: changedetectionio/templates/base.html:128
msgid "Change language"
msgstr "更改语言"
#: changedetectionio/templates/base.html:253
#, fuzzy
msgid "Watch List"
msgstr "监控列表"
#: changedetectionio/templates/base.html:258
#, fuzzy
msgid "Watches"
msgstr "监控项"
#: changedetectionio/templates/base.html:261
msgid "Queue Status"
msgstr ""
#: changedetectionio/templates/base.html:270
#, fuzzy
msgid "Queue"
msgstr "排队"
#: changedetectionio/templates/base.html:274
#: changedetectionio/templates/base.html:279
#, fuzzy
msgid "Settings"
msgstr "设置"
#: changedetectionio/templates/base.html:293
msgid "Sitemap Crawler"
msgstr ""
#: changedetectionio/templates/base.html:318
msgid "Sitemap"
msgstr ""
#: changedetectionio/templates/base.html:354
#: changedetectionio/templates/base.html:249
msgid "Real-time updates offline"
msgstr "离线实时更新"
#: changedetectionio/templates/base.html:364
#: changedetectionio/templates/base.html:259
msgid "Select Language"
msgstr "选择语言"
#: changedetectionio/templates/base.html:375
#: changedetectionio/templates/base.html:270
msgid ""
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr ""
#: changedetectionio/templates/base.html:387
#: changedetectionio/templates/base.html:400
#, fuzzy
msgid "Search"
msgstr "搜寻中"
#: changedetectionio/templates/base.html:392
msgid "URL or Title"
msgstr ""
#: changedetectionio/templates/base.html:392
#, fuzzy
msgid "in"
msgstr "更多信息"
#: changedetectionio/templates/base.html:393
msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/login.html:11
#: changedetectionio/templates/login.html:10
msgid "Password"
msgstr "密码"
#: changedetectionio/templates/login.html:17
#: changedetectionio/templates/login.html:16
msgid "Login"
msgstr "登录"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
"PO-Revision-Date: 2026-01-02 12:37+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh_Hant_TW\n"
@@ -19,21 +19,21 @@ msgstr ""
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
#: changedetectionio/flask_app.py:247
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
#: changedetectionio/flask_app.py:246
#: changedetectionio/realtime/socket_server.py:171
msgid "Not yet"
msgstr "還沒有"
#: changedetectionio/flask_app.py:534
msgid "Already logged in"
msgstr ""
#: changedetectionio/flask_app.py:536
#: changedetectionio/flask_app.py:468
msgid "You must be logged in, please log in."
msgstr ""
#: changedetectionio/flask_app.py:551
#: changedetectionio/flask_app.py:495
msgid "Already logged in"
msgstr ""
#: changedetectionio/flask_app.py:522
#, fuzzy
msgid "Incorrect password"
msgstr "密碼"
@@ -176,16 +176,18 @@ msgid "Invalid value."
msgstr "無效值。"
#: changedetectionio/forms.py:732
#, fuzzy
msgid "Watch"
msgstr "監控"
msgstr "# 手錶"
#: changedetectionio/forms.py:733 changedetectionio/forms.py:766
msgid "Processor"
msgstr "處理器"
#: changedetectionio/forms.py:734
#, fuzzy
msgid "Edit > Watch"
msgstr "編輯 > 監控"
msgstr "編輯後觀看"
#: changedetectionio/forms.py:747 changedetectionio/forms.py:994
#, fuzzy
@@ -361,7 +363,7 @@ msgstr "節省"
#: changedetectionio/forms.py:829
msgid "Proxy"
msgstr "代理"
msgstr "代理"
#: changedetectionio/forms.py:831
msgid "Send a notification when the filter can no longer be found on the page"
@@ -425,8 +427,9 @@ msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
#: changedetectionio/forms.py:920 changedetectionio/forms.py:932
#, fuzzy
msgid "Name"
msgstr "名稱"
msgstr "取消靜音"
#: changedetectionio/forms.py:921
msgid "Proxy URL"
@@ -449,8 +452,9 @@ msgid "Plaintext requests"
msgstr "明文請求"
#: changedetectionio/forms.py:946
#, fuzzy
msgid "Chrome requests"
msgstr "Chrome請求"
msgstr "求"
#: changedetectionio/forms.py:952
msgid "Default proxy"
@@ -507,8 +511,9 @@ msgid "API access token security check enabled"
msgstr "已啟用 API 訪問令牌安全檢查"
#: changedetectionio/forms.py:989
#, fuzzy
msgid "Notification base URL override"
msgstr "通知基礎URL"
msgstr "通知警報計數"
#: changedetectionio/forms.py:993
msgid "Treat empty pages as a change?"
@@ -617,8 +622,6 @@ msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:6
#: changedetectionio/templates/base.html:282
#: changedetectionio/templates/base.html:290
msgid "Backups"
msgstr "備份"
@@ -1033,7 +1036,7 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
msgid "# Watches"
msgstr "# 監控項"
msgstr "# 手錶"
#: changedetectionio/blueprint/tags/templates/groups-overview.html:32
msgid "Tag / Label name"
@@ -1304,8 +1307,7 @@ msgid "Clear History!"
msgstr "清除歷史!"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
#: changedetectionio/templates/base.html:379
#: changedetectionio/templates/base.html:399
#: changedetectionio/templates/base.html:274
msgid "Cancel"
msgstr "取消"
@@ -1923,11 +1925,11 @@ msgstr "添加新的網頁更改檢測監視"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:34
msgid "Watch this URL!"
msgstr "監控此URL"
msgstr "關注這個網址"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:35
msgid "Edit first then Watch"
msgstr "編輯後監控"
msgstr "編輯後觀看"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:45
msgid "Create a shareable link"
@@ -2064,7 +2066,7 @@ msgid "No information"
msgstr "暫無信息"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
#: changedetectionio/templates/base.html:353
#: changedetectionio/templates/base.html:248
msgid "Checking now"
msgstr "立即檢查"
@@ -2250,264 +2252,69 @@ msgstr "網頁文本/HTML、JSON 和 PDF 更改"
msgid "Detects all text changes where possible"
msgstr "盡可能檢測所有文本更改"
#: changedetectionio/templates/_helpers.html:25
msgid "Entry"
msgstr ""
#: changedetectionio/templates/_helpers.html:153
#, fuzzy
msgid "Actions"
msgstr "狀況"
#: changedetectionio/templates/_helpers.html:172
msgid "Add a row/rule after"
msgstr ""
#: changedetectionio/templates/_helpers.html:173
msgid "Remove this row/rule"
msgstr ""
#: changedetectionio/templates/_helpers.html:174
msgid "Verify this rule against current snapshot"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
"Chrome based fetching is not enabled."
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid "Alternatively try our"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"very affordable subscription based service which has all this setup for "
"you"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "You may need to"
msgstr "你需要"
#: changedetectionio/templates/_helpers.html:185
msgid "Enable playwright environment variable"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "and uncomment the"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "in the"
msgstr "這"
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "file"
msgstr "標題"
#: changedetectionio/templates/_helpers.html:240
msgid "Set a hourly/week day schedule"
msgstr ""
#: changedetectionio/templates/_helpers.html:247
#, fuzzy
msgid "Schedule time limits"
msgstr "複檢時間(分鐘)"
#: changedetectionio/templates/_helpers.html:248
msgid "Business hours"
msgstr ""
#: changedetectionio/templates/_helpers.html:249
#, fuzzy
msgid "Weekends"
msgstr "週數"
#: changedetectionio/templates/_helpers.html:250
#, fuzzy
msgid "Reset"
msgstr "要求"
#: changedetectionio/templates/_helpers.html:259
msgid ""
"Warning, one or more of your 'days' has a duration that would extend into"
" the next day."
msgstr ""
#: changedetectionio/templates/_helpers.html:260
msgid "This could have unintended consequences."
msgstr ""
#: changedetectionio/templates/_helpers.html:270
#, fuzzy
msgid "More help and examples about using the scheduler"
msgstr "更多幫助和示例請參見此處"
#: changedetectionio/templates/_helpers.html:275
#, fuzzy
msgid "Want to use a time schedule?"
msgstr "使用時間調度器"
#: changedetectionio/templates/_helpers.html:275
msgid "First confirm/save your Time Zone Settings"
msgstr ""
#: changedetectionio/templates/_helpers.html:284
msgid ""
"Triggers a change if this text appears, AND something changed in the "
"document."
msgstr ""
#: changedetectionio/templates/_helpers.html:284
#, fuzzy
msgid "Triggered text"
msgstr "錯誤文本"
#: changedetectionio/templates/_helpers.html:285
msgid "Ignored for calculating changes, but still shown."
msgstr ""
#: changedetectionio/templates/_helpers.html:285
#, fuzzy
msgid "Ignored text"
msgstr "錯誤文本"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "No change-detection will occur because this text exists."
msgstr "文本匹配時阻止更改檢測"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "Blocked text"
msgstr "錯誤文本"
#: changedetectionio/templates/base.html:78
#: changedetectionio/templates/base.html:168
#: changedetectionio/templates/base.html:77
msgid "GROUPS"
msgstr "團體"
#: changedetectionio/templates/base.html:81
#: changedetectionio/templates/base.html:169
#: changedetectionio/templates/base.html:80
msgid "SETTINGS"
msgstr "設定"
#: changedetectionio/templates/base.html:84
#: changedetectionio/templates/base.html:170
#: changedetectionio/templates/base.html:83
msgid "IMPORT"
msgstr "導入"
msgstr "進口"
#: changedetectionio/templates/base.html:87
#: changedetectionio/templates/base.html:171
#: changedetectionio/templates/base.html:86
msgid "BACKUPS"
msgstr "備份"
#: changedetectionio/templates/base.html:91
#: changedetectionio/templates/base.html:173
#: changedetectionio/templates/base.html:90
msgid "EDIT"
msgstr "編輯"
#: changedetectionio/templates/base.html:101
#: changedetectionio/templates/base.html:177
#: changedetectionio/templates/base.html:100
msgid "LOG OUT"
msgstr "退出"
#: changedetectionio/templates/base.html:108
#: changedetectionio/templates/base.html:109
msgid "Search, or Use Alt+S Key"
msgstr "搜索或使用 Alt+S 鍵"
#: changedetectionio/templates/base.html:114
#: changedetectionio/templates/base.html:116
msgid "Toggle Light/Dark Mode"
msgstr "切換亮/暗模式"
#: changedetectionio/templates/base.html:115
#: changedetectionio/templates/base.html:117
msgid "Toggle light/dark mode"
msgstr "切換亮/暗模式"
#: changedetectionio/templates/base.html:125
#: changedetectionio/templates/base.html:127
msgid "Change Language"
msgstr "更改語言"
#: changedetectionio/templates/base.html:126
#: changedetectionio/templates/base.html:128
msgid "Change language"
msgstr "更改語言"
#: changedetectionio/templates/base.html:253
#, fuzzy
msgid "Watch List"
msgstr "監控列表"
#: changedetectionio/templates/base.html:258
#, fuzzy
msgid "Watches"
msgstr "監控項"
#: changedetectionio/templates/base.html:261
msgid "Queue Status"
msgstr ""
#: changedetectionio/templates/base.html:270
#, fuzzy
msgid "Queue"
msgstr "排隊"
#: changedetectionio/templates/base.html:274
#: changedetectionio/templates/base.html:279
#, fuzzy
msgid "Settings"
msgstr "設定"
#: changedetectionio/templates/base.html:293
msgid "Sitemap Crawler"
msgstr ""
#: changedetectionio/templates/base.html:318
msgid "Sitemap"
msgstr ""
#: changedetectionio/templates/base.html:354
#: changedetectionio/templates/base.html:249
msgid "Real-time updates offline"
msgstr "離線實時更新"
#: changedetectionio/templates/base.html:364
#: changedetectionio/templates/base.html:259
msgid "Select Language"
msgstr "選擇語言"
#: changedetectionio/templates/base.html:375
#: changedetectionio/templates/base.html:270
msgid ""
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr ""
#: changedetectionio/templates/base.html:387
#: changedetectionio/templates/base.html:400
#, fuzzy
msgid "Search"
msgstr "搜尋中"
#: changedetectionio/templates/base.html:392
msgid "URL or Title"
msgstr ""
#: changedetectionio/templates/base.html:392
#, fuzzy
msgid "in"
msgstr "資訊"
#: changedetectionio/templates/base.html:393
msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/login.html:11
#: changedetectionio/templates/login.html:10
msgid "Password"
msgstr "密碼"
#: changedetectionio/templates/login.html:17
#: changedetectionio/templates/login.html:16
msgid "Login"
msgstr "登入"

View File

@@ -28,7 +28,7 @@ info:
For example: `x-api-key: YOUR_API_KEY`
version: 0.1.4
version: 0.1.3
contact:
name: ChangeDetection.io
url: https://github.com/dgtlmoon/changedetection.io
@@ -761,9 +761,9 @@ paths:
get:
operationId: getWatchHistoryDiff
tags: [Watch History]
summary: Get the difference between two snapshots
summary: Get diff between two snapshots
description: |
Generate a difference (comparison) between two historical snapshots of a web page change monitor (watch).
Generate a formatted diff (comparison) between two historical snapshots of a web page change monitor (watch).
This endpoint compares content between two points in time and returns the differences in your chosen format.
Perfect for reviewing what changed between specific versions or comparing recent changes.
@@ -798,10 +798,6 @@ paths:
# Compare two specific timestamps in plain text with word-level diff
curl -X GET "http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/1640995200/1640998800?format=text&word_diff=true" \
-H "x-api-key: YOUR_API_KEY"
# Show only additions (hide removed/replaced content), ignore whitespace
curl -X GET "http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/previous/latest?format=htmlcolor&removed=false&replaced=false&ignoreWhitespace=true" \
-H "x-api-key: YOUR_API_KEY"
- lang: 'Python'
source: |
import requests
@@ -826,20 +822,6 @@ paths:
params={'format': 'text', 'word_diff': 'true'}
)
print(response.text)
# Show only additions, ignore whitespace and use word-level diff
response = requests.get(
f'http://localhost:5000/api/v1/watch/{uuid}/difference/previous/latest',
headers=headers,
params={
'format': 'htmlcolor',
'type': 'diffWords',
'removed': 'false',
'replaced': 'false',
'ignoreWhitespace': 'true'
}
)
print(response.text)
parameters:
- name: uuid
in: path
@@ -879,10 +861,9 @@ paths:
- `text` (default): Plain text with (removed) and (added) prefixes
- `html`: Basic HTML format
- `htmlcolor`: Rich HTML with colored backgrounds (red for deletions, green for additions)
- `markdown`: Markdown format with HTML rendering
schema:
type: string
enum: [text, html, htmlcolor, markdown]
enum: [text, html, htmlcolor]
default: text
- name: word_diff
in: query
@@ -907,69 +888,6 @@ paths:
type: string
enum: ["true", "false", "1", "0", "yes", "no", "on", "off"]
default: "false"
- name: type
in: query
description: |
Diff granularity type:
- `diffLines` (default): Line-level comparison, showing which lines changed
- `diffWords`: Word-level comparison, showing which words changed within lines
This parameter is an alternative to `word_diff` for better alignment with the UI.
If both are specified, `type=diffWords` will enable word-level diffing.
schema:
type: string
enum: [diffLines, diffWords]
default: diffLines
- name: changesOnly
in: query
description: |
When enabled, only show lines/content that changed (no surrounding context).
When disabled, include unchanged lines for context around changes.
Accepts: true, false, 1, 0, yes, no, on, off
schema:
type: string
enum: ["true", "false", "1", "0", "yes", "no", "on", "off"]
default: "true"
- name: ignoreWhitespace
in: query
description: |
When enabled, ignore whitespace-only changes (spaces, tabs, newlines).
Useful for focusing on content changes and ignoring formatting differences.
Accepts: true, false, 1, 0, yes, no, on, off
schema:
type: string
enum: ["true", "false", "1", "0", "yes", "no", "on", "off"]
default: "false"
- name: removed
in: query
description: |
Include removed/deleted content in the diff output.
When disabled, content that was deleted will not appear in the diff.
Accepts: true, false, 1, 0, yes, no, on, off
schema:
type: string
enum: ["true", "false", "1", "0", "yes", "no", "on", "off"]
default: "true"
- name: added
in: query
description: |
Include added/new content in the diff output.
When disabled, content that was added will not appear in the diff.
Accepts: true, false, 1, 0, yes, no, on, off
schema:
type: string
enum: ["true", "false", "1", "0", "yes", "no", "on", "off"]
default: "true"
- name: replaced
in: query
description: |
Include replaced/modified content in the diff output.
When disabled, content that was modified (changed from one value to another) will not appear in the diff.
Accepts: true, false, 1, 0, yes, no, on, off
schema:
type: string
enum: ["true", "false", "1", "0", "yes", "no", "on", "off"]
default: "true"
responses:
'200':
description: Formatted diff between the two snapshots

File diff suppressed because one or more lines are too long

View File

@@ -12,8 +12,8 @@ janus # Thread-safe async/sync queue bridge
flask_wtf~=1.2
flask~=3.1
flask-socketio~=5.6.0
python-socketio~=5.16.0
python-engineio~=4.13.0
python-socketio~=5.14.3
python-engineio~=4.12.3
inscriptis~=2.2
pytz
timeago~=1.0
@@ -60,7 +60,7 @@ cryptography==46.0.3
paho-mqtt!=2.0.*
# Used for CSS filtering, JSON extraction from HTML
beautifulsoup4>=4.0.0,<=4.14.3
beautifulsoup4>=4.0.0,<=4.14.2
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
# #2328 - 5.2.0 and 5.2.1 had extra CPU flag CFLAGS set which was not compatible on older hardware
@@ -148,7 +148,7 @@ tzdata
pluggy ~= 1.6
# Needed for testing, cross-platform for process and system monitoring
psutil==7.2.1
psutil==7.1.0
ruff >= 0.11.2
pre_commit >= 4.2.0