Compare commits

..

9 Commits

Author SHA1 Message Date
dgtlmoon
1058debc12 Fix for When MoreThanOnePriceFound() is raised, plugins dont fire #3840 #3833
Some checks are pending
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
2026-02-05 20:07:47 +01:00
dgtlmoon
61b41b0b16 Rebuild translations (#3842) 2026-02-05 18:17:46 +01:00
dgtlmoon
efe3afd383 UI - Favicon use lazy load for faster rendering 2026-02-05 17:21:57 +01:00
dgtlmoon
84d26640cc Adding more tests and Watch object improvements (#3841) 2026-02-05 17:01:08 +01:00
dgtlmoon
2349344d9e Improved watch global settings handling (#3839) 2026-02-05 16:40:00 +01:00
dgtlmoon
bdc2916c07 New datastore message should be warning not critical 2026-02-05 16:25:22 +01:00
dgtlmoon
4fd477a60c Improving upgrade path
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2026-02-05 13:00:01 +01:00
dgtlmoon
dc8b387f40 History length limit size option (#3834) 2026-02-05 12:29:20 +01:00
dgtlmoon
2149a6fe3b Memory improvement - Use builtin markupsafe instead of creating a jinja2 template env each time for small strings (#3836) 2026-02-05 10:07:36 +01:00
37 changed files with 1172 additions and 288 deletions

View File

@@ -66,47 +66,42 @@ class Watch(Resource):
@validate_openapi_request('getWatch')
def get(self, uuid):
"""Get information about a single watch, recheck, pause, or mute."""
import time
from copy import deepcopy
watch = None
# Retry up to 20 times if dict is being modified
# With sleep(0), this is fast: ~200µs best case, ~20ms worst case under heavy load
for attempt in range(20):
try:
watch = deepcopy(self.datastore.data['watching'].get(uuid))
break
except RuntimeError:
# Dict changed during deepcopy, retry after yielding to scheduler
# sleep(0) releases GIL and yields - no fixed delay, just lets other threads run
if attempt < 19: # Don't yield on last attempt
time.sleep(0) # Yield to scheduler (microseconds, not milliseconds)
if not watch:
# Get watch reference first (for pause/mute operations)
watch_obj = self.datastore.data['watching'].get(uuid)
if not watch_obj:
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
# Create a dict copy for JSON response (with lock for thread safety)
# This is much faster than deepcopy and doesn't copy the datastore reference
# WARNING: dict() is a SHALLOW copy - nested dicts are shared with original!
# Only safe because we only ADD scalar properties (line 97-101), never modify nested dicts
# If you need to modify nested dicts, use: from copy import deepcopy; watch = deepcopy(dict(watch_obj))
with self.datastore.lock:
watch = dict(watch_obj)
if request.args.get('recheck'):
worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
return "OK", 200
if request.args.get('paused', '') == 'paused':
self.datastore.data['watching'].get(uuid).pause()
watch_obj.pause()
return "OK", 200
elif request.args.get('paused', '') == 'unpaused':
self.datastore.data['watching'].get(uuid).unpause()
watch_obj.unpause()
return "OK", 200
if request.args.get('muted', '') == 'muted':
self.datastore.data['watching'].get(uuid).mute()
watch_obj.mute()
return "OK", 200
elif request.args.get('muted', '') == 'unmuted':
self.datastore.data['watching'].get(uuid).unmute()
watch_obj.unmute()
return "OK", 200
# Return without history, get that via another API call
# Properties are not returned as a JSON, so add the required props manually
watch['history_n'] = watch.history_n
watch['history_n'] = watch_obj.history_n
# attr .last_changed will check for the last written text snapshot on change
watch['last_changed'] = watch.last_changed
watch['viewed'] = watch.viewed
watch['link'] = watch.link,
watch['last_changed'] = watch_obj.last_changed
watch['viewed'] = watch_obj.viewed
watch['link'] = watch_obj.link,
return watch

View File

@@ -59,6 +59,14 @@
{{ _('Set to') }} <strong>0</strong> {{ _('to disable') }}
</span>
</div>
<div class="pure-control-group">
{{ render_field(form.application.form.history_snapshot_max_length, class="history_snapshot_max_length") }}
<span class="pure-form-message-inline">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}
<br>
{{ _('Set to empty to disable / no limit') }}
</span>
</div>
<div class="pure-control-group">
{% if not hide_remove_pass %}
{% if current_user.is_authenticated %}

View File

@@ -197,7 +197,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Recast it if need be to right data Watch handler
watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor'))
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid])
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, __datastore=datastore.data, default=datastore.data['watching'][uuid])
flash(gettext("Updated watch - unpaused!") if request.args.get('unpause_on_save') else gettext("Updated watch."))
# Cleanup any browsersteps session for this watch

View File

@@ -115,6 +115,13 @@
{{ _('Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore.') }}
</span>
</div>
<div class="pure-control-group">
{{ render_field(form.history_snapshot_max_length, class="history_snapshot_max_length") }}
<span class="pure-form-message-inline">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}
<br>
{{ _('Set to empty to use system settings default') }}
</span>
</div>
<div class="pure-control-group">
{{ render_ternary_field(form.use_page_title_in_list) }}
</div>

View File

@@ -14,6 +14,46 @@
// Initialize Feather icons after the page loads
document.addEventListener('DOMContentLoaded', function() {
feather.replace();
// Intersection Observer for lazy loading favicons
// Only load favicon images when they enter the viewport
if ('IntersectionObserver' in window) {
const faviconObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.getAttribute('data-src');
if (src) {
// Load the actual favicon
img.src = src;
img.removeAttribute('data-src');
}
// Stop observing this image
observer.unobserve(img);
}
});
}, {
// Start loading slightly before the image enters viewport
rootMargin: '50px',
threshold: 0.01
});
// Observe all lazy favicon images
document.querySelectorAll('.lazy-favicon').forEach(img => {
faviconObserver.observe(img);
});
} else {
// Fallback for older browsers: load all favicons immediately
document.querySelectorAll('.lazy-favicon').forEach(img => {
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
}
});
}
});
</script>
<style>
@@ -206,8 +246,17 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<td class="title-col inline">
<div class="flex-wrapper">
{% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
<div>{# A page might have hundreds of these images, set IMG options for lazy loading, don't set SRC if we dont have it so it doesnt fetch the placeholder' #}
<img alt="Favicon thumbnail" class="favicon" loading="lazy" decoding="async" fetchpriority="low" {% if favicon %} src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}" {% else %} src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E' {% endif %} >
<div>
{# Intersection Observer lazy loading: store real URL in data-src, load only when visible in viewport #}
<img alt="Favicon thumbnail"
class="favicon lazy-favicon"
loading="lazy"
decoding="async"
fetchpriority="low"
{% if favicon %}
data-src="{{url_for('static_content', group='favicon', filename=watch.uuid)}}"
{% endif %}
src='data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="7.087" height="7.087" viewBox="0 0 7.087 7.087"%3E%3Ccircle cx="3.543" cy="3.543" r="3.279" stroke="%23e1e1e1" stroke-width="0.45" fill="none" opacity="0.74"/%3E%3C/svg%3E'>
</div>
{% endif %}
<div>

View File

@@ -837,6 +837,8 @@ class processor_text_json_diff_form(commonSettingsForm):
conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
use_page_title_in_list = TernaryNoneBooleanField(_l('Use page <title> in list'), default=None)
history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)])
def extra_tab_content(self):
return None
@@ -1034,6 +1036,8 @@ class globalSettingsApplicationForm(commonSettingsForm):
render_kw={"style": "width: 5em;"},
validators=[validators.NumberRange(min=0,
message=_l("Should contain zero or more attempts"))])
history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)])
ui = FormField(globalSettingsApplicationUIForm)

