Compare commits

..

3 Commits

Author SHA1 Message Date
dgtlmoon b886b0eafc APi test 2026-02-16 20:19:00 +01:00
dgtlmoon 56b25fa19a Adding config 2026-02-16 20:10:57 +01:00
dgtlmoon b9d2c52e12 Adding nginx reverse proxy test 2026-02-16 20:05:24 +01:00
82 changed files with 544 additions and 4584 deletions
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
- name: Build a binary wheel and a source tarball
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: python-package-distributions
path: dist/
@@ -34,7 +34,7 @@ jobs:
- build
steps:
- name: Download all the dists
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: python-package-distributions
path: dist/
@@ -93,7 +93,7 @@ jobs:
steps:
- name: Download all the dists
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: python-package-distributions
path: dist/
@@ -71,7 +71,7 @@ jobs:
docker save test-changedetectionio -o /tmp/test-changedetectionio.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp/test-changedetectionio.tar
@@ -88,7 +88,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -116,7 +116,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -165,14 +165,14 @@ jobs:
- name: Store test artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
path: output-logs
- name: Store CLI test output
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: test-cdio-cli-opts-output-py${{ env.PYTHON_VERSION }}
path: cli-opts-output.txt
@@ -188,7 +188,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -230,7 +230,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -270,7 +270,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -306,7 +306,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -334,7 +334,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -460,10 +460,10 @@ jobs:
exit 1
fi
if echo "$BODY" | grep -q 'OK'; then
echo "✓ Watch updated successfully (HTTP 200, response: OK)"
if echo "$BODY" | grep -q '"ok": *true'; then
echo "✓ Watch updated successfully (HTTP 200, ok: true)"
else
echo "ERROR: Expected response 'OK', got: $BODY"
echo "ERROR: Response missing 'ok: true'"
echo "Response: $BODY"
exit 1
fi
@@ -504,7 +504,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -544,7 +544,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -574,7 +574,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -598,7 +598,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -643,7 +643,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download Docker image artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
path: /tmp
@@ -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
@@ -820,7 +820,7 @@ jobs:
- name: Upload upgrade test logs
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: upgrade-test-logs-py${{ env.PYTHON_VERSION }}
path: /tmp/upgrade-test.log
+1 -1
View File
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.54.1'
__version__ = '0.53.2'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
-21
View File
@@ -1,21 +0,0 @@
import functools
from flask import make_response
from flask_restful import Resource
@functools.cache
def _get_spec_yaml():
"""Build and cache the merged spec as a YAML string (only serialized once per process)."""
import yaml
from changedetectionio.api import build_merged_spec_dict
return yaml.dump(build_merged_spec_dict(), default_flow_style=False, allow_unicode=True)
class Spec(Resource):
def get(self):
"""Return the merged OpenAPI spec including all registered processor extensions."""
return make_response(
_get_spec_yaml(),
200,
{'Content-Type': 'application/yaml'}
)
+12 -1
View File
@@ -17,7 +17,7 @@ class Tag(Resource):
self.update_q = kwargs['update_q']
# Get information about a single tag
# curl http://localhost:5000/api/v1/tag/<uuid_str:uuid>
# curl http://localhost:5000/api/v1/tag/<string:uuid>
@auth.check_token
@validate_openapi_request('getTag')
def get(self, uuid):
@@ -97,6 +97,17 @@ class Tag(Resource):
# Delete the tag, and any tag reference
del self.datastore.data['settings']['application']['tags'][uuid]
# Delete tag.json file if it exists
import os
tag_dir = os.path.join(self.datastore.datastore_path, uuid)
tag_json = os.path.join(tag_dir, "tag.json")
if os.path.exists(tag_json):
try:
os.unlink(tag_json)
logger.info(f"Deleted tag.json for tag {uuid}")
except Exception as e:
logger.error(f"Failed to delete tag.json for tag {uuid}: {e}")
# Remove tag from all watches
for watch_uuid, watch in self.datastore.data['watching'].items():
if watch.get('tags') and uuid in watch['tags']:
+2 -2
View File
@@ -57,7 +57,7 @@ class Watch(Resource):
self.update_q = kwargs['update_q']
# Get information about a single watch, excluding the history list (can be large)
# curl http://localhost:5000/api/v1/watch/<uuid_str:uuid>
# curl http://localhost:5000/api/v1/watch/<string:uuid>
# @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK"
# ?recheck=true
@auth.check_token
@@ -217,7 +217,7 @@ class WatchHistory(Resource):
self.datastore = kwargs['datastore']
# Get a list of available history for a watch by UUID
# curl http://localhost:5000/api/v1/watch/<uuid_str:uuid>/history
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history
@auth.check_token
@validate_openapi_request('getWatchHistory')
def get(self, uuid):
+24 -80
View File
@@ -3,18 +3,29 @@ from flask import request, abort
from loguru import logger
@functools.cache
def build_merged_spec_dict():
def get_openapi_spec():
"""Lazy load OpenAPI spec and dependencies only when validation is needed."""
import os
import yaml # Lazy import - only loaded when API validation is actually used
from openapi_core import OpenAPI # Lazy import - saves ~10.7 MB on startup
spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')
if not os.path.exists(spec_path):
# Possibly for pip3 packages
spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')
with open(spec_path, 'r', encoding='utf-8') as f:
spec_dict = yaml.safe_load(f)
_openapi_spec = OpenAPI.from_dict(spec_dict)
return _openapi_spec
@functools.cache
def get_openapi_schema_dict():
"""
Load the base OpenAPI spec and merge in any per-processor api.yaml extensions.
Get the raw OpenAPI spec dictionary for schema access.
Each processor can provide an api.yaml file alongside its __init__.py that defines
additional schemas (e.g., processor_config_restock_diff). These are merged into
WatchBase.properties so the spec accurately reflects what the API accepts.
Plugin processors (via pluggy) are also supported - they just need an api.yaml
next to their processor module.
Returns the merged dict (cached - do not mutate the returned value).
Used by Import endpoint to validate and convert query parameters.
Returns the YAML dict directly (not the OpenAPI object).
"""
import os
import yaml
@@ -24,59 +35,7 @@ def build_merged_spec_dict():
spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')
with open(spec_path, 'r', encoding='utf-8') as f:
spec_dict = yaml.safe_load(f)
try:
from changedetectionio.processors import find_processors, get_parent_module
for module, proc_name in find_processors():
parent = get_parent_module(module)
if not parent or not hasattr(parent, '__file__'):
continue
api_yaml_path = os.path.join(os.path.dirname(parent.__file__), 'api.yaml')
if not os.path.exists(api_yaml_path):
continue
with open(api_yaml_path, 'r', encoding='utf-8') as f:
proc_spec = yaml.safe_load(f)
# Merge schemas
proc_schemas = proc_spec.get('components', {}).get('schemas', {})
spec_dict['components']['schemas'].update(proc_schemas)
# Inject processor_config_{name} into WatchBase if the schema is defined
schema_key = f'processor_config_{proc_name}'
if schema_key in proc_schemas:
spec_dict['components']['schemas']['WatchBase']['properties'][schema_key] = {
'$ref': f'#/components/schemas/{schema_key}'
}
# Append x-code-samples from processor paths into existing path operations
for path, path_item in proc_spec.get('paths', {}).items():
if path not in spec_dict.get('paths', {}):
continue
for method, operation in path_item.items():
if method not in spec_dict['paths'][path]:
continue
if 'x-code-samples' in operation:
existing = spec_dict['paths'][path][method].get('x-code-samples', [])
spec_dict['paths'][path][method]['x-code-samples'] = existing + operation['x-code-samples']
except Exception as e:
logger.warning(f"Failed to merge processor API specs: {e}")
return spec_dict
@functools.cache
def get_openapi_spec():
"""Lazy load OpenAPI spec and dependencies only when validation is needed."""
from openapi_core import OpenAPI # Lazy import - saves ~10.7 MB on startup
return OpenAPI.from_dict(build_merged_spec_dict())
@functools.cache
def get_openapi_schema_dict():
"""
Get the raw OpenAPI spec dictionary for schema access.
Used by Import endpoint to validate and convert query parameters.
Returns the merged YAML dict (not the OpenAPI object).
"""
return build_merged_spec_dict()
return yaml.safe_load(f)
@functools.cache
def _resolve_schema_properties(schema_name):
@@ -144,7 +103,6 @@ def validate_openapi_request(operation_id):
if request.method.upper() != 'GET':
# Lazy import - only loaded when actually validating a request
from openapi_core.contrib.flask import FlaskOpenAPIRequest
from openapi_core.templating.paths.exceptions import ServerNotFound, PathNotFound, PathError
spec = get_openapi_spec()
openapi_request = FlaskOpenAPIRequest(request)
@@ -152,16 +110,6 @@ def validate_openapi_request(operation_id):
if result.errors:
error_details = []
for error in result.errors:
# Skip path/server validation errors for reverse proxy compatibility
# Flask routing already validates that endpoints exist (returns 404 if not).
# OpenAPI validation here is primarily for request body schema validation.
# When behind nginx/reverse proxy, URLs may have path prefixes that don't
# match the OpenAPI server definitions, causing false positives.
if isinstance(error, PathError):
logger.debug(f"API Call - Skipping path/server validation (delegated to Flask): {error}")
continue
error_str = str(error)
# Extract detailed schema errors from __cause__
if hasattr(error, '__cause__') and hasattr(error.__cause__, 'schema_errors'):
for schema_error in error.__cause__.schema_errors:
@@ -169,12 +117,9 @@ def validate_openapi_request(operation_id):
msg = schema_error.message if hasattr(schema_error, 'message') else str(schema_error)
error_details.append(f"{field}: {msg}")
else:
error_details.append(error_str)
# Only raise if we have actual validation errors (not path/server issues)
if error_details:
error_details.append(str(error))
logger.error(f"API Call - Validation failed: {'; '.join(error_details)}")
raise BadRequest(f"Validation failed: {'; '.join(error_details)}")
raise BadRequest(f"Validation failed: {'; '.join(error_details)}")
except BadRequest:
# Re-raise BadRequest exceptions (validation failures)
raise
@@ -191,6 +136,5 @@ from .Watch import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, Cr
from .Tags import Tags, Tag
from .Import import Import
from .SystemInfo import SystemInfo
from .Spec import Spec
from .Notifications import Notifications
@@ -13,7 +13,7 @@ from loguru import logger
BACKUP_FILENAME_FORMAT = "changedetection-backup-{}.zip"
def create_backup(datastore_path, watches: dict, tags: dict = None):
def create_backup(datastore_path, watches: dict):
logger.debug("Creating backup...")
import zipfile
from pathlib import Path
@@ -45,15 +45,6 @@ def create_backup(datastore_path, watches: dict, tags: dict = None):
if os.path.isfile(secret_file):
zipObj.write(secret_file, arcname="secret.txt")
# Add tag data directories (each tag has its own {uuid}/tag.json)
for uuid, tag in (tags or {}).items():
for f in Path(tag.data_dir).glob('*'):
zipObj.write(f,
arcname=os.path.join(f.parts[-2], f.parts[-1]),
compress_type=zipfile.ZIP_DEFLATED,
compresslevel=8)
logger.debug(f"Added tag '{tag.get('title')}' ({uuid}) to backup")
# Add any data in the watch data directory.
for uuid, w in watches.items():
for f in Path(w.data_dir).glob('*'):
@@ -97,10 +88,7 @@ def create_backup(datastore_path, watches: dict, tags: dict = None):
def construct_blueprint(datastore: ChangeDetectionStore):
from .restore import construct_restore_blueprint
backups_blueprint = Blueprint('backups', __name__, template_folder="templates")
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
backup_threads = []
@login_optionally_required
@@ -108,17 +96,16 @@ def construct_blueprint(datastore: ChangeDetectionStore):
def request_backup():
if any(thread.is_alive() for thread in backup_threads):
flash(gettext("A backup is already running, check back in a few minutes"), "error")
return redirect(url_for('backups.create'))
return redirect(url_for('backups.index'))
if len(find_backups()) > int(os.getenv("MAX_NUMBER_BACKUPS", 100)):
flash(gettext("Maximum number of backups reached, please remove some"), "error")
return redirect(url_for('backups.create'))
return redirect(url_for('backups.index'))
# With immediate persistence, all data is already saved
zip_thread = threading.Thread(
target=create_backup,
args=(datastore.datastore_path, datastore.data.get("watching")),
kwargs={'tags': datastore.data['settings']['application'].get('tags', {})},
daemon=True,
name="BackupCreator"
)
@@ -126,7 +113,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
backup_threads.append(zip_thread)
flash(gettext("Backup building in background, check back in a few minutes."))
return redirect(url_for('backups.create'))
return redirect(url_for('backups.index'))
def find_backups():
backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
@@ -168,14 +155,14 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
@login_optionally_required
@backups_blueprint.route("/", methods=['GET'])
@backups_blueprint.route("/create", methods=['GET'])
def create():
@backups_blueprint.route("", methods=['GET'])
def index():
backups = find_backups()
output = render_template("backup_create.html",
output = render_template("overview.html",
available_backups=backups,
backup_running=any(thread.is_alive() for thread in backup_threads)
)
return output
@login_optionally_required
@@ -189,6 +176,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
flash(gettext("Backups were deleted."))
return redirect(url_for('backups.create'))
return redirect(url_for('backups.index'))
return backups_blueprint
@@ -1,208 +0,0 @@
import io
import json
import os
import shutil
import tempfile
import threading
import zipfile
from flask import Blueprint, render_template, flash, url_for, redirect, request
from flask_babel import gettext, lazy_gettext as _l
from wtforms import Form, BooleanField, SubmitField
from flask_wtf.file import FileField, FileAllowed
from loguru import logger
from changedetectionio.flask_app import login_optionally_required
class RestoreForm(Form):
zip_file = FileField(_l('Backup zip file'), validators=[
FileAllowed(['zip'], _l('Must be a .zip backup file!'))
])
include_groups = BooleanField(_l('Include groups'), default=True)
include_groups_replace_existing = BooleanField(_l('Replace existing groups of the same UUID'), default=True)
include_watches = BooleanField(_l('Include watches'), default=True)
include_watches_replace_existing = BooleanField(_l('Replace existing watches of the same UUID'), default=True)
submit = SubmitField(_l('Restore backup'))
def import_from_zip(zip_stream, datastore, include_groups, include_groups_replace, include_watches, include_watches_replace):
"""
Extract and import watches and groups from a backup zip stream.
Mirrors the store's _load_watches / _load_tags loading pattern:
- UUID dirs with tag.json Tag.model + tag_obj.commit()
- UUID dirs with watch.json rehydrate_entity + watch_obj.commit()
Returns a dict with counts: restored_groups, skipped_groups, restored_watches, skipped_watches.
Raises zipfile.BadZipFile if the stream is not a valid zip.
"""
from changedetectionio.model import Tag
restored_groups = 0
skipped_groups = 0
restored_watches = 0
skipped_watches = 0
current_tags = datastore.data['settings']['application'].get('tags', {})
current_watches = datastore.data['watching']
with tempfile.TemporaryDirectory() as tmpdir:
logger.debug(f"Restore: extracting zip to {tmpdir}")
with zipfile.ZipFile(zip_stream, 'r') as zf:
zf.extractall(tmpdir)
logger.debug("Restore: zip extracted, scanning UUID directories")
for entry in os.scandir(tmpdir):
if not entry.is_dir():
continue
uuid = entry.name
tag_json_path = os.path.join(entry.path, 'tag.json')
watch_json_path = os.path.join(entry.path, 'watch.json')
# --- Tags (groups) ---
if include_groups and os.path.exists(tag_json_path):
if uuid in current_tags and not include_groups_replace:
logger.debug(f"Restore: skipping existing group {uuid} (replace not requested)")
skipped_groups += 1
continue
try:
with open(tag_json_path, 'r', encoding='utf-8') as f:
tag_data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.error(f"Restore: failed to read tag.json for {uuid}: {e}")
continue
title = tag_data.get('title', uuid)
logger.debug(f"Restore: importing group '{title}' ({uuid})")
# Mirror _load_tags: set uuid and force processor
tag_data['uuid'] = uuid
tag_data['processor'] = 'restock_diff'
# Copy the UUID directory so data_dir exists for commit()
dst_dir = os.path.join(datastore.datastore_path, uuid)
if os.path.exists(dst_dir):
shutil.rmtree(dst_dir)
shutil.copytree(entry.path, dst_dir)
tag_obj = Tag.model(
datastore_path=datastore.datastore_path,
__datastore=datastore.data,
default=tag_data
)
current_tags[uuid] = tag_obj
tag_obj.commit()
restored_groups += 1
logger.success(f"Restore: group '{title}' ({uuid}) restored")
# --- Watches ---
elif include_watches and os.path.exists(watch_json_path):
if uuid in current_watches and not include_watches_replace:
logger.debug(f"Restore: skipping existing watch {uuid} (replace not requested)")
skipped_watches += 1
continue
try:
with open(watch_json_path, 'r', encoding='utf-8') as f:
watch_data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.error(f"Restore: failed to read watch.json for {uuid}: {e}")
continue
url = watch_data.get('url', uuid)
logger.debug(f"Restore: importing watch '{url}' ({uuid})")
# Copy UUID directory first so data_dir and history files exist
dst_dir = os.path.join(datastore.datastore_path, uuid)
if os.path.exists(dst_dir):
shutil.rmtree(dst_dir)
shutil.copytree(entry.path, dst_dir)
# Mirror _load_watches / rehydrate_entity
watch_data['uuid'] = uuid
watch_obj = datastore.rehydrate_entity(uuid, watch_data)
current_watches[uuid] = watch_obj
watch_obj.commit()
restored_watches += 1
logger.success(f"Restore: watch '{url}' ({uuid}) restored")
logger.debug(f"Restore: scan complete - groups {restored_groups} restored / {skipped_groups} skipped, "
f"watches {restored_watches} restored / {skipped_watches} skipped")
# Persist changedetection.json (includes the updated tags dict)
logger.debug("Restore: committing datastore settings")
datastore.commit()
return {
'restored_groups': restored_groups,
'skipped_groups': skipped_groups,
'restored_watches': restored_watches,
'skipped_watches': skipped_watches,
}
def construct_restore_blueprint(datastore):
restore_blueprint = Blueprint('restore', __name__, template_folder="templates")
restore_threads = []
@login_optionally_required
@restore_blueprint.route("/restore", methods=['GET'])
def restore():
form = RestoreForm()
return render_template("backup_restore.html",
form=form,
restore_running=any(t.is_alive() for t in restore_threads))
@login_optionally_required
@restore_blueprint.route("/restore/start", methods=['POST'])
def backups_restore_start():
if any(t.is_alive() for t in restore_threads):
flash(gettext("A restore is already running, check back in a few minutes"), "error")
return redirect(url_for('backups.restore.restore'))
zip_file = request.files.get('zip_file')
if not zip_file or not zip_file.filename:
flash(gettext("No file uploaded"), "error")
return redirect(url_for('backups.restore.restore'))
if not zip_file.filename.lower().endswith('.zip'):
flash(gettext("File must be a .zip backup file"), "error")
return redirect(url_for('backups.restore.restore'))
# Read into memory now — the request stream is gone once we return
try:
zip_bytes = io.BytesIO(zip_file.read())
zipfile.ZipFile(zip_bytes) # quick validity check before spawning
zip_bytes.seek(0)
except zipfile.BadZipFile:
flash(gettext("Invalid or corrupted zip file"), "error")
return redirect(url_for('backups.restore.restore'))
include_groups = request.form.get('include_groups') == 'y'
include_groups_replace = request.form.get('include_groups_replace_existing') == 'y'
include_watches = request.form.get('include_watches') == 'y'
include_watches_replace = request.form.get('include_watches_replace_existing') == 'y'
restore_thread = threading.Thread(
target=import_from_zip,
kwargs={
'zip_stream': zip_bytes,
'datastore': datastore,
'include_groups': include_groups,
'include_groups_replace': include_groups_replace,
'include_watches': include_watches,
'include_watches_replace': include_watches_replace,
},
daemon=True,
name="BackupRestore"
)
restore_thread.start()
restore_threads.append(restore_thread)
flash(gettext("Restore started in background, check back in a few minutes."))
return redirect(url_for('backups.restore.restore'))
return restore_blueprint
@@ -1,49 +0,0 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_simple_field, render_field %}
<div class="edit-form">
<div class="tabs collapsable">
<ul>
<li class="tab active" id=""><a href="{{ url_for('backups.create') }}">{{ _('Create') }}</a></li>
<li class="tab"><a href="{{ url_for('backups.restore.restore') }}">{{ _('Restore') }}</a></li>
</ul>
</div>
<div class="box-wrap inner">
<div id="general">
{% if backup_running %}
<p>
<span class="spinner"></span>&nbsp;<strong>{{ _('A backup is running!') }}</strong>
</p>
{% endif %}
<p>
{{ _('Here you can download and request a new backup, when a backup is completed you will see it listed below.') }}
</p>
<br>
{% if available_backups %}
<ul>
{% for backup in available_backups %}
<li>
<a href="{{ url_for('backups.download_backup', filename=backup["filename"]) }}">{{ backup["filename"] }}</a> {{ backup["filesize"] }} {{ _('Mb') }}
</li>
{% endfor %}
</ul>
{% else %}
<p>
<strong>{{ _('No backups found.') }}</strong>
</p>
{% endif %}
<a class="pure-button pure-button-primary"
href="{{ url_for('backups.request_backup') }}">{{ _('Create backup') }}</a>
{% if available_backups %}
<a class="pure-button button-small button-error "
href="{{ url_for('backups.remove_backups') }}">{{ _('Remove backups') }}</a>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@@ -1,58 +0,0 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field %}
<div class="edit-form">
<div class="tabs collapsable">
<ul>
<li class="tab"><a href="{{ url_for('backups.create') }}">{{ _('Create') }}</a></li>
<li class="tab active"><a href="{{ url_for('backups.restore.restore') }}">{{ _('Restore') }}</a></li>
</ul>
</div>
<div class="box-wrap inner">
<div id="general">
{% if restore_running %}
<p>
<span class="spinner"></span>&nbsp;<strong>{{ _('A restore is running!') }}</strong>
</p>
{% endif %}
<p>{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}</p>
<p>{{ _('Note: This does not override the main application settings, only watches and groups.') }}</p>
<form class="pure-form pure-form-stacked settings"
action="{{ url_for('backups.restore.backups_restore_start') }}"
method="POST"
enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="pure-control-group">
{{ render_checkbox_field(form.include_groups) }}
<span class="pure-form-message-inline">{{ _('Include all groups found in backup?') }}</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.include_groups_replace_existing) }}
<span class="pure-form-message-inline">{{ _('Replace any existing groups of the same UUID?') }}</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.include_watches) }}
<span class="pure-form-message-inline">{{ _('Include all watches found in backup?') }}</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.include_watches_replace_existing) }}
<span class="pure-form-message-inline">{{ _('Replace any existing watches of the same UUID?') }}</span>
</div>
<div class="pure-control-group">
{{ render_field(form.zip_file) }}
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary">{{ _('Restore backup') }}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.html' import render_simple_field, render_field %}
<div class="edit-form">
<div class="box-wrap inner">
<h2>{{ _('Backups') }}</h2>
{% if backup_running %}
<p>
<span class="spinner"></span>&nbsp;<strong>{{ _('A backup is running!') }}</strong>
</p>
{% endif %}
<p>
{{ _('Here you can download and request a new backup, when a backup is completed you will see it listed below.') }}
</p>
<br>
{% if available_backups %}
<ul>
{% for backup in available_backups %}
<li><a href="{{ url_for('backups.download_backup', filename=backup["filename"]) }}">{{ backup["filename"] }}</a> {{ backup["filesize"] }} {{ _('Mb') }}</li>
{% endfor %}
</ul>
{% else %}
<p>
<strong>{{ _('No backups found.') }}</strong>
</p>
{% endif %}
<a class="pure-button pure-button-primary" href="{{ url_for('backups.request_backup') }}">{{ _('Create backup') }}</a>
{% if available_backups %}
<a class="pure-button button-small button-error " href="{{ url_for('backups.remove_backups') }}">{{ _('Remove backups') }}</a>
{% endif %}
</div>
</div>
{% endblock %}
@@ -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:
@@ -16,11 +16,6 @@
<form class="pure-form" action="{{url_for('imports.import_page')}}" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="tab-pane-inner" id="url-list">
<p>
{{ _('Restoring changedetection.io backups is in the') }}<a href="{{ url_for('backups.restore.restore') }}"> {{ _('backups section') }}</a>.
<br>
</p>
<div class="pure-control-group">
{{ _('Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):') }}
<br>
@@ -42,6 +37,9 @@
</div>
<div class="tab-pane-inner" id="distill-io">
<div class="pure-control-group">
{{ _('Copy and Paste your Distill.io watch \'export\' file, this should be a JSON file.') }}<br>
{{ _('This is') }} <i>{{ _('experimental') }}</i>, {{ _('supported fields are') }} <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, {{ _('the rest (including') }} <code>schedule</code>) {{ _('are ignored.') }}
@@ -51,6 +49,8 @@
{{ _('Be sure to set your default fetcher to Chrome if required.') }}<br>
</p>
</div>
<textarea name="distill-io" class="pure-input-1-2" style="width: 100%;
font-family:monospace;
white-space: pre;
@@ -114,7 +114,6 @@
</div>
</div>
<button type="submit" class="pure-button pure-input-1-2 pure-button-primary">{{ _('Import') }}</button>
</form>
</div>
@@ -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)
@@ -25,7 +25,7 @@
<li class="tab"><a href="#ui-options">{{ _('UI Options') }}</a></li>
<li class="tab"><a href="#api">{{ _('API') }}</a></li>
<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="{{ url_for('backups.index') }}">{{ _('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 %}
+30 -23
View File
@@ -54,7 +54,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/mute/<uuid_str:uuid>", methods=['GET'])
@tags_blueprint.route("/mute/<string:uuid>", methods=['GET'])
@login_optionally_required
def mute(uuid):
tag = datastore.data['settings']['application']['tags'].get(uuid)
@@ -63,13 +63,24 @@ 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
if datastore.data['settings']['application']['tags'].get(uuid):
del datastore.data['settings']['application']['tags'][uuid]
# Delete tag.json file if it exists
import os
tag_dir = os.path.join(datastore.datastore_path, uuid)
tag_json = os.path.join(tag_dir, "tag.json")
if os.path.exists(tag_json):
try:
os.unlink(tag_json)
logger.info(f"Deleted tag.json for tag {uuid}")
except Exception as e:
logger.error(f"Failed to delete tag.json for tag {uuid}: {e}")
# Remove tag from all watches in background thread to avoid blocking
def remove_tag_background(tag_uuid):
"""Background thread to remove tag from watches - discarded after completion."""
@@ -90,7 +101,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
@@ -116,11 +127,19 @@ def construct_blueprint(datastore: ChangeDetectionStore):
@tags_blueprint.route("/delete_all", methods=['GET'])
@login_optionally_required
def delete_all():
# Delete all tag.json files
import os
for tag_uuid in list(datastore.data['settings']['application']['tags'].keys()):
# TagsDict 'del' handler will remove the dir
del datastore.data['settings']['application']['tags'][tag_uuid]
tag_dir = os.path.join(datastore.datastore_path, tag_uuid)
tag_json = os.path.join(tag_dir, "tag.json")
if os.path.exists(tag_json):
try:
os.unlink(tag_json)
except Exception as e:
logger.error(f"Failed to delete tag.json for tag {tag_uuid}: {e}")
# Clear all tags from settings immediately
datastore.data['settings']['application']['tags'] = {}
# Clear tags from all watches in background thread to avoid blocking
def clear_all_tags_background():
@@ -141,7 +160,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
@@ -160,21 +179,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
default_system_settings = datastore.data['settings'],
)
# Bridge API-stored processor_config_* values into the form's FormField sub-forms.
# The API stores processor_config_restock_diff in the tag dict; find the matching
# FormField by checking which one's sub-fields cover the config keys.
from wtforms.fields.form import FormField as WTFormField
for key, value in default.items():
if not key.startswith('processor_config_') or not isinstance(value, dict):
continue
for form_field in form:
if isinstance(form_field, WTFormField) and all(k in form_field.form._fields for k in value):
for sub_key, sub_value in value.items():
sub_field = form_field.form._fields.get(sub_key)
if sub_field is not None:
sub_field.data = sub_value
break
template_args = {
'data': default,
'form': form,
@@ -218,7 +222,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
@@ -251,4 +255,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return redirect(url_for('tags.tags_overview_page'))
@tags_blueprint.route("/delete/<string:uuid>", methods=['GET'])
def form_tag_delete(uuid):
return redirect(url_for('tags.tags_overview_page'))
return tags_blueprint
+11 -17
View File
@@ -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:
@@ -194,9 +194,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
tag_limit = request.args.get('tag')
now = int(time.time())
# Mark watches as viewed - use background thread only for large watch counts
def mark_viewed_impl():
"""Mark watches as viewed - can run synchronously or in background thread."""
# Mark watches as viewed in background thread to avoid blocking
def mark_viewed_background():
"""Background thread to mark watches as viewed - discarded after completion."""
marked_count = 0
try:
for watch_uuid, watch in datastore.data['watching'].items():
@@ -209,21 +209,15 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
datastore.set_last_viewed(watch_uuid, now)
marked_count += 1
logger.info(f"Marking complete: {marked_count} watches marked as viewed")
logger.info(f"Background marking complete: {marked_count} watches marked as viewed")
except Exception as e:
logger.error(f"Error marking as viewed: {e}")
logger.error(f"Error in background mark as viewed: {e}")
# For small watch counts (< 10), run synchronously to avoid race conditions in tests
# For larger counts, use background thread to avoid blocking the UI
watch_count = len(datastore.data['watching'])
if watch_count < 10:
# Run synchronously for small watch counts
mark_viewed_impl()
else:
# Start background thread for large watch counts
thread = threading.Thread(target=mark_viewed_impl, daemon=True)
thread.start()
# Start background thread and return immediately
thread = threading.Thread(target=mark_viewed_background, daemon=True)
thread.start()
flash(gettext("Marking watches as viewed in background..."))
return redirect(url_for('watchlist.index', tag=tag_limit))
@ui_blueprint.route("/delete", methods=['GET'])
@@ -366,7 +360,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
+4 -4
View File
@@ -66,7 +66,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return Markup(result)
@diff_blueprint.route("/diff/<uuid_str:uuid>", methods=['GET'])
@diff_blueprint.route("/diff/<string:uuid>", methods=['GET'])
@login_optionally_required
def diff_history_page(uuid):
"""
@@ -128,7 +128,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
redirect=redirect
)
@diff_blueprint.route("/diff/<uuid_str:uuid>/extract", methods=['GET'])
@diff_blueprint.route("/diff/<string:uuid>/extract", methods=['GET'])
@login_optionally_required
def diff_history_page_extract_GET(uuid):
"""
@@ -182,7 +182,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
redirect=redirect
)
@diff_blueprint.route("/diff/<uuid_str:uuid>/extract", methods=['POST'])
@diff_blueprint.route("/diff/<string:uuid>/extract", methods=['POST'])
@login_optionally_required
def diff_history_page_extract_POST(uuid):
"""
@@ -238,7 +238,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
redirect=redirect
)
@diff_blueprint.route("/diff/<uuid_str:uuid>/processor-asset/<string:asset_name>", methods=['GET'])
@diff_blueprint.route("/diff/<string:uuid>/processor-asset/<string:asset_name>", methods=['GET'])
@login_optionally_required
def processor_asset(uuid, asset_name):
"""
+8 -21
View File
@@ -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 ?
@@ -117,25 +117,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
processor_config = processor_instance.get_extra_watch_config(config_filename)
if processor_config:
from wtforms.fields.form import FormField
# Populate processor-config-* fields from JSON
for config_key, config_value in processor_config.items():
if not isinstance(config_value, dict):
continue
# Try exact API-named field first (e.g., processor_config_restock_diff)
target_field = getattr(form, f'processor_config_{config_key}', None)
# Fallback: find any FormField sub-form whose fields cover config_value keys
if target_field is None:
for form_field in form:
if isinstance(form_field, FormField) and all(k in form_field.form._fields for k in config_value):
target_field = form_field
break
if target_field is not None:
for sub_key, sub_value in config_value.items():
sub_field = target_field.form._fields.get(sub_key)
if sub_field is not None:
sub_field.data = sub_value
logger.debug(f"Loaded processor config from {config_filename}: {sub_key} = {sub_value}")
field_name = f'processor_config_{config_key}'
if hasattr(form, field_name):
getattr(form, field_name).data = config_value
logger.debug(f"Loaded processor config from {config_filename}: {field_name} = {config_value}")
except Exception as e:
logger.warning(f"Failed to load processor config: {e}")
@@ -340,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
@@ -367,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"""
@@ -418,7 +405,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
mimetype='application/zip')
# 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'''
+2 -2
View File
@@ -10,7 +10,7 @@ from changedetectionio import html_tools
def construct_blueprint(datastore: ChangeDetectionStore):
preview_blueprint = Blueprint('ui_preview', __name__, template_folder="../ui/templates")
@preview_blueprint.route("/preview/<uuid_str:uuid>", methods=['GET'])
@preview_blueprint.route("/preview/<string:uuid>", methods=['GET'])
@login_optionally_required
def preview_page(uuid):
"""
@@ -125,7 +125,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return output
@preview_blueprint.route("/preview/<uuid_str:uuid>/processor-asset/<string:asset_name>", methods=['GET'])
@preview_blueprint.route("/preview/<string:uuid>/processor-asset/<string:asset_name>", methods=['GET'])
@login_optionally_required
def processor_asset(uuid, asset_name):
"""
@@ -86,8 +86,8 @@ async def capture_full_page(page, screenshot_format='JPEG', watch_uuid=None, loc
# better than scrollTo incase they override it in the page
await page.evaluate(
"""(y) => {
const el = document.scrollingElement;
if (el) el.scrollTop = y;
document.documentElement.scrollTop = y;
document.body.scrollTop = y;
}""",
y
)
@@ -305,8 +305,6 @@ class fetcher(Fetcher):
await asyncio.wait_for(self.browser.close(), timeout=3.0)
except Exception as cleanup_error:
logger.error(f"[{watch_uuid}] Failed to cleanup browser after page creation failure: {cleanup_error}")
finally:
self.browser = None
raise
# Add console handler to capture console.log from favicon fetcher
@@ -534,14 +532,6 @@ class fetcher(Fetcher):
)
except asyncio.TimeoutError:
raise (BrowserFetchTimedOut(msg=f"Browser connected but was unable to process the page in {max_time} seconds."))
finally:
# Internal cleanup on any exception/timeout - call quit() immediately
# This prevents connection leaks during exception bursts
# Worker.py's quit() call becomes a redundant safety net (idempotent)
try:
await self.quit(watch={'uuid': watch_uuid} if watch_uuid else None)
except Exception as cleanup_error:
logger.error(f"[{watch_uuid}] Error during internal quit() cleanup: {cleanup_error}")
# Plugin registration for built-in fetcher
+1 -37
View File
@@ -1,5 +1,4 @@
from loguru import logger
from urllib.parse import urljoin, urlparse
import hashlib
import os
import re
@@ -8,7 +7,6 @@ import asyncio
from changedetectionio import strtobool
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
from changedetectionio.content_fetchers.base import Fetcher
from changedetectionio.validate_url import is_private_hostname
# "html_requests" is listed as the default fetcher in store.py!
@@ -81,48 +79,14 @@ class fetcher(Fetcher):
if strtobool(os.getenv('ALLOW_FILE_URI', 'false')) and url.startswith('file://'):
from requests_file import FileAdapter
session.mount('file://', FileAdapter())
allow_iana_restricted = strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false'))
try:
# Fresh DNS check at fetch time — catches DNS rebinding regardless of add-time cache.
if not allow_iana_restricted:
parsed_initial = urlparse(url)
if parsed_initial.hostname and is_private_hostname(parsed_initial.hostname):
raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.")
r = session.request(method=request_method,
data=request_body.encode('utf-8') if type(request_body) is str else request_body,
url=url,
headers=request_headers,
timeout=timeout,
proxies=proxies,
verify=False,
allow_redirects=False)
# Manually follow redirects so each hop's resolved IP can be validated,
# preventing SSRF via an open redirect on a public host.
current_url = url
for _ in range(10):
if not r.is_redirect:
break
location = r.headers.get('Location', '')
redirect_url = urljoin(current_url, location)
if not allow_iana_restricted:
parsed_redirect = urlparse(redirect_url)
if parsed_redirect.hostname and is_private_hostname(parsed_redirect.hostname):
raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address.")
current_url = redirect_url
r = session.request('GET', redirect_url,
headers=request_headers,
timeout=timeout,
proxies=proxies,
verify=False,
allow_redirects=False)
else:
raise Exception("Too many redirects")
verify=False)
except Exception as e:
msg = str(e)
if proxies and 'SOCKSHTTPSConnectionPool' in msg:
+11 -36
View File
@@ -27,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
@@ -39,7 +40,7 @@ from loguru import logger
from changedetectionio import __version__
from changedetectionio import queuedWatchMetaData
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon, Spec
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon
from changedetectionio.api.Search import Search
from .time_handler import is_within_schedule
from changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale
@@ -68,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
@@ -557,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',
@@ -585,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',
@@ -594,8 +571,6 @@ def changedetection_app(config=None, datastore_o=None):
watch_api.add_resource(Notifications, '/api/v1/notifications',
resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(Spec, '/api/v1/full-spec')
@login_manager.user_loader
def user_loader(email):
user = User()
+1 -24
View File
@@ -561,33 +561,10 @@ 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()
# 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']
html_content = str(soup)
text_content = get_text(html_content, config=parser_config)
return text_content
+2 -9
View File
@@ -2,7 +2,6 @@ from os import getenv
from copy import deepcopy
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT
from changedetectionio.model.Tags import TagsDict
from changedetectionio.notification import (
default_notification_body,
@@ -69,7 +68,7 @@ class model(dict):
'schema_version' : 0,
'shared_diff_access': False,
'strip_ignored_lines': False,
'tags': None, # Initialized in __init__ with real datastore_path
'tags': {}, #@todo use Tag.model initialisers
'webdriver_delay': None , # Extra delay in seconds before extracting text
'ui': {
'use_page_title_in_list': True,
@@ -81,16 +80,10 @@ class model(dict):
}
}
def __init__(self, *arg, datastore_path=None, **kw):
def __init__(self, *arg, **kw):
super(model, self).__init__(*arg, **kw)
# Capture any tags data passed in before base_config overwrites the structure
existing_tags = self.get('settings', {}).get('application', {}).get('tags') or {}
# CRITICAL: deepcopy to avoid sharing mutable objects between instances
self.update(deepcopy(self.base_config))
# TagsDict requires the real datastore_path at runtime (cannot be set at class-definition time)
if datastore_path is None:
raise ValueError("App.model() requires 'datastore_path' keyword argument")
self['settings']['application']['tags'] = TagsDict(existing_tags, datastore_path=datastore_path)
def parse_headers_from_text_file(filepath):
-39
View File
@@ -1,39 +0,0 @@
import os
import shutil
from pathlib import Path
from loguru import logger
_SENTINEL = object()
class TagsDict(dict):
"""Dict subclass that removes the corresponding tag.json file when a tag is deleted."""
def __init__(self, *args, datastore_path: str | os.PathLike, **kwargs) -> None:
self._datastore_path = Path(datastore_path)
super().__init__(*args, **kwargs)
def __delitem__(self, key: str) -> None:
super().__delitem__(key)
tag_dir = self._datastore_path / key
tag_json_file = tag_dir / "tag.json"
if not os.path.exists(tag_json_file):
logger.critical(f"Aborting deletion of directory '{tag_dir}' because '{tag_json_file}' does not exist.")
return
try:
shutil.rmtree(tag_dir)
logger.info(f"Deleted tag directory for tag {key!r}")
except FileNotFoundError:
pass
except OSError as e:
logger.error(f"Failed to delete tag directory for tag {key!r}: {e}")
def pop(self, key: str, default=_SENTINEL):
"""Remove and return tag, deleting its tag.json file. Raises KeyError if missing and no default given."""
if key in self:
value = self[key]
del self[key]
return value
if default is _SENTINEL:
raise KeyError(key)
return default
+42 -110
View File
@@ -54,128 +54,34 @@ 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='%A') }} 'Monday'
{{ 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()
class FormattableDiff(str):
"""
A str subclass representing a rendered diff. As a plain string it renders
with the default options for that variant, but can be called with custom
arguments in Jinja2 templates:
{{ diff }} default diff output
{{ diff(lines=5) }} truncate to 5 lines
{{ diff(added_only=true) }} only show added lines
{{ diff(removed_only=true) }} only show removed lines
{{ diff(context=3) }} 3 lines of context around changes
{{ diff(word_diff=false) }} line-level diff instead of word-level
{{ diff(lines=10, added_only=true) }} combine args
{{ diff_added(lines=5) }} works on any diff_* variant too
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs)
else:
rendered = ''
instance = super().__new__(cls, rendered)
instance._prev = prev_snapshot
instance._current = current_snapshot
instance._base_kwargs = base_kwargs
return instance
def __call__(self, lines=None, added_only=False, removed_only=False, context=0,
word_diff=None, case_insensitive=False, ignore_junk=False):
from changedetectionio import diff as diff_module
kwargs = dict(self._base_kwargs)
if added_only:
kwargs['include_removed'] = False
if removed_only:
kwargs['include_added'] = False
if context:
kwargs['context_lines'] = int(context)
if word_diff is not None:
kwargs['word_diff'] = bool(word_diff)
if case_insensitive:
kwargs['case_insensitive'] = True
if ignore_junk:
kwargs['ignore_junk'] = True
result = diff_module.render_diff(self._prev or '', self._current or '', **kwargs)
if lines is not None:
result = '\n'.join(result.splitlines()[:int(lines)])
return result
# 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': FormattableDiff('', ''),
'diff_clean': FormattableDiff('', '', include_change_type_prefix=False),
'diff_added': FormattableDiff('', '', include_removed=False),
'diff_added_clean': FormattableDiff('', '', include_removed=False, include_change_type_prefix=False),
'diff_full': FormattableDiff('', '', include_equal=True),
'diff_full_clean': FormattableDiff('', '', include_equal=True, include_change_type_prefix=False),
'diff_patch': FormattableDiff('', '', patch_format=True),
'diff_removed': FormattableDiff('', '', include_added=False),
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),
'diff': None,
'diff_clean': None,
'diff_added': None,
'diff_added_clean': None,
'diff_full': None,
'diff_full_clean': None,
'diff_patch': None,
'diff_removed': None,
'diff_removed_clean': None,
'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/',
'watch_uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', # Converted to 'watch_uuid' in create_notification_parameters
})
# Apply any initial data passed in
@@ -197,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'] or key.startswith('diff'):
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
@@ -209,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.
@@ -226,12 +150,13 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
Returns:
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
"""
from changedetectionio import diff
import re
from functools import lru_cache
now = time.time()
# Define base kwargs for each diff variant — these become the stored defaults
# on the FormattableDiff object, so {{ diff(lines=5) }} overrides on top of them
# Define specifications for each diff variant
diff_specs = {
'diff': {'word_diff': word_diff},
'diff_clean': {'word_diff': word_diff, 'include_change_type_prefix': False},
@@ -244,15 +169,22 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
}
# Memoize render_diff to avoid duplicate renders with same kwargs
@lru_cache(maxsize=4)
def cached_render(kwargs_tuple):
return diff.render_diff(prev_snapshot, current_snapshot, **dict(kwargs_tuple))
ret = {}
rendered_count = 0
# Only create FormattableDiff objects for diff keys actually used in the notification text
# Only check and render diff keys that exist in NotificationContextData
for key in NotificationContextData().keys():
if key.startswith('diff') and key in diff_specs:
# Check if this placeholder is actually used in the notification text
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
if re.search(pattern, notification_scan_text, re.IGNORECASE):
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
kwargs = diff_specs[key]
# Convert dict to sorted tuple for cache key (handles duplicate kwarg combinations)
ret[key] = cached_render(tuple(sorted(kwargs.items())))
rendered_count += 1
if rendered_count:
@@ -266,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,
+1 -108
View File
@@ -129,51 +129,6 @@ class ChangeDetectionSpec:
"""
pass
@hookspec
def update_handler_alter(update_handler, watch, datastore):
"""Modify or wrap the update_handler before it processes a watch.
This hook is called after the update_handler (perform_site_check instance) is created
but before it calls call_browser() and run_changedetection(). Plugins can use this to:
- Wrap the handler to add logging/metrics
- Modify handler configuration
- Add custom preprocessing logic
Args:
update_handler: The perform_site_check instance that will process the watch
watch: The watch dict being processed
datastore: The application datastore
Returns:
object or None: Return a modified/wrapped handler, or None to keep the original.
If multiple plugins return handlers, they are chained in registration order.
"""
pass
@hookspec
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,
allowing plugins to perform cleanup, update metrics, or log final status.
The plugin can access update_handler.last_logging_insert_id if it was stored
during update_handler_alter, and use processing_exception to determine if
the processing succeeded or failed.
Args:
update_handler: The perform_site_check instance (may be None if creation failed)
watch: The watch dict that was processed (may be None if not loaded)
datastore: The application datastore
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).
Returns:
None: This hook doesn't return a value
"""
pass
# Set up Plugin Manager
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
@@ -544,66 +499,4 @@ def get_plugin_template_paths():
template_paths.append(templates_dir)
logger.debug(f"Added plugin template path: {templates_dir}")
return template_paths
def apply_update_handler_alter(update_handler, watch, datastore):
"""Apply update_handler_alter hooks from all plugins.
Allows plugins to wrap or modify the update_handler before it processes a watch.
Multiple plugins can chain modifications - each plugin receives the result from
the previous plugin.
Args:
update_handler: The perform_site_check instance to potentially modify
watch: The watch dict being processed
datastore: The application datastore
Returns:
object: The (potentially modified/wrapped) update_handler
"""
# Get all plugins that implement the update_handler_alter hook
results = plugin_manager.hook.update_handler_alter(
update_handler=update_handler,
watch=watch,
datastore=datastore
)
# Chain results - each plugin gets the result from the previous one
current_handler = update_handler
if results:
for result in results:
if result is not None:
logger.debug(f"Plugin modified update_handler for watch {watch.get('uuid')}")
current_handler = result
return current_handler
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
to perform cleanup, update metrics, or log final status.
Args:
update_handler: The perform_site_check instance (may be None)
watch: The watch dict that was processed (may be None)
datastore: The application datastore
processing_exception: The exception from processing, or None if successful
Returns:
None
"""
try:
# Call all plugins that implement the update_finalize hook
plugin_manager.hook.update_finalize(
update_handler=update_handler,
watch=watch,
datastore=datastore,
processing_exception=processing_exception
)
except Exception as e:
# Don't let plugin errors crash the worker
logger.error(f"Error in update_finalize hook: {e}")
logger.exception(f"update_finalize hook exception details:")
return template_paths
-9
View File
@@ -9,15 +9,6 @@ Some suggestions for the future
- `graphical`
## API schema extension (`api.yaml`)
A processor can extend the Watch/Tag API schema by placing an `api.yaml` alongside its `__init__.py`.
Define a `components.schemas.processor_config_<name>` entry and it will be merged into `WatchBase` at startup,
making `processor_config_<name>` a valid field on all watch create/update API calls.
The fully merged spec is served live at `/api/v1/full-spec`.
See `restock_diff/api.yaml` for a working example.
## Todo
- Make each processor return a extra list of sub-processed (so you could configure a single processor in different ways)
-22
View File
@@ -1,15 +1,12 @@
import asyncio
import re
import hashlib
from changedetectionio.browser_steps.browser_steps import browser_steps_get_valid_steps
from changedetectionio.content_fetchers.base import Fetcher
from changedetectionio.strtobool import strtobool
from changedetectionio.validate_url import is_private_hostname
from copy import deepcopy
from abc import abstractmethod
import os
from urllib.parse import urlparse
from loguru import logger
SCREENSHOT_FORMAT_JPEG = 'JPEG'
@@ -98,23 +95,6 @@ class difference_detection_processor():
self.last_raw_content_checksum = None
async def validate_iana_url(self):
"""Pre-flight SSRF check — runs DNS lookup in executor to avoid blocking the event loop.
Covers all fetchers (requests, playwright, puppeteer, plugins) since every fetch goes
through call_browser().
"""
if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
return
parsed = urlparse(self.watch.link)
if not parsed.hostname:
return
loop = asyncio.get_running_loop()
if await loop.run_in_executor(None, is_private_hostname, parsed.hostname):
raise Exception(
f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
)
async def call_browser(self, preferred_proxy_id=None):
from requests.structures import CaseInsensitiveDict
@@ -128,8 +108,6 @@ class difference_detection_processor():
"file:// type access is denied for security reasons."
)
await self.validate_iana_url()
# Requests, playwright, other browser via wss:// etc, fetch_extra_something
prefer_fetch_backend = self.watch.get('fetch_backend', 'system')
@@ -67,6 +67,10 @@ class Watch(BaseWatch):
super().__init__(*arg, **kw)
self['restock'] = Restock(kw['default']['restock']) if kw.get('default') and kw['default'].get('restock') else Restock()
self['restock_settings'] = kw['default']['restock_settings'] if kw.get('default',{}).get('restock_settings') else {
'follow_price_changes': True,
'in_stock_processing' : 'in_stock_only'
} #@todo update
def clear_watch(self):
super().clear_watch()
@@ -1,149 +0,0 @@
components:
schemas:
processor_config_restock_diff:
type: object
description: Configuration for the restock_diff processor (restock and price tracking)
properties:
in_stock_processing:
type: string
enum: [in_stock_only, all_changes, 'off']
default: in_stock_only
description: |
When to trigger on stock changes:
- `in_stock_only`: Only trigger on Out Of Stock -> In Stock transitions
- `all_changes`: Trigger on any availability change
- `off`: Disable stock/availability tracking
follow_price_changes:
type: boolean
default: true
description: Monitor and track price changes
price_change_min:
type: [number, 'null']
description: Trigger a notification when the price drops below this value
price_change_max:
type: [number, 'null']
description: Trigger a notification when the price rises above this value
price_change_threshold_percent:
type: [number, 'null']
minimum: 0
maximum: 100
description: Minimum price change percentage since the original price to trigger a notification
paths:
/watch:
post:
x-code-samples:
- lang: 'curl'
label: 'Restock & price tracking'
source: |
curl -X POST "http://localhost:5000/api/v1/watch" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/product",
"processor": "restock_diff",
"processor_config_restock_diff": {
"in_stock_processing": "in_stock_only",
"follow_price_changes": true,
"price_change_threshold_percent": 5
}
}'
- lang: 'Python'
label: 'Restock & price tracking'
source: |
import requests
headers = {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
}
data = {
'url': 'https://example.com/product',
'processor': 'restock_diff',
'processor_config_restock_diff': {
'in_stock_processing': 'in_stock_only',
'follow_price_changes': True,
'price_change_threshold_percent': 5,
}
}
response = requests.post('http://localhost:5000/api/v1/watch',
headers=headers, json=data)
print(response.json())
/watch/{uuid}:
put:
x-code-samples:
- lang: 'curl'
label: 'Update restock config'
source: |
curl -X PUT "http://localhost:5000/api/v1/watch/YOUR-UUID" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"processor_config_restock_diff": {
"in_stock_processing": "all_changes",
"follow_price_changes": true,
"price_change_min": 10.00,
"price_change_max": 500.00
}
}'
- lang: 'Python'
label: 'Update restock config'
source: |
import requests
headers = {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
}
uuid = 'YOUR-UUID'
data = {
'processor_config_restock_diff': {
'in_stock_processing': 'all_changes',
'follow_price_changes': True,
'price_change_min': 10.00,
'price_change_max': 500.00,
}
}
response = requests.put(f'http://localhost:5000/api/v1/watch/{uuid}',
headers=headers, json=data)
print(response.text)
/tag/{uuid}:
put:
x-code-samples:
- lang: 'curl'
label: 'Set restock config on group/tag'
source: |
curl -X PUT "http://localhost:5000/api/v1/tag/YOUR-TAG-UUID" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"overrides_watch": true,
"processor_config_restock_diff": {
"in_stock_processing": "in_stock_only",
"follow_price_changes": true,
"price_change_threshold_percent": 10
}
}'
- lang: 'Python'
label: 'Set restock config on group/tag'
source: |
import requests
headers = {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
}
tag_uuid = 'YOUR-TAG-UUID'
data = {
'overrides_watch': True,
'processor_config_restock_diff': {
'in_stock_processing': 'in_stock_only',
'follow_price_changes': True,
'price_change_threshold_percent': 10,
}
}
response = requests.put(f'http://localhost:5000/api/v1/tag/{tag_uuid}',
headers=headers, json=data)
print(response.text)
@@ -31,7 +31,7 @@ class RestockSettingsForm(Form):
follow_price_changes = BooleanField(_l('Follow price changes'), default=True)
class processor_settings_form(processor_text_json_diff_form):
processor_config_restock_diff = FormField(RestockSettingsForm)
restock_settings = FormField(RestockSettingsForm)
def extra_tab_content(self):
return _l('Restock & Price Detection')
@@ -48,34 +48,34 @@ class processor_settings_form(processor_text_json_diff_form):
output += """
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
<script>
<script>
$(document).ready(function () {
toggleOpacity('#processor_config_restock_diff-follow_price_changes', '.price-change-minmax', true);
toggleOpacity('#restock_settings-follow_price_changes', '.price-change-minmax', true);
});
</script>
<fieldset id="restock-fieldset-price-group">
<div class="pure-control-group">
<fieldset class="pure-group inline-radio">
{{ render_field(form.processor_config_restock_diff.in_stock_processing) }}
{{ render_field(form.restock_settings.in_stock_processing) }}
</fieldset>
<fieldset class="pure-group">
{{ render_checkbox_field(form.processor_config_restock_diff.follow_price_changes) }}
{{ render_checkbox_field(form.restock_settings.follow_price_changes) }}
<span class="pure-form-message-inline">Changes in price should trigger a notification</span>
</fieldset>
<fieldset class="pure-group price-change-minmax">
{{ render_field(form.processor_config_restock_diff.price_change_min, placeholder=watch.get('restock', {}).get('price')) }}
<fieldset class="pure-group price-change-minmax">
{{ render_field(form.restock_settings.price_change_min, placeholder=watch.get('restock', {}).get('price')) }}
<span class="pure-form-message-inline">Minimum amount, Trigger a change/notification when the price drops <i>below</i> this value.</span>
</fieldset>
<fieldset class="pure-group price-change-minmax">
{{ render_field(form.processor_config_restock_diff.price_change_max, placeholder=watch.get('restock', {}).get('price')) }}
{{ render_field(form.restock_settings.price_change_max, placeholder=watch.get('restock', {}).get('price')) }}
<span class="pure-form-message-inline">Maximum amount, Trigger a change/notification when the price rises <i>above</i> this value.</span>
</fieldset>
<fieldset class="pure-group price-change-minmax">
{{ render_field(form.processor_config_restock_diff.price_change_threshold_percent) }}
{{ render_field(form.restock_settings.price_change_threshold_percent) }}
<span class="pure-form-message-inline">Price must change more than this % to trigger a change since the first check.</span><br>
<span class="pure-form-message-inline">For example, If the product is $1,000 USD originally, <strong>2%</strong> would mean it has to change more than $20 since the first check.</span><br>
</fieldset>
</fieldset>
</div>
</fieldset>
"""
@@ -450,18 +450,13 @@ class perform_site_check(difference_detection_processor):
)
# Which restock settings to compare against?
# Settings are stored in restock_diff.json (migrated from watch.json by update_30).
_extra_config = self.get_extra_watch_config('restock_diff.json')
restock_settings = _extra_config.get('restock_diff') or {
'follow_price_changes': True,
'in_stock_processing': 'in_stock_only',
}
restock_settings = watch.get('restock_settings', {})
# See if any tags have 'activate for individual watches in this tag/group?' enabled and use the first we find
for tag_uuid in watch.get('tags'):
tag = self.datastore.data['settings']['application']['tags'].get(tag_uuid, {})
if tag.get('overrides_watch'):
restock_settings = tag.get('processor_config_restock_diff') or {}
restock_settings = tag.get('restock_settings', {})
logger.info(f"Watch {watch.get('uuid')} - Tag '{tag.get('title')}' selected for restock settings override")
break
@@ -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
@@ -347,7 +347,6 @@ class ContentProcessor:
def extract_text_from_html(self, html_content, stream_content_type):
"""Convert HTML to plain text."""
do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False)
return html_tools.html_to_text(
html_content=html_content,
render_anchor_tag_content=do_anchor,
+1 -1
View File
@@ -345,4 +345,4 @@ def init_socketio(app, datastore):
logger.info("Socket.IO initialized and attached to main Flask app")
logger.info(f"Socket.IO: Registered event handlers: {socketio.handlers if hasattr(socketio, 'handlers') else 'No handlers found'}")
return socketio
return socketio
+2 -2
View File
@@ -44,12 +44,12 @@ data_sanity_test () {
cd ..
TMPDIR=$(mktemp -d)
PORT_N=$((5000 + RANDOM % (6501 - 5000)))
ALLOW_IANA_RESTRICTED_ADDRESSES=true ./changedetection.py -p $PORT_N -d $TMPDIR -u "https://localhost?test-url-is-sanity=1" &
./changedetection.py -p $PORT_N -d $TMPDIR -u "https://localhost?test-url-is-sanity=1" &
PID=$!
sleep 5
kill $PID
sleep 2
ALLOW_IANA_RESTRICTED_ADDRESSES=true ./changedetection.py -p $PORT_N -d $TMPDIR &
./changedetection.py -p $PORT_N -d $TMPDIR &
PID=$!
sleep 5
# On a restart the URL should still be there
+1 -3
View File
@@ -102,9 +102,7 @@
}
// Navigate to search results (always redirect to watchlist home)
// Use base_path if available (for sub-path deployments like /enlighten-richerx)
const basePath = typeof base_path !== 'undefined' ? base_path : '';
window.location.href = basePath + '/?' + params.toString();
window.location.href = '/?' + params.toString();
});
}
});
+1 -1
View File
@@ -1 +1 @@
#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces;overflow-wrap:anywhere}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}
#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}
@@ -62,7 +62,6 @@ body.difference-page {
pre {
white-space: break-spaces;
overflow-wrap: anywhere;
}
File diff suppressed because one or more lines are too long
+3 -16
View File
@@ -22,8 +22,6 @@ import uuid as uuid_builder
from loguru import logger
from blinker import signal
from ..model.Tags import TagsDict
# Try to import orjson for faster JSON serialization
try:
import orjson
@@ -123,11 +121,6 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
if 'application' in settings_data['settings']:
self.__data['settings']['application'].update(settings_data['settings']['application'])
# Use our Tags dict with cleanup helpers etc
# @todo Same for Watches
existing_tags = settings_data.get('settings', {}).get('application', {}).get('tags') or {}
self.__data['settings']['application']['tags'] = TagsDict(existing_tags, datastore_path=self.datastore_path)
# More or less for the old format which had this data in the one url-watches.json
# cant hurt to leave it here,
if 'watching' in settings_data:
@@ -203,7 +196,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
self.datastore_path = datastore_path
# Initialize data structure
self.__data = App.model(datastore_path=datastore_path)
self.__data = App.model()
self.json_store_path = os.path.join(self.datastore_path, "changedetection.json")
# Base definition for all watchers (deepcopy part of #569)
@@ -362,9 +355,6 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# Deep copy settings to avoid modifying the original
settings_copy = copy.deepcopy(self.__data['settings'])
# Is saved as {uuid}/tag.json
settings_copy['application']['tags'] = {}
return {
'note': 'Settings file - watches are in {uuid}/watch.json, tags are in {uuid}/tag.json',
'app_guid': self.__data.get('app_guid'),
@@ -728,11 +718,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
+3 -55
View File
@@ -669,9 +669,7 @@ class DatastoreUpdatesMixin:
def update_26(self):
self.migrate_legacy_db_format()
# Re-run tag to JSON migration
def update_29(self):
def update_28(self):
"""
Migrate tags to individual tag.json files.
@@ -684,6 +682,8 @@ class DatastoreUpdatesMixin:
- Enables independent tag versioning/backup
- Maintains backwards compatibility (tags stay in settings too)
"""
# Force save as tag.json (not watch.json) even if object is corrupted
logger.critical("=" * 80)
logger.critical("Running migration: Individual tag persistence (update_28)")
logger.critical("Creating individual tag.json files")
@@ -702,9 +702,6 @@ class DatastoreUpdatesMixin:
failed_count = 0
for uuid, tag_data in tags.items():
if os.path.isfile(os.path.join(self.datastore_path, uuid, "tag.json")):
logger.debug(f"Tag {uuid} tag.json exists, skipping")
continue
try:
tag_data.commit()
saved_count += 1
@@ -726,52 +723,3 @@ class DatastoreUpdatesMixin:
logger.info("Future tag edits will update both locations (dual storage)")
logger.critical("=" * 80)
# write it to disk, it will be saved without ['tags'] in the JSON db because we find it from disk glob
# (left this out by accident in previous update, added tags={} in the changedetection.json save_to_disk)
self._save_settings()
def update_30(self):
"""Migrate restock_settings out of watch.json into restock_diff.json processor config file.
Previously, restock_diff processor settings (in_stock_processing, follow_price_changes, etc.)
were stored directly in the watch dict (watch.json). They now belong in a separate per-watch
processor config file (restock_diff.json) consistent with the processor_config_* API system.
For tags: restock_settings key is renamed to processor_config_restock_diff in the tag dict,
matching what the API writes when updating a tag.
Safe to re-run: skips watches that already have a restock_diff.json, skips tags that already
have processor_config_restock_diff set.
"""
import json
# --- Watches ---
for uuid, watch in self.data['watching'].items():
if watch.get('processor') != 'restock_diff':
continue
restock_settings = watch.get('restock_settings')
if not restock_settings:
continue
data_dir = watch.data_dir
if data_dir:
watch.ensure_data_dir_exists()
filepath = os.path.join(data_dir, 'restock_diff.json')
if not os.path.isfile(filepath):
with open(filepath, 'w', encoding='utf-8') as f:
json.dump({'restock_diff': restock_settings}, f, indent=2)
logger.info(f"update_30: migrated restock_settings → {filepath}")
del self.data['watching'][uuid]['restock_settings']
watch.commit()
# --- Tags ---
for tag_uuid, tag in self.data['settings']['application']['tags'].items():
restock_settings = tag.get('restock_settings')
if not restock_settings or tag.get('processor_config_restock_diff'):
continue
tag['processor_config_restock_diff'] = restock_settings
del tag['restock_settings']
tag.commit()
logger.info(f"update_30: migrated tag {tag_uuid} restock_settings → processor_config_restock_diff")
@@ -44,26 +44,13 @@
<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>
</tr>
<tr>
<td><code>{{ '{{diff}}' }}</code></td>
<td>{{ _('The diff output - only changes, additions, and removals') }}<br>
<small>
{{ _('All diff variants accept') }} <code>lines=</code>, <code>context=</code>, <code>word_diff=</code>, <code>ignore_junk=</code> {{ _('args, e.g.') }}
<code>{{ '{{diff(lines=10)}}' }}</code>, <code>{{ '{{diff_added(lines=5, context=2)}}' }}</code>
</small>
</td>
<td>{{ _('The diff output - only changes, additions, and removals') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_clean}}' }}</code></td>
@@ -10,7 +10,6 @@
<li>{{ _('Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>
<li>{{ _('Each line is processed separately (think of each line as "OR")') }}</li>
<li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\d/</code></li>
<li>{{ _('You can also use')}} <a href="#conditions">{{ _('conditions')}}</a> - {{ _('"Page text" - with Contains, Starts With, Not Contains and many more' ) }} <code>/foo\d/</code></li>
</ul>
</span>
</div>
-4
View File
@@ -13,10 +13,6 @@ import sys
# When test server is slow/unresponsive, workers fail fast instead of holding UUIDs for 45s
# This prevents exponential priority growth from repeated deferrals (priority × 10 each defer)
os.environ['DEFAULT_SETTINGS_REQUESTS_TIMEOUT'] = '5'
# Test server runs on localhost (127.0.0.1) which is a private IP.
# Allow it globally so all existing tests keep working; test_ssrf_protection
# uses monkeypatch to temporarily override this for its own assertions.
os.environ['ALLOW_IANA_RESTRICTED_ADDRESSES'] = 'true'
from changedetectionio.flask_app import init_app_secret, changedetection_app
from changedetectionio.tests.util import live_server_setup, new_live_server_setup
-82
View File
@@ -807,88 +807,6 @@ def test_api_import_large_background(client, live_server, measure_memory_usage,
print(f"\n✓ Successfully created {num_urls} watches in background (took {elapsed}s)")
def test_api_restock_processor_config(client, live_server, measure_memory_usage, datastore_path):
"""
Test that processor_config_restock_diff is accepted by the API for watches using
restock_diff processor, that its schema is validated (enum values, types), and that
genuinely unknown fields are rejected with an error that originates from the
OpenAPI spec validation layer.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
test_url = url_for('test_endpoint', _external=True)
# Create a watch in restock_diff mode WITH processor_config in the POST body (matches the API docs example)
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url,
"processor": "restock_diff",
"title": "Restock test",
"processor_config_restock_diff": {
"in_stock_processing": "in_stock_only",
"follow_price_changes": True,
"price_change_min": 8888888.0,
}
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 201
watch_uuid = res.json.get('uuid')
assert is_valid_uuid(watch_uuid)
# Verify the value set on POST is reflected in the UI edit page (not just via PUT)
res = client.get(url_for("ui.ui_edit.edit_page", uuid=watch_uuid))
assert res.status_code == 200
assert b'8888888' in res.data, "price_change_min set via POST should appear in the UI edit form"
# Valid processor_config_restock_diff update via PUT should also be accepted
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({
"processor_config_restock_diff": {
"in_stock_processing": "all_changes",
"follow_price_changes": False,
"price_change_min": 8888888.0,
"price_change_max": 9999999.0,
}
}),
)
assert res.status_code == 200, f"Valid processor_config_restock_diff should be accepted, got: {res.data}"
# Verify the updated value is still reflected in the UI edit page
res = client.get(url_for("ui.ui_edit.edit_page", uuid=watch_uuid))
assert res.status_code == 200
assert b'8888888' in res.data, "price_change_min set via PUT should appear in the UI edit form"
# An invalid enum value inside processor_config_restock_diff should be rejected by the spec
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({
"processor_config_restock_diff": {
"in_stock_processing": "not_a_valid_enum_value"
}
}),
)
assert res.status_code == 400, "Invalid enum value in processor config should be rejected"
assert b'Validation failed' in res.data, "Rejection should come from OpenAPI spec validation layer"
# A completely unknown field should be rejected (either by OpenAPI spec validation or
# the application-level field filter — both are acceptable gatekeepers)
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({"field_that_is_not_in_the_spec_at_all": "some value"}),
)
assert res.status_code == 400, "Unknown fields should be rejected"
assert (b'Validation failed' in res.data or b'Unknown field' in res.data), \
"Rejection should come from either the OpenAPI spec validation layer or application field filter"
delete_all_watches(client)
def test_api_conflict_UI_password(client, live_server, measure_memory_usage, datastore_path):
@@ -12,50 +12,6 @@ from flask import url_for
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
def test_openapi_merged_spec_contains_restock_fields():
"""
Unit test: verify that build_merged_spec_dict() correctly merges the
restock_diff processor api.yaml into the base spec so that
WatchBase.properties includes processor_config_restock_diff with all
expected sub-fields. No live server required.
"""
from changedetectionio.api import build_merged_spec_dict
spec = build_merged_spec_dict()
schemas = spec['components']['schemas']
# The merged schema for processor_config_restock_diff should exist
assert 'processor_config_restock_diff' in schemas, \
"processor_config_restock_diff schema missing from merged spec"
restock_schema = schemas['processor_config_restock_diff']
props = restock_schema.get('properties', {})
expected_fields = {
'in_stock_processing',
'follow_price_changes',
'price_change_min',
'price_change_max',
'price_change_threshold_percent',
}
missing = expected_fields - set(props.keys())
assert not missing, f"Missing fields in processor_config_restock_diff schema: {missing}"
# in_stock_processing must be an enum with the three valid values
enum_values = set(props['in_stock_processing'].get('enum', []))
assert enum_values == {'in_stock_only', 'all_changes', 'off'}, \
f"Unexpected enum values for in_stock_processing: {enum_values}"
# WatchBase.properties must carry a $ref to the restock schema so the
# validation middleware can enforce it on every POST/PUT to /watch
watchbase_props = schemas['WatchBase']['properties']
assert 'processor_config_restock_diff' in watchbase_props, \
"processor_config_restock_diff not wired into WatchBase.properties"
ref = watchbase_props['processor_config_restock_diff'].get('$ref', '')
assert 'processor_config_restock_diff' in ref, \
f"WatchBase.processor_config_restock_diff should $ref the schema, got: {ref}"
def test_openapi_validation_invalid_content_type_on_create_watch(client, live_server, measure_memory_usage, datastore_path):
"""Test that creating a watch with invalid content-type triggers OpenAPI validation error."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
-70
View File
@@ -176,76 +176,6 @@ def test_api_tags_listing(client, live_server, measure_memory_usage, datastore_p
assert res.status_code == 204
def test_api_tag_restock_processor_config(client, live_server, measure_memory_usage, datastore_path):
"""
Test that a tag/group can be updated with processor_config_restock_diff via the API.
Since Tag extends WatchBase, processor config fields injected into WatchBase are also valid for tags.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
set_original_response(datastore_path=datastore_path)
# Create a tag
res = client.post(
url_for("tag"),
data=json.dumps({"title": "Restock Group"}),
headers={'content-type': 'application/json', 'x-api-key': api_key}
)
assert res.status_code == 201
tag_uuid = res.json.get('uuid')
# Update tag with valid processor_config_restock_diff
res = client.put(
url_for("tag", uuid=tag_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({
"overrides_watch": True,
"processor_config_restock_diff": {
"in_stock_processing": "in_stock_only",
"follow_price_changes": True,
"price_change_min": 8888888
}
})
)
assert res.status_code == 200, f"PUT tag with restock config failed: {res.data}"
# Verify the config was stored via API
res = client.get(
url_for("tag", uuid=tag_uuid),
headers={'x-api-key': api_key}
)
assert res.status_code == 200
tag_data = res.json
assert tag_data.get('overrides_watch') == True
assert tag_data.get('processor_config_restock_diff', {}).get('in_stock_processing') == 'in_stock_only'
assert tag_data.get('processor_config_restock_diff', {}).get('price_change_min') == 8888888
# Verify the value is also reflected in the UI tag edit page
res = client.get(url_for("tags.form_tag_edit", uuid=tag_uuid))
assert res.status_code == 200
assert b'8888888' in res.data, "price_change_min set via API should appear in the UI tag edit form"
# Invalid enum value should be rejected by OpenAPI spec validation
res = client.put(
url_for("tag", uuid=tag_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({
"processor_config_restock_diff": {
"in_stock_processing": "not_a_valid_value"
}
})
)
assert res.status_code == 400
assert b'Validation failed' in res.data
# Clean up
res = client.delete(
url_for("tag", uuid=tag_uuid),
headers={'x-api-key': api_key}
)
assert res.status_code == 204
def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path):
"""
Test the full round trip, this way we test the default Model fits back into OpenAPI spec
+5 -88
View File
@@ -6,10 +6,11 @@ import io
from zipfile import ZipFile
import re
import time
from changedetectionio.model import Watch, Tag
def test_backup(client, live_server, measure_memory_usage, datastore_path):
# live_server_setup(live_server) # Setup on conftest per function
set_original_response(datastore_path=datastore_path)
@@ -31,7 +32,7 @@ def test_backup(client, live_server, measure_memory_usage, datastore_path):
time.sleep(4)
res = client.get(
url_for("backups.create"),
url_for("backups.index"),
follow_redirects=True
)
# Can see the download link to the backup
@@ -79,12 +80,11 @@ def test_backup(client, live_server, measure_memory_usage, datastore_path):
def test_watch_data_package_download(client, live_server, measure_memory_usage, datastore_path):
"""Test downloading a single watch's data as a zip package"""
import os
set_original_response(datastore_path=datastore_path)
uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_endpoint', _external=True))
tag_uuid = client.application.config.get('DATASTORE').add_tag(title="Tasty backup tag")
tag_uuid2 = client.application.config.get('DATASTORE').add_tag(title="Tasty backup tag number two")
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
@@ -113,87 +113,4 @@ def test_watch_data_package_download(client, live_server, measure_memory_usage,
# Should contain history/snapshot files
uuid4hex_txt = re.compile(f'^{re.escape(uuid)}/.*\\.txt', re.I)
txt_files = list(filter(uuid4hex_txt.match, files))
assert len(txt_files) > 0, f"Should have at least one .txt file (history/snapshot), got: {files}"
def test_backup_restore(client, live_server, measure_memory_usage, datastore_path):
"""Test that a full backup zip can be restored — watches and tags survive a round-trip."""
set_original_response(datastore_path=datastore_path)
datastore = live_server.app.config['DATASTORE']
watch_url = url_for('test_endpoint', _external=True)
# Set up: one watch and two tags
uuid = datastore.add_watch(url=watch_url)
tag_uuid = datastore.add_tag(title="Tasty backup tag")
tag_uuid2 = datastore.add_tag(title="Tasty backup tag number two")
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# Create a full backup
client.get(url_for("backups.request_backup"), follow_redirects=True)
time.sleep(4)
# Download the latest backup zip
res = client.get(url_for("backups.download_backup", filename="latest"), follow_redirects=True)
assert res.content_type == "application/zip"
zip_data = res.data
# Confirm the zip contains both watch.json and tag.json entries
backup = ZipFile(io.BytesIO(zip_data))
names = backup.namelist()
assert f"{uuid}/watch.json" in names, f"watch.json missing from backup: {names}"
assert f"{tag_uuid}/tag.json" in names, f"tag.json for tag 1 missing from backup: {names}"
assert f"{tag_uuid2}/tag.json" in names, f"tag.json for tag 2 missing from backup: {names}"
# --- Wipe everything ---
datastore.delete('all')
client.get(url_for("tags.delete_all"), follow_redirects=True)
assert uuid not in datastore.data['watching'], "Watch should be gone after delete"
assert tag_uuid not in datastore.data['settings']['application']['tags'], "Tag 1 should be gone after delete"
assert tag_uuid2 not in datastore.data['settings']['application']['tags'], "Tag 2 should be gone after delete"
# --- Restore from the backup zip ---
res = client.post(
url_for("backups.restore.backups_restore_start"),
data={
'zip_file': (io.BytesIO(zip_data), 'backup.zip'),
'include_groups': 'y',
'include_groups_replace_existing': 'y',
'include_watches': 'y',
'include_watches_replace_existing': 'y',
},
content_type='multipart/form-data',
follow_redirects=True
)
assert res.status_code == 200
# Wait for the thread to finish
time.sleep(2)
# --- Watch checks ---
restored_watch = datastore.data['watching'].get(uuid)
assert restored_watch is not None, f"Watch {uuid} not found after restore"
assert restored_watch['url'] == watch_url, "Restored watch URL does not match"
assert isinstance(restored_watch, Watch.model), \
f"Watch not properly rehydrated, got {type(restored_watch)}"
assert restored_watch.history_n >= 1, \
f"Restored watch should have at least 1 history entry, got {restored_watch.history_n}"
# --- Tag checks ---
restored_tags = datastore.data['settings']['application']['tags']
restored_tag = restored_tags.get(tag_uuid)
assert restored_tag is not None, f"Tag {tag_uuid} not found after restore"
assert restored_tag['title'] == "Tasty backup tag", "Restored tag 1 title does not match"
assert isinstance(restored_tag, Tag.model), \
f"Tag 1 not properly rehydrated, got {type(restored_tag)}"
restored_tag2 = restored_tags.get(tag_uuid2)
assert restored_tag2 is not None, f"Tag {tag_uuid2} not found after restore"
assert restored_tag2['title'] == "Tasty backup tag number two", "Restored tag 2 title does not match"
assert isinstance(restored_tag2, Tag.model), \
f"Tag 2 not properly rehydrated, got {type(restored_tag2)}"
assert len(txt_files) > 0, f"Should have at least one .txt file (history/snapshot), got: {files}"
+2 -17
View File
@@ -17,7 +17,6 @@ from changedetectionio.notification import (
)
from ..diff import HTML_CHANGED_STYLE
from ..model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
from ..notification_service import FormattableTimestamp
# Hard to just add more live server URLs when one test is already running (I think)
@@ -108,11 +107,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
"Diff Added: {{diff_added}}\n"
"Diff Removed: {{diff_removed}}\n"
"Diff Full: {{diff_full}}\n"
"Diff with args: {{diff(context=3)}}"
"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'}
@@ -140,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)
@@ -175,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'
@@ -109,7 +109,7 @@ def test_itemprop_price_change(client, live_server, measure_memory_usage, datast
set_original_response(props_markup=instock_props[0], price='120.45', datastore_path=datastore_path)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"),
data={"processor_config_restock_diff-follow_price_changes": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
data={"restock_settings-follow_price_changes": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
follow_redirects=True
)
assert b"Updated watch." in res.data
@@ -204,9 +204,9 @@ def _run_test_minmax_limit(client, extra_watch_edit_form, datastore_path):
def test_restock_itemprop_minmax(client, live_server, measure_memory_usage, datastore_path):
extras = {
"processor_config_restock_diff-follow_price_changes": "y",
"processor_config_restock_diff-price_change_min": 900.0,
"processor_config_restock_diff-price_change_max": 1100.10
"restock_settings-follow_price_changes": "y",
"restock_settings-price_change_min": 900.0,
"restock_settings-price_change_max": 1100.10
}
_run_test_minmax_limit(client, extra_watch_edit_form=extras, datastore_path=datastore_path)
@@ -223,9 +223,9 @@ def test_restock_itemprop_with_tag(client, live_server, measure_memory_usage, da
res = client.post(
url_for("tags.form_tag_edit_submit", uuid="first"),
data={"name": "test-tag",
"processor_config_restock_diff-follow_price_changes": "y",
"processor_config_restock_diff-price_change_min": 900.0,
"processor_config_restock_diff-price_change_max": 1100.10,
"restock_settings-follow_price_changes": "y",
"restock_settings-price_change_min": 900.0,
"restock_settings-price_change_max": 1100.10,
"overrides_watch": "y", #overrides_watch should be restock_overrides_watch
},
follow_redirects=True
@@ -258,8 +258,8 @@ def test_itemprop_percent_threshold(client, live_server, measure_memory_usage, d
res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"),
data={"processor_config_restock_diff-follow_price_changes": "y",
"processor_config_restock_diff-price_change_threshold_percent": 5.0,
data={"restock_settings-follow_price_changes": "y",
"restock_settings-price_change_threshold_percent": 5.0,
"url": test_url,
"tags": "",
"headers": "",
@@ -305,8 +305,8 @@ def test_itemprop_percent_threshold(client, live_server, measure_memory_usage, d
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={"processor_config_restock_diff-follow_price_changes": "y",
"processor_config_restock_diff-price_change_threshold_percent": 5.05,
data={"restock_settings-follow_price_changes": "y",
"restock_settings-price_change_threshold_percent": 5.05,
"processor": "text_json_diff",
"url": test_url,
'fetch_backend': "html_requests",
-158
View File
@@ -1,5 +1,4 @@
import os
import pytest
from flask import url_for
@@ -34,7 +33,6 @@ def test_favicon(client, live_server, measure_memory_usage, datastore_path):
favicon_base_64=SVG_BASE64
)
res = client.get(url_for('static_content', group='favicon', filename=uuid))
assert res.status_code == 200
assert len(res.data) > 10
@@ -581,159 +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')
# ------------------------------------------------------------------
# 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')
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')
# ------------------------------------------------------------------
# 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',
)
def test_unresolvable_hostname_is_allowed(client, live_server, monkeypatch):
"""
Unresolvable hostnames must NOT be blocked at add-time when ALLOW_IANA_RESTRICTED_ADDRESSES=false.
DNS failure (gaierror) at add-time does not mean the URL resolves to a private IP
the domain may simply be offline or not yet live. Blocking it would be a false positive.
The real DNS-rebinding protection happens at fetch-time in call_browser().
"""
from changedetectionio.validate_url import is_safe_valid_url
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
url = 'http://this-host-does-not-exist-xyz987.invalid/some/path'
# Should pass URL validation despite being unresolvable
assert is_safe_valid_url(url), \
"Unresolvable hostname should pass is_safe_valid_url — DNS failure is not a private-IP signal"
# Should be accepted via the UI form and appear in the watch list
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' not in res.data, \
"UI should not reject a URL just because its hostname is unresolvable"
res = client.get(url_for('watchlist.index'))
assert b'this-host-does-not-exist-xyz987.invalid' in res.data, \
"Unresolvable hostname watch should appear in the watch overview list"
@@ -199,428 +199,6 @@ class TestHtmlToText(unittest.TestCase):
print(f"✓ Basic thread-safety test passed: {len(results)} threads, no errors")
def test_large_html_with_bloated_head(self):
"""
Test that html_to_text can handle large HTML documents with massive <head> bloat.
SPAs often dump 10MB+ of styles, scripts, and other bloat into the <head> section.
This can cause inscriptis to silently exit when processing very large documents.
The fix strips <style>, <script>, <svg>, <noscript>, <link>, <meta>, and HTML comments
before processing, allowing extraction of actual body content.
"""
# Generate massive style block (~5MB)
large_style = '<style>' + '.class{color:red;}\n' * 200000 + '</style>\n'
# Generate massive script block (~5MB)
large_script = '<script>' + 'console.log("bloat");\n' * 200000 + '</script>\n'
# Generate lots of SVG bloat (~3MB)
svg_bloat = '<svg><path d="M0,0 L100,100"/></svg>\n' * 50000
# Generate meta/link tags (~2MB)
meta_bloat = '<meta name="description" content="bloat"/>\n' * 50000
link_bloat = '<link rel="stylesheet" href="bloat.css"/>\n' * 50000
# Generate HTML comments (~1MB)
comment_bloat = '<!-- This is bloat -->\n' * 50000
# Generate noscript bloat
noscript_bloat = '<noscript>Enable JavaScript</noscript>\n' * 10000
# Build the large HTML document
html = f'''<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
{large_style}
{large_script}
{svg_bloat}
{meta_bloat}
{link_bloat}
{comment_bloat}
{noscript_bloat}
</head>
<body>
<h1>Important Heading</h1>
<p>This is the actual content that should be extracted.</p>
<div>
<p>First paragraph with meaningful text.</p>
<p>Second paragraph with more content.</p>
</div>
<footer>Footer text</footer>
</body>
</html>
'''
# Verify the HTML is actually large (should be ~20MB+)
html_size_mb = len(html) / (1024 * 1024)
assert html_size_mb > 15, f"HTML should be >15MB, got {html_size_mb:.2f}MB"
print(f" Testing {html_size_mb:.2f}MB HTML document with bloated head...")
# This should not crash or silently exit
text = html_to_text(html)
# Verify we got actual text output (not empty/None)
assert text is not None, "html_to_text returned None"
assert len(text) > 0, "html_to_text returned empty string"
# Verify the actual body content was extracted
assert 'Important Heading' in text, "Failed to extract heading"
assert 'actual content that should be extracted' in text, "Failed to extract paragraph"
assert 'First paragraph with meaningful text' in text, "Failed to extract first paragraph"
assert 'Second paragraph with more content' in text, "Failed to extract second paragraph"
assert 'Footer text' in text, "Failed to extract footer"
# Verify bloat was stripped (output should be tiny compared to input)
text_size_kb = len(text) / 1024
assert text_size_kb < 1, f"Output too large ({text_size_kb:.2f}KB), bloat not stripped"
# Verify no CSS, script content, or SVG leaked through
assert 'color:red' not in text, "Style content leaked into text output"
assert 'console.log' not in text, "Script content leaked into text output"
assert '<path' not in text, "SVG content leaked into text output"
assert 'bloat.css' not in text, "Link href leaked into text output"
print(f" ✓ Successfully processed {html_size_mb:.2f}MB HTML -> {text_size_kb:.2f}KB text")
def test_body_display_none_spa_pattern(self):
"""
Test that html_to_text can extract content from pages with display:none body.
SPAs (Single Page Applications) often use <body style="display:none"> to hide content
until JavaScript loads and renders the page. inscriptis respects CSS display rules,
so without preprocessing, it would skip all content and return only newlines.
The fix strips display:none and visibility:hidden styles from the body tag before
processing, allowing text extraction from client-side rendered applications.
"""
# Test case 1: Basic display:none
html1 = '''<!DOCTYPE html>
<html lang="en">
<head><title>What's New Fluxguard</title></head>
<body style="display:none">
<h1>Important Heading</h1>
<p>This is actual content that should be extracted.</p>
<div>
<p>First paragraph with meaningful text.</p>
<p>Second paragraph with more content.</p>
</div>
</body>
</html>'''
text1 = html_to_text(html1)
# Before fix: would return ~33 newlines, len(text) ~= 33
# After fix: should extract actual content, len(text) > 100
assert len(text1) > 100, f"Expected substantial text output, got {len(text1)} chars"
assert 'Important Heading' in text1, "Failed to extract heading from display:none body"
assert 'actual content' in text1, "Failed to extract paragraph from display:none body"
assert 'First paragraph' in text1, "Failed to extract nested content"
# Should not be mostly newlines
newline_ratio = text1.count('\n') / len(text1)
assert newline_ratio < 0.5, f"Output is mostly newlines ({newline_ratio:.2%}), content not extracted"
# Test case 2: visibility:hidden (another hiding pattern)
html2 = '<html><body style="visibility:hidden"><h1>Hidden Content</h1><p>Test paragraph.</p></body></html>'
text2 = html_to_text(html2)
assert 'Hidden Content' in text2, "Failed to extract content from visibility:hidden body"
assert 'Test paragraph' in text2, "Failed to extract paragraph from visibility:hidden body"
# Test case 3: Mixed styles (display:none with other CSS)
html3 = '<html><body style="color: red; display:none; font-size: 12px"><p>Mixed style content</p></body></html>'
text3 = html_to_text(html3)
assert 'Mixed style content' in text3, "Failed to extract content from body with mixed styles"
# Test case 4: Case insensitivity (DISPLAY:NONE uppercase)
html4 = '<html><body style="DISPLAY:NONE"><p>Uppercase style</p></body></html>'
text4 = html_to_text(html4)
assert 'Uppercase style' in text4, "Failed to handle uppercase DISPLAY:NONE"
# Test case 5: Space variations (display: none vs display:none)
html5 = '<html><body style="display: none"><p>With spaces</p></body></html>'
text5 = html_to_text(html5)
assert 'With spaces' in text5, "Failed to handle 'display: none' with space"
# Test case 6: Body with other attributes (class, id)
html6 = '<html><body class="foo" style="display:none" id="bar"><p>With attributes</p></body></html>'
text6 = html_to_text(html6)
assert 'With attributes' in text6, "Failed to extract from body with multiple attributes"
# Test case 7: Should NOT affect opacity:0 (which doesn't hide from inscriptis)
html7 = '<html><body style="opacity:0"><p>Transparent content</p></body></html>'
text7 = html_to_text(html7)
# Opacity doesn't affect inscriptis text extraction, content should be there
assert 'Transparent content' in text7, "Incorrectly stripped opacity:0 style"
print(" ✓ All display:none body tag tests passed")
def test_style_tag_with_svg_data_uri(self):
"""
Test that style tags containing SVG data URIs are properly stripped.
Some WordPress and modern sites embed SVG as data URIs in CSS, which contains
<svg> and </svg> tags within the style content. The regex must use backreferences
to ensure <style> matches </style> (not </svg> inside the CSS).
This was causing errors where the regex would match <style> and stop at the first
</svg> it encountered inside a CSS data URI, breaking the HTML structure.
"""
# Real-world example from WordPress wp-block-image styles
html = '''<!DOCTYPE html>
<html>
<head>
<style id='wp-block-image-inline-css'>
.wp-block-image>a,.wp-block-image>figure>a{display:inline-block}.wp-block-image img{box-sizing:border-box;height:auto;max-width:100%;vertical-align:bottom}@supports ((-webkit-mask-image:none) or (mask-image:none)) or (-webkit-mask-image:none){.wp-block-image.is-style-circle-mask img{border-radius:0;-webkit-mask-image:url('data:image/svg+xml;utf8,<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50"/></svg>');mask-image:url('data:image/svg+xml;utf8,<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50"/></svg>');mask-mode:alpha}}
</style>
</head>
<body>
<h1>Test Heading</h1>
<p>This is the actual content that should be extracted.</p>
<div class="wp-block-image">
<img src="test.jpg" alt="Test image">
</div>
</body>
</html>'''
# This should not crash and should extract the body content
text = html_to_text(html)
# Verify the actual body content was extracted
assert text is not None, "html_to_text returned None"
assert len(text) > 0, "html_to_text returned empty string"
assert 'Test Heading' in text, "Failed to extract heading"
assert 'actual content that should be extracted' in text, "Failed to extract paragraph"
# Verify CSS content was stripped (including the SVG data URI)
assert '.wp-block-image' not in text, "CSS class selector leaked into text"
assert 'mask-image' not in text, "CSS property leaked into text"
assert 'data:image/svg+xml' not in text, "SVG data URI leaked into text"
assert 'viewBox' not in text, "SVG attributes leaked into text"
# Verify no broken HTML structure
assert '<style' not in text, "Unclosed style tag in output"
assert '</svg>' not in text, "SVG closing tag leaked into text"
print(" ✓ Style tag with SVG data URI test passed")
def test_style_tag_closes_correctly(self):
"""
Test that each tag type (style, script, svg) closes with the correct closing tag.
Before the fix, the regex used (?:style|script|svg|noscript) for both opening and
closing tags, which meant <style> could incorrectly match </svg> as its closing tag.
With backreferences, <style> must close with </style>, <svg> with </svg>, etc.
"""
# Test nested tags where incorrect matching would break
html = '''<!DOCTYPE html>
<html>
<head>
<style>
body { background: url('data:image/svg+xml,<svg><rect/></svg>'); }
</style>
<script>
const svg = '<svg><path d="M0,0"/></svg>';
</script>
</head>
<body>
<h1>Content</h1>
<svg><circle cx="50" cy="50" r="40"/></svg>
<p>After SVG</p>
</body>
</html>'''
text = html_to_text(html)
# Should extract body content
assert 'Content' in text, "Failed to extract heading"
assert 'After SVG' in text, "Failed to extract content after SVG"
# Should strip all style/script/svg content
assert 'background:' not in text, "Style content leaked"
assert 'const svg' not in text, "Script content leaked"
assert '<circle' not in text, "SVG element leaked"
assert 'data:image/svg+xml' not in text, "Data URI leaked"
print(" ✓ Tag closing validation test passed")
def test_script_with_closing_tag_in_string_does_not_eat_content(self):
"""
Script tag containing </script> inside a JS string must not prematurely end the block.
This is the classic regex failure mode: the old pattern would find the first </script>
inside the JS string literal and stop there, leaving the tail of the script block
(plus any following content) exposed as raw text. BS4 parses the HTML correctly.
"""
html = '''<html><body>
<p>Before script</p>
<script>
var html = "<div>foo<\\/script><p>bar</p>";
var also = 1;
</script>
<p>AFTER SCRIPT</p>
</body></html>'''
text = html_to_text(html)
assert 'Before script' in text
assert 'AFTER SCRIPT' in text
# Script internals must not leak
assert 'var html' not in text
assert 'var also' not in text
def test_content_sandwiched_between_multiple_body_scripts(self):
"""Content between multiple script/style blocks in the body must all survive."""
html = '''<html><body>
<script>var a = 1;</script>
<p>CONTENT A</p>
<style>.x { color: red; }</style>
<p>CONTENT B</p>
<script>var b = 2;</script>
<p>CONTENT C</p>
<style>.y { color: blue; }</style>
<p>CONTENT D</p>
</body></html>'''
text = html_to_text(html)
for label in ['CONTENT A', 'CONTENT B', 'CONTENT C', 'CONTENT D']:
assert label in text, f"'{label}' was eaten by script/style stripping"
assert 'var a' not in text
assert 'var b' not in text
assert 'color: red' not in text
assert 'color: blue' not in text
def test_unicode_and_international_content_preserved(self):
"""Non-ASCII content (umlauts, CJK, soft hyphens) must survive stripping."""
html = '''<html><body>
<style>.x{color:red}</style>
<p>German: Aus\xadge\xadbucht! ANMELDUNG Fan\xadday 2026</p>
<p>Chinese: \u6ce8\u518c</p>
<p>Japanese: \u767b\u9332</p>
<p>Korean: \ub4f1\ub85d</p>
<p>Emoji: \U0001f4e2</p>
<script>var x = 1;</script>
</body></html>'''
text = html_to_text(html)
assert 'ANMELDUNG' in text
assert '\u6ce8\u518c' in text # Chinese
assert '\u767b\u9332' in text # Japanese
assert '\ub4f1\ub85d' in text # Korean
def test_style_with_type_attribute_is_stripped(self):
"""<style type="text/css"> (with type attribute) must be stripped just like bare <style>."""
html = '''<html><body>
<style type="text/css">.important { display: none; }</style>
<p>VISIBLE CONTENT</p>
</body></html>'''
text = html_to_text(html)
assert 'VISIBLE CONTENT' in text
assert '.important' not in text
assert 'display: none' not in text
def test_ldjson_script_is_stripped(self):
"""<script type="application/ld+json"> must be stripped — raw JSON must not appear as text."""
html = '''<html><body>
<script type="application/ld+json">
{"@type": "Product", "name": "Widget", "price": "9.99"}
</script>
<p>PRODUCT PAGE</p>
</body></html>'''
text = html_to_text(html)
assert 'PRODUCT PAGE' in text
assert '@type' not in text
assert '"price"' not in text
def test_inline_svg_is_stripped_entirely(self):
"""
Inline SVG elements in the body are stripped by BS4 before passing to inscriptis.
SVGs can be huge (icon libraries, data visualisations) and produce garbage path-data
text. The old regex code explicitly stripped <svg>; the BS4 path must do the same.
"""
html = '''<html><body>
<p>Before SVG</p>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M14 5L7 12L14 19Z" fill="none"/>
<circle cx="12" cy="12" r="10"/>
</svg>
<p>After SVG</p>
</body></html>'''
text = html_to_text(html)
assert 'Before SVG' in text
assert 'After SVG' in text
assert 'M14 5L7' not in text, "SVG path data should not appear in text output"
assert 'viewBox' not in text, "SVG attributes should not appear in text output"
def test_tag_inside_json_data_attribute_does_not_eat_content(self):
"""
Tags inside JSON data attributes with JS-escaped closing tags must not eat real content.
Real-world case: Elementor/JetEngine WordPress widgets embed HTML (including SVG icons)
inside JSON data attributes like data-slider-atts. The HTML inside is JS-escaped, so
closing tags appear as <\\/svg> rather than </svg>.
The old regex approach would find <svg> inside the attribute value, then fail to find
<\/svg> as a matching close tag, and scan forward to the next real </svg> in the DOM
eating tens of kilobytes of actual page content in the process.
"""
html = '''<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<div class="slider" data-slider-atts="{&quot;prevArrow&quot;:&quot;<i class=\\&quot;icon\\&quot;><svg width=\\&quot;24\\&quot; height=\\&quot;24\\&quot; viewBox=\\&quot;0 0 24 24\\&quot; xmlns=\\&quot;http:\\/\\/www.w3.org\\/2000\\/svg\\&quot;><path d=\\&quot;M14 5L7 12L14 19\\&quot;\\/><\\/svg><\\/i>&quot;}">
</div>
<div class="content">
<h1>IMPORTANT CONTENT</h1>
<p>This text must not be eaten by the tag-stripping logic.</p>
</div>
<svg><circle cx="50" cy="50" r="40"/></svg>
</body>
</html>'''
text = html_to_text(html)
assert 'IMPORTANT CONTENT' in text, (
"Content after a JS-escaped tag in a data attribute was incorrectly stripped. "
"The tag-stripping logic is matching <tag> inside attribute values and scanning "
"forward to the next real closing tag in the DOM."
)
assert 'This text must not be eaten' in text
def test_script_inside_json_data_attribute_does_not_eat_content(self):
"""Same issue as above but with <script> embedded in a data attribute with JS-escaped closing tag."""
html = '''<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<div data-config="{&quot;template&quot;:&quot;<script type=\\&quot;text\\/javascript\\&quot;>var x=1;<\\/script>&quot;}">
</div>
<div>
<h1>MUST SURVIVE</h1>
<p>Real content after the data attribute with embedded script tag.</p>
</div>
<script>var real = 1;</script>
</body>
</html>'''
text = html_to_text(html)
assert 'MUST SURVIVE' in text, (
"Content after a JS-escaped <script> in a data attribute was incorrectly stripped."
)
assert 'Real content after the data attribute' in text
if __name__ == '__main__':
# Can run this file directly for quick testing
@@ -8,7 +8,6 @@ python3 -m pytest changedetectionio/tests/unit/test_time_handler.py -v
"""
import unittest
import unittest.mock
import arrow
from changedetectionio import time_handler
@@ -241,211 +240,6 @@ class TestAmIInsideTime(unittest.TestCase):
# Result depends on current time
self.assertIsInstance(result, bool)
def test_24_hour_schedule_from_midnight(self):
"""Test 24-hour schedule starting at midnight covers entire day."""
timezone_str = 'UTC'
# Test at a specific time: Monday 00:00
test_time = arrow.get('2024-01-01 00:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd') # Monday
# Mock current time for testing
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="00:00",
timezone_str=timezone_str,
duration=1440 # 24 hours
)
self.assertTrue(result, "Should be active at start of 24-hour schedule")
def test_24_hour_schedule_at_end_of_day(self):
"""Test 24-hour schedule is active at 23:59:59."""
timezone_str = 'UTC'
# Test at Monday 23:59:59
test_time = arrow.get('2024-01-01 23:59:59', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd') # Monday
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="00:00",
timezone_str=timezone_str,
duration=1440 # 24 hours
)
self.assertTrue(result, "Should be active at end of 24-hour schedule")
def test_24_hour_schedule_at_midnight_transition(self):
"""Test 24-hour schedule at exactly midnight transition."""
timezone_str = 'UTC'
# Test at Tuesday 00:00:00 (end of Monday's 24-hour schedule)
test_time = arrow.get('2024-01-02 00:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
monday = test_time.shift(days=-1).format('dddd') # Monday
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=monday,
time_str="00:00",
timezone_str=timezone_str,
duration=1440 # 24 hours
)
self.assertTrue(result, "Should include exactly midnight at end of 24-hour schedule")
def test_schedule_crosses_midnight_before_midnight(self):
"""Test schedule crossing midnight - before midnight."""
timezone_str = 'UTC'
# Monday 23:30
test_time = arrow.get('2024-01-01 23:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd') # Monday
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="23:00",
timezone_str=timezone_str,
duration=120 # 2 hours (until 01:00 next day)
)
self.assertTrue(result, "Should be active before midnight in cross-midnight schedule")
def test_schedule_crosses_midnight_after_midnight(self):
"""Test schedule crossing midnight - after midnight."""
timezone_str = 'UTC'
# Tuesday 00:30
test_time = arrow.get('2024-01-02 00:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
monday = test_time.shift(days=-1).format('dddd') # Monday
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=monday,
time_str="23:00",
timezone_str=timezone_str,
duration=120 # 2 hours (until 01:00 Tuesday)
)
self.assertTrue(result, "Should be active after midnight in cross-midnight schedule")
def test_schedule_crosses_midnight_at_exact_end(self):
"""Test schedule crossing midnight at exact end time."""
timezone_str = 'UTC'
# Tuesday 01:00 (exact end of Monday 23:00 + 120 minutes)
test_time = arrow.get('2024-01-02 01:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
monday = test_time.shift(days=-1).format('dddd') # Monday
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=monday,
time_str="23:00",
timezone_str=timezone_str,
duration=120 # 2 hours
)
self.assertTrue(result, "Should include exact end time of schedule")
def test_duration_60_minutes(self):
"""Test that duration of 60 minutes works correctly."""
timezone_str = 'UTC'
test_time = arrow.get('2024-01-01 12:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd')
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="12:00",
timezone_str=timezone_str,
duration=60 # Exactly 60 minutes
)
self.assertTrue(result, "60-minute duration should work")
def test_duration_at_exact_end_minute(self):
"""Test at exact end of 60-minute window."""
timezone_str = 'UTC'
# Exactly 13:00 (end of 12:00 + 60 minutes)
test_time = arrow.get('2024-01-01 13:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd')
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="12:00",
timezone_str=timezone_str,
duration=60
)
self.assertTrue(result, "Should include exact end minute")
def test_one_second_after_schedule_ends(self):
"""Test one second after schedule should end."""
timezone_str = 'UTC'
# 13:00:01 (one second after 12:00 + 60 minutes)
test_time = arrow.get('2024-01-01 13:00:01', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd')
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="12:00",
timezone_str=timezone_str,
duration=60
)
self.assertFalse(result, "Should be False one second after schedule ends")
def test_multi_day_schedule(self):
"""Test schedule longer than 24 hours (48 hours)."""
timezone_str = 'UTC'
# Tuesday 12:00 (36 hours after Monday 00:00)
test_time = arrow.get('2024-01-02 12:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
monday = test_time.shift(days=-1).format('dddd')
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=monday,
time_str="00:00",
timezone_str=timezone_str,
duration=2880 # 48 hours
)
self.assertTrue(result, "Should support multi-day schedules")
def test_schedule_one_minute_duration(self):
"""Test very short 1-minute schedule."""
timezone_str = 'UTC'
test_time = arrow.get('2024-01-01 12:00:30', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd')
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="12:00",
timezone_str=timezone_str,
duration=1 # Just 1 minute
)
self.assertTrue(result, "1-minute schedule should work")
def test_schedule_at_exact_start_time(self):
"""Test at exact start time (00:00:00.000000)."""
timezone_str = 'UTC'
test_time = arrow.get('2024-01-01 12:00:00.000000', 'YYYY-MM-DD HH:mm:ss.SSSSSS').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd')
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="12:00",
timezone_str=timezone_str,
duration=30
)
self.assertTrue(result, "Should include exact start time")
def test_schedule_one_microsecond_before_start(self):
"""Test one microsecond before schedule starts."""
timezone_str = 'UTC'
test_time = arrow.get('2024-01-01 11:59:59.999999', 'YYYY-MM-DD HH:mm:ss.SSSSSS').replace(tzinfo=timezone_str)
day_of_week = test_time.format('dddd')
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.am_i_inside_time(
day_of_week=day_of_week,
time_str="12:00",
timezone_str=timezone_str,
duration=30
)
self.assertFalse(result, "Should not include time before start")
class TestIsWithinSchedule(unittest.TestCase):
"""Tests for the is_within_schedule function."""
@@ -611,175 +405,6 @@ class TestIsWithinSchedule(unittest.TestCase):
result = time_handler.is_within_schedule(time_schedule_limit)
self.assertTrue(result, "Should handle timezone with whitespace")
def test_schedule_with_60_minutes(self):
"""Test schedule with duration of 0 hours and 60 minutes."""
timezone_str = 'UTC'
now = arrow.now(timezone_str)
current_day = now.format('dddd').lower()
current_hour = now.format('HH:00')
time_schedule_limit = {
'enabled': True,
'timezone': timezone_str,
current_day: {
'enabled': True,
'start_time': current_hour,
'duration': {'hours': 0, 'minutes': 60} # 60 minutes
}
}
result = time_handler.is_within_schedule(time_schedule_limit)
self.assertTrue(result, "Should accept 60 minutes as valid duration")
def test_schedule_with_24_hours(self):
"""Test schedule with duration of 24 hours and 0 minutes."""
timezone_str = 'UTC'
now = arrow.now(timezone_str)
current_day = now.format('dddd').lower()
start_hour = now.format('HH:00')
time_schedule_limit = {
'enabled': True,
'timezone': timezone_str,
current_day: {
'enabled': True,
'start_time': start_hour,
'duration': {'hours': 24, 'minutes': 0} # Full 24 hours
}
}
result = time_handler.is_within_schedule(time_schedule_limit)
self.assertTrue(result, "Should accept 24 hours as valid duration")
def test_schedule_with_90_minutes(self):
"""Test schedule with duration of 0 hours and 90 minutes."""
timezone_str = 'UTC'
now = arrow.now(timezone_str)
current_day = now.format('dddd').lower()
current_hour = now.format('HH:00')
time_schedule_limit = {
'enabled': True,
'timezone': timezone_str,
current_day: {
'enabled': True,
'start_time': current_hour,
'duration': {'hours': 0, 'minutes': 90} # 90 minutes = 1.5 hours
}
}
result = time_handler.is_within_schedule(time_schedule_limit)
self.assertTrue(result, "Should accept 90 minutes as valid duration")
def test_schedule_24_hours_from_midnight(self):
"""Test 24-hour schedule from midnight using is_within_schedule."""
timezone_str = 'UTC'
test_time = arrow.get('2024-01-01 12:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
current_day = test_time.format('dddd').lower() # monday
time_schedule_limit = {
'enabled': True,
'timezone': timezone_str,
current_day: {
'enabled': True,
'start_time': '00:00',
'duration': {'hours': 24, 'minutes': 0}
}
}
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.is_within_schedule(time_schedule_limit)
self.assertTrue(result, "24-hour schedule from midnight should cover entire day")
def test_schedule_24_hours_at_end_of_day(self):
"""Test 24-hour schedule at 23:59 using is_within_schedule."""
timezone_str = 'UTC'
test_time = arrow.get('2024-01-01 23:59:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
current_day = test_time.format('dddd').lower()
time_schedule_limit = {
'enabled': True,
'timezone': timezone_str,
current_day: {
'enabled': True,
'start_time': '00:00',
'duration': {'hours': 24, 'minutes': 0}
}
}
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.is_within_schedule(time_schedule_limit)
self.assertTrue(result, "Should be active at 23:59 in 24-hour schedule")
def test_schedule_crosses_midnight_with_is_within_schedule(self):
"""Test schedule crossing midnight using is_within_schedule."""
timezone_str = 'UTC'
# Tuesday 00:30
test_time = arrow.get('2024-01-02 00:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)
# Get Monday as that's when the schedule started
monday = test_time.shift(days=-1).format('dddd').lower()
time_schedule_limit = {
'enabled': True,
'timezone': timezone_str,
'monday': {
'enabled': True,
'start_time': '23:00',
'duration': {'hours': 2, 'minutes': 0} # Until 01:00 Tuesday
},
'tuesday': {
'enabled': False,
'start_time': '09:00',
'duration': {'hours': 8, 'minutes': 0}
}
}
with unittest.mock.patch('arrow.now', return_value=test_time):
result = time_handler.is_within_schedule(time_schedule_limit)
# Note: This checks Tuesday's schedule, not Monday's overlap
# So it should be False because Tuesday is disabled
self.assertFalse(result, "Should check current day (Tuesday), which is disabled")
def test_schedule_with_mixed_hours_minutes(self):
"""Test schedule with both hours and minutes (23 hours 60 minutes = 24 hours)."""
timezone_str = 'UTC'
now = arrow.now(timezone_str)
current_day = now.format('dddd').lower()
current_hour = now.format('HH:00')
time_schedule_limit = {
'enabled': True,
'timezone': timezone_str,
current_day: {
'enabled': True,
'start_time': current_hour,
'duration': {'hours': 23, 'minutes': 60} # = 1440 minutes = 24 hours
}
}
result = time_handler.is_within_schedule(time_schedule_limit)
self.assertTrue(result, "Should handle 23 hours + 60 minutes = 24 hours")
def test_schedule_48_hours(self):
"""Test schedule with 48-hour duration."""
timezone_str = 'UTC'
now = arrow.now(timezone_str)
current_day = now.format('dddd').lower()
start_hour = now.format('HH:00')
time_schedule_limit = {
'enabled': True,
'timezone': timezone_str,
current_day: {
'enabled': True,
'start_time': start_hour,
'duration': {'hours': 48, 'minutes': 0} # 2 full days
}
}
result = time_handler.is_within_schedule(time_schedule_limit)
self.assertTrue(result, "Should support 48-hour (multi-day) schedules")
class TestWeekdayEnum(unittest.TestCase):
"""Tests for the Weekday enum."""
+3 -3
View File
@@ -62,19 +62,19 @@ def am_i_inside_time(
# Calculate start and end times for the overlap from the previous day
start_datetime_tz = start_datetime_tz.shift(days=-1)
end_datetime_tz = start_datetime_tz.shift(minutes=duration)
if start_datetime_tz <= now_tz <= end_datetime_tz:
if start_datetime_tz <= now_tz < end_datetime_tz:
return True
# Handle current day's range
if target_weekday == current_weekday:
end_datetime_tz = start_datetime_tz.shift(minutes=duration)
if start_datetime_tz <= now_tz <= end_datetime_tz:
if start_datetime_tz <= now_tz < end_datetime_tz:
return True
# Handle next day's overlap
if target_weekday == (current_weekday + 1) % 7:
end_datetime_tz = start_datetime_tz.shift(minutes=duration)
if now_tz < start_datetime_tz and now_tz.shift(days=1) <= end_datetime_tz:
if now_tz < start_datetime_tz and now_tz.shift(days=1) < end_datetime_tz:
return True
return False
@@ -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 ""
@@ -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 ""
@@ -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 ""
@@ -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 ""
@@ -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 ""
@@ -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 ""
@@ -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 ""
+55 -195
View File
@@ -6,16 +6,16 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.53.6\n"
"Project-Id-Version: changedetection.io 0.52.9\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
@@ -33,121 +33,40 @@ msgstr ""
msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "A backup is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Mb"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "No backups found."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Create backup"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/overview.html
msgid "Remove backups"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} Imported from list in {:.2f}s, {} Skipped."
msgstr ""
@@ -160,6 +79,7 @@ msgid "JSON structure looks invalid, was it broken?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
msgstr ""
@@ -168,18 +88,22 @@ msgid "Unable to read export XLSX file, something wrong with the file?"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "Error processing row number {}, URL value was incorrect, row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} imported from Wachete .xlsx in {:.2f}s"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} imported from custom .xlsx in {:.2f}s"
msgstr ""
@@ -195,14 +119,6 @@ msgstr ""
msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
@@ -287,25 +203,17 @@ msgstr ""
msgid "Import"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Worker count adjusted: {}"
msgstr ""
@@ -314,6 +222,7 @@ msgid "Dynamic worker adjustment not supported for sync workers"
msgstr ""
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Error adjusting workers: {}"
msgstr ""
@@ -379,10 +288,6 @@ msgstr ""
msgid "RSS"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Backups"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Time & Date"
msgstr ""
@@ -399,6 +304,10 @@ msgstr ""
msgid "Default recheck time for all watches, current system minimum is"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "seconds"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "more info"
msgstr ""
@@ -752,10 +661,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Uptime:"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -777,6 +682,7 @@ msgid "Clear Snapshot History"
msgstr ""
#: changedetectionio/blueprint/tags/__init__.py
#, python-brace-format
msgid "The tag \"{}\" already exists"
msgstr ""
@@ -937,46 +843,57 @@ msgid "RSS Feed for this watch"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches deleted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches paused"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches unpaused"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches updated"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches muted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches un-muted"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches queued for rechecking"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches errors cleared"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches cleared/reset."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches set to use default notification settings"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "{} watches were tagged"
msgstr ""
@@ -985,6 +902,7 @@ msgid "Watch not found"
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Cleared snapshot history for watch {}"
msgstr ""
@@ -997,6 +915,11 @@ msgid "Incorrect confirmation text."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
msgid "Marking watches as viewed in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "The watch by UUID {} does not exist."
msgstr ""
@@ -1017,10 +940,12 @@ msgid "Queued 1 watch for rechecking."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Queued {} watches for rechecking ({} already queued or running)."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Queued {} watches for rechecking."
msgstr ""
@@ -1029,6 +954,7 @@ msgid "Queueing watches for rechecking in background..."
msgstr ""
#: changedetectionio/blueprint/ui/__init__.py
#, python-brace-format
msgid "Could not share, something went wrong while communicating with the share server - {}"
msgstr ""
@@ -1049,18 +975,22 @@ msgid "No watches to edit"
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "No watch with the UUID {} found."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Switched to mode - {}."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Could not load '{}' processor, processor plugin might be missing."
msgstr ""
@@ -1592,10 +1522,6 @@ msgstr ""
msgid "Download latest HTML snapshot"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Download watch data package"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Delete Watch?"
msgstr ""
@@ -1645,6 +1571,7 @@ msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc
msgstr ""
#: changedetectionio/blueprint/ui/views.py
#, python-brace-format
msgid "Warning, URL {} already exists"
msgstr ""
@@ -1657,6 +1584,7 @@ msgid "Watch added."
msgstr ""
#: changedetectionio/blueprint/watchlist/__init__.py
#, python-brace-format
msgid "displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>"
msgstr ""
@@ -1851,66 +1779,6 @@ msgstr ""
msgid "Not yet"
msgstr ""
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
msgstr ""
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr ""
@@ -2457,10 +2325,12 @@ msgid "Not enough history to compare. Need at least 2 snapshots."
msgstr ""
#: changedetectionio/processors/image_ssim_diff/difference.py
#, python-brace-format
msgid "Failed to load screenshots: {}"
msgstr ""
#: changedetectionio/processors/image_ssim_diff/difference.py
#, python-brace-format
msgid "Failed to calculate diff: {}"
msgstr ""
@@ -2586,6 +2456,7 @@ msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
@@ -2594,6 +2465,7 @@ msgid "Watch protocol is not permitted or invalid URL format"
msgstr ""
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
@@ -3001,18 +2873,6 @@ msgstr ""
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "You can also use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "conditions"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
@@ -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 "正在后台将监控项标记为已读..."
@@ -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 -29
View File
@@ -1,5 +1,3 @@
import ipaddress
import socket
from functools import lru_cache
from loguru import logger
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
@@ -58,25 +56,7 @@ 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.
Unresolvable hostnames return False (allow them) DNS may be temporarily unavailable
or the domain not yet live. The actual DNS rebinding attack is mitigated by fetch-time
re-validation in requests.py, not by blocking unresolvable domains at add-time.
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 as e:
logger.warning(f"{hostname} error checking {str(e)}")
return False
return False
@lru_cache(maxsize=10000)
def is_safe_valid_url(test_url):
from changedetectionio import strtobool
from changedetectionio.jinja2_custom import render as jinja_render
@@ -139,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
+12 -46
View File
@@ -4,10 +4,11 @@ import changedetectionio.content_fetchers.exceptions as content_fetchers_excepti
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
from changedetectionio import html_tools
from changedetectionio import worker_pool
from changedetectionio.flask_app import watch_check_update
from changedetectionio.queuedWatchMetaData import PrioritizedItem
from changedetectionio.pluggy_interface import apply_update_handler_alter, apply_update_finalize
import asyncio
import importlib
import os
import sys
import time
@@ -55,7 +56,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
while not app.config.exit.is_set():
update_handler = None
watch = None
processing_exception = None # Reset at start of each iteration to prevent state bleeding
try:
# Efficient blocking via run_in_executor (no polling overhead!)
@@ -119,7 +119,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# to prevent race condition with wait_for_all_checks()
fetch_start_time = round(time.time())
try:
if uuid in list(datastore.data['watching'].keys()) and datastore.data['watching'][uuid].get('url'):
changed_detected = False
@@ -136,8 +136,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.info(f"Worker {worker_id} processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}")
try:
# Retrieve signal by name to ensure thread-safe access across worker threads
watch_check_update = signal('watch_check_update')
watch_check_update.send(watch_uuid=uuid)
# Processor is what we are using for detecting the "Change"
@@ -156,9 +154,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
update_handler = processor_module.perform_site_check(datastore=datastore,
watch_uuid=uuid)
# Allow plugins to modify/wrap the update_handler
update_handler = apply_update_handler_alter(update_handler, watch, datastore)
update_signal = signal('watch_small_status_comment')
update_signal.send(watch_uuid=uuid, status="Fetching page..")
@@ -478,6 +473,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.exception(f"Worker {worker_id} full exception details:")
logger.warning(f"UUID: {uuid} Exception when extracting <title> - {str(e)}")
# Store favicon if necessary
if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
@@ -501,8 +498,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
gc.collect()
except Exception as e:
# Store the processing exception for plugin finalization hook
processing_exception = e
logger.error(f"Worker {worker_id} unexpected error processing {uuid}: {e}")
logger.exception(f"Worker {worker_id} full exception details:")
@@ -514,11 +509,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
finally:
# Always cleanup - this runs whether there was an exception or not
if uuid:
# Capture references for plugin finalize hook BEFORE cleanup
# (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
# Call quit() as backup (Puppeteer/Playwright have internal cleanup, but this acts as safety net)
try:
if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
@@ -528,6 +518,12 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.exception(f"Worker {worker_id} full exception details:")
try:
# Release UUID from processing (thread-safe)
worker_pool.release_uuid_from_processing(uuid, worker_id=worker_id)
# Send completion signal
if watch:
watch_check_update.send(watch_uuid=watch['uuid'])
# Clean up all memory references BEFORE garbage collection
if update_handler:
@@ -551,37 +547,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}")
logger.exception(f"Worker {worker_id} full exception details:")
# Call plugin finalization hook after all cleanup is done
# Use captured references from before cleanup
try:
apply_update_finalize(
update_handler=finalize_handler,
watch=finalize_watch,
datastore=datastore,
processing_exception=processing_exception
)
except Exception as finalize_error:
logger.error(f"Worker {worker_id} error in finalize hook: {finalize_error}")
logger.exception(f"Worker {worker_id} full exception details:")
finally:
# Clean up captured references to allow immediate garbage collection
del finalize_handler
del finalize_watch
# Release UUID from processing AFTER all cleanup and hooks complete (thread-safe)
# This ensures wait_for_all_checks() waits for finalize hooks to complete
try:
worker_pool.release_uuid_from_processing(uuid, worker_id=worker_id)
except Exception as release_error:
logger.error(f"Worker {worker_id} error releasing UUID: {release_error}")
logger.exception(f"Worker {worker_id} full exception details:")
finally:
# Send completion signal - retrieve by name to ensure thread-safe access
if watch:
watch_check_update = signal('watch_check_update')
watch_check_update.send(watch_uuid=watch['uuid'])
del (uuid)
del(uuid)
# Brief pause before continuing to avoid tight error loops (only on error)
if 'e' in locals():
+2 -31
View File
@@ -108,14 +108,9 @@ tags:
- name: System Information
description: |
Retrieve system status and statistics about your changedetection.io instance, including total watch
Retrieve system status and statistics about your changedetection.io instance, including total watch
counts, uptime information, and version details.
- name: Plugin API Extensions
description: |
Retrieve the live OpenAPI specification for this instance. Unlike the static spec, this endpoint
returns the fully merged spec including schemas for any processor plugins installed on this instance.
components:
securitySchemes:
ApiKeyAuth:
@@ -1894,7 +1889,7 @@ paths:
- lang: 'Python'
source: |
import requests
headers = {'x-api-key': 'YOUR_API_KEY'}
response = requests.get('http://localhost:5000/api/v1/systeminfo', headers=headers)
print(response.json())
@@ -1910,27 +1905,3 @@ paths:
tag_count: 5
uptime: "2 days, 3:45:12"
version: "0.50.10"
/full-spec:
get:
operationId: getFullApiSpec
tags: [Plugin API Extensions]
summary: Get full live API spec
description: |
Return the fully merged OpenAPI specification for this instance.
Unlike the static `api-spec.yaml` shipped with the application, this endpoint returns the
spec dynamically merged with any `api.yaml` schemas provided by installed processor plugins.
Use this URL with Swagger UI or Redoc to get accurate documentation for your specific install.
security: []
x-code-samples:
- lang: 'curl'
source: |
curl -X GET "http://localhost:5000/api/v1/full-spec"
responses:
'200':
description: Merged OpenAPI specification in YAML format
content:
application/yaml:
schema:
type: string
+16 -10
View File
@@ -1,21 +1,22 @@
# eventlet>=0.38.0 # Removed - replaced with threading mode for better Python 3.12+ compatibility
feedgen~=1.0
feedparser~=6.0 # For parsing RSS/Atom feeds
flask-compress
# 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers)
flask-login>=0.6.3
flask-paginate
flask-socketio>=5.6.1,<6 # Re #3910
flask>=3.1,<4
flask_cors # For the Chrome extension to operate
flask_restful
flask_cors # For the Chrome extension to operate
# janus # No longer needed - using pure threading.Queue for multi-loop support
flask_wtf~=1.2
flask~=3.1
flask-socketio~=5.6.0
python-socketio~=5.16.1
python-engineio~=4.13.1
inscriptis~=2.2
python-engineio>=4.9.0,<5
python-socketio>=5.11.0,<6
pytz
timeago~=1.0
validators~=0.35
werkzeug==3.1.6
# Set these versions together to avoid a RequestsDependencyWarning
# >= 2.26 also adds Brotli support if brotli is installed
@@ -28,7 +29,7 @@ requests-file
chardet>2.3.0
wtforms~=3.2
jsonpath-ng~=1.8.0
jsonpath-ng~=1.7.0
# Fast JSON serialization for better performance
orjson~=3.11
@@ -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