Compare commits

..

6 Commits

Author SHA1 Message Date
dgtlmoon
e1b048f961 oops 2026-02-18 17:52:57 +01:00
dgtlmoon
9ba645d4cc Do it threaded 2026-02-18 17:39:09 +01:00
dgtlmoon
e6c0d538e6 oops forgot module 2026-02-18 17:34:01 +01:00
dgtlmoon
e2fffc36e4 Small tidy 2026-02-18 17:32:16 +01:00
dgtlmoon
b9a2f781ac Backups was missing tags 2026-02-18 17:29:45 +01:00
dgtlmoon
76abb4ab71 WIP 2026-02-18 17:26:44 +01:00
63 changed files with 362 additions and 4358 deletions

View File

@@ -715,7 +715,7 @@ jobs:
pip install 'pyOpenSSL>=23.2.0'
echo "=== Running version 0.49.1 to create datastore ==="
ALLOW_IANA_RESTRICTED_ADDRESSES=true python3 ./changedetection.py -C -d /tmp/data &
python3 ./changedetection.py -C -d /tmp/data &
APP_PID=$!
# Wait for app to be ready
@@ -763,7 +763,7 @@ jobs:
pip install -r requirements.txt
echo "=== Running current version (commit ${{ github.sha }}) with old datastore (testing mode) ==="
ALLOW_IANA_RESTRICTED_ADDRESSES=true TESTING_SHUTDOWN_AFTER_DATASTORE_LOAD=1 python3 ./changedetection.py -d /tmp/data > /tmp/upgrade-test.log 2>&1
TESTING_SHUTDOWN_AFTER_DATASTORE_LOAD=1 python3 ./changedetection.py -d /tmp/data > /tmp/upgrade-test.log 2>&1
echo "=== Upgrade test output ==="
cat /tmp/upgrade-test.log
@@ -771,7 +771,7 @@ jobs:
# Now start the current version normally to verify the tag survived
echo "=== Starting current version to verify tag exists after upgrade ==="
ALLOW_IANA_RESTRICTED_ADDRESSES=true timeout 20 python3 ./changedetection.py -d /tmp/data > /tmp/ui-test.log 2>&1 &
timeout 20 python3 ./changedetection.py -d /tmp/data > /tmp/ui-test.log 2>&1 &
APP_PID=$!
# Wait for app to be ready and fetch UI

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.54.1'
__version__ = '0.53.4'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError

View File

@@ -17,7 +17,7 @@ class Tag(Resource):
self.update_q = kwargs['update_q']
# Get information about a single tag
# curl http://localhost:5000/api/v1/tag/<uuid_str:uuid>
# curl http://localhost:5000/api/v1/tag/<string:uuid>
@auth.check_token
@validate_openapi_request('getTag')
def get(self, uuid):

View File

@@ -57,7 +57,7 @@ class Watch(Resource):
self.update_q = kwargs['update_q']
# Get information about a single watch, excluding the history list (can be large)
# curl http://localhost:5000/api/v1/watch/<uuid_str:uuid>
# curl http://localhost:5000/api/v1/watch/<string:uuid>
# @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK"
# ?recheck=true
@auth.check_token
@@ -217,7 +217,7 @@ class WatchHistory(Resource):
self.datastore = kwargs['datastore']
# Get a list of available history for a watch by UUID
# curl http://localhost:5000/api/v1/watch/<uuid_str:uuid>/history
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history
@auth.check_token
@validate_openapi_request('getWatchHistory')
def get(self, uuid):

View File

@@ -94,13 +94,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return results
@login_required
@check_proxies_blueprint.route("/<uuid_str:uuid>/status", methods=['GET'])
@check_proxies_blueprint.route("/<string:uuid>/status", methods=['GET'])
def get_recheck_status(uuid):
results = _recalc_check_status(uuid=uuid)
return results
@login_required
@check_proxies_blueprint.route("/<uuid_str:uuid>/start", methods=['GET'])
@check_proxies_blueprint.route("/<string:uuid>/start", methods=['GET'])
def start_check(uuid):
if not datastore.proxy_list:

View File

@@ -15,7 +15,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
price_data_follower_blueprint = Blueprint('price_data_follower', __name__)
@login_required
@price_data_follower_blueprint.route("/<uuid_str:uuid>/accept", methods=['GET'])
@price_data_follower_blueprint.route("/<string:uuid>/accept", methods=['GET'])
def accept(uuid):
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
datastore.data['watching'][uuid]['processor'] = 'restock_diff'
@@ -25,7 +25,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
return redirect(url_for("watchlist.index"))
@login_required
@price_data_follower_blueprint.route("/<uuid_str:uuid>/reject", methods=['GET'])
@price_data_follower_blueprint.route("/<string:uuid>/reject", methods=['GET'])
def reject(uuid):
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT
datastore.data['watching'][uuid].commit()

View File

@@ -9,12 +9,11 @@ def construct_single_watch_routes(rss_blueprint, datastore):
datastore: The ChangeDetectionStore instance
"""
@rss_blueprint.route("/watch/<uuid_str:uuid>", methods=['GET'])
@rss_blueprint.route("/watch/<string:uuid>", methods=['GET'])
def rss_single_watch(uuid):
import time
from flask import make_response, request, Response
from flask_babel import lazy_gettext as _l
from flask import make_response, request
from feedgen.feed import FeedGenerator
from loguru import logger
@@ -43,12 +42,12 @@ def construct_single_watch_routes(rss_blueprint, datastore):
# Get the watch by UUID
watch = datastore.data['watching'].get(uuid)
if not watch:
return Response(_l("Watch with UUID %(uuid)s not found", uuid=uuid), status=404, mimetype='text/plain')
return f"Watch with UUID {uuid} not found", 404
# Check if watch has at least 2 history snapshots
dates = list(watch.history.keys())
if len(dates) < 2:
return Response(_l("Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)", uuid=uuid), status=400, mimetype='text/plain')
return f"Watch {uuid} does not have enough history snapshots to show changes (need at least 2)", 400
# Get the number of diffs to include (default: 5)
rss_diff_length = datastore.data['settings']['application'].get('rss_diff_length', 5)

View File

@@ -45,7 +45,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
extra_notification_tokens=datastore.get_unique_notification_tokens_available()
)
# Remove the last option 'System default'
form.application.form.notification_format.choices.pop()
@@ -130,12 +129,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Instantiate plugin form with POST data
plugin_form = form_class(formdata=request.form)
# Save plugin settings — use plugin's own save_fn if provided
# (allows plugins to strip ephemeral staging fields etc.)
save_fn = tab.get('save_fn')
if save_fn:
save_fn(datastore, plugin_form)
elif plugin_form.data:
# Save plugin settings (validation is optional for plugins)
if plugin_form.data:
save_plugin_settings(datastore.datastore_path, plugin_id, plugin_form.data)
flash(gettext("Settings updated."))

View File

@@ -27,7 +27,6 @@
<li class="tab"><a href="#rss">{{ _('RSS') }}</a></li>
<li class="tab"><a href="{{ url_for('backups.create') }}">{{ _('Backups') }}</a></li>
<li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li>
<li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li>
{% if plugin_tabs %}
{% for tab in plugin_tabs %}
@@ -309,7 +308,6 @@ nav
</div>
</div>
<div class="tab-pane-inner" id="proxies">
<div id="recommended-proxy">
<div>

View File

@@ -54,7 +54,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/mute/<uuid_str:uuid>", methods=['GET'])
@tags_blueprint.route("/mute/<string:uuid>", methods=['GET'])
@login_optionally_required
def mute(uuid):
tag = datastore.data['settings']['application']['tags'].get(uuid)
@@ -63,7 +63,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
tag.commit()
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/delete/<uuid_str:uuid>", methods=['GET'])
@tags_blueprint.route("/delete/<string:uuid>", methods=['GET'])
@login_optionally_required
def delete(uuid):
# Delete the tag from settings immediately
@@ -90,7 +90,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
flash(gettext("Tag deleted, removing from watches in background"))
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/unlink/<uuid_str:uuid>", methods=['GET'])
@tags_blueprint.route("/unlink/<string:uuid>", methods=['GET'])
@login_optionally_required
def unlink(uuid):
# Unlink tag from all watches in background thread to avoid blocking
@@ -141,7 +141,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
flash(gettext("All tags deleted, clearing from watches in background"))
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/edit/<uuid_str:uuid>", methods=['GET'])
@tags_blueprint.route("/edit/<string:uuid>", methods=['GET'])
@login_optionally_required
def form_tag_edit(uuid):
from changedetectionio.blueprint.tags.form import group_restock_settings_form
@@ -203,7 +203,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return output
@tags_blueprint.route("/edit/<uuid_str:uuid>", methods=['POST'])
@tags_blueprint.route("/edit/<string:uuid>", methods=['POST'])
@login_optionally_required
def form_tag_edit_submit(uuid):
from changedetectionio.blueprint.tags.form import group_restock_settings_form

View File

@@ -116,11 +116,11 @@ def _handle_operations(op, uuids, datastore, worker_pool, update_q, queuedWatchM
for uuid in uuids:
watch_check_update.send(watch_uuid=uuid)
def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, queuedWatchMetaData, watch_check_update, llm_summary_q=None):
def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, queuedWatchMetaData, watch_check_update):
ui_blueprint = Blueprint('ui', __name__, template_folder="templates")
# Register the edit blueprint
edit_blueprint = construct_edit_blueprint(datastore, update_q, queuedWatchMetaData, llm_summary_q=llm_summary_q)
edit_blueprint = construct_edit_blueprint(datastore, update_q, queuedWatchMetaData)
ui_blueprint.register_blueprint(edit_blueprint)
# Register the notification blueprint
@@ -141,7 +141,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
# Import the login decorator
from changedetectionio.auth_decorator import login_optionally_required
@ui_blueprint.route("/clear_history/<uuid_str:uuid>", methods=['GET'])
@ui_blueprint.route("/clear_history/<string:uuid>", methods=['GET'])
@login_optionally_required
def clear_watch_history(uuid):
try:
@@ -366,7 +366,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
return redirect(url_for('watchlist.index'))
@ui_blueprint.route("/share-url/<uuid_str:uuid>", methods=['GET'])
@ui_blueprint.route("/share-url/<string:uuid>", methods=['GET'])
@login_optionally_required
def form_share_put_watch(uuid):
"""Given a watch UUID, upload the info and return a share-link

View File

@@ -66,7 +66,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return Markup(result)
@diff_blueprint.route("/diff/<uuid_str:uuid>", methods=['GET'])
@diff_blueprint.route("/diff/<string:uuid>", methods=['GET'])
@login_optionally_required
def diff_history_page(uuid):
"""
@@ -128,7 +128,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
redirect=redirect
)
@diff_blueprint.route("/diff/<uuid_str:uuid>/extract", methods=['GET'])
@diff_blueprint.route("/diff/<string:uuid>/extract", methods=['GET'])
@login_optionally_required
def diff_history_page_extract_GET(uuid):
"""
@@ -182,7 +182,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
redirect=redirect
)
@diff_blueprint.route("/diff/<uuid_str:uuid>/extract", methods=['POST'])
@diff_blueprint.route("/diff/<string:uuid>/extract", methods=['POST'])
@login_optionally_required
def diff_history_page_extract_POST(uuid):
"""
@@ -238,7 +238,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
redirect=redirect
)
@diff_blueprint.route("/diff/<uuid_str:uuid>/processor-asset/<string:asset_name>", methods=['GET'])
@diff_blueprint.route("/diff/<string:uuid>/processor-asset/<string:asset_name>", methods=['GET'])
@login_optionally_required
def processor_asset(uuid, asset_name):
"""

View File

@@ -11,7 +11,7 @@ from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio.time_handler import is_within_schedule
from changedetectionio import worker_pool
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, llm_summary_q=None):
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates")
def _watch_has_tag_options_set(watch):
@@ -20,7 +20,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')):
return True
@edit_blueprint.route("/edit/<uuid_str:uuid>", methods=['GET', 'POST'])
@edit_blueprint.route("/edit/<string:uuid>", methods=['GET', 'POST'])
@login_optionally_required
# https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists
# https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ?
@@ -327,7 +327,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
return output
@edit_blueprint.route("/edit/<uuid_str:uuid>/get-html", methods=['GET'])
@edit_blueprint.route("/edit/<string:uuid>/get-html", methods=['GET'])
@login_optionally_required
def watch_get_latest_html(uuid):
from io import BytesIO
@@ -354,7 +354,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Return a 500 error
abort(500)
@edit_blueprint.route("/edit/<uuid_str:uuid>/get-data-package", methods=['GET'])
@edit_blueprint.route("/edit/<string:uuid>/get-data-package", methods=['GET'])
@login_optionally_required
def watch_get_data_package(uuid):
"""Download all data for a single watch as a zip file"""
@@ -404,49 +404,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
download_name=filename,
mimetype='application/zip')
@edit_blueprint.route("/edit/<string:uuid>/regenerate-llm-summaries", methods=['GET'])
@login_optionally_required
def watch_regenerate_llm_summaries(uuid):
"""Queue LLM summary generation for all history entries that don't yet have one."""
from flask import flash
from changedetectionio.llm.tokens import is_llm_data_ready
watch = datastore.data['watching'].get(uuid)
if not watch:
abort(404)
if not llm_summary_q:
flash(gettext("LLM summarisation is not configured."), 'error')
return redirect(url_for('ui.ui_edit.edit_page', uuid=uuid))
history = watch.history
history_keys = list(history.keys())
queued = 0
# Skip the first entry — there is no prior snapshot to diff against
for timestamp in history_keys[1:]:
snapshot_fname = history[timestamp]
snapshot_id = os.path.basename(snapshot_fname).split('.')[0] # always 32-char MD5
# Skip entries that already have a summary
if is_llm_data_ready(watch.data_dir, snapshot_id):
continue
llm_summary_q.put({
'uuid': uuid,
'snapshot_id': snapshot_id,
'attempts': 0,
})
queued += 1
if queued:
flash(gettext("Queued %(count)d LLM summaries for generation.", count=queued), 'success')
else:
flash(gettext("All history entries already have LLM summaries."), 'notice')
return redirect(url_for('ui.ui_edit.edit_page', uuid=uuid) + '#info')
# Ajax callback
@edit_blueprint.route("/edit/<uuid_str:uuid>/preview-rendered", methods=['POST'])
@edit_blueprint.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])
@login_optionally_required
def watch_get_preview_rendered(uuid):
'''For when viewing the "preview" of the rendered text from inside of Edit'''

View File

@@ -10,7 +10,7 @@ from changedetectionio import html_tools
def construct_blueprint(datastore: ChangeDetectionStore):
preview_blueprint = Blueprint('ui_preview', __name__, template_folder="../ui/templates")
@preview_blueprint.route("/preview/<uuid_str:uuid>", methods=['GET'])
@preview_blueprint.route("/preview/<string:uuid>", methods=['GET'])
@login_optionally_required
def preview_page(uuid):
"""
@@ -125,7 +125,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return output
@preview_blueprint.route("/preview/<uuid_str:uuid>/processor-asset/<string:asset_name>", methods=['GET'])
@preview_blueprint.route("/preview/<string:uuid>/processor-asset/<string:asset_name>", methods=['GET'])
@login_optionally_required
def processor_asset(uuid, asset_name):
"""

View File

@@ -489,9 +489,6 @@ Math: {{ 1 + 1 }}") }}
<p>
<a href="{{url_for('ui.ui_edit.watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">{{ _('Download latest HTML snapshot') }}</a>
<a href="{{url_for('ui.ui_edit.watch_get_data_package', uuid=uuid)}}" class="pure-button button-small">{{ _('Download watch data package') }}</a>
{% if watch.history_n > 1 %}
<a href="{{url_for('ui.ui_edit.watch_regenerate_llm_summaries', uuid=uuid)}}" class="pure-button button-small">{{ _('Regenerate LLM summaries') }}</a>
{% endif %}
</p>
{% endif %}

View File

@@ -1,5 +1,4 @@
from loguru import logger
from urllib.parse import urljoin, urlparse
import hashlib
import os
import re
@@ -8,7 +7,6 @@ import asyncio
from changedetectionio import strtobool
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
from changedetectionio.content_fetchers.base import Fetcher
from changedetectionio.validate_url import is_private_hostname
# "html_requests" is listed as the default fetcher in store.py!
@@ -81,48 +79,14 @@ class fetcher(Fetcher):
if strtobool(os.getenv('ALLOW_FILE_URI', 'false')) and url.startswith('file://'):
from requests_file import FileAdapter
session.mount('file://', FileAdapter())
allow_iana_restricted = strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false'))
try:
# Fresh DNS check at fetch time — catches DNS rebinding regardless of add-time cache.
if not allow_iana_restricted:
parsed_initial = urlparse(url)
if parsed_initial.hostname and is_private_hostname(parsed_initial.hostname):
raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.")
r = session.request(method=request_method,
data=request_body.encode('utf-8') if type(request_body) is str else request_body,
url=url,
headers=request_headers,
timeout=timeout,
proxies=proxies,
verify=False,
allow_redirects=False)
# Manually follow redirects so each hop's resolved IP can be validated,
# preventing SSRF via an open redirect on a public host.
current_url = url
for _ in range(10):
if not r.is_redirect:
break
location = r.headers.get('Location', '')
redirect_url = urljoin(current_url, location)
if not allow_iana_restricted:
parsed_redirect = urlparse(redirect_url)
if parsed_redirect.hostname and is_private_hostname(parsed_redirect.hostname):
raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address.")
current_url = redirect_url
r = session.request('GET', redirect_url,
headers=request_headers,
timeout=timeout,
proxies=proxies,
verify=False,
allow_redirects=False)
else:
raise Exception("Too many redirects")
verify=False)
except Exception as e:
msg = str(e)
if proxies and 'SOCKSHTTPSConnectionPool' in msg:

View File

@@ -15,7 +15,6 @@ from changedetectionio.strtobool import strtobool
from threading import Event
from changedetectionio.queue_handlers import RecheckPriorityQueue, NotificationQueue
from changedetectionio import worker_pool
import changedetectionio.llm as llm
from flask import (
Flask,
@@ -28,6 +27,7 @@ from flask import (
session,
url_for,
)
from flask_compress import Compress as FlaskCompress
from flask_restful import abort, Api
from flask_cors import CORS
@@ -57,7 +57,6 @@ extra_stylesheets = []
# Use bulletproof janus-based queues for sync/async reliability
update_q = RecheckPriorityQueue()
notification_q = NotificationQueue()
llm_summary_q = llm.create_queue()
MAX_QUEUE_SIZE = 5000
app = Flask(__name__,
@@ -70,43 +69,19 @@ socketio_server = None
# Enable CORS, especially useful for the Chrome extension to operate from anywhere
CORS(app)
from werkzeug.routing import BaseConverter, ValidationError
from uuid import UUID
class StrictUUIDConverter(BaseConverter):
# Special sentinel values allowed in addition to strict UUIDs
_ALLOWED_SENTINELS = frozenset({'first'})
def to_python(self, value: str) -> str:
if value in self._ALLOWED_SENTINELS:
return value
try:
u = UUID(value)
except ValueError as e:
raise ValidationError() from e
# Reject non-standard formats (braces, URNs, no-hyphens)
if str(u) != value.lower():
raise ValidationError()
return str(u)
def to_url(self, value) -> str:
return str(value)
# app setup (once)
app.url_map.converters["uuid_str"] = StrictUUIDConverter
# Flask-Compress handles HTTP compression, Socket.IO compression disabled to prevent memory leak.
# There's also a bug between flask compress and socketio that causes some kind of slow memory leak
# It's better to use compression on your reverse proxy (nginx etc) instead.
if strtobool(os.getenv("FLASK_ENABLE_COMPRESSION")):
from flask_compress import Compress as FlaskCompress
app.config['COMPRESS_MIN_SIZE'] = 2096
app.config['COMPRESS_MIMETYPES'] = ['text/html', 'text/css', 'text/javascript', 'application/json', 'application/javascript', 'image/svg+xml']
# Use gzip only - smaller memory footprint than zstd/brotli (4-8KB vs 200-500KB contexts)
app.config['COMPRESS_ALGORITHM'] = ['gzip']
compress = FlaskCompress()
compress.init_app(app)
compress = FlaskCompress()
compress.init_app(app)
app.config['TEMPLATES_AUTO_RELOAD'] = False
@@ -559,22 +534,22 @@ def changedetection_app(config=None, datastore_o=None):
watch_api.add_resource(WatchHistoryDiff,
'/api/v1/watch/<uuid_str:uuid>/difference/<string:from_timestamp>/<string:to_timestamp>',
'/api/v1/watch/<string:uuid>/difference/<string:from_timestamp>/<string:to_timestamp>',
resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(WatchSingleHistory,
'/api/v1/watch/<uuid_str:uuid>/history/<string:timestamp>',
'/api/v1/watch/<string:uuid>/history/<string:timestamp>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
watch_api.add_resource(WatchFavicon,
'/api/v1/watch/<uuid_str:uuid>/favicon',
'/api/v1/watch/<string:uuid>/favicon',
resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(WatchHistory,
'/api/v1/watch/<uuid_str:uuid>/history',
'/api/v1/watch/<string:uuid>/history',
resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(CreateWatch, '/api/v1/watch',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
watch_api.add_resource(Watch, '/api/v1/watch/<uuid_str:uuid>',
watch_api.add_resource(Watch, '/api/v1/watch/<string:uuid>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
watch_api.add_resource(SystemInfo, '/api/v1/systeminfo',
@@ -587,7 +562,7 @@ def changedetection_app(config=None, datastore_o=None):
watch_api.add_resource(Tags, '/api/v1/tags',
resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/<uuid_str:uuid>',
watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/<string:uuid>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
watch_api.add_resource(Search, '/api/v1/search',
@@ -877,7 +852,7 @@ def changedetection_app(config=None, datastore_o=None):
# watchlist UI buttons etc
import changedetectionio.blueprint.ui as ui
app.register_blueprint(ui.construct_blueprint(datastore, update_q, worker_pool, queuedWatchMetaData, watch_check_update, llm_summary_q=llm_summary_q))
app.register_blueprint(ui.construct_blueprint(datastore, update_q, worker_pool, queuedWatchMetaData, watch_check_update))
import changedetectionio.blueprint.watchlist as watchlist
app.register_blueprint(watchlist.construct_blueprint(datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData), url_prefix='')
@@ -1000,17 +975,6 @@ def changedetection_app(config=None, datastore_o=None):
).start()
logger.info(f"Started {notification_workers} notification worker(s)")
llm.start_workers(app=app, datastore=datastore, llm_q=llm_summary_q,
n_workers=int(os.getenv("LLM_WORKERS", "1")))
# Register the LLM queue plugin so changes trigger summary jobs
from changedetectionio.llm.plugin import LLMQueuePlugin
from changedetectionio.pluggy_interface import plugin_manager
plugin_manager.register(LLMQueuePlugin(llm_summary_q), 'llm_queue_plugin')
# Re-run template path configuration now that all plugins (including LLM) are registered
_configure_plugin_templates()
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
# Check for new release version, but not when running in test/build or pytest
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest:
@@ -1065,65 +1029,19 @@ def notification_runner(worker_id=0):
else:
# ── LLM deferred-send gate ─────────────────────────────────────────
# If the notification was re-queued to wait for LLM data, honour the
# scheduled retry time before doing any further processing.
_llm_next_retry = n_object.get('_llm_next_retry_at', 0)
if _llm_next_retry and _llm_next_retry > time.time():
notification_q.put(n_object)
app.config.exit.wait(min(_llm_next_retry - time.time(), 2))
continue
# Apply system-config fallbacks first so we can scan the final body/title.
if not n_object.get('notification_body') and datastore.data['settings']['application'].get('notification_body'):
n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
if not n_object.get('notification_title') and datastore.data['settings']['application'].get('notification_title'):
n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title')
# If the body or title references llm_* tokens, wait until LLM data is ready.
import re as _re
_llm_scan = (n_object.get('notification_body') or '') + ' ' + (n_object.get('notification_title') or '')
if _re.search(r'\bllm_(?:summary|headline|importance|sentiment|one_liner)\b', _llm_scan):
from changedetectionio.llm.tokens import (
is_llm_data_ready, read_llm_tokens,
LLM_NOTIFICATION_RETRY_DELAY_SECONDS, LLM_NOTIFICATION_MAX_WAIT_ATTEMPTS,
)
_llm_uuid = n_object.get('uuid')
_llm_watch = datastore.data['watching'].get(_llm_uuid) if _llm_uuid else None
_llm_snap_id = n_object.get('_llm_snapshot_id')
if _llm_watch and _llm_snap_id and not is_llm_data_ready(_llm_watch.data_dir, _llm_snap_id):
_llm_attempts = n_object.get('_llm_wait_attempts', 0)
if _llm_attempts < LLM_NOTIFICATION_MAX_WAIT_ATTEMPTS:
n_object['_llm_wait_attempts'] = _llm_attempts + 1
n_object['_llm_next_retry_at'] = time.time() + LLM_NOTIFICATION_RETRY_DELAY_SECONDS
notification_q.put(n_object)
logger.debug(
f"Notification gate: LLM data pending for {_llm_uuid} "
f"(attempt {n_object['_llm_wait_attempts']}/{LLM_NOTIFICATION_MAX_WAIT_ATTEMPTS})"
)
continue
else:
logger.warning(
f"Notification: LLM data never arrived for {_llm_uuid} after "
f"{LLM_NOTIFICATION_MAX_WAIT_ATTEMPTS} attempts — sending without LLM tokens"
)
elif _llm_watch and _llm_snap_id:
# Data is ready — populate the LLM tokens into n_object
_llm_data = read_llm_tokens(_llm_watch.data_dir, _llm_snap_id)
n_object['llm_summary'] = _llm_data.get('summary', '')
n_object['llm_headline'] = _llm_data.get('headline', '')
n_object['llm_importance'] = _llm_data.get('importance')
n_object['llm_sentiment'] = _llm_data.get('sentiment', '')
n_object['llm_one_liner'] = _llm_data.get('one_liner', '')
# ── end LLM gate ───────────────────────────────────────────────────
now = datetime.now()
sent_obj = None
try:
from changedetectionio.notification.handler import process_notification
# Fallback to system config if not set
if not n_object.get('notification_body') and datastore.data['settings']['application'].get('notification_body'):
n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
if not n_object.get('notification_title') and datastore.data['settings']['application'].get('notification_title'):
n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title')
if not n_object.get('notification_format') and datastore.data['settings']['application'].get('notification_format'):
n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format')
if n_object.get('notification_urls', {}):

View File

@@ -561,33 +561,31 @@ def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=Fals
)
else:
parser_config = None
if is_rss:
html_content = re.sub(r'<title([\s>])', r'<h1\1', html_content)
html_content = re.sub(r'</title>', r'</h1>', html_content)
else:
# Use BS4 html.parser to strip bloat — SPA's often dump 10MB+ of CSS/JS into <head>,
# causing inscriptis to silently give up. Regex-based stripping is unsafe because tags
# can appear inside JSON data attributes with JS-escaped closing tags (e.g. <\/script>),
# causing the regex to scan past the intended close and eat real page content.
from bs4 import BeautifulSoup
soup = BeautifulSoup(html_content, 'html.parser')
# Strip tags that inscriptis cannot render as meaningful text and which can be very large.
# svg/math: produce path-data/MathML garbage; canvas/iframe/template: no inscriptis handlers.
# video/audio/picture are kept — they may contain meaningful fallback text or captions.
for tag in soup.find_all(['head', 'script', 'style', 'noscript', 'svg',
'math', 'canvas', 'iframe', 'template']):
tag.decompose()
# Strip bloat in one pass, SPA's often dump 10Mb+ into the <head> for styles, which is not needed
# Causing inscriptis to silently exit when more than ~10MB is found.
# All we are doing here is converting the HTML to text, no CSS layout etc
# Use backreference (\1) to ensure opening/closing tags match (prevents <style> matching </svg> in CSS data URIs)
html_content = re.sub(r'<(style|script|svg|noscript)[^>]*>.*?</\1>|<(?:link|meta)[^>]*/?>|<!--.*?-->',
'', html_content, flags=re.DOTALL | re.IGNORECASE)
# SPAs often use <body style="display:none"> to hide content until JS loads.
# inscriptis respects CSS display rules, so strip hiding styles from the body tag.
body_tag = soup.find('body')
if body_tag and body_tag.get('style'):
style = body_tag['style']
if re.search(r'\b(?:display\s*:\s*none|visibility\s*:\s*hidden)\b', style, re.IGNORECASE):
logger.debug(f"html_to_text: Removing hiding styles from body tag (found: '{style}')")
del body_tag['style']
# SPAs often use <body style="display:none"> to hide content until JS loads
# inscriptis respects CSS display rules, so we need to remove these hiding styles
# to extract the actual page content
body_style_pattern = r'(<body[^>]*)\s+style\s*=\s*["\']([^"\']*\b(?:display\s*:\s*none|visibility\s*:\s*hidden)\b[^"\']*)["\']'
# Check if body has hiding styles that need to be fixed
body_match = re.search(body_style_pattern, html_content, flags=re.IGNORECASE)
if body_match:
from loguru import logger
logger.debug(f"html_to_text: Removing hiding styles from body tag (found: '{body_match.group(2)}')")
html_content = re.sub(body_style_pattern, r'\1', html_content, flags=re.IGNORECASE)
html_content = str(soup)
text_content = get_text(html_content, config=parser_config)
return text_content

View File

@@ -1,64 +0,0 @@
"""
changedetectionio.llm
~~~~~~~~~~~~~~~~~~~~~
LLM summary queue and workers.
Usage in flask_app.py
---------------------
import changedetectionio.llm as llm
# At module level alongside notification_q:
llm_summary_q = llm.create_queue()
# Inside changedetection_app(), after datastore is ready:
llm.start_workers(
app=app,
datastore=datastore,
llm_q=llm_summary_q,
n_workers=int(os.getenv("LLM_WORKERS", "1")),
)
Enqueueing a summary job (e.g. from the pluggy update_finalize hook)
---------------------------------------------------------------------
if changed_detected and not processing_exception:
llm_summary_q.put({
'uuid': watch_uuid,
'snapshot_id': snapshot_id,
'attempts': 0,
})
"""
import queue
import threading
from loguru import logger
def create_queue() -> queue.Queue:
"""Return a plain Queue for LLM summary jobs. No maxsize — jobs are small dicts."""
return queue.Queue()
def start_workers(app, datastore, llm_q: queue.Queue, n_workers: int = 1) -> None:
"""
Start N LLM summary worker threads.
Args:
app: Flask application instance (for app_context and exit event)
datastore: Application datastore
llm_q: Queue returned by create_queue()
n_workers: Number of parallel workers (default 1; increase for local Ollama)
"""
from changedetectionio.llm.queue_worker import llm_summary_runner
for i in range(n_workers):
threading.Thread(
target=llm_summary_runner,
args=(i, app, datastore, llm_q),
daemon=True,
name=f"LLMSummaryWorker-{i}",
).start()
logger.info(f"Started {n_workers} LLM summary worker(s)")

View File

@@ -1,104 +0,0 @@
"""
LLM plugin — provides settings tab and enqueues summary jobs on change detection.
Registered with the pluggy plugin manager at startup (flask_app.py).
The worker (llm/queue_worker.py) drains the queue asynchronously.
"""
from loguru import logger
from changedetectionio.pluggy_interface import hookimpl
def get_llm_settings(datastore):
"""Load LLM plugin settings with fallback to legacy datastore settings.
Tries the plugin settings file (llm.json) first.
Falls back to the old storage location in datastore.data['settings']['application']
for users upgrading from a version before LLM became a first-class plugin.
"""
from changedetectionio.pluggy_interface import load_plugin_settings
settings = load_plugin_settings(datastore.datastore_path, 'llm')
if settings.get('llm_connection') is not None:
return settings
# Legacy fallback: settings were stored in datastore application settings
app_settings = datastore.data['settings']['application']
connections_dict = app_settings.get('llm_connections') or {}
connections_list = [
{
'connection_id': k,
'name': v.get('name', ''),
'model': v.get('model', ''),
'api_key': v.get('api_key', ''),
'api_base': v.get('api_base', ''),
'tokens_per_minute': int(v.get('tokens_per_minute', 0) or 0),
'is_default': bool(v.get('is_default', False)),
}
for k, v in connections_dict.items()
]
return {
'llm_connection': connections_list,
'llm_summary_prompt': app_settings.get('llm_summary_prompt', ''),
}
def save_llm_settings(datastore, plugin_form):
"""Custom save handler — strips the ephemeral new_connection staging fields
so they are never persisted to llm.json."""
from changedetectionio.pluggy_interface import save_plugin_settings
data = {
'llm_connection': plugin_form.llm_connection.data,
'llm_summary_prompt': plugin_form.llm_summary_prompt.data or '',
'llm_diff_context_lines': plugin_form.llm_diff_context_lines.data or 2,
}
save_plugin_settings(datastore.datastore_path, 'llm', data)
class LLMQueuePlugin:
"""Enqueues LLM summary jobs on successful change detection and provides settings tab."""
def __init__(self, llm_q):
self.llm_q = llm_q
@hookimpl
def plugin_settings_tab(self):
from changedetectionio.llm.settings_form import LLMSettingsForm
return {
'plugin_id': 'llm',
'tab_label': 'LLM',
'form_class': LLMSettingsForm,
'template_path': 'settings-llm.html',
'save_fn': save_llm_settings,
}
@hookimpl
def update_finalize(self, update_handler, watch, datastore, processing_exception,
changed_detected=False, snapshot_id=None):
"""Queue an LLM summary job when a change was successfully detected."""
if not changed_detected or processing_exception or not snapshot_id:
return
if watch is None:
return
# Need ≥2 history entries — first entry has nothing to diff against
if watch.history_n < 2:
return
# Only queue when at least one LLM connection is configured
llm_settings = get_llm_settings(datastore)
has_connection = bool(
llm_settings.get('llm_connection')
or datastore.data['settings']['application'].get('llm_api_key') # legacy
or datastore.data['settings']['application'].get('llm_model') # legacy
or watch.get('llm_api_key')
or watch.get('llm_model')
)
if not has_connection:
return
uuid = watch.get('uuid')
self.llm_q.put({'uuid': uuid, 'snapshot_id': snapshot_id, 'attempts': 0})
logger.debug(f"LLM: queued summary for uuid={uuid} snapshot={snapshot_id}")

View File

@@ -1,544 +0,0 @@
import fcntl
import os
import queue
import re
import threading
import time
from datetime import datetime, timezone
from loguru import logger
from changedetectionio.llm.tokens import (
STRUCTURED_OUTPUT_INSTRUCTION,
parse_llm_response,
write_llm_data,
)
MAX_RETRIES = 5
RETRY_BACKOFF_BASE_SECONDS = 60 # 1m, 2m, 4m, 8m, 16m
# Token thresholds that control which summarisation strategy is used.
# Small diffs: single-pass summarise.
# Larger diffs: two-pass (enumerate all changes first, then compress).
# Very large diffs: map-reduce (chunk → enumerate per chunk → final synthesis).
TOKEN_SINGLE_PASS_THRESHOLD = 5000 # below this: one call
TOKEN_TWO_PASS_THRESHOLD = 15000 # below this: enumerate then summarise
TOKEN_CHUNK_SIZE = 5000 # tokens per map-reduce chunk
# ---------------------------------------------------------------------------
# Proactive token-bucket rate limiter — shared across all workers in process
# ---------------------------------------------------------------------------
class _RateLimitWait(Exception):
"""Raised when the bucket is empty; worker re-queues without incrementing attempts."""
def __init__(self, wait_seconds):
self.wait_seconds = wait_seconds
super().__init__(f"Rate limit: wait {wait_seconds:.1f}s")
class _TokenBucket:
"""Thread-safe continuous token bucket. tpm=0 means unlimited."""
def __init__(self, tpm):
self._lock = threading.Lock()
self._tpm = tpm
self._tokens = float(tpm) # start full
self._last_ts = time.monotonic()
def try_consume(self, n):
"""Consume n tokens. Returns (True, 0.0) on success or (False, wait_secs) if dry."""
if self._tpm == 0:
return True, 0.0
with self._lock:
now = time.monotonic()
elapsed = now - self._last_ts
self._tokens = min(self._tpm, self._tokens + elapsed * (self._tpm / 60.0))
self._last_ts = now
if self._tokens >= n:
self._tokens -= n
return True, 0.0
deficit = n - self._tokens
return False, deficit / (self._tpm / 60.0)
_rate_buckets = {}
_rate_buckets_lock = threading.Lock()
def _get_rate_bucket(conn_id, tpm):
"""Return (or lazily create) the shared _TokenBucket for this connection."""
with _rate_buckets_lock:
if conn_id not in _rate_buckets:
_rate_buckets[conn_id] = _TokenBucket(int(tpm or 0))
return _rate_buckets[conn_id]
def _parse_retry_after(exc):
"""Extract a retry-after delay (seconds) from a litellm RateLimitError."""
if hasattr(exc, 'retry_after') and exc.retry_after:
try:
return float(exc.retry_after)
except (TypeError, ValueError):
pass
m = re.search(r'(?:try again in|retry after)\s*([\d.]+)\s*s', str(exc), re.IGNORECASE)
return float(m.group(1)) + 1.0 if m else 60.0
def _read_snapshot(watch, snapshot_fname):
"""Read a snapshot file from disk, handling plain text and brotli compression."""
path = os.path.join(watch.data_dir, snapshot_fname)
if snapshot_fname.endswith('.br'):
import brotli
with open(path, 'rb') as f:
return brotli.decompress(f.read()).decode('utf-8', errors='replace')
else:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
return f.read()
def _append_llm_log(log_path, model, sent_tokens, recv_tokens, elapsed_ms):
"""Append one line to the datastore-level LLM activity log.
Line format (tab-separated, LF terminated):
ISO-8601-UTC fetched LLM via <model> sent=<N> recv=<N> ms=<N>
The file is flock-locked for the duration of the write so concurrent
workers don't interleave lines.
"""
ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] # ms precision
line = f"{ts}\tfetched LLM via {model}\tsent={sent_tokens}\trecv={recv_tokens}\tms={elapsed_ms}\n"
try:
with open(log_path, 'a', encoding='utf-8', newline='\n') as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
f.write(line)
f.flush()
finally:
fcntl.flock(f, fcntl.LOCK_UN)
except Exception as exc:
logger.warning(f"LLM log write failed: {exc}")
def _call_llm(model, messages, api_key=None, api_base=None, max_tokens=600, conn_id=None, tpm=0, log_path=None):
"""
Thin wrapper around litellm.completion.
Isolated as a named function so tests can mock.patch it without importing litellm.
Determinism settings
--------------------
temperature=0 — greedy decoding; same input produces the same output consistently.
seed=0 — passed through to providers that support it (OpenAI, some others)
for near-bit-identical reproducibility across calls.
Deliberately NOT set
--------------------
top_p — redundant at temperature=0 and can interact badly with some providers.
frequency_penalty / presence_penalty — would penalise the model for repeating specific
values (e.g. "$10 → $10") which is exactly wrong for change detection.
max_tokens — caller sets this based on the pass type:
enumerate pass needs more room than the final summary pass.
conn_id / tpm — optional rate limiting; when both are set, a proactive token-bucket
check is performed before calling the API. Raises _RateLimitWait if
the bucket is empty so the worker can re-queue without retrying.
log_path — when set, each call is appended to the datastore LLM activity log.
Returns the response text string.
"""
import litellm
# Proactive rate check (skipped when tpm=0 or conn_id is None)
if conn_id and tpm:
prompt_tokens = litellm.token_counter(model=model, messages=messages)
total_est = prompt_tokens + max_tokens
bucket = _get_rate_bucket(conn_id, tpm)
ok, wait = bucket.try_consume(total_est)
if not ok:
raise _RateLimitWait(wait)
kwargs = dict(
model=model,
messages=messages,
temperature=0,
seed=0,
max_tokens=max_tokens,
)
if api_key:
kwargs['api_key'] = api_key
if api_base:
kwargs['api_base'] = api_base
t0 = time.monotonic()
response = litellm.completion(**kwargs)
elapsed_ms = round((time.monotonic() - t0) * 1000)
if log_path:
usage = getattr(response, 'usage', None)
sent_tok = getattr(usage, 'prompt_tokens', 0) or 0
recv_tok = getattr(usage, 'completion_tokens', 0) or 0
_append_llm_log(log_path, model, sent_tok, recv_tok, elapsed_ms)
return response.choices[0].message.content.strip()
def _resolve_llm_connection(watch, datastore):
"""Return (model, api_key, api_base, conn_id, tpm) for the given watch.
Resolution order:
1. Watch-level connection_id pointing to a named entry in plugin settings.
2. The default entry in plugin settings (is_default=True).
3. Legacy flat fields on the watch or in global settings — backward compat.
4. Hard-coded fallback: gpt-4o-mini with no key / base.
"""
from changedetectionio.llm.plugin import get_llm_settings
from changedetectionio.llm.settings_form import sanitised_conn_id
llm_settings = get_llm_settings(datastore)
connections = llm_settings.get('llm_connection') or []
# 1. Watch-level override by explicit connection_id
watch_conn_id = watch.get('llm_connection_id')
if watch_conn_id:
for c in connections:
if c.get('connection_id') == watch_conn_id:
cid = sanitised_conn_id(c.get('connection_id', ''))
return (c.get('model', 'gpt-4o-mini'), c.get('api_key', ''), c.get('api_base', ''),
cid, int(c.get('tokens_per_minute', 0) or 0))
# 2. Global default connection
for c in connections:
if c.get('is_default'):
cid = sanitised_conn_id(c.get('connection_id', ''))
return (c.get('model', 'gpt-4o-mini'), c.get('api_key', ''), c.get('api_base', ''),
cid, int(c.get('tokens_per_minute', 0) or 0))
# 3. Legacy flat fields (backward compat)
app_settings = datastore.data['settings']['application']
model = watch.get('llm_model') or app_settings.get('llm_model', 'gpt-4o-mini')
api_key = watch.get('llm_api_key') or app_settings.get('llm_api_key', '')
api_base = watch.get('llm_api_base') or app_settings.get('llm_api_base', '')
return model, api_key, api_base, 'legacy', 0
SYSTEM_PROMPT = (
'You are a change detection assistant. '
'Be precise and factual. Never speculate. '
'Always use exact numbers, values, and quoted text when present in the diff. '
'If nothing meaningful changed, say so explicitly.'
)
def _build_context_header(watch, datastore):
"""Return a short multi-line string describing what this watch monitors.
Included lines (only when non-empty / non-redundant):
URL: <url>
Monitor: <user title or fetched page title> (omitted when same as URL)
Tags: <comma-separated tag titles> (omitted when none)
"""
url = watch.get('url', '')
title = watch.get('title', '') or watch.get('page_title', '')
lines = [f"URL: {url}"]
if title and title != url:
lines.append(f"Monitor: {title}")
tag_titles = []
for tag_uuid in (watch.get('tags') or []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid, {})
t = tag.get('title', '').strip()
if t:
tag_titles.append(t)
if tag_titles:
lines.append(f"Tags: {', '.join(tag_titles)}")
return '\n'.join(lines)
def _chunk_lines(lines, model, chunk_token_size):
"""Split lines into chunks that each fit within chunk_token_size tokens."""
import litellm
chunks, current, current_tokens = [], [], 0
for line in lines:
line_tokens = litellm.token_counter(model=model, text=line)
if current and current_tokens + line_tokens > chunk_token_size:
chunks.append('\n'.join(current))
current, current_tokens = [], 0
current.append(line)
current_tokens += line_tokens
if current:
chunks.append('\n'.join(current))
return chunks
def _enumerate_changes(diff_text, context_header, model, llm_kwargs):
"""
Pass 1 — ask the model to list every distinct change exhaustively, one per line.
Returns a plain-text list string.
This avoids compression decisions: the model just lists, it does not prioritise.
"""
messages = [
{'role': 'system', 'content': SYSTEM_PROMPT},
{
'role': 'user',
'content': (
f"{context_header}\n"
f"Diff:\n{diff_text}\n\n"
"List every distinct change you see, one item per line. "
"Be exhaustive — do not filter or prioritise. "
"Use exact values from the diff (prices, dates, counts, quoted text)."
),
},
]
# Enumerate pass needs more output room than the final summary
return _call_llm(model=model, messages=messages, max_tokens=1200, **llm_kwargs)
def _summarise_enumeration(enumerated, context_header, model, llm_kwargs, summary_instruction=None):
"""
Pass 2 — compress the exhaustive enumeration into the final output.
Operates on a small, structured input so nothing is lost that wasn't already listed.
summary_instruction overrides the default STRUCTURED_OUTPUT_INSTRUCTION when set.
"""
instruction = summary_instruction or (
"Now produce the final structured output for all of these changes.\n\n"
+ STRUCTURED_OUTPUT_INSTRUCTION
)
messages = [
{'role': 'system', 'content': SYSTEM_PROMPT},
{
'role': 'user',
'content': (
f"{context_header}\n"
f"All changes detected:\n{enumerated}\n\n"
+ instruction
),
},
]
return _call_llm(model=model, messages=messages, max_tokens=500, **llm_kwargs)
def process_llm_summary(item, datastore):
"""
Generate an LLM summary for a detected change and write {snapshot_id}-llm.txt.
item keys:
uuid - watch UUID
snapshot_id - the newer snapshot ID (md5 hex), maps to {snapshot_id}.txt[.br]
attempts - retry counter
Summarisation strategy (chosen by diff token count):
Small (< SINGLE_PASS_TOKEN_LIMIT): one call — enumerate + summarise together.
Medium (< TWO_PASS_TOKEN_LIMIT): two calls — enumerate all changes, then compress.
Large (≥ TWO_PASS_TOKEN_LIMIT): map-reduce — chunk → enumerate per chunk →
synthesise chunk enumerations → final summary.
The two-pass / map-reduce approach prevents lossiness: temperature=0 causes the model
to greedily commit to the most prominent change and drop the rest in a single pass.
Enumerating first forces comprehensive coverage before any compression happens.
Split into _call_llm / _write_summary so each step is independently patchable in tests.
"""
import difflib
import litellm
uuid = item['uuid']
snapshot_id = item['snapshot_id']
watch = datastore.data['watching'].get(uuid)
if not watch:
raise ValueError(f"Watch {uuid} not found")
# Find this snapshot and the one before it in history
history = watch.history
history_keys = list(history.keys())
try:
idx = next(
i for i, k in enumerate(history_keys)
if os.path.basename(history[k]).split('.')[0] == snapshot_id
)
except StopIteration:
raise ValueError(f"snapshot_id {snapshot_id} not found in history for watch {uuid}")
if idx == 0:
raise ValueError(f"snapshot_id {snapshot_id} is the first history entry — no prior to diff against")
before_text = _read_snapshot(watch, history[history_keys[idx - 1]])
current_text = _read_snapshot(watch, history[history_keys[idx]])
# Resolve model / credentials via connections table (with legacy flat-field fallback)
model, api_key, api_base, conn_id, tpm = _resolve_llm_connection(watch, datastore)
url = watch.get('url', '')
context_header = _build_context_header(watch, datastore)
llm_kwargs = {
'log_path': os.path.join(datastore.datastore_path, 'llm-log.txt'),
}
if api_key:
llm_kwargs['api_key'] = api_key
if api_base:
llm_kwargs['api_base'] = api_base
if conn_id:
llm_kwargs['conn_id'] = conn_id
if tpm:
llm_kwargs['tpm'] = tpm
# Use custom prompt / context-line setting if configured
from changedetectionio.llm.plugin import get_llm_settings
llm_settings = get_llm_settings(datastore)
custom_prompt = (llm_settings.get('llm_summary_prompt') or '').strip()
summary_instruction = custom_prompt if custom_prompt else (
"Analyse all changes in this diff.\n\n" + STRUCTURED_OUTPUT_INSTRUCTION
)
context_n = int(llm_settings.get('llm_diff_context_lines') or 2)
diff_lines = list(difflib.unified_diff(
before_text.splitlines(),
current_text.splitlines(),
lineterm='',
n=context_n,
))
diff_text = '\n'.join(diff_lines)
if not diff_text.strip():
logger.debug(f"LLM: no diff content for {uuid}/{snapshot_id}, skipping")
return
diff_tokens = litellm.token_counter(model=model, text=diff_text)
logger.debug(f"LLM: diff is {diff_tokens} tokens for {uuid}/{snapshot_id}")
if diff_tokens < TOKEN_SINGLE_PASS_THRESHOLD:
# Small diff — single call, model can see everything at once
messages = [
{'role': 'system', 'content': SYSTEM_PROMPT},
{
'role': 'user',
'content': (
f"{context_header}\n"
f"Diff:\n{diff_text}\n\n"
+ summary_instruction
),
},
]
raw = _call_llm(model=model, messages=messages, max_tokens=500, **llm_kwargs)
strategy = 'single'
elif diff_tokens < TOKEN_TWO_PASS_THRESHOLD:
# Medium diff — two-pass: enumerate exhaustively, then compress
enumerated = _enumerate_changes(diff_text, context_header, model, llm_kwargs)
raw = _summarise_enumeration(enumerated, context_header, model, llm_kwargs, summary_instruction)
strategy = 'two-pass'
else:
# Large diff — map-reduce: chunk → enumerate per chunk → synthesise
chunks = _chunk_lines(diff_lines, model, TOKEN_CHUNK_SIZE)
logger.debug(f"LLM: map-reduce over {len(chunks)} chunks for {uuid}/{snapshot_id}")
chunk_enumerations = []
for i, chunk in enumerate(chunks):
logger.debug(f"LLM: enumerating chunk {i+1}/{len(chunks)}")
chunk_enumerations.append(
_enumerate_changes(chunk, context_header, model, llm_kwargs)
)
combined = '\n'.join(chunk_enumerations)
raw = _summarise_enumeration(combined, context_header, model, llm_kwargs, summary_instruction)
strategy = 'map-reduce'
llm_data = parse_llm_response(raw)
write_llm_data(watch.data_dir, snapshot_id, llm_data)
logger.info(f"LLM tokens written for {uuid}/{snapshot_id} (strategy: {strategy}, tokens: {diff_tokens})")
def llm_summary_runner(worker_id, app, datastore, llm_q):
"""
Sync LLM summary worker — mirrors the notification_runner pattern.
One worker is the right default (LLM API rate limits constrain throughput
more than parallelism helps). Increase via LLM_WORKERS env var if using
a local Ollama endpoint with no rate limits.
Failed items are re-queued with exponential backoff (see MAX_RETRIES /
RETRY_BACKOFF_BASE_SECONDS). After MAX_RETRIES the item is dropped and
the failure is recorded on the watch.
"""
with app.app_context():
while not app.config.exit.is_set():
try:
item = llm_q.get(block=False)
except queue.Empty:
app.config.exit.wait(1)
continue
# Honour retry delay — if the item isn't due yet, put it back
# and sleep briefly rather than spinning.
next_retry_at = item.get('next_retry_at', 0)
if next_retry_at > time.time():
llm_q.put(item)
app.config.exit.wait(min(next_retry_at - time.time(), 5))
continue
uuid = item.get('uuid')
snapshot_id = item.get('snapshot_id')
attempts = item.get('attempts', 0)
logger.debug(f"LLM worker {worker_id} processing uuid={uuid} snapshot={snapshot_id} attempt={attempts}")
try:
process_llm_summary(item, datastore)
logger.info(f"LLM worker {worker_id} completed summary for uuid={uuid} snapshot={snapshot_id}")
except NotImplementedError:
# Silently drop until the processor is implemented
logger.debug(f"LLM worker {worker_id} skipping — processor not yet implemented")
except _RateLimitWait as rw:
# Proactive bucket empty — re-queue without counting as a failure
item['next_retry_at'] = time.time() + rw.wait_seconds
llm_q.put(item)
logger.info(
f"LLM worker {worker_id} rate-limited (proactive) for {rw.wait_seconds:.1f}s "
f"uuid={uuid}"
)
except Exception as e:
# Reactive: check if the API itself returned a rate-limit error
try:
import litellm as _litellm
if isinstance(e, _litellm.RateLimitError):
wait = _parse_retry_after(e)
item['next_retry_at'] = time.time() + wait
llm_q.put(item)
logger.warning(
f"LLM worker {worker_id} API rate limit for uuid={uuid}, "
f"retry in {wait:.1f}s"
)
continue
except ImportError:
pass
logger.error(f"LLM worker {worker_id} error for uuid={uuid} snapshot={snapshot_id}: {e}")
if attempts < MAX_RETRIES:
backoff = RETRY_BACKOFF_BASE_SECONDS * (2 ** attempts)
item['attempts'] = attempts + 1
item['next_retry_at'] = time.time() + backoff
llm_q.put(item)
logger.info(
f"LLM worker {worker_id} re-queued uuid={uuid} "
f"attempt={item['attempts']}/{MAX_RETRIES} retry_in={backoff}s"
)
else:
logger.error(
f"LLM worker {worker_id} gave up on uuid={uuid} snapshot={snapshot_id} "
f"after {MAX_RETRIES} attempts"
)
if uuid and uuid in datastore.data['watching']:
datastore.update_watch(
uuid=uuid,
update_obj={'last_error': f"LLM summary failed after {MAX_RETRIES} attempts: {e}"}
)

View File

@@ -1,139 +0,0 @@
import re
import uuid as _uuid
from flask_babel import lazy_gettext as _l
from wtforms import (
BooleanField,
FieldList,
Form,
FormField,
HiddenField,
IntegerField,
PasswordField,
SelectField,
StringField,
TextAreaField,
)
from wtforms.validators import Length, NumberRange, Optional
from changedetectionio.llm.tokens import STRUCTURED_OUTPUT_INSTRUCTION
# The built-in instruction appended after the diff — shown as placeholder text.
DEFAULT_SUMMARY_PROMPT = (
"Analyse all changes in this diff.\n\n"
+ STRUCTURED_OUTPUT_INSTRUCTION
)
# Allowed characters for a connection ID coming from the browser.
_CONN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
def sanitised_conn_id(raw):
"""Return raw if it looks like a safe identifier, otherwise a fresh UUID."""
s = (raw or '').strip()
return s if _CONN_ID_RE.match(s) else str(_uuid.uuid4())
class LLMConnectionEntryForm(Form):
"""Schema for a single LLM connection.
Declaring every field here is what prevents arbitrary key injection:
only these fields can ever reach the datastore from this form.
"""
connection_id = HiddenField()
name = StringField(_l('Name'), validators=[Optional(), Length(max=100)])
model = StringField(_l('Model string'), validators=[Optional(), Length(max=200)])
api_key = StringField(_l('API Key'), validators=[Optional(), Length(max=500)])
api_base = StringField(_l('API Endpoint'), validators=[Optional(), Length(max=500)])
tokens_per_minute = IntegerField(_l('Tokens/min'), validators=[Optional(), NumberRange(min=0, max=10_000_000)], default=0)
is_default = BooleanField(_l('Default'), validators=[Optional()])
class LLMNewConnectionForm(Form):
"""Staging fields for the 'Add a connection' UI.
These are read client-side by llm.js to build a new FieldList entry on click.
They are never used server-side — render_kw sets the id attributes llm.js
looks up with $('#llm-add-name') etc.
"""
preset = SelectField(
_l('Provider template'),
validate_choice=False,
# WTForms 3.x uses a dict for optgroups (has_groups() checks isinstance(choices, dict)).
# An empty-string key renders as <optgroup label=""> which browsers treat as ungrouped.
choices={
'': [('', '')],
_l('Cloud'): [
('openai-mini', 'OpenAI — gpt-4o-mini'),
('openai-4o', 'OpenAI — gpt-4o'),
('anthropic-haiku', 'Anthropic — claude-3-haiku'),
('anthropic-sonnet', 'Anthropic — claude-3-5-sonnet'),
('groq-8b', 'Groq — llama-3.1-8b-instant'),
('groq-70b', 'Groq — llama-3.3-70b-versatile'),
('gemini-flash', 'Google — gemini-1.5-flash'),
('mistral-small', 'Mistral — mistral-small'),
('deepseek', 'DeepSeek — deepseek-chat'),
('openrouter', 'OpenRouter (custom model)'),
],
_l('Local'): [
('ollama-llama', 'Ollama — llama3.1'),
('ollama-mistral', 'Ollama — mistral'),
('lmstudio', 'LM Studio'),
],
_l('Custom'): [
('custom', _l('Manual entry')),
],
},
render_kw={'id': 'llm-preset'},
)
name = StringField(_l('Name'),
render_kw={'id': 'llm-add-name', 'size': 30,
'autocomplete': 'off'})
model = StringField(_l('Model string'),
render_kw={'id': 'llm-add-model', 'size': 40,
'placeholder': 'gpt-4o-mini', 'autocomplete': 'off'})
api_key = PasswordField(_l('API Key'),
render_kw={'id': 'llm-add-key', 'size': 40,
'placeholder': 'sk-…', 'autocomplete': 'off'})
api_base = StringField(_l('API Endpoint'),
render_kw={'id': 'llm-add-base', 'size': 40,
'placeholder': 'http://localhost:11434', 'autocomplete': 'off'})
tokens_per_minute = IntegerField(_l('Tokens/min'), default=0,
render_kw={'id': 'llm-add-tpm', 'style': 'width: 8em;',
'min': '0', 'step': '1000'})
class LLMSettingsForm(Form):
"""WTForms form for the LLM settings tab.
llm_connection is a FieldList of LLMConnectionEntryForm entries.
llm.js emits individual hidden inputs (llm_connection-N-fieldname) on submit
instead of a JSON blob, so WTForms processes them through the declared schema.
"""
llm_connection = FieldList(FormField(LLMConnectionEntryForm), min_entries=0)
new_connection = FormField(LLMNewConnectionForm)
llm_diff_context_lines = IntegerField(
_l('Diff context lines'),
validators=[Optional(), NumberRange(min=0, max=20)],
default=2,
description=_l(
'Number of unchanged lines shown around each change in the diff. '
'More lines give the LLM more context but increase token usage. (default: 2)'
),
render_kw={'style': 'width: 5em;', 'min': '0', 'max': '20'},
)
llm_summary_prompt = TextAreaField(
_l('Summary prompt'),
validators=[Optional()],
description=_l(
'Override the instruction sent to the LLM after the diff. '
'Leave blank to use the built-in default (structured JSON output).'
),
render_kw={
'rows': 8,
'placeholder': DEFAULT_SUMMARY_PROMPT,
'class': 'pure-input-1',
},
)

View File

@@ -1,103 +0,0 @@
<script src="{{url_for('static_content', group='js', filename='llm.js')}}" defer></script>
<script>
var LLM_CONNECTIONS = (function () {
var list = {{ plugin_form.llm_connection.data|tojson }};
var out = {};
(list || []).forEach(function (c) { if (c && c.connection_id) out[c.connection_id] = c; });
return out;
}());
var LLM_I18N = {
noConnections: '{{ _("No connections configured yet.") }}',
setDefault: '{{ _("Set as default") }}',
remove: '{{ _("Remove") }}',
show: '{{ _("show") }}',
hide: '{{ _("hide") }}',
nameModelRequired: '{{ _("Name and Model string are required.") }}'
};
</script>
{# ── Configured connections table ──────────────────── #}
<fieldset>
<legend>{{ _('LLM Connections') }}</legend>
<table class="pure-table pure-table-horizontal llm-connections">
<thead>
<tr>
<th class="llm-col-def" title="{{ _('Default') }}">{{ _('Default') }}</th>
<th class="llm-col-name">{{ _('Name') }}</th>
<th class="llm-col-model">{{ _('Model') }}</th>
<th class="llm-col-key">{{ _('API Key') }}</th>
<th class="llm-col-tpm" title="{{ _('Tokens per minute limit (0 = unlimited)') }}">{{ _('TPM') }}</th>
<th class="llm-col-del"></th>
</tr>
</thead>
<tbody id="llm-connections-tbody">
</tbody>
</table>
</fieldset>
{# ── Add connection ─────────────────────────────────── #}
{% set nf = plugin_form.new_connection.form %}
<fieldset>
<legend>{{ _('Add a connection') }}</legend>
<div class="pure-control-group">
{{ nf.preset.label }}
{{ nf.preset() }}
</div>
<div class="pure-control-group">
{{ nf.name.label }}
{{ nf.name(placeholder=_('e.g. My OpenAI')) }}
</div>
<div class="pure-control-group">
{{ nf.model.label }}
{{ nf.model() }}
</div>
<div class="pure-control-group">
<label for="llm-add-key">
{{ _('API Key') }}
<span class="pure-form-message-inline">({{ _('leave blank for local') }})</span>
</label>
<div class="llm-key-wrap">
{{ nf.api_key() }}
<button type="button" id="llm-key-toggle" class="pure-button">{{ _('show') }}</button>
</div>
</div>
<div class="pure-control-group" id="llm-base-group" style="display:none">
<label for="llm-add-base">
{{ _('API Endpoint') }}
<span class="pure-form-message-inline">({{ _('optional') }})</span>
</label>
{{ nf.api_base() }}
</div>
<div class="pure-control-group">
<label for="llm-add-tpm">
{{ _('Tokens/min limit') }}
<span class="pure-form-message-inline">({{ _('0 = unlimited') }})</span>
</label>
{{ nf.tokens_per_minute() }}
</div>
<div class="pure-controls">
<button type="button" id="llm-btn-add" class="pure-button pure-button-primary">{{ _('+ Add connection') }}</button>
</div>
</fieldset>
{# ── Prompt configuration ────────────────────────────────── #}
<fieldset>
<legend>{{ _('Summary Prompt') }}</legend>
<div class="pure-control-group">
{{ plugin_form.llm_diff_context_lines.label }}
{{ plugin_form.llm_diff_context_lines() }}
<span class="pure-form-message-inline">
{{ _('Unchanged lines shown around each change in the diff sent to the LLM. More lines = more context but higher token cost. (default: 2)') }}
</span>
</div>
<div class="pure-control-group">
{{ render_field(plugin_form.llm_summary_prompt) }}
<span class="pure-form-message-inline">
{{ _('Instruction appended after the diff in every LLM call. Leave blank to use the built-in default (structured JSON output).') }}
</span>
</div>
</fieldset>

View File

@@ -1,197 +0,0 @@
"""
LLM notification token definitions and file I/O helpers.
All LLM data for a snapshot is stored under a dedicated subdirectory:
{data_dir}/llm/{snapshot_id}-llm.json
A plain-text {snapshot_id}-llm.txt is also written containing just the
summary field, for backward compatibility with any code that already reads it.
Token catalogue
---------------
llm_summary 1-3 sentence description of all changes, exact values.
llm_headline 5-8 word punchy title — ideal for the notification subject line.
llm_importance Numeric 1-10 significance score; enables routing rules like
"only escalate if llm_importance >= 8".
llm_sentiment Machine-readable: "positive", "negative", or "neutral".
Useful for trend tracking and coloured alert styling.
llm_one_liner Shortest useful summary — one sentence for SMS, Pushover,
and other character-limited channels.
"""
import os
import json
from loguru import logger
# ── Constants ──────────────────────────────────────────────────────────────
LLM_TOKEN_NAMES = (
'llm_summary',
'llm_headline',
'llm_importance',
'llm_sentiment',
'llm_one_liner',
)
# How long the notification runner waits for LLM data before giving up.
LLM_NOTIFICATION_RETRY_DELAY_SECONDS = int(os.getenv('LLM_NOTIFICATION_RETRY_DELAY', '10'))
LLM_NOTIFICATION_MAX_WAIT_ATTEMPTS = int(os.getenv('LLM_NOTIFICATION_MAX_WAIT', '18')) # 18 × 10s = 3 min
# JSON prompt fragment — embedded in the final summarisation call.
STRUCTURED_OUTPUT_INSTRUCTION = (
'Return ONLY a valid JSON object — no markdown fences, no extra text — using exactly these keys:\n'
'{"summary":"1-3 sentences covering ALL changes; use exact values from the diff.","headline":"5-8 word punchy title for this specific change","importance":7,"sentiment":"positive","one_liner":"One sentence for SMS/push character limits."}\n'
'importance: 1=trivial whitespace, 5=moderate content change, 10=critical price/availability change.\n'
'sentiment: "positive" (desirable for the user), "negative" (undesirable), or "neutral" (informational only).'
)
# ── File I/O ───────────────────────────────────────────────────────────────
def llm_subdir(data_dir: str) -> str:
"""Return the llm/ subdirectory path (does not create it)."""
return os.path.join(data_dir, 'llm')
def llm_json_path(data_dir: str, snapshot_id: str) -> str:
return os.path.join(llm_subdir(data_dir), f"{snapshot_id}-llm.json")
def llm_txt_path(data_dir: str, snapshot_id: str) -> str:
return os.path.join(llm_subdir(data_dir), f"{snapshot_id}-llm.txt")
def is_llm_data_ready(data_dir: str, snapshot_id: str) -> bool:
"""Return True if LLM data has been written for this snapshot."""
return os.path.exists(llm_json_path(data_dir, snapshot_id)) or \
os.path.exists(llm_txt_path(data_dir, snapshot_id))
def read_llm_tokens(data_dir: str, snapshot_id: str) -> dict:
"""
Read LLM token data for a snapshot.
Tries JSON first (new format), falls back to plain .txt (old format).
Returns an empty dict if no data is available yet.
"""
json_file = llm_json_path(data_dir, snapshot_id)
if os.path.exists(json_file):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return _normalise(data)
except Exception as exc:
logger.warning(f"LLM tokens: failed to read {json_file}: {exc}")
txt_file = llm_txt_path(data_dir, snapshot_id)
if os.path.exists(txt_file):
try:
with open(txt_file, 'r', encoding='utf-8') as f:
summary = f.read().strip()
return _normalise({'summary': summary, 'one_liner': summary[:200]})
except Exception as exc:
logger.warning(f"LLM tokens: failed to read {txt_file}: {exc}")
return {}
def write_llm_data(data_dir: str, snapshot_id: str, data: dict) -> str:
"""
Atomically write LLM data to the llm/ subdirectory.
Writes:
llm/{snapshot_id}-llm.json — full structured data (all tokens)
llm/{snapshot_id}-llm.txt — plain summary text (backward compat)
Returns the path of the JSON file.
"""
normalised = _normalise(data)
subdir = llm_subdir(data_dir)
os.makedirs(subdir, exist_ok=True)
json_file = llm_json_path(data_dir, snapshot_id)
_atomic_write_text(json_file, json.dumps(normalised, ensure_ascii=False))
txt_file = llm_txt_path(data_dir, snapshot_id)
_atomic_write_text(txt_file, normalised.get('summary', ''))
return json_file
def parse_llm_response(response: str) -> dict:
"""
Parse a structured JSON response from the LLM.
Tries strict JSON parse, then extracts from markdown code fences,
then a bare object search. Falls back to treating the whole response
as the 'summary' field if nothing parses.
"""
import re
text = response.strip()
# 1. Direct JSON parse
try:
obj = json.loads(text)
if isinstance(obj, dict):
return _normalise(obj)
except (json.JSONDecodeError, ValueError):
pass
# 2. Markdown code fence: ```json { ... } ```
m = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL)
if m:
try:
obj = json.loads(m.group(1))
if isinstance(obj, dict):
return _normalise(obj)
except (json.JSONDecodeError, ValueError):
pass
# 3. Bare JSON object anywhere in the response
m = re.search(r'\{[^{}]*\}', text, re.DOTALL)
if m:
try:
obj = json.loads(m.group(0))
if isinstance(obj, dict):
return _normalise(obj)
except (json.JSONDecodeError, ValueError):
pass
# 4. Fallback — treat entire response as summary
logger.debug("LLM response was not valid JSON — using raw text as summary")
return _normalise({'summary': text, 'one_liner': text[:200] if len(text) > 200 else text})
# ── Internal helpers ───────────────────────────────────────────────────────
def _normalise(data: dict) -> dict:
"""Return a clean token dict with all expected keys present."""
importance = data.get('importance')
if importance is not None:
try:
importance = max(1, min(10, int(float(importance))))
except (TypeError, ValueError):
importance = None
sentiment = str(data.get('sentiment', '')).lower().strip()
if sentiment not in ('positive', 'negative', 'neutral'):
sentiment = ''
return {
'summary': str(data.get('summary', '') or '').strip(),
'headline': str(data.get('headline', '') or '').strip(),
'importance': importance,
'sentiment': sentiment,
'one_liner': str(data.get('one_liner', '') or '').strip(),
}
def _atomic_write_text(path: str, text: str) -> None:
tmp = path + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)

View File

@@ -1009,31 +1009,14 @@ class model(EntityPersistenceMixin, watch_base):
def extra_notification_token_values(self):
from changedetectionio.llm.tokens import read_llm_tokens
history = self.history
if not history:
return {}
latest_fname = history[list(history.keys())[-1]]
snapshot_id = os.path.basename(latest_fname).split('.')[0] # always 32-char MD5
data = read_llm_tokens(self.data_dir, snapshot_id)
if not data:
return {}
return {
'llm_summary': data.get('summary', ''),
'llm_headline': data.get('headline', ''),
'llm_importance': data.get('importance'),
'llm_sentiment': data.get('sentiment', ''),
'llm_one_liner': data.get('one_liner', ''),
}
# Used for providing extra tokens
# return {'widget': 555}
return {}
def extra_notification_token_placeholder_info(self):
return [
('llm_summary', "LLM: 1-3 sentence summary of all changes with exact values"),
('llm_headline', "LLM: 5-8 word punchy title for this specific change"),
('llm_importance', "LLM: Significance score 1-10 (1=trivial, 10=critical)"),
('llm_sentiment', "LLM: Change sentiment — positive, negative, or neutral"),
('llm_one_liner', "LLM: One sentence for SMS/push character limits"),
]
# Used for providing extra tokens
# return [('widget', "Get widget amounts")]
return []
def extract_regex_from_all_history(self, regex):

View File

@@ -6,7 +6,6 @@ Extracted from update_worker.py to provide standalone notification functionality
for both sync and async workers
"""
import datetime
import os
import pytz
from loguru import logger
@@ -55,51 +54,16 @@ def _check_cascading_vars(datastore, var_name, watch):
return None
class FormattableTimestamp(str):
"""
A str subclass representing a formatted datetime. As a plain string it renders
with the default format, but can also be called with a custom format argument
in Jinja2 templates:
{{ change_datetime }} → '2024-01-15 10:30:00 UTC'
{{ change_datetime(format='%Y') }} → '2024'
{{ change_datetime(format='%Y-%m-%d') }} → '2024-01-15'
Being a str subclass means it is natively JSON serializable.
"""
_DEFAULT_FORMAT = '%Y-%m-%d %H:%M:%S %Z'
def __new__(cls, timestamp):
dt = datetime.datetime.fromtimestamp(int(timestamp), tz=pytz.UTC)
local_tz = datetime.datetime.now().astimezone().tzinfo
dt_local = dt.astimezone(local_tz)
try:
formatted = dt_local.strftime(cls._DEFAULT_FORMAT)
except Exception:
formatted = dt_local.isoformat()
instance = super().__new__(cls, formatted)
instance._dt = dt_local
return instance
def __call__(self, format=_DEFAULT_FORMAT):
try:
return self._dt.strftime(format)
except Exception:
return self._dt.isoformat()
# What is passed around as notification context, also used as the complete list of valid {{ tokens }}
class NotificationContextData(dict):
def __init__(self, initial_data=None, **kwargs):
# ValidateJinja2Template() validates against the keynames of this dict to check for valid tokens in the body (user submission)
super().__init__({
'base_url': None,
'change_datetime': FormattableTimestamp(time.time()),
'current_snapshot': None,
'diff': None,
'diff_clean': None,
'diff_added': None,
'diff_added_clean': None,
'diff_clean': None,
'diff_full': None,
'diff_full_clean': None,
'diff_patch': None,
@@ -108,24 +72,16 @@ class NotificationContextData(dict):
'diff_url': None,
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
'notification_timestamp': time.time(),
'prev_snapshot': None,
'preview_url': None,
'screenshot': None,
'triggered_text': None,
'timestamp_from': None,
'timestamp_to': None,
'triggered_text': None,
'uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', # Converted to 'watch_uuid' in create_notification_parameters
'watch_mime_type': None,
'watch_tag': None,
'watch_title': None,
'watch_url': 'https://WATCH-PLACE-HOLDER/',
# LLM-generated tokens (populated by notification_runner once LLM data is ready)
'llm_headline': None,
'llm_importance': None,
'llm_one_liner': None,
'llm_sentiment': None,
'llm_summary': None,
'watch_uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', # Converted to 'watch_uuid' in create_notification_parameters
})
# Apply any initial data passed in
@@ -147,7 +103,7 @@ class NotificationContextData(dict):
So we can test the output in the notification body
"""
for key in self.keys():
if key in ['uuid', 'time', 'watch_uuid', 'change_datetime']:
if key in ['uuid', 'time', 'watch_uuid']:
continue
rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12))
self[key] = rand_str
@@ -159,6 +115,24 @@ class NotificationContextData(dict):
super().__setitem__(key, value)
def timestamp_to_localtime(timestamp):
# Format the date using locale-aware formatting with timezone
dt = datetime.datetime.fromtimestamp(int(timestamp))
dt = dt.replace(tzinfo=pytz.UTC)
# Get local timezone-aware datetime
local_tz = datetime.datetime.now().astimezone().tzinfo
local_dt = dt.astimezone(local_tz)
# Format date with timezone - using strftime for locale awareness
try:
formatted_date = local_dt.strftime('%Y-%m-%d %H:%M:%S %Z')
except:
# Fallback if locale issues
formatted_date = local_dt.isoformat()
return formatted_date
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):
"""
Efficiently renders only the diff placeholders that are actually used in the notification text.
@@ -224,7 +198,7 @@ def set_basic_notification_vars(current_snapshot, prev_snapshot, watch, triggere
'current_snapshot': current_snapshot,
'prev_snapshot': prev_snapshot,
'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,
'change_datetime': FormattableTimestamp(timestamp_changed) if timestamp_changed else None,
'change_datetime': timestamp_to_localtime(timestamp_changed) if timestamp_changed else None,
'triggered_text': triggered_text,
'uuid': watch.get('uuid') if watch else None,
'watch_url': watch.get('url') if watch else None,
@@ -300,11 +274,6 @@ class NotificationService:
timestamp_changed=dates[date_index_to]))
if self.notification_q:
# Store snapshot_id hint so notification_runner can gate on LLM data readiness
if watch and len(dates) > 0:
latest_fname = watch.history.get(dates[date_index_to], '')
if latest_fname:
n_object['_llm_snapshot_id'] = os.path.basename(latest_fname).split('.')[0]
logger.debug("Queued notification for sending")
self.notification_q.put(n_object)
else:

View File

@@ -151,8 +151,7 @@ class ChangeDetectionSpec:
pass
@hookspec
def update_finalize(update_handler, watch, datastore, processing_exception,
changed_detected=False, snapshot_id=None):
def update_finalize(update_handler, watch, datastore, processing_exception):
"""Called after watch processing completes (success or failure).
This hook is called in the finally block after all processing is complete,
@@ -169,10 +168,6 @@ class ChangeDetectionSpec:
processing_exception: The exception from the main processing block, or None if successful.
This does NOT include cleanup exceptions - only exceptions from
the actual watch processing (fetch, diff, etc).
changed_detected: True when the processor detected a content change (default False).
snapshot_id: MD5 hex string of the new snapshot, matches the prefix of the history
filename (e.g. 'abc123…''abc123….txt[.br]'). None when no snapshot
was saved (first run, error, same content).
Returns:
None: This hook doesn't return a value
@@ -585,8 +580,7 @@ def apply_update_handler_alter(update_handler, watch, datastore):
return current_handler
def apply_update_finalize(update_handler, watch, datastore, processing_exception,
changed_detected=False, snapshot_id=None):
def apply_update_finalize(update_handler, watch, datastore, processing_exception):
"""Apply update_finalize hooks from all plugins.
Called in the finally block after watch processing completes, allowing plugins
@@ -597,8 +591,6 @@ def apply_update_finalize(update_handler, watch, datastore, processing_exception
watch: The watch dict that was processed (may be None)
datastore: The application datastore
processing_exception: The exception from processing, or None if successful
changed_detected: True when the processor detected a content change.
snapshot_id: MD5 hex string of the new snapshot, or None.
Returns:
None
@@ -609,9 +601,7 @@ def apply_update_finalize(update_handler, watch, datastore, processing_exception
update_handler=update_handler,
watch=watch,
datastore=datastore,
processing_exception=processing_exception,
changed_detected=changed_detected,
snapshot_id=snapshot_id,
processing_exception=processing_exception
)
except Exception as e:
# Don't let plugin errors crash the worker

View File

@@ -36,7 +36,7 @@ def _task(watch, update_handler):
def prepare_filter_prevew(datastore, watch_uuid, form_data):
'''Used by @app.route("/edit/<uuid_str:uuid>/preview-rendered", methods=['POST'])'''
'''Used by @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])'''
from changedetectionio import forms, html_tools
from changedetectionio.model.Watch import model as watch_model
from concurrent.futures import ThreadPoolExecutor

View File

@@ -198,6 +198,7 @@ def handle_watch_update(socketio, **kwargs):
except Exception as e:
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
def init_socketio(app, datastore):
"""Initialize SocketIO with the main Flask app"""
import platform

View File

@@ -44,12 +44,12 @@ data_sanity_test () {
cd ..
TMPDIR=$(mktemp -d)
PORT_N=$((5000 + RANDOM % (6501 - 5000)))
ALLOW_IANA_RESTRICTED_ADDRESSES=true ./changedetection.py -p $PORT_N -d $TMPDIR -u "https://localhost?test-url-is-sanity=1" &
./changedetection.py -p $PORT_N -d $TMPDIR -u "https://localhost?test-url-is-sanity=1" &
PID=$!
sleep 5
kill $PID
sleep 2
ALLOW_IANA_RESTRICTED_ADDRESSES=true ./changedetection.py -p $PORT_N -d $TMPDIR &
./changedetection.py -p $PORT_N -d $TMPDIR &
PID=$!
sleep 5
# On a restart the URL should still be there

View File

@@ -1,187 +0,0 @@
/* llm.js — LLM Connections management (settings page)
* Depends on: jQuery (global), LLM_CONNECTIONS + LLM_I18N injected by Jinja2 template.
*/
(function ($) {
'use strict';
// Provider presets: [value, label, model, api_base, tpm]
// tpm = tokens-per-minute limit (0 = unlimited / local).
// Defaults reflect free-tier or conservative tier-1 limits.
var LLM_PRESETS = [
['openai-mini', 'OpenAI — gpt-4o-mini', 'gpt-4o-mini', '', 200000],
['openai-4o', 'OpenAI — gpt-4o', 'gpt-4o', '', 30000],
['anthropic-haiku', 'Anthropic — claude-3-haiku', 'anthropic/claude-3-haiku-20240307', '', 100000],
['anthropic-sonnet', 'Anthropic — claude-3-5-sonnet', 'anthropic/claude-3-5-sonnet-20241022', '', 40000],
['groq-8b', 'Groq — llama-3.1-8b-instant', 'groq/llama-3.1-8b-instant', '', 6000],
['groq-70b', 'Groq — llama-3.3-70b-versatile', 'groq/llama-3.3-70b-versatile', '', 6000],
['gemini-flash', 'Google — gemini-1.5-flash', 'gemini/gemini-1.5-flash', '', 1000000],
['mistral-small', 'Mistral — mistral-small', 'mistral/mistral-small-latest', '', 500000],
['deepseek', 'DeepSeek — deepseek-chat', 'deepseek/deepseek-chat', '', 50000],
['openrouter', 'OpenRouter (custom model)', 'openrouter/', '', 20000],
['ollama-llama', 'Ollama — llama3.1 (local)', 'ollama/llama3.1', 'http://localhost:11434', 0],
['ollama-mistral', 'Ollama — mistral (local)', 'ollama/mistral', 'http://localhost:11434', 0],
['lmstudio', 'LM Studio (local)', 'openai/local', 'http://localhost:1234/v1', 0],
];
var presetMap = {};
$.each(LLM_PRESETS, function (_, p) { presetMap[p[0]] = p; });
function escHtml(s) {
return $('<div>').text(String(s)).html();
}
function maskKey(k) {
if (!k) return '<span style="color:var(--color-grey-700)">—</span>';
return escHtml(k.substring(0, 4)) + '••••';
}
// Emit WTForms FieldList hidden inputs (llm_connection-N-fieldname) so the
// server processes connections through the declared schema — no arbitrary keys.
function serialise() {
var $form = $('form.settings');
$form.find('input[data-llm-gen]').remove();
var ids = Object.keys(LLM_CONNECTIONS);
$.each(ids, function (i, id) {
var c = LLM_CONNECTIONS[id];
var prefix = 'llm_connection-' + i + '-';
var fields = {
connection_id: id,
name: c.name || '',
model: c.model || '',
api_key: c.api_key || '',
api_base: c.api_base || '',
tokens_per_minute: parseInt(c.tokens_per_minute || 0, 10)
};
$.each(fields, function (field, value) {
$('<input>').attr({ type: 'hidden', name: prefix + field, value: value, 'data-llm-gen': '1' }).appendTo($form);
});
// BooleanField: only emit when true (absence == false in WTForms)
if (c.is_default) {
$('<input>').attr({ type: 'hidden', name: prefix + 'is_default', value: 'y', 'data-llm-gen': '1' }).appendTo($form);
}
});
}
function renderTable() {
var $tbody = $('#llm-connections-tbody');
$tbody.empty();
var ids = Object.keys(LLM_CONNECTIONS);
if (!ids.length) {
$tbody.html('<tr class="llm-empty"><td colspan="6">' + escHtml(LLM_I18N.noConnections) + '</td></tr>');
return;
}
$.each(ids, function (_, id) {
var c = LLM_CONNECTIONS[id];
var tpm = parseInt(c.tokens_per_minute || 0, 10);
var tpmLabel = tpm ? tpm.toLocaleString() : '<span style="color:var(--color-grey-700)">∞</span>';
$tbody.append(
'<tr>' +
'<td class="llm-col-def">' +
'<input type="radio" class="llm-default-radio" name="llm_default_radio"' +
' title="' + escHtml(LLM_I18N.setDefault) + '"' +
(c.is_default ? ' checked' : '') +
' data-id="' + escHtml(id) + '">' +
'</td>' +
'<td class="llm-col-name">' + escHtml(c.name) + '</td>' +
'<td class="llm-col-model">' + escHtml(c.model) + '</td>' +
'<td class="llm-col-key">' + maskKey(c.api_key) + '</td>' +
'<td class="llm-col-tpm">' + tpmLabel + '</td>' +
'<td class="llm-col-del">' +
'<button type="button" class="llm-del"' +
' title="' + escHtml(LLM_I18N.remove) + '"' +
' data-id="' + escHtml(id) + '">×</button>' +
'</td>' +
'</tr>'
);
});
}
$(function () {
// Event delegation on tbody — survives re-renders
$('#llm-connections-tbody')
.on('change', '.llm-default-radio', function () {
var chosen = String($(this).data('id'));
$.each(LLM_CONNECTIONS, function (k) {
LLM_CONNECTIONS[k].is_default = (k === chosen);
});
serialise();
})
.on('click', '.llm-del', function () {
var id = String($(this).data('id'));
delete LLM_CONNECTIONS[id];
var remaining = Object.keys(LLM_CONNECTIONS);
if (remaining.length && !remaining.some(function (k) { return LLM_CONNECTIONS[k].is_default; })) {
LLM_CONNECTIONS[remaining[0]].is_default = true;
}
renderTable();
serialise();
});
function updateBaseVisibility() {
var val = $('#llm-preset').val();
var preset = presetMap[val];
var hasBase = preset ? !!preset[3] : (val === 'custom');
var show = (val === 'custom') || hasBase;
$('#llm-base-group').toggle(show);
}
// Preset dropdown pre-fills add form
$('#llm-preset').on('change', function () {
var val = $(this).val();
var p = presetMap[val];
if (p) {
$('#llm-add-name').val(p[1].replace(/\s*—.*/, '').trim());
$('#llm-add-model').val(p[2]);
$('#llm-add-base').val(p[3]);
$('#llm-add-tpm').val(p[4] !== undefined ? p[4] : 0);
$('#llm-add-key').val('');
}
updateBaseVisibility();
});
// Add connection
$('#llm-btn-add').on('click', function () {
var name = $.trim($('#llm-add-name').val());
var model = $.trim($('#llm-add-model').val());
var key = $.trim($('#llm-add-key').val());
var base = $.trim($('#llm-add-base').val());
var tpm = parseInt($('#llm-add-tpm').val(), 10) || 0;
if (!name || !model) {
alert(LLM_I18N.nameModelRequired);
return;
}
var id = 'llm-' + Date.now();
var isFirst = !Object.keys(LLM_CONNECTIONS).length;
LLM_CONNECTIONS[id] = {
name: name, model: model, api_key: key, api_base: base,
tokens_per_minute: tpm, is_default: isFirst
};
$('#llm-preset, #llm-add-name, #llm-add-model, #llm-add-key, #llm-add-base').val('');
$('#llm-add-tpm').val('0');
$('#llm-base-group').hide();
renderTable();
serialise();
});
// Show/hide API key visibility
$('#llm-key-toggle').on('click', function () {
var $inp = $('#llm-add-key');
if ($inp.attr('type') === 'password') {
$inp.attr('type', 'text');
$(this).text(LLM_I18N.hide);
} else {
$inp.attr('type', 'password');
$(this).text(LLM_I18N.show);
}
});
// Serialise connections to hidden field before form submit
$('form.settings').on('submit', serialise);
// Init
renderTable();
serialise();
});
}(jQuery));

View File

@@ -1,57 +0,0 @@
#llm {
// ── Key field wrapper — input + show/hide toggle inline ───────────────
.llm-key-wrap {
display: flex;
gap: 0.3em;
align-items: center;
input { flex: 1; min-width: 0; }
button {
flex: 0 0 auto;
}
}
// ── Pure-grid column padding consistency ──────────────────────────────
.pure-u-md-1-2 {
.pure-control-group {
padding-right: 1em;
}
}
// ── Connections table ─────────────────────────────────────────────────
table.llm-connections {
width: 100%;
.llm-col-def { width: 3em; text-align: center; }
.llm-col-name { font-weight: 500; }
.llm-col-model { font-family: monospace; font-size: 0.85em; color: var(--color-grey-400); }
.llm-col-key {
font-family: monospace; font-size: 0.82em; color: var(--color-grey-600);
max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.llm-col-del { width: 2.5em; text-align: center; }
.llm-del {
background: none;
border: none;
cursor: pointer;
color: var(--color-grey-600);
padding: 0.15em 0.4em;
border-radius: 3px;
font-size: 1.1em;
line-height: 1;
&:hover { color: var(--color-dark-red); background: #ffeaea; }
}
.llm-empty td {
text-align: center;
color: var(--color-grey-600);
padding: 1.8em;
font-style: italic;
font-size: 0.92em;
}
}
.llm-default-radio { cursor: pointer; }
}

View File

@@ -32,7 +32,6 @@
@use "parts/toast";
@use "parts/login_form";
@use "parts/tabs";
@use "parts/llm";
// Smooth transitions for theme switching
body,

File diff suppressed because one or more lines are too long

View File

@@ -728,11 +728,8 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
return False
if not is_safe_valid_url(url):
from flask import has_request_context
if has_request_context():
flash(gettext('Watch protocol is not permitted or invalid URL format'), 'error')
else:
logger.error(f"add_watch: URL '{url}' is not permitted or invalid, skipping.")
flash(gettext('Watch protocol is not permitted or invalid URL format'), 'error')
return None
# Check PAGE_WATCH_LIMIT if set

View File

@@ -44,14 +44,6 @@
<td><code>{{ '{{preview_url}}' }}</code></td>
<td>{{ _('The URL of the preview page generated by changedetection.io.') }}</td>
</tr>
<tr>
<td><code>{{ '{{change_datetime}}' }}</code></td>
<td>{{ _('Date/time of the change, accepts format=, change_datetime(format=\'%A\')\', default is \'%Y-%m-%d %H:%M:%S %Z\'') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_url}}' }}</code></td>
<td>{{ _('The URL of the diff output for the watch.') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_url}}' }}</code></td>
<td>{{ _('The URL of the diff output for the watch.') }}</td>

View File

@@ -13,10 +13,6 @@ import sys
# When test server is slow/unresponsive, workers fail fast instead of holding UUIDs for 45s
# This prevents exponential priority growth from repeated deferrals (priority × 10 each defer)
os.environ['DEFAULT_SETTINGS_REQUESTS_TIMEOUT'] = '5'
# Test server runs on localhost (127.0.0.1) which is a private IP.
# Allow it globally so all existing tests keep working; test_ssrf_protection
# uses monkeypatch to temporarily override this for its own assertions.
os.environ['ALLOW_IANA_RESTRICTED_ADDRESSES'] = 'true'
from changedetectionio.flask_app import init_app_secret, changedetection_app
from changedetectionio.tests.util import live_server_setup, new_live_server_setup
@@ -253,14 +249,13 @@ def prepare_test_function(live_server, datastore_path):
# CRITICAL: Get datastore and stop it from writing stale data
datastore = live_server.app.config.get('DATASTORE')
# Clear the queues before starting the test to prevent state leakage
from changedetectionio.flask_app import update_q, llm_summary_q
for q in (update_q, llm_summary_q):
while not q.empty():
try:
q.get_nowait()
except:
break
# Clear the queue before starting the test to prevent state leakage
from changedetectionio.flask_app import update_q
while not update_q.empty():
try:
update_q.get_nowait()
except:
break
# Add test helper methods to the app for worker management
def set_workers(count):

View File

@@ -1,260 +0,0 @@
"""
Tests for LLM summary queue, worker, and regenerate route.
Mocking strategy
----------------
- `_call_llm` is patched at the module level so no real LiteLLM/API calls are made.
- `_write_summary` is left un-patched so we can assert the file was actually written.
- `process_llm_summary` is called directly in unit tests (no worker thread needed).
"""
import os
import queue
import time
from unittest.mock import patch, MagicMock
import pytest
from flask import url_for
from changedetectionio.tests.util import set_original_response, set_modified_response, wait_for_all_checks
# ---------------------------------------------------------------------------
# Unit tests — process_llm_summary directly, no HTTP, no worker thread
# ---------------------------------------------------------------------------
class TestProcessLlmSummary:
def _make_watch_with_two_snapshots(self, client, datastore_path):
"""Helper: returns (datastore, uuid, snapshot_id) with 2 history entries."""
set_original_response(datastore_path=datastore_path)
datastore = client.application.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
uuid = datastore.add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
set_modified_response(datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
watch = datastore.data['watching'][uuid]
history_keys = list(watch.history.keys())
snapshot_id = os.path.basename(watch.history[history_keys[1]]).split('.')[0]
return datastore, uuid, snapshot_id
def test_writes_summary_file(self, client, live_server, datastore_path):
"""process_llm_summary writes {snapshot_id}-llm.txt when _call_llm succeeds."""
datastore, uuid, snapshot_id = self._make_watch_with_two_snapshots(client, datastore_path)
watch = datastore.data['watching'][uuid]
item = {'uuid': uuid, 'snapshot_id': snapshot_id, 'attempts': 0}
from changedetectionio.llm.queue_worker import process_llm_summary
with patch('changedetectionio.llm.queue_worker._call_llm', return_value='Price dropped from $10 to $8.') as mock_llm:
process_llm_summary(item, datastore)
assert mock_llm.called
summary_path = os.path.join(watch.data_dir, f"{snapshot_id}-llm.txt")
assert os.path.exists(summary_path), "Summary file was not written"
assert open(summary_path).read() == 'Price dropped from $10 to $8.'
def test_call_llm_uses_temperature_zero_and_seed(self, client, live_server, datastore_path):
"""_call_llm always passes temperature=0 and seed=0 to litellm for determinism."""
import litellm
from changedetectionio.llm.queue_worker import _call_llm
messages = [{'role': 'user', 'content': 'hello'}]
mock_response = MagicMock()
mock_response.choices[0].message.content = 'ok'
with patch('litellm.completion', return_value=mock_response) as mock_completion:
_call_llm(model='gpt-4o-mini', messages=messages)
call_kwargs = mock_completion.call_args.kwargs
assert call_kwargs['temperature'] == 0, "temperature must be 0"
assert call_kwargs['seed'] == 0, "seed must be 0 for reproducibility"
assert 'top_p' not in call_kwargs, "top_p must not be set (redundant at temp=0)"
assert 'frequency_penalty' not in call_kwargs, "frequency_penalty must not be set"
assert 'presence_penalty' not in call_kwargs, "presence_penalty must not be set"
def test_skips_first_history_entry(self, client, live_server, datastore_path):
"""process_llm_summary raises ValueError for the first history entry (no prior to diff)."""
set_original_response(datastore_path=datastore_path)
datastore = client.application.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
uuid = datastore.add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
watch = datastore.data['watching'][uuid]
history_keys = list(watch.history.keys())
first_fname = watch.history[history_keys[0]]
snapshot_id = os.path.basename(first_fname).split('.')[0]
item = {'uuid': uuid, 'snapshot_id': snapshot_id, 'attempts': 0}
from changedetectionio.llm.queue_worker import process_llm_summary
with pytest.raises(ValueError, match="first history entry"):
process_llm_summary(item, datastore)
def test_raises_for_unknown_watch(self, client, live_server, datastore_path):
"""process_llm_summary raises ValueError if the watch UUID doesn't exist."""
datastore = client.application.config['DATASTORE']
item = {'uuid': 'does-not-exist', 'snapshot_id': 'abc123', 'attempts': 0}
from changedetectionio.llm.queue_worker import process_llm_summary
with pytest.raises(ValueError, match="not found"):
process_llm_summary(item, datastore)
# ---------------------------------------------------------------------------
# Unit tests — worker retry logic, no HTTP
# ---------------------------------------------------------------------------
class TestWorkerRetry:
def test_requeues_on_failure_with_backoff(self, client, live_server, datastore_path):
"""Worker re-queues a failed item with incremented attempts and future next_retry_at."""
from changedetectionio.llm.queue_worker import MAX_RETRIES, RETRY_BACKOFF_BASE_SECONDS
llm_q = queue.Queue()
app = client.application
datastore = client.application.config['DATASTORE']
item = {'uuid': 'fake-uuid', 'snapshot_id': 'abc123', 'attempts': 0}
llm_q.put(item)
from changedetectionio.llm.queue_worker import process_llm_summary
with patch('changedetectionio.llm.queue_worker.process_llm_summary', side_effect=RuntimeError("API down")):
# Run one iteration manually (don't start the full runner thread)
from changedetectionio.llm import queue_worker
got = llm_q.get(block=False)
try:
queue_worker.process_llm_summary(got, datastore)
except Exception as e:
got['attempts'] += 1
got['next_retry_at'] = time.time() + RETRY_BACKOFF_BASE_SECONDS * (2 ** (got['attempts'] - 1))
llm_q.put(got)
assert llm_q.qsize() == 1
requeued = llm_q.get_nowait()
assert requeued['attempts'] == 1
assert requeued['next_retry_at'] > time.time()
def test_drops_after_max_retries(self, client, live_server, datastore_path):
"""Worker drops item and records last_error after MAX_RETRIES exhausted."""
set_original_response(datastore_path=datastore_path)
datastore = client.application.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
uuid = datastore.add_watch(url=test_url)
from changedetectionio.llm.queue_worker import MAX_RETRIES
item = {'uuid': uuid, 'snapshot_id': 'abc123', 'attempts': MAX_RETRIES}
llm_q = queue.Queue()
llm_q.put(item)
with patch('changedetectionio.llm.queue_worker.process_llm_summary', side_effect=RuntimeError("still down")):
from changedetectionio.llm import queue_worker
got = llm_q.get(block=False)
try:
queue_worker.process_llm_summary(got, datastore)
except Exception as e:
if got['attempts'] < MAX_RETRIES:
llm_q.put(got)
else:
datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
# Queue should be empty — item was dropped
assert llm_q.empty()
watch = datastore.data['watching'][uuid]
assert 'still down' in (watch.get('last_error') or '')
# ---------------------------------------------------------------------------
# Route tests — GET /edit/<uuid>/regenerate-llm-summaries
# ---------------------------------------------------------------------------
class TestRegenerateLlmSummariesRoute:
def test_queues_missing_summaries(self, client, live_server, datastore_path):
"""Route queues one item per history entry that lacks a -llm.txt file."""
set_original_response(datastore_path=datastore_path)
datastore = client.application.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
uuid = datastore.add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
set_modified_response(datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
watch = datastore.data['watching'][uuid]
assert watch.history_n >= 2
from changedetectionio.flask_app import llm_summary_q
res = client.get(
url_for('ui.ui_edit.watch_regenerate_llm_summaries', uuid=uuid),
follow_redirects=True,
)
assert res.status_code == 200
# history_n - 1 items queued (first entry skipped, no prior to diff)
expected = watch.history_n - 1
assert llm_summary_q.qsize() == expected
# Each item has the right shape
items = []
while not llm_summary_q.empty():
items.append(llm_summary_q.get_nowait())
for item in items:
assert item['uuid'] == uuid
assert item['attempts'] == 0
assert len(item['snapshot_id']) == 32 # MD5 hex
def test_skips_already_summarised_entries(self, client, live_server, datastore_path):
"""Route skips entries where {snapshot_id}-llm.txt already exists."""
set_original_response(datastore_path=datastore_path)
datastore = client.application.config['DATASTORE']
test_url = url_for('test_endpoint', _external=True)
uuid = datastore.add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
set_modified_response(datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
watch = datastore.data['watching'][uuid]
history_keys = list(watch.history.keys())
second_fname = watch.history[history_keys[1]]
snapshot_id = os.path.basename(second_fname).split('.')[0]
# Pre-write a summary file
summary_path = os.path.join(watch.data_dir, f"{snapshot_id}-llm.txt")
with open(summary_path, 'w') as f:
f.write('already done')
from changedetectionio.flask_app import llm_summary_q
client.get(
url_for('ui.ui_edit.watch_regenerate_llm_summaries', uuid=uuid),
follow_redirects=True,
)
# That entry should have been skipped — queue should be empty
assert llm_summary_q.empty()
def test_404_for_unknown_watch(self, client, live_server, datastore_path):
res = client.get(
url_for('ui.ui_edit.watch_regenerate_llm_summaries', uuid='does-not-exist'),
follow_redirects=False,
)
assert res.status_code == 404

View File

@@ -17,7 +17,6 @@ from changedetectionio.notification import (
)
from ..diff import HTML_CHANGED_STYLE
from ..model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
from ..notification_service import FormattableTimestamp
# Hard to just add more live server URLs when one test is already running (I think)
@@ -109,9 +108,6 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
"Diff Removed: {{diff_removed}}\n"
"Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n"
"Change datetime: {{change_datetime}}\n"
"Change datetime format: Weekday {{change_datetime(format='%A')}}\n"
"Change datetime format: {{change_datetime(format='%Y-%m-%dT%H:%M:%S%z')}}\n"
":-)",
"notification_screenshot": True,
"notification_format": 'text'}
@@ -139,6 +135,8 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
assert bytes(notification_url.encode('utf-8')) in res.data
assert bytes("New ChangeDetection.io Notification".encode('utf-8')) in res.data
## Now recheck, and it should have sent the notification
wait_for_all_checks(client)
set_modified_response(datastore_path=datastore_path)
@@ -174,23 +172,11 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
assert ":-)" in notification_submission
assert "New ChangeDetection.io Notification - {}".format(test_url) in notification_submission
assert test_url in notification_submission
assert ':-)' in notification_submission
# Check the attachment was added, and that it is a JPEG from the original PNG
notification_submission_object = json.loads(notification_submission)
assert notification_submission_object
import time
# Could be from a few seconds ago (when the notification was fired vs in this test checking), so check for any
times_possible = [str(FormattableTimestamp(int(time.time()) - i)) for i in range(15)]
assert any(t in notification_submission for t in times_possible)
txt = f"Weekday {FormattableTimestamp(int(time.time()))(format='%A')}"
assert txt in notification_submission
# We keep PNG screenshots for now
# IF THIS FAILS YOU SHOULD BE TESTING WITH ENV VAR REMOVE_REQUESTS_OLD_SCREENSHOTS=False
assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.png'

View File

@@ -1,5 +1,4 @@
import os
import pytest
from flask import url_for
@@ -580,131 +579,3 @@ def test_static_directory_traversal(client, live_server, measure_memory_usage, d
# Should get 403 (not authenticated) or 404 (file not found), not a path traversal
assert res.status_code in [403, 404]
def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
"""
SSRF protection: IANA-reserved/private IP addresses must be blocked by default.
Covers:
1. is_private_hostname() correctly classifies all reserved ranges
2. is_safe_valid_url() rejects private-IP URLs at add-time (env var off)
3. is_safe_valid_url() allows private-IP URLs when ALLOW_IANA_RESTRICTED_ADDRESSES=true
4. UI form rejects private-IP URLs and shows the standard error message
5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch)
6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass)
conftest.py sets ALLOW_IANA_RESTRICTED_ADDRESSES=true globally so the test
server (localhost) keeps working for all other tests. monkeypatch temporarily
overrides it to 'false' here, and is automatically restored after the test.
"""
from unittest.mock import patch, MagicMock
from changedetectionio.validate_url import is_safe_valid_url, is_private_hostname
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
# Clear any URL results cached while the env var was 'true'
is_safe_valid_url.cache_clear()
# ------------------------------------------------------------------
# 1. is_private_hostname() — unit tests across all reserved ranges
# ------------------------------------------------------------------
private_hosts = [
'127.0.0.1', # loopback
'10.0.0.1', # RFC 1918
'172.16.0.1', # RFC 1918
'192.168.1.1', # RFC 1918
'169.254.169.254', # link-local / AWS metadata endpoint
'::1', # IPv6 loopback
'fc00::1', # IPv6 unique local
'fe80::1', # IPv6 link-local
]
for host in private_hosts:
assert is_private_hostname(host), f"{host} should be identified as private/reserved"
for host in ['8.8.8.8', '1.1.1.1']:
assert not is_private_hostname(host), f"{host} should be identified as public"
# ------------------------------------------------------------------
# 2. is_safe_valid_url() blocks private-IP URLs (env var off)
# ------------------------------------------------------------------
blocked_urls = [
'http://127.0.0.1/',
'http://10.0.0.1/',
'http://172.16.0.1/',
'http://192.168.1.1/',
'http://169.254.169.254/',
'http://169.254.169.254/latest/meta-data/iam/security-credentials/',
'http://[::1]/',
'http://[fc00::1]/',
'http://[fe80::1]/',
]
for url in blocked_urls:
assert not is_safe_valid_url(url), f"{url} should be blocked by is_safe_valid_url"
# ------------------------------------------------------------------
# 3. ALLOW_IANA_RESTRICTED_ADDRESSES=true bypasses the block
# ------------------------------------------------------------------
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
is_safe_valid_url.cache_clear()
assert is_safe_valid_url('http://127.0.0.1/'), \
"Private IP should be allowed when ALLOW_IANA_RESTRICTED_ADDRESSES=true"
# Restore the block for the remaining assertions
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
is_safe_valid_url.cache_clear()
# ------------------------------------------------------------------
# 4. UI form rejects private-IP URLs
# ------------------------------------------------------------------
for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']:
res = client.post(
url_for('ui.ui_views.form_quick_watch_add'),
data={'url': url, 'tags': ''},
follow_redirects=True
)
assert b'Watch protocol is not permitted or invalid URL format' in res.data, \
f"UI should reject {url}"
# ------------------------------------------------------------------
# 5. Fetch-time DNS-rebinding check in the requests fetcher
# Simulates: URL passed add-time validation with a public IP, but
# by fetch time DNS has been rebound to a private IP.
# ------------------------------------------------------------------
from changedetectionio.content_fetchers.requests import fetcher as RequestsFetcher
f = RequestsFetcher()
with patch('changedetectionio.content_fetchers.requests.is_private_hostname', return_value=True):
with pytest.raises(Exception, match='private/reserved'):
f._run_sync(
url='http://example.com/',
timeout=5,
request_headers={},
request_body=None,
request_method='GET',
)
# ------------------------------------------------------------------
# 6. Redirect-to-private-IP blocked (open-redirect SSRF bypass)
# Public host returns a 302 pointing at an IANA-reserved address.
# ------------------------------------------------------------------
mock_redirect = MagicMock()
mock_redirect.is_redirect = True
mock_redirect.status_code = 302
mock_redirect.headers = {'Location': 'http://169.254.169.254/latest/meta-data/'}
def _private_only_for_redirect(hostname):
# Initial host is "public"; the redirect target is private
return hostname in {'169.254.169.254', '10.0.0.1', '172.16.0.1',
'192.168.0.1', '127.0.0.1', '::1'}
with patch('changedetectionio.content_fetchers.requests.is_private_hostname',
side_effect=_private_only_for_redirect):
with patch('requests.Session.request', return_value=mock_redirect):
with pytest.raises(Exception, match='Redirect blocked'):
f._run_sync(
url='http://example.com/',
timeout=5,
request_headers={},
request_body=None,
request_method='GET',
)

View File

@@ -453,175 +453,6 @@ class TestHtmlToText(unittest.TestCase):
def test_script_with_closing_tag_in_string_does_not_eat_content(self):
"""
Script tag containing </script> inside a JS string must not prematurely end the block.
This is the classic regex failure mode: the old pattern would find the first </script>
inside the JS string literal and stop there, leaving the tail of the script block
(plus any following content) exposed as raw text. BS4 parses the HTML correctly.
"""
html = '''<html><body>
<p>Before script</p>
<script>
var html = "<div>foo<\\/script><p>bar</p>";
var also = 1;
</script>
<p>AFTER SCRIPT</p>
</body></html>'''
text = html_to_text(html)
assert 'Before script' in text
assert 'AFTER SCRIPT' in text
# Script internals must not leak
assert 'var html' not in text
assert 'var also' not in text
def test_content_sandwiched_between_multiple_body_scripts(self):
"""Content between multiple script/style blocks in the body must all survive."""
html = '''<html><body>
<script>var a = 1;</script>
<p>CONTENT A</p>
<style>.x { color: red; }</style>
<p>CONTENT B</p>
<script>var b = 2;</script>
<p>CONTENT C</p>
<style>.y { color: blue; }</style>
<p>CONTENT D</p>
</body></html>'''
text = html_to_text(html)
for label in ['CONTENT A', 'CONTENT B', 'CONTENT C', 'CONTENT D']:
assert label in text, f"'{label}' was eaten by script/style stripping"
assert 'var a' not in text
assert 'var b' not in text
assert 'color: red' not in text
assert 'color: blue' not in text
def test_unicode_and_international_content_preserved(self):
"""Non-ASCII content (umlauts, CJK, soft hyphens) must survive stripping."""
html = '''<html><body>
<style>.x{color:red}</style>
<p>German: Aus\xadge\xadbucht! — ANMELDUNG — Fan\xadday 2026</p>
<p>Chinese: \u6ce8\u518c</p>
<p>Japanese: \u767b\u9332</p>
<p>Korean: \ub4f1\ub85d</p>
<p>Emoji: \U0001f4e2</p>
<script>var x = 1;</script>
</body></html>'''
text = html_to_text(html)
assert 'ANMELDUNG' in text
assert '\u6ce8\u518c' in text # Chinese
assert '\u767b\u9332' in text # Japanese
assert '\ub4f1\ub85d' in text # Korean
def test_style_with_type_attribute_is_stripped(self):
"""<style type="text/css"> (with type attribute) must be stripped just like bare <style>."""
html = '''<html><body>
<style type="text/css">.important { display: none; }</style>
<p>VISIBLE CONTENT</p>
</body></html>'''
text = html_to_text(html)
assert 'VISIBLE CONTENT' in text
assert '.important' not in text
assert 'display: none' not in text
def test_ldjson_script_is_stripped(self):
"""<script type="application/ld+json"> must be stripped — raw JSON must not appear as text."""
html = '''<html><body>
<script type="application/ld+json">
{"@type": "Product", "name": "Widget", "price": "9.99"}
</script>
<p>PRODUCT PAGE</p>
</body></html>'''
text = html_to_text(html)
assert 'PRODUCT PAGE' in text
assert '@type' not in text
assert '"price"' not in text
def test_inline_svg_is_stripped_entirely(self):
"""
Inline SVG elements in the body are stripped by BS4 before passing to inscriptis.
SVGs can be huge (icon libraries, data visualisations) and produce garbage path-data
text. The old regex code explicitly stripped <svg>; the BS4 path must do the same.
"""
html = '''<html><body>
<p>Before SVG</p>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M14 5L7 12L14 19Z" fill="none"/>
<circle cx="12" cy="12" r="10"/>
</svg>
<p>After SVG</p>
</body></html>'''
text = html_to_text(html)
assert 'Before SVG' in text
assert 'After SVG' in text
assert 'M14 5L7' not in text, "SVG path data should not appear in text output"
assert 'viewBox' not in text, "SVG attributes should not appear in text output"
def test_tag_inside_json_data_attribute_does_not_eat_content(self):
"""
Tags inside JSON data attributes with JS-escaped closing tags must not eat real content.
Real-world case: Elementor/JetEngine WordPress widgets embed HTML (including SVG icons)
inside JSON data attributes like data-slider-atts. The HTML inside is JS-escaped, so
closing tags appear as <\\/svg> rather than </svg>.
The old regex approach would find <svg> inside the attribute value, then fail to find
<\/svg> as a matching close tag, and scan forward to the next real </svg> in the DOM —
eating tens of kilobytes of actual page content in the process.
"""
html = '''<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<div class="slider" data-slider-atts="{&quot;prevArrow&quot;:&quot;<i class=\\&quot;icon\\&quot;><svg width=\\&quot;24\\&quot; height=\\&quot;24\\&quot; viewBox=\\&quot;0 0 24 24\\&quot; xmlns=\\&quot;http:\\/\\/www.w3.org\\/2000\\/svg\\&quot;><path d=\\&quot;M14 5L7 12L14 19\\&quot;\\/><\\/svg><\\/i>&quot;}">
</div>
<div class="content">
<h1>IMPORTANT CONTENT</h1>
<p>This text must not be eaten by the tag-stripping logic.</p>
</div>
<svg><circle cx="50" cy="50" r="40"/></svg>
</body>
</html>'''
text = html_to_text(html)
assert 'IMPORTANT CONTENT' in text, (
"Content after a JS-escaped tag in a data attribute was incorrectly stripped. "
"The tag-stripping logic is matching <tag> inside attribute values and scanning "
"forward to the next real closing tag in the DOM."
)
assert 'This text must not be eaten' in text
def test_script_inside_json_data_attribute_does_not_eat_content(self):
"""Same issue as above but with <script> embedded in a data attribute with JS-escaped closing tag."""
html = '''<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<div data-config="{&quot;template&quot;:&quot;<script type=\\&quot;text\\/javascript\\&quot;>var x=1;<\\/script>&quot;}">
</div>
<div>
<h1>MUST SURVIVE</h1>
<p>Real content after the data attribute with embedded script tag.</p>
</div>
<script>var real = 1;</script>
</body>
</html>'''
text = html_to_text(html)
assert 'MUST SURVIVE' in text, (
"Content after a JS-escaped <script> in a data attribute was incorrectly stripped."
)
assert 'Real content after the data attribute' in text
if __name__ == '__main__':
# Can run this file directly for quick testing
unittest.main()

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-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: cs\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,116 +34,34 @@ msgstr ""
msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "Backups"
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr "A backup is running!"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr "Mb"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr "No backups found."
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr "Vytvořit zálohu"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr "Odstranit zálohy"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr ""
@@ -202,14 +120,6 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX a Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -296,16 +206,6 @@ msgstr "Znovu zkontrolovat čas (minuty)"
msgid "Import"
msgstr "IMPORTOVAT"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
@@ -391,10 +291,6 @@ msgstr "API"
msgid "RSS"
msgstr "RSS"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "Backups"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr "Čas a datum"
@@ -411,6 +307,10 @@ msgstr "Info"
msgid "Default recheck time for all watches, current system minimum is"
msgstr "Výchozí čas opětovné kontroly pro všechny monitory, aktuální systémové minimum je"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr "sekundy"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr "Více informací"
@@ -766,10 +666,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Verze Pythonu:"
@@ -1023,6 +919,10 @@ msgstr ""
msgid "Incorrect confirmation text."
msgstr "Žádné informace"
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1627,10 +1527,6 @@ msgstr "Odpověď typu serveru"
msgid "Download latest HTML snapshot"
msgstr "Stáhněte si nejnovější HTML snímek"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr "Smazat monitory?"
@@ -1888,66 +1784,6 @@ msgstr "v '%(title)s'"
msgid "Not yet"
msgstr "Ještě ne"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr "sekundy"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr ""
@@ -3042,18 +2878,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -3267,6 +3091,3 @@ msgstr "Hlavní nastavení"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Tip: Můžete také přidat „sdílené“ monitory."
#~ msgid "Marking watches as viewed in background..."
#~ 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-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-14 03:57+0100\n"
"Last-Translator: \n"
"Language: de\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,118 +34,36 @@ msgstr "Backup läuft im Hintergrund, bitte in ein paar Minuten erneut versuchen
msgid "Backups were deleted."
msgstr "Backups wurden gelöscht."
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "Backups"
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr "Ein Backup läuft!"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
"Hier können Sie ein neues Backup herunterladen und anfordern. Sobald ein Backup abgeschlossen ist, wird es unten "
"aufgelistet."
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr "Mb"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr "Keine Backups gefunden."
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr "Backup erstellen"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr "Backups entfernen"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr "Es werden 5.000 der ersten URLs aus Ihrer Liste importiert, der Rest kann erneut importiert werden."
@@ -204,14 +122,6 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX & Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -300,16 +210,6 @@ msgstr "Nachprüfzeit (Minuten)"
msgid "Import"
msgstr "IMPORT"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr "Passwortschutz entfernt."
@@ -395,10 +295,6 @@ msgstr "API"
msgid "RSS"
msgstr "RSS"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "Backups"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr "Uhrzeit und Datum"
@@ -415,6 +311,10 @@ msgstr "Info"
msgid "Default recheck time for all watches, current system minimum is"
msgstr "Standardmäßige Überprüfungszeit für alle Observationen, derzeitiges Systemminimum ist"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr "Sekunden"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr "Weitere Informationen"
@@ -776,10 +676,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python-Version:"
@@ -1039,6 +935,10 @@ msgstr ""
msgid "Incorrect confirmation text."
msgstr "Falscher Bestätigungstext"
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1665,10 +1565,6 @@ msgstr "Antwort vom Servertyp"
msgid "Download latest HTML snapshot"
msgstr "Laden Sie den neuesten HTML-Snapshot herunter"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr "Überwachung löschen?"
@@ -1930,66 +1826,6 @@ msgstr "in '%(title)s'"
msgid "Not yet"
msgstr "Noch nicht"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr "Sekunden"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr "Bereits angemeldet"
@@ -3091,18 +2927,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -3382,6 +3206,3 @@ msgstr "Haupteinstellungen"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen."
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-12 16:33+0100\n"
"Last-Translator: British English Translation Team\n"
"Language: en_GB\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,116 +34,34 @@ msgstr ""
msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr ""
@@ -202,14 +120,6 @@ msgstr ""
msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -294,16 +204,6 @@ msgstr ""
msgid "Import"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
@@ -389,10 +289,6 @@ msgstr ""
msgid "RSS"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr ""
@@ -409,6 +305,10 @@ msgstr ""
msgid "Default recheck time for all watches, current system minimum is"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr ""
@@ -762,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -1019,6 +915,10 @@ msgstr ""
msgid "Incorrect confirmation text."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1623,10 +1523,6 @@ msgstr ""
msgid "Download latest HTML snapshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr ""
@@ -1884,66 +1780,6 @@ msgstr ""
msgid "Not yet"
msgstr ""
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr ""
@@ -3038,18 +2874,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -3212,6 +3036,3 @@ msgstr ""
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-12 16:37+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en_US\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,116 +34,34 @@ msgstr ""
msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr ""
@@ -202,14 +120,6 @@ msgstr ""
msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -294,16 +204,6 @@ msgstr ""
msgid "Import"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
@@ -389,10 +289,6 @@ msgstr ""
msgid "RSS"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr ""
@@ -409,6 +305,10 @@ msgstr ""
msgid "Default recheck time for all watches, current system minimum is"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr ""
@@ -762,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -1019,6 +915,10 @@ msgstr ""
msgid "Incorrect confirmation text."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1623,10 +1523,6 @@ msgstr ""
msgid "Download latest HTML snapshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr ""
@@ -1884,66 +1780,6 @@ msgstr ""
msgid "Not yet"
msgstr ""
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr ""
@@ -3038,18 +2874,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -3212,6 +3036,3 @@ msgstr ""
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""
#~ msgid "Marking watches as viewed in background..."
#~ 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-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,116 +34,34 @@ msgstr "Sauvegarde en cours de création en arrière-plan, revenez dans quelques
msgid "Backups were deleted."
msgstr "Les sauvegardes ont été supprimées."
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "SAUVEGARDES"
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr "Une sauvegarde est en cours !"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr "Mo"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr "Aucune sauvegarde trouvée."
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr "Créer sauvegarde"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr "Supprimer sauvegardes"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr "Importation de 5 000 des premières URL de votre liste, le reste peut être importé à nouveau."
@@ -204,14 +122,6 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX et Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -296,16 +206,6 @@ msgstr "Temps de revérification (minutes)"
msgid "Import"
msgstr "IMPORTER"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
@@ -391,10 +291,6 @@ msgstr "API"
msgid "RSS"
msgstr "RSS"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "SAUVEGARDES"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr "Heure et date"
@@ -411,6 +307,10 @@ msgstr "Info"
msgid "Default recheck time for all watches, current system minimum is"
msgstr "Heure de revérification par défaut pour tous les moniteurs, le minimum actuel du système est"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr "secondes"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr "Plus d'informations"
@@ -766,10 +666,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Version Python :"
@@ -1023,6 +919,10 @@ msgstr ""
msgid "Incorrect confirmation text."
msgstr "Aucune information"
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1629,10 +1529,6 @@ msgstr "Réponse du type de serveur"
msgid "Download latest HTML snapshot"
msgstr "Télécharger le dernier instantané HTML"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr "Supprimer les montres ?"
@@ -1890,66 +1786,6 @@ msgstr "dans '%(title)s'"
msgid "Not yet"
msgstr "Pas encore"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr "secondes"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr "Déjà connecté"
@@ -3050,18 +2886,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -3275,6 +3099,3 @@ msgstr "Paramètres principaux"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Astuce : Vous pouvez également ajouter des montres « partagées »."
#~ msgid "Marking watches as viewed in background..."
#~ 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-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: it\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,116 +34,34 @@ msgstr "Backup in creazione in background, riprova tra qualche minuto."
msgid "Backups were deleted."
msgstr "I backup sono stati eliminati."
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "Backup"
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr "Un backup è in esecuzione!"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr "MB"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr "Nessun backup trovato."
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr "Crea backup"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr "Rimuovi backup"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr "Importazione delle prime 5.000 URL dalla tua lista, il resto può essere importato di nuovo."
@@ -204,14 +122,6 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX & Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -296,16 +206,6 @@ msgstr "Tempo di ricontrollo (minuti)"
msgid "Import"
msgstr "Importa"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
@@ -391,10 +291,6 @@ msgstr "API"
msgid "RSS"
msgstr "RSS"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "Backup"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr "Data e ora"
@@ -411,6 +307,10 @@ msgstr "Info"
msgid "Default recheck time for all watches, current system minimum is"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr "secondi"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr ""
@@ -764,10 +664,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -1021,6 +917,10 @@ msgstr ""
msgid "Incorrect confirmation text."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1625,10 +1525,6 @@ msgstr ""
msgid "Download latest HTML snapshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr ""
@@ -1886,66 +1782,6 @@ msgstr ""
msgid "Not yet"
msgstr "Non ancora"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr "secondi"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr "Già autenticato"
@@ -3040,18 +2876,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -3247,6 +3071,3 @@ msgstr "Impostazioni principali"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""
#~ msgid "Marking watches as viewed in background..."
#~ 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-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: ko\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,116 +34,34 @@ msgstr ""
msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "백업"
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr "백업이 실행 중입니다!"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr "MB"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr "백업을 찾을 수 없습니다."
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr "백업 생성"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr "백업 삭제"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr ""
@@ -202,14 +120,6 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX 및 와체테"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -294,16 +204,6 @@ msgstr "재확인 시간(분)"
msgid "Import"
msgstr "수입"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
@@ -389,10 +289,6 @@ msgstr "API"
msgid "RSS"
msgstr "RSS"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "백업"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr "시간 및 날짜"
@@ -409,6 +305,10 @@ msgstr "정보"
msgid "Default recheck time for all watches, current system minimum is"
msgstr "모든 시계의 기본 재확인 시간, 현재 시스템 최소값은 다음과 같습니다."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr "초"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr "추가 정보"
@@ -762,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "파이썬 버전:"
@@ -1019,6 +915,10 @@ msgstr ""
msgid "Incorrect confirmation text."
msgstr "잘못된 확인 텍스트."
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1623,10 +1523,6 @@ msgstr "서버 유형 응답"
msgid "Download latest HTML snapshot"
msgstr "최신 HTML 스냅샷 다운로드"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr "시계를 삭제하시겠습니까?"
@@ -1884,66 +1780,6 @@ msgstr "'%(title)s'에서"
msgid "Not yet"
msgstr "아직 아님"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr "초"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr ""
@@ -3038,18 +2874,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -3368,6 +3192,3 @@ msgstr "기본 설정"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "팁: '공유' 시계를 추가할 수도 있습니다."
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""

View File

@@ -6,16 +6,16 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.53.6\n"
"Project-Id-Version: changedetection.io 0.52.9\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -33,121 +33,40 @@ msgstr ""
msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} Imported from list in {:.2f}s, {} Skipped."
msgstr ""
@@ -160,6 +79,7 @@ msgid "JSON structure looks invalid, was it broken?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
msgstr ""
@@ -168,18 +88,22 @@ msgid "Unable to read export XLSX file, something wrong with the file?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "Error processing row number {}, URL value was incorrect, row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} imported from Wachete .xlsx in {:.2f}s"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} imported from custom .xlsx in {:.2f}s"
msgstr ""
@@ -195,14 +119,6 @@ msgstr ""
msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -287,25 +203,17 @@ msgstr ""
msgid "Import"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Worker count adjusted: {}"
msgstr ""
@@ -314,6 +222,7 @@ msgid "Dynamic worker adjustment not supported for sync workers"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Error adjusting workers: {}"
msgstr ""
@@ -379,10 +288,6 @@ msgstr ""
msgid "RSS"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr ""
@@ -399,6 +304,10 @@ msgstr ""
msgid "Default recheck time for all watches, current system minimum is"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr ""
@@ -752,10 +661,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -777,6 +682,7 @@ msgid "Clear Snapshot History"
msgstr ""
#: changedetectionio/blueprint/tags/__init__.py
#, python-brace-format
msgid "The tag \"{}\" already exists"
msgstr ""
@@ -937,46 +843,57 @@ msgid "RSS Feed for this watch"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches deleted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches paused"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches unpaused"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches updated"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches muted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches un-muted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches queued for rechecking"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches errors cleared"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches cleared/reset."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches set to use default notification settings"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches were tagged"
msgstr ""
@@ -985,6 +902,7 @@ msgid "Watch not found"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Cleared snapshot history for watch {}"
msgstr ""
@@ -997,6 +915,11 @@ msgid "Incorrect confirmation text."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
msgstr ""
@@ -1017,10 +940,12 @@ msgid "Queued 1 watch for rechecking."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Queued {} watches for rechecking ({} already queued or running)."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Queued {} watches for rechecking."
msgstr ""
@@ -1029,6 +954,7 @@ msgid "Queueing watches for rechecking in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Could not share, something went wrong while communicating with the share server - {}"
msgstr ""
@@ -1049,18 +975,22 @@ msgid "No watches to edit"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "No watch with the UUID {} found."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Switched to mode - {}."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
@@ -1592,10 +1522,6 @@ msgstr ""
msgid "Download latest HTML snapshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr ""
@@ -1645,6 +1571,7 @@ msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc
msgstr ""
#: changedetectionio/blueprint/ui/views.py
#, python-brace-format
msgid "Warning, URL {} already exists"
msgstr ""
@@ -1657,6 +1584,7 @@ msgid "Watch added."
msgstr ""
#: changedetectionio/blueprint/watchlist/__init__.py
#, python-brace-format
msgid "displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>"
msgstr ""
@@ -1851,66 +1779,6 @@ msgstr ""
msgid "Not yet"
msgstr ""
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr ""
@@ -2457,10 +2325,12 @@ msgid "Not enough history to compare. Need at least 2 snapshots."
msgstr ""
#: changedetectionio/processors/image_ssim_diff/difference.py
#, python-brace-format
msgid "Failed to load screenshots: {}"
msgstr ""
#: changedetectionio/processors/image_ssim_diff/difference.py
#, python-brace-format
msgid "Failed to calculate diff: {}"
msgstr ""
@@ -2586,6 +2456,7 @@ msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
@@ -2594,6 +2465,7 @@ msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
@@ -3001,18 +2873,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
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-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-18 21:31+0800\n"
"Last-Translator: 吾爱分享 <admin@wuaishare.cn>\n"
"Language: zh\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,116 +34,34 @@ msgstr "备份正在后台生成,请几分钟后再查看。"
msgid "Backups were deleted."
msgstr "备份已删除。"
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "备份"
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr "备份正在运行!"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr "在此可下载并请求新的备份,备份完成后会在下方列表中显示。"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr "MB"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr "未找到备份。"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr "创建备份"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr "删除备份"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr "仅导入列表前 5,000 个 URL其余可稍后继续导入。"
@@ -202,14 +120,6 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX 与 Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr "每行输入一个 URL可在 URL 后用空格追加标签,标签以逗号 (,) 分隔:"
@@ -294,16 +204,6 @@ msgstr "复检间隔(分钟)"
msgid "Import"
msgstr "导入"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr "已移除密码保护。"
@@ -389,10 +289,6 @@ msgstr "API"
msgid "RSS"
msgstr "RSS"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "备份"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr "时间与日期"
@@ -409,6 +305,10 @@ msgstr "信息"
msgid "Default recheck time for all watches, current system minimum is"
msgstr "所有监控项的默认复检间隔,当前系统最小值为"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr "秒"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr "更多信息"
@@ -762,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr "带认证的 SOCKS5 代理仅支持“明文请求”抓取器,其他抓取器请改为白名单 IP"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python 版本:"
@@ -1019,6 +915,10 @@ msgstr "历史清理已在后台开始"
msgid "Incorrect confirmation text."
msgstr "确认文本不正确。"
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr "正在后台将监控项标记为已读..."
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1623,10 +1523,6 @@ msgstr "服务器类型响应"
msgid "Download latest HTML snapshot"
msgstr "下载最新的 HTML 快照"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr "删除监控项?"
@@ -1884,66 +1780,6 @@ msgstr "(“%(title)s”中"
msgid "Not yet"
msgstr "尚未"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr "秒"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr "已登录"
@@ -3038,18 +2874,6 @@ msgstr "每行单独处理(可理解为每行都是“或”)"
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr "注意:使用正则时请用斜杠 / 包裹,例如:"
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr "匹配的文本会在文本快照中被忽略(仍可见但不会触发变更)"
@@ -3197,6 +3021,3 @@ msgstr "主设置"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "提示:你也可以添加“共享”的监控项。"
#~ msgid "Marking watches as viewed in background..."
#~ 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-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: 2026-01-15 12:00+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh_Hant_TW\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -34,116 +34,34 @@ msgstr "正在背景建立備份,請稍後再回來查看。"
msgid "Backups were deleted."
msgstr "備份已刪除。"
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "備份"
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr "備份正在執行中!"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr "您可以在此下載並請求建立新備份,備份完成後將顯示於下方。"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr "MB"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr "找不到備份。"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr "建立備份"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr "移除備份"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr "正在匯入清單中的前 5,000 個 URL其餘的可以再次匯入。"
@@ -202,14 +120,6 @@ msgstr "Distill.io"
msgid ".XLSX & Wachete"
msgstr ".XLSX 和 Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr "每行輸入一個 URL可選用空格分隔後為每個 URL 新增標籤,標籤間用逗號 (,) 分隔:"
@@ -294,16 +204,6 @@ msgstr "複查時間(分鐘)"
msgid "Import"
msgstr "匯入"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr "密碼保護已移除。"
@@ -389,10 +289,6 @@ msgstr "API"
msgid "RSS"
msgstr "RSS"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr "備份"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr "時間與日期"
@@ -409,6 +305,10 @@ msgstr "資訊"
msgid "Default recheck time for all watches, current system minimum is"
msgstr "所有監測任務的預設複查時間,目前系統最小值為"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr "秒"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr "更多資訊"
@@ -762,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python 版本:"
@@ -1019,6 +915,10 @@ msgstr ""
msgid "Incorrect confirmation text."
msgstr "確認文字不正確。"
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
@@ -1623,10 +1523,6 @@ msgstr "伺服器類型回應"
msgid "Download latest HTML snapshot"
msgstr "下載最新 HTML 快照"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr "刪除監測任務?"
@@ -1884,66 +1780,6 @@ msgstr "於 '%(title)s'"
msgid "Not yet"
msgstr "還沒有"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr "秒"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr "已經登入"
@@ -3038,18 +2874,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -3326,6 +3150,3 @@ msgstr "主設定"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "提示:您也可以新增「共享」監測任務。"
#~ msgid "Marking watches as viewed in background..."
#~ msgstr ""

View File

@@ -1,5 +1,3 @@
import ipaddress
import socket
from functools import lru_cache
from loguru import logger
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
@@ -58,23 +56,6 @@ def normalize_url_encoding(url):
return url
def is_private_hostname(hostname):
"""Return True if hostname resolves to an IANA-restricted (private/reserved) IP address.
Fails closed: unresolvable hostnames return True (block them).
Never cached — callers that need fresh DNS resolution (e.g. at fetch time) can call
this directly without going through the lru_cached is_safe_valid_url().
"""
try:
for info in socket.getaddrinfo(hostname, None):
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
except socket.gaierror:
return True
return False
@lru_cache(maxsize=10000)
def is_safe_valid_url(test_url):
from changedetectionio import strtobool
@@ -138,12 +119,4 @@ def is_safe_valid_url(test_url):
logger.warning(f'URL f"{test_url}" failed validation, aborting.')
return False
# Block IANA-restricted (private/reserved) IP addresses unless explicitly allowed.
# This is an add-time check; fetch-time re-validation in requests.py handles DNS rebinding.
if not strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
parsed = urlparse(test_url)
if parsed.hostname and is_private_hostname(parsed.hostname):
logger.warning(f'URL "{test_url}" resolves to a private/reserved IP address, aborting.')
return False
return True

View File

@@ -518,8 +518,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# (cleanup may delete these variables, but plugins need the original references)
finalize_handler = update_handler # Capture now, before cleanup deletes it
finalize_watch = watch # Capture now, before any modifications
finalize_changed_detected = locals().get('changed_detected', False)
finalize_snapshot_id = (locals().get('update_obj') or {}).get('previous_md5') or ''
# Call quit() as backup (Puppeteer/Playwright have internal cleanup, but this acts as safety net)
try:
@@ -560,9 +558,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
update_handler=finalize_handler,
watch=finalize_watch,
datastore=datastore,
processing_exception=processing_exception,
changed_detected=finalize_changed_detected,
snapshot_id=finalize_snapshot_id,
processing_exception=processing_exception
)
except Exception as finalize_error:
logger.error(f"Worker {worker_id} error in finalize hook: {finalize_error}")

View File

@@ -1,21 +1,22 @@
# eventlet>=0.38.0 # Removed - replaced with threading mode for better Python 3.12+ compatibility
feedgen~=1.0
feedparser~=6.0 # For parsing RSS/Atom feeds
flask-compress
# 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers)
flask-login>=0.6.3
flask-paginate
flask-socketio>=5.6.1,<6 # Re #3910
flask>=3.1,<4
flask_cors # For the Chrome extension to operate
flask_restful
flask_cors # For the Chrome extension to operate
# janus # No longer needed - using pure threading.Queue for multi-loop support
flask_wtf~=1.2
flask~=3.1
flask-socketio~=5.6.0
python-socketio~=5.16.1
python-engineio~=4.13.1
inscriptis~=2.2
python-engineio>=4.9.0,<5
python-socketio>=5.11.0,<6
pytz
timeago~=1.0
validators~=0.35
werkzeug==3.1.6
# Set these versions together to avoid a RequestsDependencyWarning
# >= 2.26 also adds Brotli support if brotli is installed
@@ -97,8 +98,12 @@ pytest ~=9.0
pytest-flask ~=1.3
pytest-mock ~=3.15
# Anything 4.0 and up but not 5.0
jsonschema ~= 4.26
# OpenAPI validation support
openapi-core[flask] ~= 0.22
openapi-core[flask] >= 0.19.0
loguru
@@ -120,7 +125,8 @@ greenlet >= 3.0.3
# Default SOCKETIO_MODE=threading is recommended for better compatibility
gevent
referencing # Don't pin — jsonschema-path (required by openapi-core>=0.18) caps referencing<0.37.0, so pinning 0.37.0 forces openapi-core back to 0.17.2. Revisit once jsonschema-path>=0.3.5 relaxes the cap.
# Previously pinned for flask_expects_json (removed 2026-02). Unpinning for now.
referencing
# For conditions
panzi-json-logic
@@ -151,10 +157,3 @@ blinker
pytest-xdist
litellm
# pydantic-core >=2.41 imports typing_extensions.Sentinel, which is absent in the
# system-installed typing_extensions on many Linux distros (e.g. Ubuntu 22/24).
# When the system path leaks into sys.path before the venv, the system copy is
# cached first and the import fails at runtime inside the LLM worker thread.
pydantic-core<2.41
pydantic<2.12