View File

@@ -46,6 +46,7 @@ class model(dict):
'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT,
'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum
'global_subtractive_selectors': [],
'history_snapshot_max_length': None,
'ignore_whitespace': True,
'ignore_status_codes': False, #@todo implement, as ternary.
'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison

View File

@@ -5,6 +5,11 @@ from changedetectionio.model import watch_base
class model(watch_base):
def __init__(self, *arg, **kw):
# Store datastore reference (optional for Tags, but good for consistency)
self.__datastore = kw.get('__datastore')
if kw.get('__datastore'):
del kw['__datastore']
super(model, self).__init__(*arg, **kw)
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')

View File

@@ -1,3 +1,6 @@
import gc
from copy import copy
from blinker import signal
from changedetectionio.validate_url import is_safe_valid_url
@@ -13,7 +16,7 @@ from .. import jinja2_custom as safe_jinja
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024*20))
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
@@ -109,8 +112,15 @@ class model(watch_base):
self.__datastore_path = kw.get('datastore_path')
if kw.get('datastore_path'):
del kw['datastore_path']
self.__datastore = kw.get('__datastore')
if not self.__datastore:
raise ValueError("Watch object requires '__datastore' reference - cannot access global settings without it")
if kw.get('__datastore'):
del kw['__datastore']
super(model, self).__init__(*arg, **kw)
if kw.get('default'):
self.update(kw['default'])
del kw['default']
@@ -121,6 +131,9 @@ class model(watch_base):
# Be sure the cached timestamp is ready
bump = self.history
# Note: __deepcopy__, __getstate__, and __setstate__ are inherited from watch_base
# This prevents memory leaks by sharing __datastore reference instead of copying it
@property
def viewed(self):
# Don't return viewed when last_viewed is 0 and newest_key is 0
@@ -423,16 +436,49 @@ class model(watch_base):
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()
def _write_atomic(self, dest, data):
def _write_atomic(self, dest, data, mode='wb'):
"""Write data atomically to dest using a temp file"""
if not os.path.exists(dest):
import tempfile
with tempfile.NamedTemporaryFile('wb', delete=False, dir=self.watch_data_dir) as tmp:
tmp.write(data)
tmp.flush()
os.fsync(tmp.fileno())
tmp_path = tmp.name
os.replace(tmp_path, dest)
import tempfile
with tempfile.NamedTemporaryFile(mode, delete=False, dir=self.watch_data_dir) as tmp:
tmp.write(data)
tmp.flush()
os.fsync(tmp.fileno())
tmp_path = tmp.name
os.replace(tmp_path, dest)
def history_trim(self, newest_n_items):
from pathlib import Path
# Sort by timestamp (key)
sorted_items = sorted(self.history.items(), key=lambda x: int(x[0]))
keep_part = dict(sorted_items[-newest_n_items:])
delete_part = dict(sorted_items[:-newest_n_items])
logger.info( f"[{self.get('uuid')}] Trimming history to most recent {newest_n_items} items, keeping {len(keep_part)} items deleting {len(delete_part)} items.")
if delete_part:
for item in delete_part.items():
try:
Path(item[1]).unlink(missing_ok=True)
except Exception as e:
logger.critical(f"{str(e)}")
finally:
logger.debug(f"[{self.get('uuid')}] Deleted {item[1]} history snapshot")
try:
dest = os.path.join(self.watch_data_dir, self.history_index_filename)
output = "\r\n".join(
f"{k},{Path(v).name}"
for k, v in keep_part.items()
)+"\r\n"
self._write_atomic(dest=dest, data=output, mode='w')
except Exception as e:
logger.critical(f"{str(e)}")
finally:
logger.debug(f"[{self.get('uuid')}] Updated history index {dest}")
# reimport
bump = self.history
gc.collect()
# Save some text file to the appropriate path and bump the history
# result_obj from fetch_site_status.run()
@@ -441,7 +487,6 @@ class model(watch_base):
logger.trace(f"{self.get('uuid')} - Updating {self.history_index_filename} with timestamp {timestamp}")
self.ensure_data_dir_exists()
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
# Binary data - detect file type and save without compression
@@ -501,6 +546,15 @@ class model(watch_base):
self.__newest_history_key = timestamp
self.__history_n += 1
maxlen = (
self.get('history_snapshot_max_length')
or (self.__datastore and self.__datastore['settings']['application'].get('history_snapshot_max_length'))
)
if maxlen and self.__history_n and self.__history_n > maxlen:
self.history_trim(newest_n_items=maxlen)
# @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status
return snapshot_fname

View File

