mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-04-28 13:57:10 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b886b0eafc | |||
| 56b25fa19a | |||
| b9d2c52e12 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
)
|
||||
@@ -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']:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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> <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> <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> <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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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'''
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
#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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
|
||||
@@ -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="{"prevArrow":"<i class=\\"icon\\"><svg width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http:\\/\\/www.w3.org\\/2000\\/svg\\"><path d=\\"M14 5L7 12L14 19\\"\\/><\\/svg><\\/i>"}">
|
||||
</div>
|
||||
<div class="content">
|
||||
<h1>IMPORTANT CONTENT</h1>
|
||||
<p>This text must not be eaten by the tag-stripping logic.</p>
|
||||
</div>
|
||||
<svg><circle cx="50" cy="50" r="40"/></svg>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
text = html_to_text(html)
|
||||
|
||||
assert 'IMPORTANT CONTENT' in text, (
|
||||
"Content after a JS-escaped tag in a data attribute was incorrectly stripped. "
|
||||
"The tag-stripping logic is matching <tag> inside attribute values and scanning "
|
||||
"forward to the next real closing tag in the DOM."
|
||||
)
|
||||
assert 'This text must not be eaten' in text
|
||||
|
||||
def test_script_inside_json_data_attribute_does_not_eat_content(self):
|
||||
"""Same issue as above but with <script> embedded in a data attribute with JS-escaped closing tag."""
|
||||
html = '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test</title></head>
|
||||
<body>
|
||||
<div data-config="{"template":"<script type=\\"text\\/javascript\\">var x=1;<\\/script>"}">
|
||||
</div>
|
||||
<div>
|
||||
<h1>MUST SURVIVE</h1>
|
||||
<p>Real content after the data attribute with embedded script tag.</p>
|
||||
</div>
|
||||
<script>var real = 1;</script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
text = html_to_text(html)
|
||||
|
||||
assert 'MUST SURVIVE' in text, (
|
||||
"Content after a JS-escaped <script> in a data attribute was incorrectly stripped."
|
||||
)
|
||||
assert 'Real content after the data attribute' in text
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Can run this file directly for quick testing
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: cs\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,116 +34,34 @@ msgstr ""
|
||||
msgid "Backups were deleted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
msgstr ""
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr "A backup is running!"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr "Mb"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr "No backups found."
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr "Vytvořit zálohu"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr "Odstranit zálohy"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr ""
|
||||
@@ -202,14 +120,6 @@ msgstr "Distill.io"
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX a Wachete"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
@@ -296,16 +206,6 @@ msgstr "Znovu zkontrolovat čas (minuty)"
|
||||
msgid "Import"
|
||||
msgstr "IMPORTOVAT"
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
@@ -391,10 +291,6 @@ msgstr "API"
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr "Čas a datum"
|
||||
@@ -411,6 +307,10 @@ msgstr "Info"
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr "Výchozí čas opětovné kontroly pro všechny monitory, aktuální systémové minimum je"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr "sekundy"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr "Více informací"
|
||||
@@ -766,10 +666,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Verze Pythonu:"
|
||||
@@ -1023,6 +919,10 @@ msgstr ""
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr "Žádné informace"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1627,10 +1527,6 @@ msgstr "Odpověď typu serveru"
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr "Stáhněte si nejnovější HTML snímek"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr "Smazat monitory?"
|
||||
@@ -1888,66 +1784,6 @@ msgstr "v '%(title)s'"
|
||||
msgid "Not yet"
|
||||
msgstr "Ještě ne"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr "sekundy"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr ""
|
||||
@@ -3042,18 +2878,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
@@ -3267,6 +3091,3 @@ msgstr "Hlavní nastavení"
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "Tip: Můžete také přidat „sdílené“ monitory."
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-14 03:57+0100\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: de\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,118 +34,36 @@ msgstr "Backup läuft im Hintergrund, bitte in ein paar Minuten erneut versuchen
|
||||
msgid "Backups were deleted."
|
||||
msgstr "Backups wurden gelöscht."
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
msgstr ""
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr "Ein Backup läuft!"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr ""
|
||||
"Hier können Sie ein neues Backup herunterladen und anfordern. Sobald ein Backup abgeschlossen ist, wird es unten "
|
||||
"aufgelistet."
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr "Mb"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr "Keine Backups gefunden."
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr "Backup erstellen"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr "Backups entfernen"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr "Es werden 5.000 der ersten URLs aus Ihrer Liste importiert, der Rest kann erneut importiert werden."
|
||||
@@ -204,14 +122,6 @@ msgstr "Distill.io"
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX & Wachete"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
@@ -300,16 +210,6 @@ msgstr "Nachprüfzeit (Minuten)"
|
||||
msgid "Import"
|
||||
msgstr "IMPORT"
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr "Passwortschutz entfernt."
|
||||
@@ -395,10 +295,6 @@ msgstr "API"
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr "Uhrzeit und Datum"
|
||||
@@ -415,6 +311,10 @@ msgstr "Info"
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr "Standardmäßige Überprüfungszeit für alle Observationen, derzeitiges Systemminimum ist"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr "Sekunden"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr "Weitere Informationen"
|
||||
@@ -776,10 +676,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Python-Version:"
|
||||
@@ -1039,6 +935,10 @@ msgstr ""
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr "Falscher Bestätigungstext"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1665,10 +1565,6 @@ msgstr "Antwort vom Servertyp"
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr "Laden Sie den neuesten HTML-Snapshot herunter"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr "Überwachung löschen?"
|
||||
@@ -1930,66 +1826,6 @@ msgstr "in '%(title)s'"
|
||||
msgid "Not yet"
|
||||
msgstr "Noch nicht"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr "Sekunden"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr "Bereits angemeldet"
|
||||
@@ -3091,18 +2927,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
@@ -3382,6 +3206,3 @@ msgstr "Haupteinstellungen"
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen."
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io\n"
|
||||
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-12 16:33+0100\n"
|
||||
"Last-Translator: British English Translation Team\n"
|
||||
"Language: en_GB\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,116 +34,34 @@ msgstr ""
|
||||
msgid "Backups were deleted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr ""
|
||||
@@ -202,14 +120,6 @@ msgstr ""
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
@@ -294,16 +204,6 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
@@ -389,10 +289,6 @@ msgstr ""
|
||||
msgid "RSS"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr ""
|
||||
@@ -409,6 +305,10 @@ msgstr ""
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr ""
|
||||
@@ -762,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr ""
|
||||
@@ -1019,6 +915,10 @@ msgstr ""
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1623,10 +1523,6 @@ msgstr ""
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr ""
|
||||
@@ -1884,66 +1780,6 @@ msgstr ""
|
||||
msgid "Not yet"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr ""
|
||||
@@ -3038,18 +2874,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
@@ -3212,6 +3036,3 @@ msgstr ""
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-12 16:37+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: en_US\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,116 +34,34 @@ msgstr ""
|
||||
msgid "Backups were deleted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr ""
|
||||
@@ -202,14 +120,6 @@ msgstr ""
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
@@ -294,16 +204,6 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
@@ -389,10 +289,6 @@ msgstr ""
|
||||
msgid "RSS"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr ""
|
||||
@@ -409,6 +305,10 @@ msgstr ""
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr ""
|
||||
@@ -762,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr ""
|
||||
@@ -1019,6 +915,10 @@ msgstr ""
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1623,10 +1523,6 @@ msgstr ""
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr ""
|
||||
@@ -1884,66 +1780,6 @@ msgstr ""
|
||||
msgid "Not yet"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr ""
|
||||
@@ -3038,18 +2874,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
@@ -3212,6 +3036,3 @@ msgstr ""
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: fr\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,116 +34,34 @@ msgstr "Sauvegarde en cours de création en arrière-plan, revenez dans quelques
|
||||
msgid "Backups were deleted."
|
||||
msgstr "Les sauvegardes ont été supprimées."
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
msgstr ""
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "SAUVEGARDES"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr "Une sauvegarde est en cours !"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr "Mo"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr "Aucune sauvegarde trouvée."
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr "Créer sauvegarde"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr "Supprimer sauvegardes"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr "Importation de 5 000 des premières URL de votre liste, le reste peut être importé à nouveau."
|
||||
@@ -204,14 +122,6 @@ msgstr "Distill.io"
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX et Wachete"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
@@ -296,16 +206,6 @@ msgstr "Temps de revérification (minutes)"
|
||||
msgid "Import"
|
||||
msgstr "IMPORTER"
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
@@ -391,10 +291,6 @@ msgstr "API"
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "SAUVEGARDES"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr "Heure et date"
|
||||
@@ -411,6 +307,10 @@ msgstr "Info"
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr "Heure de revérification par défaut pour tous les moniteurs, le minimum actuel du système est"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr "secondes"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr "Plus d'informations"
|
||||
@@ -766,10 +666,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Version Python :"
|
||||
@@ -1023,6 +919,10 @@ msgstr ""
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr "Aucune information"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1629,10 +1529,6 @@ msgstr "Réponse du type de serveur"
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr "Télécharger le dernier instantané HTML"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr "Supprimer les montres ?"
|
||||
@@ -1890,66 +1786,6 @@ msgstr "dans '%(title)s'"
|
||||
msgid "Not yet"
|
||||
msgstr "Pas encore"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr "secondes"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr "Déjà connecté"
|
||||
@@ -3050,18 +2886,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
@@ -3275,6 +3099,3 @@ msgstr "Paramètres principaux"
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "Astuce : Vous pouvez également ajouter des montres « partagées »."
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: it\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,116 +34,34 @@ msgstr "Backup in creazione in background, riprova tra qualche minuto."
|
||||
msgid "Backups were deleted."
|
||||
msgstr "I backup sono stati eliminati."
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
msgstr ""
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backup"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr "Un backup è in esecuzione!"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr "MB"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr "Nessun backup trovato."
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr "Crea backup"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr "Rimuovi backup"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr "Importazione delle prime 5.000 URL dalla tua lista, il resto può essere importato di nuovo."
|
||||
@@ -204,14 +122,6 @@ msgstr "Distill.io"
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX & Wachete"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
@@ -296,16 +206,6 @@ msgstr "Tempo di ricontrollo (minuti)"
|
||||
msgid "Import"
|
||||
msgstr "Importa"
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
@@ -391,10 +291,6 @@ msgstr "API"
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Backup"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr "Data e ora"
|
||||
@@ -411,6 +307,10 @@ msgstr "Info"
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr "secondi"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr ""
|
||||
@@ -764,10 +664,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr ""
|
||||
@@ -1021,6 +917,10 @@ msgstr ""
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1625,10 +1525,6 @@ msgstr ""
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr ""
|
||||
@@ -1886,66 +1782,6 @@ msgstr ""
|
||||
msgid "Not yet"
|
||||
msgstr "Non ancora"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr "secondi"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr "Già autenticato"
|
||||
@@ -3040,18 +2876,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
@@ -3247,6 +3071,3 @@ msgstr "Impostazioni principali"
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: ko\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,116 +34,34 @@ msgstr ""
|
||||
msgid "Backups were deleted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
msgstr ""
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "백업"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr "백업이 실행 중입니다!"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr "MB"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr "백업을 찾을 수 없습니다."
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr "백업 생성"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr "백업 삭제"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr ""
|
||||
@@ -202,14 +120,6 @@ msgstr "Distill.io"
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX 및 와체테"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
@@ -294,16 +204,6 @@ msgstr "재확인 시간(분)"
|
||||
msgid "Import"
|
||||
msgstr "수입"
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
@@ -389,10 +289,6 @@ msgstr "API"
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "백업"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr "시간 및 날짜"
|
||||
@@ -409,6 +305,10 @@ msgstr "정보"
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr "모든 시계의 기본 재확인 시간, 현재 시스템 최소값은 다음과 같습니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr "초"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr "추가 정보"
|
||||
@@ -762,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "파이썬 버전:"
|
||||
@@ -1019,6 +915,10 @@ msgstr ""
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr "잘못된 확인 텍스트."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1623,10 +1523,6 @@ msgstr "서버 유형 응답"
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr "최신 HTML 스냅샷 다운로드"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr "시계를 삭제하시겠습니까?"
|
||||
@@ -1884,66 +1780,6 @@ msgstr "'%(title)s'에서"
|
||||
msgid "Not yet"
|
||||
msgstr "아직 아님"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr "초"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr ""
|
||||
@@ -3038,18 +2874,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
@@ -3368,6 +3192,3 @@ msgstr "기본 설정"
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "팁: '공유' 시계를 추가할 수도 있습니다."
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io 0.53.6\n"
|
||||
"Project-Id-Version: changedetection.io 0.52.9\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -33,121 +33,40 @@ msgstr ""
|
||||
msgid "Backups were deleted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{} Imported from list in {:.2f}s, {} Skipped."
|
||||
msgstr ""
|
||||
|
||||
@@ -160,6 +79,7 @@ msgid "JSON structure looks invalid, was it broken?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
|
||||
msgstr ""
|
||||
|
||||
@@ -168,18 +88,22 @@ msgid "Unable to read export XLSX file, something wrong with the file?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "Error processing row number {}, URL value was incorrect, row was skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{} imported from Wachete .xlsx in {:.2f}s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
#, python-brace-format
|
||||
msgid "{} imported from custom .xlsx in {:.2f}s"
|
||||
msgstr ""
|
||||
|
||||
@@ -195,14 +119,6 @@ msgstr ""
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr ""
|
||||
@@ -287,25 +203,17 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr ""
|
||||
|
||||
@@ -314,6 +222,7 @@ msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error adjusting workers: {}"
|
||||
msgstr ""
|
||||
|
||||
@@ -379,10 +288,6 @@ msgstr ""
|
||||
msgid "RSS"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr ""
|
||||
@@ -399,6 +304,10 @@ msgstr ""
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr ""
|
||||
@@ -752,10 +661,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr ""
|
||||
@@ -777,6 +682,7 @@ msgid "Clear Snapshot History"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The tag \"{}\" already exists"
|
||||
msgstr ""
|
||||
|
||||
@@ -937,46 +843,57 @@ msgid "RSS Feed for this watch"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches deleted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches paused"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches unpaused"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches updated"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches muted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches un-muted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches queued for rechecking"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches errors cleared"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches cleared/reset."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches set to use default notification settings"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches were tagged"
|
||||
msgstr ""
|
||||
|
||||
@@ -985,6 +902,7 @@ msgid "Watch not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Cleared snapshot history for watch {}"
|
||||
msgstr ""
|
||||
|
||||
@@ -997,6 +915,11 @@ msgid "Incorrect confirmation text."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
msgstr ""
|
||||
|
||||
@@ -1017,10 +940,12 @@ msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr ""
|
||||
|
||||
@@ -1029,6 +954,7 @@ msgid "Queueing watches for rechecking in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Could not share, something went wrong while communicating with the share server - {}"
|
||||
msgstr ""
|
||||
|
||||
@@ -1049,18 +975,22 @@ msgid "No watches to edit"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "No watch with the UUID {} found."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Switched to mode - {}."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing. Please select a different processor."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
#, python-brace-format
|
||||
msgid "Could not load '{}' processor, processor plugin might be missing."
|
||||
msgstr ""
|
||||
|
||||
@@ -1592,10 +1522,6 @@ msgstr ""
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr ""
|
||||
@@ -1645,6 +1571,7 @@ msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/views.py
|
||||
#, python-brace-format
|
||||
msgid "Warning, URL {} already exists"
|
||||
msgstr ""
|
||||
|
||||
@@ -1657,6 +1584,7 @@ msgid "Watch added."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>"
|
||||
msgstr ""
|
||||
|
||||
@@ -1851,66 +1779,6 @@ msgstr ""
|
||||
msgid "Not yet"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr ""
|
||||
@@ -2457,10 +2325,12 @@ msgid "Not enough history to compare. Need at least 2 snapshots."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/image_ssim_diff/difference.py
|
||||
#, python-brace-format
|
||||
msgid "Failed to load screenshots: {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/image_ssim_diff/difference.py
|
||||
#, python-brace-format
|
||||
msgid "Failed to calculate diff: {}"
|
||||
msgstr ""
|
||||
|
||||
@@ -2586,6 +2456,7 @@ msgid "Detects all text changes where possible"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Error fetching metadata for {}"
|
||||
msgstr ""
|
||||
|
||||
@@ -2594,6 +2465,7 @@ msgid "Watch protocol is not permitted or invalid URL format"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/store/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
|
||||
msgstr ""
|
||||
|
||||
@@ -3001,18 +2873,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-18 21:31+0800\n"
|
||||
"Last-Translator: 吾爱分享 <admin@wuaishare.cn>\n"
|
||||
"Language: zh\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,116 +34,34 @@ msgstr "备份正在后台生成,请几分钟后再查看。"
|
||||
msgid "Backups were deleted."
|
||||
msgstr "备份已删除。"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
msgstr ""
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "备份"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr "备份正在运行!"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr "在此可下载并请求新的备份,备份完成后会在下方列表中显示。"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr "MB"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr "未找到备份。"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr "创建备份"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr "删除备份"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr "仅导入列表前 5,000 个 URL,其余可稍后继续导入。"
|
||||
@@ -202,14 +120,6 @@ msgstr "Distill.io"
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX 与 Wachete"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr "每行输入一个 URL,可在 URL 后用空格追加标签,标签以逗号 (,) 分隔:"
|
||||
@@ -294,16 +204,6 @@ msgstr "复检间隔(分钟)"
|
||||
msgid "Import"
|
||||
msgstr "导入"
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr "已移除密码保护。"
|
||||
@@ -389,10 +289,6 @@ msgstr "API"
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "备份"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr "时间与日期"
|
||||
@@ -409,6 +305,10 @@ msgstr "信息"
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr "所有监控项的默认复检间隔,当前系统最小值为"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr "秒"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr "更多信息"
|
||||
@@ -762,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr "带认证的 SOCKS5 代理仅支持“明文请求”抓取器,其他抓取器请改为白名单 IP"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Python 版本:"
|
||||
@@ -1019,6 +915,10 @@ msgstr "历史清理已在后台开始"
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr "确认文本不正确。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr "正在后台将监控项标记为已读..."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1623,10 +1523,6 @@ msgstr "服务器类型响应"
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr "下载最新的 HTML 快照"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr "删除监控项?"
|
||||
@@ -1884,66 +1780,6 @@ msgstr "(“%(title)s”中)"
|
||||
msgid "Not yet"
|
||||
msgstr "尚未"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr "秒"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr "已登录"
|
||||
@@ -3038,18 +2874,6 @@ msgstr "每行单独处理(可理解为每行都是“或”)"
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr "注意:使用正则时请用斜杠 / 包裹,例如:"
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr "匹配的文本会在文本快照中被忽略(仍可见但不会触发变更)"
|
||||
@@ -3197,6 +3021,3 @@ msgstr "主设置"
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "提示:你也可以添加“共享”的监控项。"
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr "正在后台将监控项标记为已读..."
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-02-23 03:54+0100\n"
|
||||
"POT-Creation-Date: 2026-02-05 17:47+0100\n"
|
||||
"PO-Revision-Date: 2026-01-15 12:00+0800\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: zh_Hant_TW\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.16.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
#: changedetectionio/blueprint/backups/__init__.py
|
||||
msgid "A backup is already running, check back in a few minutes"
|
||||
@@ -34,116 +34,34 @@ msgstr "正在背景建立備份,請稍後再回來查看。"
|
||||
msgid "Backups were deleted."
|
||||
msgstr "備份已刪除。"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Backup zip file"
|
||||
msgstr ""
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "備份"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Must be a .zip backup file!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include groups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing groups of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Include watches"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Replace existing watches of the same UUID"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore backup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "A restore is already running, check back in a few minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "No file uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "File must be a .zip backup file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Restore started in background, check back in a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "A backup is running!"
|
||||
msgstr "備份正在執行中!"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
|
||||
msgstr "您可以在此下載並請求建立新備份,備份完成後將顯示於下方。"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Mb"
|
||||
msgstr "MB"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "No backups found."
|
||||
msgstr "找不到備份。"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Create backup"
|
||||
msgstr "建立備份"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_create.html
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html
|
||||
msgid "Remove backups"
|
||||
msgstr "移除備份"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "A restore is running!"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Note: This does not override the main application settings, only watches and groups."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing groups of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all watches found in backup?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Replace any existing watches of the same UUID?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/importer.py
|
||||
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
|
||||
msgstr "正在匯入清單中的前 5,000 個 URL,其餘的可以再次匯入。"
|
||||
@@ -202,14 +120,6 @@ msgstr "Distill.io"
|
||||
msgid ".XLSX & Wachete"
|
||||
msgstr ".XLSX 和 Wachete"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "backups section"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
|
||||
msgstr "每行輸入一個 URL,可選用空格分隔後為每個 URL 新增標籤,標籤間用逗號 (,) 分隔:"
|
||||
@@ -294,16 +204,6 @@ msgstr "複查時間(分鐘)"
|
||||
msgid "Import"
|
||||
msgstr "匯入"
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch with UUID %(uuid)s not found"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/rss/single_watch.py
|
||||
#, python-format
|
||||
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py
|
||||
msgid "Password protection removed."
|
||||
msgstr "密碼保護已移除。"
|
||||
@@ -389,10 +289,6 @@ msgstr "API"
|
||||
msgid "RSS"
|
||||
msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "備份"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
msgstr "時間與日期"
|
||||
@@ -409,6 +305,10 @@ msgstr "資訊"
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr "所有監測任務的預設複查時間,目前系統最小值為"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "seconds"
|
||||
msgstr "秒"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
msgstr "更多資訊"
|
||||
@@ -762,10 +662,6 @@ msgid ""
|
||||
"whitelist the IP access instead"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Uptime:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Python version:"
|
||||
msgstr "Python 版本:"
|
||||
@@ -1019,6 +915,10 @@ msgstr ""
|
||||
msgid "Incorrect confirmation text."
|
||||
msgstr "確認文字不正確。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Marking watches as viewed in background..."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "The watch by UUID {} does not exist."
|
||||
@@ -1623,10 +1523,6 @@ msgstr "伺服器類型回應"
|
||||
msgid "Download latest HTML snapshot"
|
||||
msgstr "下載最新 HTML 快照"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Download watch data package"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr "刪除監測任務?"
|
||||
@@ -1884,66 +1780,6 @@ msgstr "於 '%(title)s'"
|
||||
msgid "Not yet"
|
||||
msgstr "還沒有"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "0 seconds"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "year"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "years"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "month"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "months"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "week"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "weeks"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "days"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hour"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "hours"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minute"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "minutes"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
|
||||
msgid "seconds"
|
||||
msgstr "秒"
|
||||
|
||||
#: changedetectionio/flask_app.py
|
||||
msgid "Already logged in"
|
||||
msgstr "已經登入"
|
||||
@@ -3038,18 +2874,6 @@ msgstr ""
|
||||
msgid "Note: Wrap in forward slash / to use regex example:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "You can also use"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "conditions"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "\"Page text\" - with Contains, Starts With, Not Contains and many more"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
msgstr ""
|
||||
@@ -3326,6 +3150,3 @@ msgstr "主設定"
|
||||
#~ msgid "Tip: You can also add 'shared' watches."
|
||||
#~ msgstr "提示:您也可以新增「共享」監測任務。"
|
||||
|
||||
#~ msgid "Marking watches as viewed in background..."
|
||||
#~ msgstr ""
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import ipaddress
|
||||
import socket
|
||||
from functools import lru_cache
|
||||
from loguru import logger
|
||||
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
|
||||
@@ -58,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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user