mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-04-11 13:38:02 +00:00
Compare commits
6 Commits
llm
...
backup-res
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1b048f961 | ||
|
|
9ba645d4cc | ||
|
|
e6c0d538e6 | ||
|
|
e2fffc36e4 | ||
|
|
b9a2f781ac | ||
|
|
76abb4ab71 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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'''
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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', {}):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
@@ -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}")
|
||||
@@ -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}"}
|
||||
)
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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="{"prevArrow":"<i class=\\"icon\\"><svg width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http:\\/\\/www.w3.org\\/2000\\/svg\\"><path d=\\"M14 5L7 12L14 19\\"\\/><\\/svg><\\/i>"}">
|
||||
</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="{"template":"<script type=\\"text\\/javascript\\">var x=1;<\\/script>"}">
|
||||
</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()
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Binary file not shown.
@@ -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 "正在后台将监控项标记为已读..."
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user