@@ -32,6 +32,7 @@ class watch_base(dict):
'filter_text_replaced': True,
'follow_price_changes': True,
'has_ldjson_price_data': None,
'history_snapshot_max_length': None,
'headers': {}, # Extra headers to send
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
'ignore_status_codes': None,
@@ -139,4 +140,100 @@ class watch_base(dict):
super(watch_base, self).__init__(*arg, **kw)
if self.get('default'):
del self['default']
del self['default']
def __deepcopy__(self, memo):
"""
Custom deepcopy for all watch_base subclasses (Watch, Tag, etc.).
CRITICAL FIX: Prevents copying large reference objects like __datastore
which would cause exponential memory growth when Watch objects are deepcopied.
This is called by:
- api/Watch.py:76 (API endpoint)
- api/Tags.py:28 (Tags API)
- processors/base.py:26 (EVERY processor run)
- store/__init__.py:544 (clone watch)
- And other locations
"""
from copy import deepcopy
# Create new instance without calling __init__
cls = self.__class__
new_obj = cls.__new__(cls)
memo[id(self)] = new_obj
# Copy the dict data (all the settings)
for key, value in self.items():
new_obj[key] = deepcopy(value, memo)
# Copy instance attributes dynamically
# This handles Watch-specific attrs (like __datastore) and any future subclass attrs
for attr_name in dir(self):
# Skip methods, special attrs, and dict keys
if attr_name.startswith('_') and not attr_name.startswith('__'):
# This catches _model__datastore, _model__history_n, etc.
try:
attr_value = getattr(self, attr_name)
# Special handling: Share references to large objects instead of copying
# Examples: __datastore, __app_reference, __global_settings, etc.
if attr_name.endswith('__datastore') or attr_name.endswith('__app'):
# Share the reference (don't copy!) to prevent memory leaks
setattr(new_obj, attr_name, attr_value)
# Skip cache attributes - let them regenerate on demand
elif 'cache' in attr_name.lower():
pass # Don't copy caches
# Copy regular instance attributes
elif not callable(attr_value):
setattr(new_obj, attr_name, attr_value)
except AttributeError:
pass # Attribute doesn't exist in this instance
return new_obj
def __getstate__(self):
"""
Custom pickle serialization for all watch_base subclasses.
Excludes large reference objects (like __datastore) from serialization.
"""
# Get the dict data
state = dict(self)
# Collect instance attributes (excluding methods and large references)
instance_attrs = {}
for attr_name in dir(self):
if attr_name.startswith('_') and not attr_name.startswith('__'):
try:
attr_value = getattr(self, attr_name)
# Exclude large reference objects and caches from serialization
if not (attr_name.endswith('__datastore') or
attr_name.endswith('__app') or
'cache' in attr_name.lower() or
callable(attr_value)):
instance_attrs[attr_name] = attr_value
except AttributeError:
pass
if instance_attrs:
state['__instance_metadata__'] = instance_attrs
return state
def __setstate__(self, state):
"""
Custom pickle deserialization for all watch_base subclasses.
WARNING: Large reference objects (like __datastore) are NOT restored!
Caller must restore these references after unpickling if needed.
"""
# Extract metadata
metadata = state.pop('__instance_metadata__', {})
# Restore dict data
self.update(state)
# Restore instance attributes
for attr_name, attr_value in metadata.items():
setattr(self, attr_name, attr_value)

View File

@@ -23,7 +23,14 @@ class difference_detection_processor():
def __init__(self, datastore, watch_uuid):
self.datastore = datastore
self.watch_uuid = watch_uuid
# Create a stable snapshot of the watch for processing
# Why deepcopy?
# 1. Prevents "dict changed during iteration" errors if watch is modified during processing
# 2. Preserves Watch object with properties (.link, .is_pdf, etc.) - can't use dict()
# 3. Safe now: Watch.__deepcopy__() shares datastore ref (no memory leak) but copies dict data
self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))
# Generic fetcher that should be extended (requests, playwright etc)
self.fetcher = Fetcher()

View File

@@ -193,18 +193,17 @@ class perform_site_check(difference_detection_processor):
itemprop_availability = {}
multiple_prices_found = False
# Try built-in extraction first, this will scan metadata in the HTML
try:
itemprop_availability = get_itemprop_availability(self.fetcher.content)
except MoreThanOnePriceFound as e:
# Add the real data
raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
url=watch.get('url'),
status_code=self.fetcher.get_last_status_code(),
screenshot=self.fetcher.screenshot,
xpath_data=self.fetcher.xpath_data
)
# Don't raise immediately - let plugins try to handle this case
# Plugins might be able to determine which price is correct
logger.warning(f"Built-in detection found multiple prices on {watch.get('url')}, will try plugin override")
multiple_prices_found = True
itemprop_availability = {}
# If built-in extraction didn't get both price AND availability, try plugin override
# Only check plugin if this watch is using a fetcher that might provide better data
@@ -216,9 +215,21 @@ class perform_site_check(difference_detection_processor):
from changedetectionio.pluggy_interface import get_itemprop_availability_from_plugin
fetcher_name = watch.get('fetch_backend', 'html_requests')
# Only try plugin override if not using system default (which might be anything)
if fetcher_name and fetcher_name != 'system':
logger.debug("Calling extra plugins for getting item price/availability")
# Resolve 'system' to the actual fetcher being used
# This allows plugins to work even when watch uses "system settings default"
if fetcher_name == 'system':
# Get the actual fetcher that was used (from self.fetcher)
# Fetcher class name gives us the actual backend (e.g., 'html_requests', 'html_webdriver')
actual_fetcher = type(self.fetcher).__name__
if 'html_requests' in actual_fetcher.lower():
fetcher_name = 'html_requests'
elif 'webdriver' in actual_fetcher.lower() or 'playwright' in actual_fetcher.lower():
fetcher_name = 'html_webdriver'
logger.debug(f"Resolved 'system' fetcher to actual fetcher: {fetcher_name}")
# Try plugin override - plugins can decide if they support this fetcher
if fetcher_name:
logger.debug(f"Calling extra plugins for getting item price/availability (fetcher: {fetcher_name})")
plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link)
if plugin_availability:
@@ -233,6 +244,16 @@ class perform_site_check(difference_detection_processor):
if not plugin_availability:
logger.debug("No item price/availability from plugins")
# If we had multiple prices and plugins also failed, NOW raise the exception
if multiple_prices_found and not itemprop_availability.get('price'):
raise ProcessorException(
message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
url=watch.get('url'),
status_code=self.fetcher.get_last_status_code(),
screenshot=self.fetcher.screenshot,
xpath_data=self.fetcher.xpath_data
)
# Something valid in get_itemprop_availability() by scraping metadata ?
if itemprop_availability.get('price') or itemprop_availability.get('availability'):
# Store for other usage

View File

@@ -64,7 +64,7 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
# Only update vars that came in via the AJAX post
p = {k: v for k, v in form.data.items() if k in form_data.keys()}
tmp_watch.update(p)
blank_watch_no_filters = watch_model()
blank_watch_no_filters = watch_model(datastore_path=datastore.datastore_path, __datastore=datastore.data)
blank_watch_no_filters['url'] = tmp_watch.get('url')
latest_filename = next(reversed(tmp_watch.history))

View File

@@ -67,7 +67,7 @@ echo "-------------------- Running rest of tests in parallel -------------------
# REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser
FETCH_WORKERS=2 REMOVE_REQUESTS_OLD_SCREENSHOTS=false \
pytest tests/test_*.py \
-n 18 \
-n 8 \
--dist=load \
-vvv \
-s \

View File

@@ -9,18 +9,14 @@ from flask import (
)
from flask_babel import gettext
from ..blueprint.rss import RSS_CONTENT_FORMAT_DEFAULT
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
from ..model import App, Watch, USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
from copy import deepcopy, copy
from ..model import App, Watch
from copy import deepcopy
from os import path, unlink
from threading import Lock
import json
import os
import re
import secrets
import sys
import threading
import time
import uuid as uuid_builder
from loguru import logger
@@ -35,7 +31,6 @@ except ImportError:
HAS_ORJSON = False
from ..processors import get_custom_watch_obj_for_processor
from ..processors.restock_diff import Restock
# Import the base class and helpers
from .file_saving_datastore import FileSavingDataStore, load_all_watches, save_watch_atomic, save_json_atomic
@@ -137,6 +132,19 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
)
logger.info(f"Tag: {uuid} {tag['title']}")
def _rehydrate_watches(self):
"""Rehydrate watch entities from stored data (converts dicts to Watch objects)."""
watch_count = len(self.__data.get('watching', {}))
if watch_count == 0:
return
logger.info(f"Rehydrating {watch_count} watches...")
watching_rehydrated = {}
for uuid, watch_dict in self.__data.get('watching', {}).items():
watching_rehydrated[uuid] = self.rehydrate_entity(uuid, watch_dict)
self.__data['watching'] = watching_rehydrated
logger.success(f"Rehydrated {watch_count} watches into Watch objects")
def _load_state(self):
"""
@@ -174,7 +182,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
self.json_store_path = os.path.join(self.datastore_path, "changedetection.json")
# Base definition for all watchers (deepcopy part of #569)
self.generic_definition = deepcopy(Watch.model(datastore_path=datastore_path, default={}))
self.generic_definition = deepcopy(Watch.model(datastore_path=datastore_path, __datastore=self.__data, default={}))
# Load build SHA if available (Docker deployments)
if path.isfile('changedetectionio/source.txt'):
@@ -218,23 +226,29 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
logger.critical(f"Legacy datastore detected at {self.datastore_path}/url-watches.json")
logger.critical("Migration will be triggered via update_26")
# Load the legacy datastore to get its schema_version
# Load the legacy datastore
from .legacy_loader import load_legacy_format
legacy_path = os.path.join(self.datastore_path, "url-watches.json")
with open(legacy_path) as f:
self.__data = json.load(f)
legacy_data = load_legacy_format(legacy_path)
if not self.__data:
if not legacy_data:
raise Exception("Failed to load legacy datastore from url-watches.json")
# update_26 will load the legacy data again and migrate to new format
# Only run updates AFTER the legacy schema version (e.g., if legacy is at 25, only run 26+)
# Store the loaded data
self.__data = legacy_data
# CRITICAL: Rehydrate watches from dicts into Watch objects
# This ensures watches have their methods available during migration
self._rehydrate_watches()
# update_26 will save watches to individual files and create changedetection.json
# Next startup will load from new format normally
self.run_updates()
else:
# Fresh install - create new datastore
logger.critical(f"No datastore found, creating new datastore at {self.datastore_path}")
logger.warning(f"No datastore found, creating new datastore at {self.datastore_path}")
# Set schema version to latest (no updates needed)
updates_available = self.get_updates_available()
@@ -308,7 +322,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
if entity.get('processor') != 'text_json_diff':
logger.trace(f"Loading Watch object '{watch_class.__module__}.{watch_class.__name__}' for UUID {uuid}")
entity = watch_class(datastore_path=self.datastore_path, default=entity)
entity = watch_class(datastore_path=self.datastore_path, __datastore=self.__data, default=entity)
return entity
# ============================================================================
@@ -527,7 +541,11 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# Clone a watch by UUID
def clone(self, uuid):
url = self.data['watching'][uuid].get('url')
extras = deepcopy(self.data['watching'][uuid])
# No need to deepcopy here - add_watch() will deepcopy extras anyway (line 569)
# Just pass a dict copy (with lock for thread safety)
# NOTE: dict() is shallow copy but safe since add_watch() deepcopies it
with self.lock:
extras = dict(self.data['watching'][uuid])
new_uuid = self.add_watch(url=url, extras=extras)
watch = self.data['watching'][new_uuid]
return new_uuid
@@ -639,7 +657,7 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# If the processor also has its own Watch implementation
watch_class = get_custom_watch_obj_for_processor(apply_extras.get('processor'))
new_watch = watch_class(datastore_path=self.datastore_path, url=url)
new_watch = watch_class(datastore_path=self.datastore_path, __datastore=self.__data, url=url)
new_uuid = new_watch.get('uuid')
@@ -858,10 +876,14 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
# So we use the same model as a Watch
with self.lock:
from ..model import Tag
new_tag = Tag.model(datastore_path=self.datastore_path, default={
'title': title.strip(),
'date_created': int(time.time())
})
new_tag = Tag.model(
datastore_path=self.datastore_path,
__datastore=self.__data,
default={
'title': title.strip(),
'date_created': int(time.time())
}
)
new_uuid = new_tag.get('uuid')

View File

@@ -182,3 +182,86 @@ def test_check_text_history_view(client, live_server, measure_memory_usage, data
assert b'test-one' not in res.data
delete_all_watches(client)
def test_history_trim_global_only(client, live_server, measure_memory_usage, datastore_path):
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
uuid = None
limit = 3
for i in range(0, 10):
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(f"<html>test {i}</html>")
if not uuid:
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
if i ==8:
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
history_n = len(list(watch.history.keys()))
logger.debug(f"History length should be at limit {limit} and it is {history_n}")
assert history_n == limit
if i == 6:
res = client.post(
url_for("settings.settings_page"),
data={"application-history_snapshot_max_length": limit},
follow_redirects=True
)
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
assert b'Settings updated' in res.data
delete_all_watches(client)
def test_history_trim_global_override_in_watch(client, live_server, measure_memory_usage, datastore_path):
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
uuid = None
limit = 3
res = client.post(
url_for("settings.settings_page"),
data={"application-history_snapshot_max_length": 10000},
follow_redirects=True
)
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
assert b'Settings updated' in res.data
for i in range(0, 10):
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(f"<html>test {i}</html>")
if not uuid:
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"),
data={"include_filters": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests",
"time_between_check_use_default": "y", "history_snapshot_max_length": str(limit)},
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
if i == 8:
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
history_n = len(list(watch.history.keys()))
logger.debug(f"History length should be at limit {limit} and it is {history_n}")
assert history_n == limit
if i == 6:
res = client.post(
url_for("settings.settings_page"),
data={"application-history_snapshot_max_length": limit},
follow_redirects=True
)
# It will need to detect one more change to start trimming it, which is really at 'start of 7'
assert b'Settings updated' in res.data
delete_all_watches(client)

View File

@@ -5,15 +5,24 @@
import unittest
import os
import pickle
from copy import deepcopy
from changedetectionio.model import Watch
from changedetectionio.model import Watch, Tag
# mostly
class TestDiffBuilder(unittest.TestCase):
def test_watch_get_suggested_from_diff_timestamp(self):
import uuid as uuid_builder
watch = Watch.model(datastore_path='/tmp', default={})
# Create minimal mock datastore for tests
mock_datastore = {
'settings': {
'application': {}
},
'watching': {}
}
watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})
watch.ensure_data_dir_exists()
@@ -49,7 +58,7 @@ class TestDiffBuilder(unittest.TestCase):
assert p == "109", "Correct when its the same time"
# new empty one
watch = Watch.model(datastore_path='/tmp', default={})
watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})
p = watch.get_from_version_based_on_last_viewed
assert p == None, "None when no history available"
@@ -61,5 +70,184 @@ class TestDiffBuilder(unittest.TestCase):
p = watch.get_from_version_based_on_last_viewed
assert p == "100", "Correct with only one history snapshot"
def test_watch_deepcopy_doesnt_copy_datastore(self):
"""
CRITICAL: Ensure deepcopy(watch) shares __datastore instead of copying it.
Without this, deepcopy causes exponential memory growth:
- 100 watches × deepcopy each = 10,000 watch objects in memory (100²)
- Memory grows from 120MB → 2GB
This test prevents regressions in the __deepcopy__ implementation.
"""
# Create mock datastore with multiple watches
mock_datastore = {
'settings': {'application': {'history_snapshot_max_length': 10}},
'watching': {}
}
# Create 3 watches that all reference the same datastore
watches = []
for i in range(3):
watch = Watch.model(
__datastore=mock_datastore,
datastore_path='/tmp/test',
default={'url': f'https://example{i}.com', 'title': f'Watch {i}'}
)
mock_datastore['watching'][watch['uuid']] = watch
watches.append(watch)
# Test 1: Deepcopy shares datastore reference (doesn't copy it)
watch_copy = deepcopy(watches[0])
self.assertIsNotNone(watch_copy._model__datastore,
"__datastore should exist in copied watch")
self.assertIs(watch_copy._model__datastore, watches[0]._model__datastore,
"__datastore should be SHARED (same object), not copied")
self.assertIs(watch_copy._model__datastore, mock_datastore,
"__datastore should reference the original datastore")
# Test 2: Dict data is properly copied (not shared)
self.assertEqual(watch_copy['title'], 'Watch 0', "Dict data should be copied")
watch_copy['title'] = 'MODIFIED'
self.assertNotEqual(watches[0]['title'], 'MODIFIED',
"Modifying copy should not affect original")
# Test 3: Verify no nested datastore copies in watch dict
# The dict should only contain watch settings, not the datastore
watch_dict = dict(watch_copy)
self.assertNotIn('__datastore', watch_dict,
"__datastore should not be in dict keys")
self.assertNotIn('_model__datastore', watch_dict,
"_model__datastore should not be in dict keys")
# Test 4: Multiple deepcopies don't cause exponential memory growth
# If datastore was copied, each copy would contain 3 watches,
# and those watches would contain the datastore, etc. (infinite recursion)
copies = []
for _ in range(5):
copies.append(deepcopy(watches[0]))
# All copies should share the same datastore
for copy in copies:
self.assertIs(copy._model__datastore, mock_datastore,
"All copies should share the original datastore")
def test_watch_pickle_doesnt_serialize_datastore(self):
"""
Ensure pickle/unpickle doesn't serialize __datastore.
This is important for multiprocessing and caching - we don't want
to serialize the entire datastore when pickling a watch.
"""
mock_datastore = {
'settings': {'application': {}},
'watching': {}
}
watch = Watch.model(
__datastore=mock_datastore,
datastore_path='/tmp/test',
default={'url': 'https://example.com', 'title': 'Test Watch'}
)
# Pickle and unpickle
pickled = pickle.dumps(watch)
unpickled_watch = pickle.loads(pickled)
# Test 1: Watch data is preserved
self.assertEqual(unpickled_watch['url'], 'https://example.com',
"Dict data should be preserved after pickle/unpickle")
# Test 2: __datastore is NOT serialized (attribute shouldn't exist after unpickle)
self.assertFalse(hasattr(unpickled_watch, '_model__datastore'),
"__datastore attribute should not exist after unpickle (not serialized)")
# Test 3: Pickled data shouldn't contain the large datastore object
# If datastore was serialized, the pickle size would be much larger
pickle_size = len(pickled)
# A single watch should be small (< 10KB), not include entire datastore
self.assertLess(pickle_size, 10000,
f"Pickled watch too large ({pickle_size} bytes) - might include datastore")
def test_tag_deepcopy_works(self):
"""
Ensure Tag objects (which also inherit from watch_base) can be deepcopied.
Tags now have optional __datastore for consistency with Watch objects.
"""
mock_datastore = {
'settings': {'application': {}},
'watching': {}
}
# Test 1: Tag without datastore (backward compatibility)
tag_without_ds = Tag.model(
datastore_path='/tmp/test',
default={'title': 'Test Tag', 'overrides_watch': True}
)
tag_copy1 = deepcopy(tag_without_ds)
self.assertEqual(tag_copy1['title'], 'Test Tag', "Tag data should be copied")
# Test 2: Tag with datastore (new pattern for consistency)
tag_with_ds = Tag.model(
datastore_path='/tmp/test',
__datastore=mock_datastore,
default={'title': 'Test Tag With DS', 'overrides_watch': True}
)
# Deepcopy should work
tag_copy2 = deepcopy(tag_with_ds)
# Test 3: Dict data is copied
self.assertEqual(tag_copy2['title'], 'Test Tag With DS', "Tag data should be copied")
# Test 4: Modifications to copy don't affect original
tag_copy2['title'] = 'MODIFIED'
self.assertNotEqual(tag_with_ds['title'], 'MODIFIED',
"Modifying copy should not affect original")
# Test 5: Tag with datastore shares it (doesn't copy it)
if hasattr(tag_with_ds, '_model__datastore'):
self.assertIs(tag_copy2._model__datastore, tag_with_ds._model__datastore,
"Tag should share __datastore reference like Watch does")
def test_watch_copy_performance(self):
"""
Verify that our __deepcopy__ implementation doesn't cause performance issues.
With the fix, deepcopy should be fast because we're sharing datastore
instead of copying it.
"""
import time
# Create a watch with large datastore (many watches)
mock_datastore = {
'settings': {'application': {}},
'watching': {}
}
# Add 100 watches to the datastore
for i in range(100):
w = Watch.model(
__datastore=mock_datastore,
datastore_path='/tmp/test',
default={'url': f'https://example{i}.com'}
)
mock_datastore['watching'][w['uuid']] = w
# Time how long deepcopy takes
watch = list(mock_datastore['watching'].values())[0]
start = time.time()
for _ in range(10):
_ = deepcopy(watch)
elapsed = time.time() - start
# Should be fast (< 0.1 seconds for 10 copies)
# If datastore was copied, it would take much longer
self.assertLess(elapsed, 0.5,
f"Deepcopy too slow ({elapsed:.3f}s for 10 copies) - might be copying datastore")
if __name__ == '__main__':
unittest.main()

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -327,6 +327,14 @@ msgstr "Nastavit na"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -345,6 +353,10 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Vyberte výchozí proxy pro všechny monitory"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -654,10 +666,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Vyberte výchozí proxy pro všechny monitory"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Verze Pythonu:"
@@ -983,7 +991,12 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1232,6 +1245,10 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1580,6 +1597,18 @@ msgstr "zobrazeno <b>{start} - {end}</b> {record_name} z celkem <b>{total}</b>"
msgid "records"
msgstr "záznamy"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Více informací"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "Přidejte nové monitory zjišťování změn webové stránky"
@@ -1592,18 +1621,6 @@ msgstr "Monitorovat tuto URL!"
msgid "Edit first then Watch"
msgstr "Upravit a monitorovat"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "Vytvořte odkaz ke sdílení"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "Tip: Můžete také přidat „sdílené“ monitory."
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Více informací"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "Pauza"
@@ -2117,6 +2134,10 @@ msgstr "Přiřaďte kteroukoli z následujících možností"
msgid "Use page <title> in list"
msgstr "V seznamu použijte stránku <title>"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "Když je metoda požadavku nastavena na GET, tělo musí být prázdné"
@@ -2439,15 +2460,20 @@ msgstr "Změny textu webové stránky/HTML, JSON a PDF"
msgid "Detects all text changes where possible"
msgstr "Detekuje všechny změny textu, kde je to možné"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
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 ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Tělo pro všechna oznámení — Můžete použít"
@@ -3056,3 +3082,12 @@ msgstr "Hlavní nastavení"
#~ msgid "Cleared snapshot history for all watches"
#~ msgstr "Vymazat/resetovat historii"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr "Vytvořte odkaz ke sdílení"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Tip: Můžete také přidat „sdílené“ monitory."

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -333,6 +333,14 @@ msgstr "Setzen auf"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -351,6 +359,10 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Wählen Sie einen Standard-Proxy für alle Überwachungen"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -664,10 +676,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Wählen Sie einen Standard-Proxy für alle Überwachungen"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python-Version:"
@@ -999,8 +1007,13 @@ msgstr "In den Modus {} gewechselt."
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr "Das Bearbeitungsformular für den Prozessor/das Plugin „{}“ kann nicht geladen werden. Fehlt das Plugin?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
@@ -1254,6 +1267,10 @@ msgstr ""
"Sendet eine Benachrichtigung, wenn der Filter auf der Seite nicht mehr sichtbar ist. So wissen Sie, wann sich die "
"Seite geändert hat und Ihr Filter nicht mehr funktioniert."
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr "Methode (default), bei der Ihre überwachte Website kein Javascript zum Rendern benötigt."
@@ -1618,6 +1635,18 @@ msgstr "zeige <b>{start} - {end}</b> {record_name} von insgesamt <b>{total}</b>"
msgid "records"
msgstr "Einträge"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Weitere Informationen"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "Fügen Sie eine neue Überwachung zur Erkennung von Webseitenänderungen hinzu"
@@ -1630,18 +1659,6 @@ msgstr "Diese URL überwachen!"
msgid "Edit first then Watch"
msgstr "Bearbeiten > Überwachen"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "Erstellen Sie einen Link zum Teilen"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen."
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Weitere Informationen"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "Pause"
@@ -2162,6 +2179,10 @@ msgstr "Entspricht einer der folgenden Bedingungen"
msgid "Use page <title> in list"
msgstr "Verwenden Sie Seite <Titel> in der Liste"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "Der Textkörper muss leer sein, wenn die Anforderungsmethode auf GET gesetzt ist"
@@ -2486,15 +2507,20 @@ msgstr "Änderungen an Webseitentext/HTML, JSON und PDF"
msgid "Detects all text changes where possible"
msgstr "Erkennt nach Möglichkeit alle Textänderungen"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr "Fehler beim Abrufen der Metadaten für {}"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Das Protokoll wird nicht unterstützt oder das URL-Format ist ungültig."
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Inhalt für alle Benachrichtigungen — Sie können verwenden"
@@ -3171,3 +3197,12 @@ msgstr "Haupteinstellungen"
#~ msgid "No watches available to recheck."
#~ msgstr "Keine Überwachungen verfügbar, um erneut zu überprüfen."
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr "Das Bearbeitungsformular für den Prozessor/das Plugin „{}“ kann nicht geladen werden. Fehlt das Plugin?"
#~ msgid "Create a shareable link"
#~ msgstr "Erstellen Sie einen Link zum Teilen"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen."

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -325,6 +325,14 @@ msgstr ""
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -341,6 +349,10 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -650,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -979,7 +987,12 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1228,6 +1241,10 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1576,6 +1593,18 @@ msgstr ""
msgid "records"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr ""
@@ -1588,18 +1617,6 @@ msgstr ""
msgid "Edit first then Watch"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr ""
@@ -2113,6 +2130,10 @@ msgstr ""
msgid "Use page <title> in list"
msgstr ""
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr ""
@@ -2435,15 +2456,20 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
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 ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""
@@ -3001,3 +3027,12 @@ msgstr ""
#~ msgid "No watches available to recheck."
#~ msgstr ""
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr ""
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -325,6 +325,14 @@ msgstr ""
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -341,6 +349,10 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -650,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -979,7 +987,12 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1228,6 +1241,10 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1576,6 +1593,18 @@ msgstr ""
msgid "records"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr ""
@@ -1588,18 +1617,6 @@ msgstr ""
msgid "Edit first then Watch"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr ""
@@ -2113,6 +2130,10 @@ msgstr ""
msgid "Use page <title> in list"
msgstr ""
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr ""
@@ -2435,15 +2456,20 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
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 ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""
@@ -3001,3 +3027,12 @@ msgstr ""
#~ msgid "No watches available to recheck."
#~ msgstr ""
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr ""
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -327,6 +327,14 @@ msgstr "Définir à"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -345,6 +353,10 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Choisir un proxy par défaut pour tous les moniteurs"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -654,10 +666,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "Choisir un proxy par défaut pour tous les moniteurs"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Version Python :"
@@ -983,7 +991,12 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1234,6 +1247,10 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1582,6 +1599,18 @@ msgstr "affichage de <b>{start} - {end}</b> {record_name} sur un total de <b>{to
msgid "records"
msgstr "enregistrements"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Plus d'informations"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "Ajouter une nouvelle surveillance de détection de changement de page Web"
@@ -1594,18 +1623,6 @@ msgstr "Surveillez cette URL !"
msgid "Edit first then Watch"
msgstr "Modifier > Surveiller"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "Créer un lien partageable"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "Astuce : Vous pouvez également ajouter des montres « partagées »."
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Plus d'informations"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "Pause"
@@ -2123,6 +2140,10 @@ msgstr "Faites correspondre l'un des éléments suivants"
msgid "Use page <title> in list"
msgstr "Utiliser la page <titre> dans la liste"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "Le corps doit être vide lorsque la méthode de requête est définie sur GET"
@@ -2445,15 +2466,20 @@ msgstr "Modifications du texte de la page Web/HTML, JSON et PDF"
msgid "Detects all text changes where possible"
msgstr "Détecte toutes les modifications de texte lorsque cela est possible"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
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 ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Corps pour toutes les notifications — Vous pouvez utiliser"
@@ -3064,3 +3090,12 @@ msgstr "Paramètres principaux"
#~ msgid "Cleared snapshot history for all watches"
#~ msgstr "Effacer/réinitialiser l'historique"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr "Créer un lien partageable"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "Astuce : Vous pouvez également ajouter des montres « partagées »."

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -327,6 +327,14 @@ msgstr ""
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -343,6 +351,10 @@ msgstr "Consenti accesso alla pagina cronologia quando la password è attiva (ut
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -652,10 +664,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -981,7 +989,12 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1230,6 +1243,10 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1578,6 +1595,18 @@ msgstr "visualizzando <b>{start} - {end}</b> {record_name} su un totale di <b>{t
msgid "records"
msgstr "record"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Maggiori informazioni"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "Aggiungi un nuovo monitoraggio modifiche pagina web"
@@ -1590,18 +1619,6 @@ msgstr "Monitora questo URL!"
msgid "Edit first then Watch"
msgstr "Modifica > Monitora"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "Maggiori informazioni"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "Pausa"
@@ -2115,6 +2132,10 @@ msgstr "Corrisponde a uno qualsiasi dei seguenti"
msgid "Use page <title> in list"
msgstr "Usa <title> pagina nell'elenco"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "Il corpo deve essere vuoto quando il metodo è impostato su GET"
@@ -2437,15 +2458,20 @@ msgstr "Modifiche testo/HTML, JSON e PDF"
msgid "Detects all text changes where possible"
msgstr "Rileva tutte le modifiche di testo possibili"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr "Errore nel recupero metadati per {}"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "Protocollo non consentito o formato URL non valido"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "Corpo per tutte le notifiche — Puoi usare"
@@ -3036,3 +3062,12 @@ msgstr "Impostazioni principali"
#~ msgid "Queue"
#~ msgstr "In coda"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr ""
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -325,6 +325,14 @@ msgstr "설정:"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -341,6 +349,10 @@ msgstr "비밀번호 활성화 시 변경 기록 페이지 액세스 허용 (dif
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "모든 모니터의 기본 프록시 선택"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -650,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "모든 모니터의 기본 프록시 선택"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "파이썬 버전:"
@@ -979,7 +987,12 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1228,6 +1241,10 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1576,6 +1593,18 @@ msgstr "총 <b>{total}</b>개 중 <b>{start} - {end}</b>개 {record_name} 표시
msgid "records"
msgstr "기록"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "추가 정보"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "새로운 웹 페이지 변경 감지 감시 추가"
@@ -1588,18 +1617,6 @@ msgstr "이 URL 모니터!"
msgid "Edit first then Watch"
msgstr "편집 후 모니터"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "공유 가능한 링크 만들기"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "팁: '공유' 시계를 추가할 수도 있습니다."
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "추가 정보"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "정지시키다"
@@ -2113,6 +2130,10 @@ msgstr "다음 중 하나와 일치"
msgid "Use page <title> in list"
msgstr "목록의 <제목> 페이지 사용"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "요청 방법이 GET으로 설정된 경우 본문이 비어 있어야 합니다."
@@ -2435,15 +2456,20 @@ msgstr "웹페이지 텍스트/HTML, JSON 및 PDF 변경"
msgid "Detects all text changes where possible"
msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
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 ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "모든 알림 본문 — 사용 가능:"
@@ -3157,3 +3183,12 @@ msgstr "기본 설정"
#~ msgid "Cleared snapshot history for all watches"
#~ msgstr "기록 지우기/재설정"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr ""
#~ msgid "Create a shareable link"
#~ msgstr "공유 가능한 링크 만들기"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "팁: '공유' 시계를 추가할 수도 있습니다."

View File

@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.52.8\n"
"Project-Id-Version: changedetection.io 0.52.9\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-22 06:29+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"
@@ -324,6 +324,14 @@ msgstr ""
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -340,6 +348,10 @@ msgstr ""
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -649,10 +661,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr ""
@@ -978,7 +986,12 @@ msgstr ""
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
@@ -1227,6 +1240,10 @@ msgid ""
"your filter will not work anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr ""
@@ -1575,6 +1592,18 @@ msgstr ""
msgid "records"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr ""
@@ -1587,18 +1616,6 @@ msgstr ""
msgid "Edit first then Watch"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr ""
@@ -2112,6 +2129,10 @@ msgstr ""
msgid "Use page <title> in list"
msgstr ""
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr ""
@@ -2434,15 +2455,20 @@ msgstr ""
msgid "Detects all text changes where possible"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr ""
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
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 ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -325,6 +325,14 @@ msgstr "设置为"
msgid "to disable"
msgstr "以禁用"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr "为你的 changedetection.io 应用启用密码保护。"
@@ -341,6 +349,10 @@ msgstr "启用密码时允许访问监视器更改历史页面(便于共享差
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr "当请求无内容返回,或 HTML 不包含任何文本时,是否视为变更?"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "为所有监视器选择默认代理"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr "用于通知链接中的"
@@ -650,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr "带认证的 SOCKS5 代理仅支持“明文请求”抓取器,其他抓取器请改为白名单 IP"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "为所有监视器选择默认代理"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python 版本:"
@@ -979,8 +987,13 @@ msgstr "已切换到模式 - {}。"
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr "无法加载处理器/插件 '{}' 的编辑表单,插件是否缺失?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
@@ -1228,6 +1241,10 @@ msgid ""
"your filter will not work anymore."
msgstr "当页面上找不到该过滤器时发送通知,便于知晓页面已变化且过滤器不再适用。"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr "方式(默认),适用于无需 JavaScript 渲染的网站。"
@@ -1576,6 +1593,18 @@ msgstr "显示第 <b>{start} - {end}</b> 条{record_name},共 <b>{total}</b>
msgid "records"
msgstr "记录"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "更多信息"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "新增网页变更监控"
@@ -1588,18 +1617,6 @@ msgstr "监控此 URL"
msgid "Edit first then Watch"
msgstr "先编辑,再监控"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "创建可分享链接"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "提示:你也可以添加“共享”的监控项。"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "更多信息"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "暂停"
@@ -2113,6 +2130,10 @@ msgstr "匹配以下任意"
msgid "Use page <title> in list"
msgstr "列表中使用页面 <title>"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "当请求方法为 GET 时,请求正文必须为空"
@@ -2435,15 +2456,20 @@ msgstr "网页文本/HTML、JSON 和 PDF 变更"
msgid "Detects all text changes where possible"
msgstr "尽可能检测所有文本变更"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr "获取 {} 的元数据失败"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "监控协议不允许或 URL 格式无效"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "所有通知的正文 — 您可以使用"
@@ -2986,3 +3012,12 @@ msgstr "否"
msgid "Main settings"
msgstr "主设置"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr "无法加载处理器/插件 '{}' 的编辑表单,插件是否缺失?"
#~ msgid "Create a shareable link"
#~ msgstr "创建可分享链接"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "提示:你也可以添加“共享”的监控项。"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-22 06:19+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"
@@ -325,6 +325,14 @@ msgstr "設置為"
msgid "to disable"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "Limit collection of history snapshots for each watch to this number of history items."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
@@ -341,6 +349,10 @@ msgstr "啟用密碼時允許匿名存取監測歷史頁面"
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "為所有監測任務選擇預設代理伺服器"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
@@ -650,10 +662,6 @@ msgid ""
"whitelist the IP access instead"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
msgstr "為所有監測任務選擇預設代理伺服器"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Python version:"
msgstr "Python 版本:"
@@ -979,8 +987,13 @@ msgstr "已切換至模式 - {}。"
#: changedetectionio/blueprint/ui/edit.py
#, python-brace-format
msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
msgstr "無法載入處理器 / 外掛 '{}' 的編輯表單,外掛是否遺失?"
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 ""
#: changedetectionio/blueprint/ui/edit.py
msgid "Updated watch - unpaused!"
@@ -1228,6 +1241,10 @@ msgid ""
"your filter will not work anymore."
msgstr "當頁面上找不到過濾器時發送通知,這有助於了解頁面何時變更導致您的過濾器失效。"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Set to empty to use system settings default"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "method (default) where your watched site doesn't need Javascript to render."
msgstr "方法(預設),適用於您監測的網站不需要 Javascript 渲染的情況。"
@@ -1576,6 +1593,18 @@ msgstr "顯示第 <b>{start} - {end}</b> 條{record_name},共 <b>{total}</b>
msgid "records"
msgstr "記錄"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changedetection.io can monitor more than just web-pages! See our plugins!"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "更多資訊"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "You can also add 'shared' watches."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Add a new web page change detection watch"
msgstr "新增網頁變更檢測任務"
@@ -1588,18 +1617,6 @@ msgstr "監測此 URL"
msgid "Edit first then Watch"
msgstr "先編輯後監測"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Create a shareable link"
msgstr "建立可分享連結"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Tip: You can also add 'shared' watches."
msgstr "提示:您也可以新增「共享」監測任務。"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "More info"
msgstr "更多資訊"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Pause"
msgstr "暫停"
@@ -2113,6 +2130,10 @@ msgstr "符合以下任一條件"
msgid "Use page <title> in list"
msgstr "在列表中使用頁面 <title>"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
msgstr "當請求方法設為 GET 時,內容必須為空"
@@ -2435,15 +2456,20 @@ msgstr "網頁文字 / HTML、JSON 和 PDF 變更"
msgid "Detects all text changes where possible"
msgstr "盡可能檢測所有文字變更"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Error fetching metadata for {}"
msgstr "讀取 {} 的中繼資料時發生錯誤"
#: changedetectionio/store.py
#: changedetectionio/store/__init__.py
msgid "Watch protocol is not permitted or invalid URL format"
msgstr "監測協定不被允許或 URL 格式無效"
#: changedetectionio/store/__init__.py
#, python-brace-format
msgid "Watch limit reached ({}/{} watches). Cannot add more watches."
msgstr ""
#: changedetectionio/templates/_common_fields.html
msgid "Body for all notifications — You can use"
msgstr "所有通知的內文 — 您可以使用"
@@ -3115,3 +3141,12 @@ msgstr "主設定"
#~ msgid "No watches available to recheck."
#~ msgstr "沒有可複查的監測任務。"
#~ msgid "Cannot load the edit form for processor/plugin '{}', plugin missing?"
#~ msgstr "無法載入處理器 / 外掛 '{}' 的編輯表單,外掛是否遺失?"
#~ msgid "Create a shareable link"
#~ msgstr "建立可分享連結"
#~ msgid "Tip: You can also add 'shared' watches."
#~ msgstr "提示:您也可以新增「共享」監測任務。"

View File

@@ -370,8 +370,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
except Exception as e:
import traceback
logger.error(f"Worker {worker_id} exception processing watch UUID: {uuid}")
logger.error(str(e))
logger.error(traceback.format_exc())
logger.exception(f"Worker {worker_id} full exception details:")
datastore.update_watch(uuid=uuid, update_obj={'last_error': "Exception: " + str(e)})
process_changedetection_results = False
@@ -435,8 +434,9 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
await send_content_changed_notification(uuid, notification_q, datastore)
except Exception as e:
logger.critical(f"Worker {worker_id} exception in process_changedetection_results")
logger.critical(str(e))
logger.exception(f"Worker {worker_id} full exception details:")
datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
# Always record attempt count
@@ -451,6 +451,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.debug(f"UUID: {uuid} Page <title> is '{page_title}'")
datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title})
except Exception as e:
logger.exception(f"Worker {worker_id} full exception details:")
logger.warning(f"UUID: {uuid} Exception when extracting <title> - {str(e)}")
# Record server header
@@ -485,12 +486,10 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
gc.collect()
except Exception as e:
import traceback
logger.error(traceback.format_exc())
logger.error(f"Worker {worker_id} unexpected error processing {uuid}: {e}")
logger.error(f"Worker {worker_id} traceback:", exc_info=True)
logger.exception(f"Worker {worker_id} full exception details:")
# Also update the watch with error information
if datastore and uuid in datastore.data['watching']:
datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"})
@@ -504,6 +503,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
await update_handler.fetcher.quit(watch=watch)
except Exception as e:
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
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)
@@ -532,6 +533,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s")
except Exception as cleanup_error:
logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}")
logger.exception(f"Worker {worker_id} full exception details:")
del(uuid)