mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-04-15 23:48:05 +00:00
Compare commits
1 Commits
4020-custo
...
error-rese
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b33492c5 |
@@ -99,7 +99,11 @@ jobs:
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest tests/unit/'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'
|
||||
|
||||
# Basic pytest tests with ancillary services
|
||||
basic-tests:
|
||||
@@ -583,10 +587,6 @@ jobs:
|
||||
run: |
|
||||
docker run -e EXTRA_PACKAGES=changedetection.io-osint-processor test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_processor.py::test_check_plugin_processor'
|
||||
|
||||
- name: Plugin get_html_head_extras hook injects into base.html
|
||||
run: |
|
||||
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_html_head_extras.py'
|
||||
|
||||
# Container startup tests
|
||||
container-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
# Semver means never use .01, or 00. Should be .1.
|
||||
__version__ = '0.54.8'
|
||||
__version__ = '0.54.6'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -98,8 +98,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
|
||||
backup_threads = []
|
||||
|
||||
@backups_blueprint.route("/request-backup", methods=['GET'])
|
||||
@login_optionally_required
|
||||
@backups_blueprint.route("/request-backup", methods=['GET'])
|
||||
def request_backup():
|
||||
if any(thread.is_alive() for thread in backup_threads):
|
||||
flash(gettext("A backup is already running, check back in a few minutes"), "error")
|
||||
@@ -141,8 +141,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
return backup_info
|
||||
|
||||
@backups_blueprint.route("/download/<string:filename>", methods=['GET'])
|
||||
@login_optionally_required
|
||||
@backups_blueprint.route("/download/<string:filename>", methods=['GET'])
|
||||
def download_backup(filename):
|
||||
import re
|
||||
filename = filename.strip()
|
||||
@@ -165,9 +165,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.debug(f"Backup download request for '{full_path}'")
|
||||
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
|
||||
|
||||
@login_optionally_required
|
||||
@backups_blueprint.route("/", methods=['GET'])
|
||||
@backups_blueprint.route("/create", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def create():
|
||||
backups = find_backups()
|
||||
output = render_template("backup_create.html",
|
||||
@@ -176,8 +176,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
)
|
||||
return output
|
||||
|
||||
@backups_blueprint.route("/remove-backups", methods=['GET'])
|
||||
@login_optionally_required
|
||||
@backups_blueprint.route("/remove-backups", methods=['GET'])
|
||||
def remove_backups():
|
||||
|
||||
backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
|
||||
|
||||
@@ -174,8 +174,8 @@ def construct_restore_blueprint(datastore):
|
||||
restore_blueprint = Blueprint('restore', __name__, template_folder="templates")
|
||||
restore_threads = []
|
||||
|
||||
@restore_blueprint.route("/restore", methods=['GET'])
|
||||
@login_optionally_required
|
||||
@restore_blueprint.route("/restore", methods=['GET'])
|
||||
def restore():
|
||||
form = RestoreForm()
|
||||
return render_template("backup_restore.html",
|
||||
@@ -184,8 +184,8 @@ def construct_restore_blueprint(datastore):
|
||||
max_upload_mb=_MAX_UPLOAD_BYTES // (1024 * 1024),
|
||||
max_decompressed_mb=_MAX_DECOMPRESSED_BYTES // (1024 * 1024))
|
||||
|
||||
@restore_blueprint.route("/restore/start", methods=['POST'])
|
||||
@login_optionally_required
|
||||
@restore_blueprint.route("/restore/start", methods=['POST'])
|
||||
def backups_restore_start():
|
||||
if any(t.is_alive() for t in restore_threads):
|
||||
flash(gettext("A restore is already running, check back in a few minutes"), "error")
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
<p>{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}</p>
|
||||
<p>{{ _('Note: This does not override the main application settings, only watches and groups.') }}</p>
|
||||
<p class="pure-form-message">
|
||||
{{ _('Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB', upload=max_upload_mb, decomp=max_decompressed_mb) }}
|
||||
{{ _('Max upload size: %(upload)s MB · Max decompressed size: %(decomp)s MB',
|
||||
upload=max_upload_mb, decomp=max_decompressed_mb) }}
|
||||
</p>
|
||||
|
||||
<form class="pure-form pure-form-stacked settings"
|
||||
|
||||
@@ -268,8 +268,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
return browsersteps_start_session
|
||||
|
||||
|
||||
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
|
||||
@login_optionally_required
|
||||
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
|
||||
def browsersteps_start_session():
|
||||
# A new session was requested, return sessionID
|
||||
import uuid
|
||||
@@ -304,8 +304,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.debug("Starting connection with playwright - done")
|
||||
return {'browsersteps_session_id': browsersteps_session_id}
|
||||
|
||||
@browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
|
||||
@login_optionally_required
|
||||
@browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
|
||||
def browser_steps_fetch_screenshot_image():
|
||||
from flask import (
|
||||
make_response,
|
||||
@@ -330,8 +330,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)
|
||||
|
||||
# A request for an action was received
|
||||
@browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
|
||||
@login_optionally_required
|
||||
@browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
|
||||
def browsersteps_ui_update():
|
||||
import base64
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<li class="tab" id=""><a href="#url-list">{{ _('URL List') }}</a></li>
|
||||
<li class="tab"><a href="#distill-io">{{ _('Distill.io') }}</a></li>
|
||||
<li class="tab"><a href="#xlsx">{{ _('.XLSX & Wachete') }}</a></li>
|
||||
<li class="tab"><a href="{{url_for('backups.restore.restore')}}">{{ _('Backup Restore') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,14 +22,11 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
tag_count = Counter(tag for watch in datastore.data['watching'].values() if watch.get('tags') for tag in watch['tags'])
|
||||
|
||||
from changedetectionio import processors
|
||||
output = render_template("groups-overview.html",
|
||||
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
|
||||
available_tags=sorted_tags,
|
||||
form=add_form,
|
||||
generate_tag_colors=processors.generate_processor_badge_colors,
|
||||
tag_count=tag_count,
|
||||
wcag_text_color=processors.wcag_text_color,
|
||||
)
|
||||
|
||||
return output
|
||||
@@ -211,17 +208,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
template = env.from_string(template_str)
|
||||
included_content = template.render(**template_args)
|
||||
|
||||
# Watches whose URL currently matches this tag's pattern
|
||||
matching_watches = {
|
||||
w_uuid: watch
|
||||
for w_uuid, watch in datastore.data['watching'].items()
|
||||
if default.matches_url(watch.get('url', ''))
|
||||
}
|
||||
|
||||
output = render_template("edit-tag.html",
|
||||
extra_form_content=included_content,
|
||||
extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None,
|
||||
matching_watches=matching_watches,
|
||||
settings_application=datastore.data['settings']['application'],
|
||||
**template_args
|
||||
)
|
||||
|
||||
@@ -10,11 +10,12 @@ from changedetectionio.processors.restock_diff.forms import processor_settings_f
|
||||
|
||||
class group_restock_settings_form(restock_settings_form):
|
||||
overrides_watch = BooleanField('Activate for individual watches in this tag/group?', default=False)
|
||||
url_match_pattern = StringField('Auto-apply to watches with URLs matching',
|
||||
render_kw={"placeholder": "e.g. *://example.com/* or github.com/myorg"})
|
||||
tag_colour = StringField('Tag colour', default='')
|
||||
|
||||
class SingleTag(Form):
|
||||
|
||||
name = StringField('Tag name', [validators.InputRequired()], render_kw={"placeholder": "Name"})
|
||||
save_button = SubmitField('Save', render_kw={"class": "pure-button pure-button-primary"})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,46 +43,6 @@
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.title, placeholder="https://...", required=true, class="m-d") }}
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.url_match_pattern, class="m-d") }}
|
||||
<span class="pure-form-message-inline">{{ _('Automatically applies this tag to any watch whose URL matches. Supports wildcards: <code>*example.com*</code> or plain substring: <code>github.com/myorg</code>')|safe }}</span>
|
||||
</div>
|
||||
{% if matching_watches %}
|
||||
<div class="pure-control-group">
|
||||
<label>{{ _('Currently matching watches') }} ({{ matching_watches|length }})</label>
|
||||
<ul class="tag-url-match-list">
|
||||
{% for w_uuid, w in matching_watches.items() %}
|
||||
<li><a href="{{ url_for('ui.ui_edit.edit_page', uuid=w_uuid) }}">{{ w.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pure-control-group">
|
||||
<label>{{ _('Tag colour') }}</label>
|
||||
<div style="display:flex; align-items:center; gap:0.75em;">
|
||||
<input type="checkbox" id="use_custom_colour"
|
||||
{% if data.get('tag_colour') %}checked{% endif %}>
|
||||
<label for="use_custom_colour" style="margin:0">{{ _('Custom colour') }}</label>
|
||||
<input type="color" id="tag_colour_picker"
|
||||
value="{{ data.get('tag_colour') or '#4f8ef7' }}"
|
||||
{% if not data.get('tag_colour') %}disabled{% endif %}>
|
||||
<input type="hidden" name="tag_colour" id="tag_colour_hidden"
|
||||
value="{{ data.get('tag_colour', '') }}">
|
||||
</div>
|
||||
<span class="pure-form-message-inline">{{ _('Leave unchecked to use the auto-generated colour based on the tag name.') }}</span>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var cb = document.getElementById('use_custom_colour');
|
||||
var picker = document.getElementById('tag_colour_picker');
|
||||
var hidden = document.getElementById('tag_colour_hidden');
|
||||
picker.addEventListener('input', function () { hidden.value = this.value; });
|
||||
cb.addEventListener('change', function () {
|
||||
picker.disabled = !this.checked;
|
||||
hidden.value = this.checked ? picker.value : '';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,26 +3,6 @@
|
||||
{% from '_helpers.html' import render_simple_field, render_field %}
|
||||
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='modal.js')}}"></script>
|
||||
<style>
|
||||
{%- for uuid, tag in available_tags -%}
|
||||
{%- if tag and tag.title -%}
|
||||
{%- set class_name = tag.title|sanitize_tag_class -%}
|
||||
{%- if tag.get('tag_colour') -%}
|
||||
.watch-tag-list.tag-{{ class_name }} { background-color: {{ tag.tag_colour }}; color: {{ wcag_text_color(tag.tag_colour) }}; }
|
||||
{%- else -%}
|
||||
{%- set colors = generate_tag_colors(tag.title) -%}
|
||||
.watch-tag-list.tag-{{ class_name }} {
|
||||
background-color: {{ colors['light']['bg'] }};
|
||||
color: {{ colors['light']['color'] }};
|
||||
}
|
||||
html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
background-color: {{ colors['dark']['bg'] }};
|
||||
color: {{ colors['dark']['color'] }};
|
||||
}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</style>
|
||||
|
||||
<div class="box">
|
||||
<form class="pure-form" action="{{ url_for('tags.form_tag_add') }}" method="POST" id="new-watch-form">
|
||||
@@ -68,7 +48,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a>
|
||||
</td>
|
||||
<td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
|
||||
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}" class="watch-tag-list tag-{{ tag.title|sanitize_tag_class }}">{{ tag.title }}</a></td>
|
||||
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td>
|
||||
<td>
|
||||
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>
|
||||
<a href="{{ url_for('ui.form_watch_checknow', tag=uuid) }}" class="pure-button pure-button-primary" >{{ _('Recheck') }}</a>
|
||||
|
||||
@@ -320,12 +320,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
'using_global_webdriver_wait': not default['webdriver_delay'],
|
||||
'uuid': uuid,
|
||||
'watch': watch,
|
||||
'capabilities': capabilities,
|
||||
'auto_applied_tags': {
|
||||
tag_uuid: tag
|
||||
for tag_uuid, tag in datastore.data['settings']['application']['tags'].items()
|
||||
if tag_uuid not in watch.get('tags', []) and tag.matches_url(watch.get('url', ''))
|
||||
},
|
||||
'capabilities': capabilities
|
||||
}
|
||||
|
||||
included_content = None
|
||||
|
||||
@@ -81,14 +81,6 @@
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.tags) }}
|
||||
<span class="pure-form-message-inline">{{ _('Organisational tag/group name used in the main listing page') }}</span>
|
||||
{% if auto_applied_tags %}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Also automatically applied by URL pattern:') }}
|
||||
{% for tag_uuid, tag in auto_applied_tags.items() %}
|
||||
<a href="{{ url_for('tags.form_tag_edit', uuid=tag_uuid) }}" class="watch-tag-list tag-{{ tag.title|sanitize_tag_class }}">{{ tag.title }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pure-control-group inline-radio">
|
||||
{{ render_field(form.processor) }}
|
||||
|
||||
@@ -92,7 +92,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
extra_classes='has-queue' if not update_q.empty() else '',
|
||||
form=form,
|
||||
generate_tag_colors=processors.generate_processor_badge_colors,
|
||||
wcag_text_color=processors.wcag_text_color,
|
||||
guid=datastore.data['app_guid'],
|
||||
has_proxies=proxy_list,
|
||||
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
|
||||
|
||||
@@ -71,13 +71,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
{%- for uuid, tag in tags -%}
|
||||
{%- if tag and tag.title -%}
|
||||
{%- set class_name = tag.title|sanitize_tag_class -%}
|
||||
{%- if tag.get('tag_colour') -%}
|
||||
.button-tag.tag-{{ class_name }},
|
||||
.watch-tag-list.tag-{{ class_name }} {
|
||||
background-color: {{ tag.tag_colour }};
|
||||
color: {{ wcag_text_color(tag.tag_colour) }};
|
||||
}
|
||||
{%- else -%}
|
||||
{%- set colors = generate_tag_colors(tag.title) -%}
|
||||
.button-tag.tag-{{ class_name }} {
|
||||
background-color: {{ colors['light']['bg'] }};
|
||||
@@ -99,7 +92,6 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
color: {{ colors['dark']['color'] }};
|
||||
}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</style>
|
||||
<div class="box" id="form-quick-watch-add">
|
||||
|
||||
@@ -49,9 +49,6 @@ async def capture_full_page_async(page, screenshot_format='JPEG', watch_uuid=Non
|
||||
if page_height > page.viewport_size['height']:
|
||||
if page_height < step_size:
|
||||
step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
|
||||
# Never set viewport taller than our max capture height - otherwise one screenshot chunk
|
||||
# captures the whole (e.g. 8098px) page even when SCREENSHOT_MAX_HEIGHT=1000
|
||||
step_size = min(step_size, SCREENSHOT_MAX_TOTAL_HEIGHT)
|
||||
viewport_start = time.time()
|
||||
logger.debug(f"{watch_info}Setting bigger viewport to step through large page width W{page.viewport_size['width']}xH{step_size} because page_height > viewport_size")
|
||||
# Set viewport to a larger size to capture more content at once
|
||||
|
||||
@@ -75,9 +75,6 @@ async def capture_full_page(page, screenshot_format='JPEG', watch_uuid=None, loc
|
||||
if page_height > page.viewport['height']:
|
||||
if page_height < step_size:
|
||||
step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
|
||||
# Never set viewport taller than our max capture height - otherwise one screenshot chunk
|
||||
# captures the whole page even when SCREENSHOT_MAX_HEIGHT is set smaller
|
||||
step_size = min(step_size, SCREENSHOT_MAX_TOTAL_HEIGHT)
|
||||
viewport_start = time.time()
|
||||
await page.setViewport({'width': page.viewport['width'], 'height': step_size})
|
||||
viewport_time = time.time() - viewport_start
|
||||
|
||||
@@ -56,10 +56,6 @@ def stitch_images_worker_raw_bytes(pipe_conn, original_page_height, capture_heig
|
||||
im.close()
|
||||
del images
|
||||
|
||||
# Clip stitched image to capture_height (chunks may overshoot by up to step_size-1 px)
|
||||
if total_height > capture_height:
|
||||
stitched = stitched.crop((0, 0, max_width, capture_height))
|
||||
|
||||
# Draw caption only if page was trimmed
|
||||
if original_page_height > capture_height:
|
||||
draw = ImageDraw.Draw(stitched)
|
||||
|
||||
@@ -104,17 +104,15 @@ class fetcher(Fetcher):
|
||||
|
||||
from selenium.webdriver.remote.remote_connection import RemoteConnection
|
||||
from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver
|
||||
from selenium.webdriver.remote.client_config import ClientConfig
|
||||
from urllib3.util import Timeout
|
||||
driver = None
|
||||
try:
|
||||
connection_timeout = int(os.getenv("WEBDRIVER_CONNECTION_TIMEOUT", 90))
|
||||
client_config = ClientConfig(
|
||||
remote_server_addr=self.browser_connection_url,
|
||||
timeout=Timeout(connect=connection_timeout, total=connection_timeout)
|
||||
# Create the RemoteConnection and set timeout (e.g., 30 seconds)
|
||||
remote_connection = RemoteConnection(
|
||||
self.browser_connection_url,
|
||||
)
|
||||
remote_connection = RemoteConnection(client_config=client_config)
|
||||
remote_connection.set_timeout(30) # seconds
|
||||
|
||||
# Now create the driver with the RemoteConnection
|
||||
driver = RemoteWebDriver(
|
||||
command_executor=remote_connection,
|
||||
options=options
|
||||
|
||||
@@ -45,36 +45,6 @@ CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'
|
||||
# Compiled regex patterns for performance
|
||||
WHITESPACE_NORMALIZE_RE = re.compile(r'\s+')
|
||||
|
||||
# Regexes built from the constants above — no brittle hardcoded strings
|
||||
_EXTRACT_REMOVED_RE = re.compile(
|
||||
re.escape(REMOVED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(REMOVED_PLACEMARKER_CLOSED)
|
||||
+ r'|' +
|
||||
re.escape(CHANGED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_PLACEMARKER_CLOSED)
|
||||
)
|
||||
_EXTRACT_ADDED_RE = re.compile(
|
||||
re.escape(ADDED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(ADDED_PLACEMARKER_CLOSED)
|
||||
+ r'|' +
|
||||
re.escape(CHANGED_INTO_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_INTO_PLACEMARKER_CLOSED)
|
||||
)
|
||||
|
||||
|
||||
def extract_changed_from(raw_diff: str) -> str:
|
||||
"""Extract only the removed/changed-from fragments from a raw diff string.
|
||||
|
||||
Useful for {{diff_changed_from}} — gives just the old value (e.g. old price),
|
||||
not the full surrounding line. Multiple fragments joined with newlines.
|
||||
"""
|
||||
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_REMOVED_RE.finditer(raw_diff))
|
||||
|
||||
|
||||
def extract_changed_to(raw_diff: str) -> str:
|
||||
"""Extract only the added/changed-into fragments from a raw diff string.
|
||||
|
||||
Useful for {{diff_changed_to}} — gives just the new value (e.g. new price),
|
||||
not the full surrounding line. Multiple fragments joined with newlines.
|
||||
"""
|
||||
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_ADDED_RE.finditer(raw_diff))
|
||||
|
||||
|
||||
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]:
|
||||
"""
|
||||
|
||||
@@ -212,11 +212,6 @@ def _is_safe_valid_url(test_url):
|
||||
from .validate_url import is_safe_valid_url
|
||||
return is_safe_valid_url(test_url)
|
||||
|
||||
@app.template_global('get_html_head_extras')
|
||||
def _get_html_head_extras():
|
||||
from .pluggy_interface import collect_html_head_extras
|
||||
return collect_html_head_extras()
|
||||
|
||||
|
||||
@app.template_filter('format_number_locale')
|
||||
def _jinja2_filter_format_number_locale(value: float) -> str:
|
||||
|
||||
@@ -667,11 +667,9 @@ class ValidateCSSJSONXPATHInput(object):
|
||||
# `jq` requires full compilation in windows and so isn't generally available
|
||||
raise ValidationError("jq not support not found")
|
||||
|
||||
from changedetectionio.html_tools import validate_jq_expression
|
||||
input = line.replace('jq:', '')
|
||||
|
||||
try:
|
||||
validate_jq_expression(input)
|
||||
jq.compile(input)
|
||||
except (ValueError) as e:
|
||||
message = field.gettext('\'%s\' is not a valid jq expression. (%s)')
|
||||
@@ -1007,7 +1005,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
||||
render_kw={"placeholder": "0.1", "style": "width: 8em;"}
|
||||
)
|
||||
|
||||
password = SaltyPasswordField(_l('Password'), render_kw={"autocomplete": "new-password"})
|
||||
password = SaltyPasswordField(_l('Password'))
|
||||
pager_size = IntegerField(_l('Pager size'),
|
||||
render_kw={"style": "width: 5em;"},
|
||||
validators=[validators.NumberRange(min=0,
|
||||
|
||||
@@ -4,7 +4,6 @@ from loguru import logger
|
||||
from typing import List
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
# HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis
|
||||
@@ -14,45 +13,6 @@ PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$'
|
||||
|
||||
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
|
||||
META_CS = re.compile(r'<meta[^>]+charset=["\']?\s*([a-z0-9_\-:+.]+)', re.I)
|
||||
|
||||
# jq builtins that can leak sensitive data or cause harm when user-supplied expressions are executed.
|
||||
# env/$ENV reads all process environment variables (passwords, API keys, etc.)
|
||||
# include/import can read arbitrary files from disk
|
||||
# input/inputs reads beyond the supplied JSON data
|
||||
# debug/stderr leaks data to stderr
|
||||
# halt/halt_error terminates the process (DoS)
|
||||
_JQ_BLOCKED_PATTERNS = [
|
||||
(re.compile(r'\benv\b'), 'env (reads environment variables)'),
|
||||
(re.compile(r'\$ENV\b'), '$ENV (reads environment variables)'),
|
||||
(re.compile(r'\binclude\b'), 'include (reads files from disk)'),
|
||||
(re.compile(r'\bimport\b'), 'import (reads files from disk)'),
|
||||
(re.compile(r'\binputs?\b'), 'input/inputs (reads beyond provided data)'),
|
||||
(re.compile(r'\bdebug\b'), 'debug (leaks data to stderr)'),
|
||||
(re.compile(r'\bstderr\b'), 'stderr (leaks data to stderr)'),
|
||||
(re.compile(r'\bhalt(?:_error)?\b'), 'halt/halt_error (terminates the process)'),
|
||||
(re.compile(r'\$__loc__\b'), '$__loc__ (leaks file path information)'),
|
||||
(re.compile(r'\bbuiltins\b'), 'builtins (enumerates available functions)'),
|
||||
(re.compile(r'\bmodulemeta\b'), 'modulemeta (leaks module information)'),
|
||||
(re.compile(r'\$JQ_BUILD_CONFIGURATION\b'), '$JQ_BUILD_CONFIGURATION (leaks build information)'),
|
||||
]
|
||||
|
||||
def validate_jq_expression(expression: str) -> None:
|
||||
"""Raise ValueError if the jq expression uses any dangerous builtin.
|
||||
|
||||
User-supplied jq expressions are executed server-side. Without this check,
|
||||
builtins like `env` expose every process environment variable (SALTED_PASS,
|
||||
proxy credentials, API keys, etc.) as watch output.
|
||||
"""
|
||||
from changedetectionio.strtobool import strtobool
|
||||
if strtobool(os.getenv('JQ_ALLOW_RISKY_EXPRESSIONS', 'false')):
|
||||
return
|
||||
|
||||
for pattern, description in _JQ_BLOCKED_PATTERNS:
|
||||
if pattern.search(expression):
|
||||
msg = f"jq expression uses disallowed builtin: {description}"
|
||||
logger.critical(f"Security: blocked jq expression containing '{description}' - expression: {expression!r}")
|
||||
raise ValueError(msg)
|
||||
|
||||
META_CT = re.compile(r'<meta[^>]+http-equiv=["\']?content-type["\']?[^>]*content=["\'][^>]*charset=([a-z0-9_\-:+.]+)', re.I)
|
||||
|
||||
# 'price' , 'lowPrice', 'highPrice' are usually under here
|
||||
@@ -70,12 +30,6 @@ _DEFAULT_UNSAFE_XPATH3_FUNCTIONS = [
|
||||
'unparsed-text-available',
|
||||
'doc',
|
||||
'doc-available',
|
||||
'json-doc',
|
||||
'json-doc-available',
|
||||
'collection', # XPath 2.0+: loads XML node collections from arbitrary URIs
|
||||
'uri-collection', # XPath 3.0+: enumerates URIs from resource collections
|
||||
'transform', # XPath 3.1: XSLT transformation (currently raises, block proactively)
|
||||
'load-xquery-module', # XPath 3.1: loads XQuery modules (currently raises, block proactively)
|
||||
'environment-variable',
|
||||
'available-environment-variables',
|
||||
]
|
||||
@@ -424,16 +378,12 @@ def _parse_json(json_data, json_filter):
|
||||
raise Exception("jq not support not found")
|
||||
|
||||
if json_filter.startswith("jq:"):
|
||||
expr = json_filter.removeprefix("jq:")
|
||||
validate_jq_expression(expr)
|
||||
jq_expression = jq.compile(expr)
|
||||
jq_expression = jq.compile(json_filter.removeprefix("jq:"))
|
||||
match = jq_expression.input(json_data).all()
|
||||
return _get_stripped_text_from_json_match(match)
|
||||
|
||||
if json_filter.startswith("jqraw:"):
|
||||
expr = json_filter.removeprefix("jqraw:")
|
||||
validate_jq_expression(expr)
|
||||
jq_expression = jq.compile(expr)
|
||||
jq_expression = jq.compile(json_filter.removeprefix("jqraw:"))
|
||||
match = jq_expression.input(json_data).all()
|
||||
return '\n'.join(str(item) for item in match)
|
||||
|
||||
|
||||
@@ -28,20 +28,18 @@ def get_timeago_locale(flask_locale):
|
||||
str: timeago library locale code (e.g., 'en', 'zh_CN', 'pt_PT')
|
||||
"""
|
||||
locale_map = {
|
||||
'zh': 'zh_CN', # Chinese Simplified
|
||||
'zh': 'zh_CN', # Chinese Simplified
|
||||
# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.
|
||||
'zh_TW': 'zh_TW', # Chinese Traditional (timeago uses zh_TW)
|
||||
'zh_TW': 'zh_TW', # Chinese Traditional (timeago uses zh_TW)
|
||||
'zh_Hant_TW': 'zh_TW', # Flask-Babel normalizes zh_TW to zh_Hant_TW, map back to timeago's zh_TW
|
||||
'pt': 'pt_PT', # Portuguese (Portugal)
|
||||
'pt_BR': 'pt_BR', # Portuguese (Brasil)
|
||||
'sv': 'sv_SE', # Swedish
|
||||
'no': 'nb_NO', # Norwegian Bokmål
|
||||
'hi': 'in_HI', # Hindi
|
||||
'cs': 'en', # Czech not supported by timeago, fallback to English
|
||||
'ja': 'ja', # Japanese
|
||||
'uk': 'uk', # Ukrainian
|
||||
'en_GB': 'en', # British English - timeago uses 'en'
|
||||
'en_US': 'en', # American English - timeago uses 'en'
|
||||
'pt': 'pt_PT', # Portuguese (Portugal)
|
||||
'sv': 'sv_SE', # Swedish
|
||||
'no': 'nb_NO', # Norwegian Bokmål
|
||||
'hi': 'in_HI', # Hindi
|
||||
'cs': 'en', # Czech not supported by timeago, fallback to English
|
||||
'uk': 'uk', # Ukrainian
|
||||
'en_GB': 'en', # British English - timeago uses 'en'
|
||||
'en_US': 'en', # American English - timeago uses 'en'
|
||||
}
|
||||
return locale_map.get(flask_locale, flask_locale)
|
||||
|
||||
@@ -55,8 +53,7 @@ LANGUAGE_DATA = {
|
||||
'ko': {'flag': 'fi fi-kr fis', 'name': '한국어'},
|
||||
'cs': {'flag': 'fi fi-cz fis', 'name': 'Čeština'},
|
||||
'es': {'flag': 'fi fi-es fis', 'name': 'Español'},
|
||||
'pt': {'flag': 'fi fi-pt fis', 'name': 'Português (Portugal)'},
|
||||
'pt_BR': {'flag': 'fi fi-br fis', 'name': 'Português (Brasil)'},
|
||||
'pt': {'flag': 'fi fi-pt fis', 'name': 'Português'},
|
||||
'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'},
|
||||
'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'},
|
||||
'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'},
|
||||
|
||||
@@ -46,26 +46,11 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
super(model, self).__init__(*arg, **kw)
|
||||
|
||||
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')
|
||||
self['url_match_pattern'] = kw.get('default', {}).get('url_match_pattern', '')
|
||||
|
||||
if kw.get('default'):
|
||||
self.update(kw['default'])
|
||||
del kw['default']
|
||||
|
||||
def matches_url(self, url: str) -> bool:
|
||||
"""Return True if this tag should be auto-applied to the given watch URL.
|
||||
|
||||
Wildcard patterns (*,?,[ ) use fnmatch; anything else is a case-insensitive
|
||||
substring match. Returns False if no pattern is configured.
|
||||
"""
|
||||
import fnmatch
|
||||
pattern = self.get('url_match_pattern', '').strip()
|
||||
if not pattern or not url:
|
||||
return False
|
||||
if any(c in pattern for c in ('*', '?', '[')):
|
||||
return fnmatch.fnmatch(url.lower(), pattern.lower())
|
||||
return pattern.lower() in url.lower()
|
||||
|
||||
# _save_to_disk() method provided by EntityPersistenceMixin
|
||||
# commit() and _get_commit_data() methods inherited from watch_base
|
||||
# Tag uses default _get_commit_data() (includes all keys)
|
||||
|
||||
@@ -88,28 +88,6 @@ class FormattableTimestamp(str):
|
||||
return self._dt.isoformat()
|
||||
|
||||
|
||||
class FormattableExtract(str):
|
||||
"""
|
||||
A str subclass that holds only the extracted changed fragments from a diff.
|
||||
Used for {{diff_changed_from}} and {{diff_changed_to}} tokens.
|
||||
|
||||
{{ diff_changed_from }} → old value(s) only, e.g. "$99.99"
|
||||
{{ diff_changed_to }} → new value(s) only, e.g. "$109.99"
|
||||
|
||||
Multiple changed fragments are joined with newlines.
|
||||
Being a str subclass means it is natively JSON serializable.
|
||||
"""
|
||||
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
|
||||
if prev_snapshot or current_snapshot:
|
||||
from changedetectionio import diff as diff_module
|
||||
raw = diff_module.render_diff(prev_snapshot, current_snapshot, word_diff=True)
|
||||
extracted = extract_fn(raw)
|
||||
else:
|
||||
extracted = ''
|
||||
instance = super().__new__(cls, extracted)
|
||||
return instance
|
||||
|
||||
|
||||
class FormattableDiff(str):
|
||||
"""
|
||||
A str subclass representing a rendered diff. As a plain string it renders
|
||||
@@ -183,8 +161,6 @@ class NotificationContextData(dict):
|
||||
'diff_patch': FormattableDiff('', '', patch_format=True),
|
||||
'diff_removed': FormattableDiff('', '', include_added=False),
|
||||
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),
|
||||
'diff_changed_from': FormattableExtract('', '', extract_fn=lambda x: x),
|
||||
'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x),
|
||||
'diff_url': None,
|
||||
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
||||
'notification_timestamp': time.time(),
|
||||
@@ -268,27 +244,16 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
|
||||
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
|
||||
}
|
||||
|
||||
from changedetectionio.diff import extract_changed_from, extract_changed_to
|
||||
extract_specs = {
|
||||
'diff_changed_from': extract_changed_from,
|
||||
'diff_changed_to': extract_changed_to,
|
||||
}
|
||||
|
||||
ret = {}
|
||||
rendered_count = 0
|
||||
# Only create FormattableDiff/FormattableExtract objects for diff keys actually used in the notification text
|
||||
# Only create FormattableDiff objects for diff keys actually used in the notification text
|
||||
for key in NotificationContextData().keys():
|
||||
if not key.startswith('diff'):
|
||||
continue
|
||||
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
|
||||
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
|
||||
continue
|
||||
if key in diff_specs:
|
||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
||||
rendered_count += 1
|
||||
elif key in extract_specs:
|
||||
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
|
||||
rendered_count += 1
|
||||
if key.startswith('diff') and key in diff_specs:
|
||||
# Check if this placeholder is actually used in the notification text
|
||||
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
|
||||
if re.search(pattern, notification_scan_text, re.IGNORECASE):
|
||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
||||
rendered_count += 1
|
||||
|
||||
if rendered_count:
|
||||
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")
|
||||
|
||||
@@ -174,64 +174,6 @@ class ChangeDetectionSpec:
|
||||
"""
|
||||
pass
|
||||
|
||||
@hookspec
|
||||
def get_html_head_extras():
|
||||
"""Return HTML to inject into the <head> of every page via base.html.
|
||||
|
||||
Plugins can use this to add <script>, <style>, or <link> tags that should
|
||||
be present on all pages. Return a raw HTML string or None.
|
||||
|
||||
IMPORTANT: Always use Flask's url_for() for any src/href URLs so that
|
||||
sub-path deployments (nginx reverse proxy with USE_X_SETTINGS / X-Forwarded-Prefix)
|
||||
work correctly. This hook is called inside a request context so url_for() is
|
||||
always available.
|
||||
|
||||
For small amounts of CSS/JS, return them inline — no file-serving needed::
|
||||
|
||||
from changedetectionio.pluggy_interface import hookimpl
|
||||
|
||||
@hookimpl
|
||||
def get_html_head_extras(self):
|
||||
return (
|
||||
'<style>.my-module-banner { color: red; }</style>\\n'
|
||||
'<script>console.log("my_module_content loaded");</script>'
|
||||
)
|
||||
|
||||
For larger assets, register your own lightweight Flask routes in the plugin
|
||||
module and point to them with url_for() so the sub-path prefix is handled
|
||||
automatically::
|
||||
|
||||
from flask import url_for, Response
|
||||
from changedetectionio.pluggy_interface import hookimpl
|
||||
from changedetectionio.flask_app import app as _app
|
||||
|
||||
MY_CSS = ".my-module-example { color: red; }"
|
||||
MY_JS = "console.log('my_module_content loaded');"
|
||||
|
||||
@_app.route('/my_module_content/css')
|
||||
def my_module_content_css():
|
||||
return Response(MY_CSS, mimetype='text/css',
|
||||
headers={'Cache-Control': 'max-age=3600'})
|
||||
|
||||
@_app.route('/my_module_content/js')
|
||||
def my_module_content_js():
|
||||
return Response(MY_JS, mimetype='application/javascript',
|
||||
headers={'Cache-Control': 'max-age=3600'})
|
||||
|
||||
@hookimpl
|
||||
def get_html_head_extras(self):
|
||||
css = url_for('my_module_content_css')
|
||||
js = url_for('my_module_content_js')
|
||||
return (
|
||||
f'<link rel="stylesheet" href="{css}">\\n'
|
||||
f'<script src="{js}" defer></script>'
|
||||
)
|
||||
|
||||
Returns:
|
||||
str or None: Raw HTML string to inject inside <head>, or None
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# Set up Plugin Manager
|
||||
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
||||
@@ -664,20 +606,4 @@ def apply_update_finalize(update_handler, watch, datastore, processing_exception
|
||||
except Exception as e:
|
||||
# Don't let plugin errors crash the worker
|
||||
logger.error(f"Error in update_finalize hook: {e}")
|
||||
logger.exception(f"update_finalize hook exception details:")
|
||||
|
||||
|
||||
def collect_html_head_extras():
|
||||
"""Collect and combine HTML head extras from all plugins.
|
||||
|
||||
Called from a Flask template global so it always runs inside a request context.
|
||||
This means url_for() works correctly in plugin implementations, including when the
|
||||
app is deployed under a sub-path via USE_X_SETTINGS / X-Forwarded-Prefix (ProxyFix
|
||||
sets SCRIPT_NAME so url_for() automatically prepends the prefix).
|
||||
|
||||
Returns:
|
||||
str: Combined HTML string to inject inside <head>, or empty string
|
||||
"""
|
||||
results = plugin_manager.hook.get_html_head_extras()
|
||||
parts = [r for r in results if r]
|
||||
return "\n".join(parts) if parts else ""
|
||||
logger.exception(f"update_finalize hook exception details:")
|
||||
@@ -341,18 +341,6 @@ def get_processor_descriptions():
|
||||
return descriptions
|
||||
|
||||
|
||||
def wcag_text_color(hex_bg: str) -> str:
|
||||
"""Return #000000 or #ffffff for maximum WCAG contrast against hex_bg."""
|
||||
hex_bg = hex_bg.lstrip('#')
|
||||
if len(hex_bg) != 6:
|
||||
return '#000000'
|
||||
r, g, b = (int(hex_bg[i:i+2], 16) / 255 for i in (0, 2, 4))
|
||||
def lin(c):
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
return '#000000' if L > 0.179 else '#ffffff'
|
||||
|
||||
|
||||
def generate_processor_badge_colors(processor_name):
|
||||
"""
|
||||
Generate consistent colors for a processor badge based on its name.
|
||||
|
||||
@@ -980,20 +980,12 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
def get_all_tags_for_watch(self, uuid):
|
||||
"""This should be in Watch model but Watch doesn't have access to datastore, not sure how to solve that yet"""
|
||||
watch = self.data['watching'].get(uuid)
|
||||
if not watch:
|
||||
return {}
|
||||
|
||||
# Start with manually assigned tags
|
||||
result = dictfilt(self.__data['settings']['application']['tags'], watch.get('tags', []))
|
||||
# Should return a dict of full tag info linked by UUID
|
||||
if watch:
|
||||
return dictfilt(self.__data['settings']['application']['tags'], watch.get('tags', []))
|
||||
|
||||
# Additionally include any tag whose url_match_pattern matches this watch's URL
|
||||
watch_url = watch.get('url', '')
|
||||
if watch_url:
|
||||
for tag_uuid, tag in self.__data['settings']['application']['tags'].items():
|
||||
if tag_uuid not in result and tag.matches_url(watch_url):
|
||||
result[tag_uuid] = tag
|
||||
|
||||
return result
|
||||
return {}
|
||||
|
||||
@property
|
||||
def extra_browsers(self):
|
||||
|
||||
@@ -98,14 +98,6 @@
|
||||
<td><code>{{ '{{diff_patch}}' }}</code></td>
|
||||
<td>{{ _('The diff output - patch in unified format') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{diff_changed_from}}' }}</code></td>
|
||||
<td>{{ _('Only the changed words/values from the previous version — e.g. the old price. Best when a single value changes per line; multiple changed fragments are joined by newline.') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{diff_changed_to}}' }}</code></td>
|
||||
<td>{{ _('Only the changed words/values from the new version — e.g. the new price. Best when a single value changes per line; multiple changed fragments are joined by newline.') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{current_snapshot}}' }}</code></td>
|
||||
<td>{{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }}
|
||||
|
||||
@@ -45,10 +45,6 @@
|
||||
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
|
||||
{% endif %}
|
||||
{%- set _html_head_extras = get_html_head_extras() -%}
|
||||
{%- if _html_head_extras %}
|
||||
{{ _html_head_extras | safe }}
|
||||
{%- endif %}
|
||||
</head>
|
||||
|
||||
<body class="{{extra_classes}}">
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Test that plugins can inject HTML into base.html <head> via get_html_head_extras hookimpl."""
|
||||
import pytest
|
||||
from flask import url_for, Response
|
||||
|
||||
from changedetectionio.pluggy_interface import hookimpl, plugin_manager
|
||||
|
||||
_MY_JS = "console.log('my_module_content loaded');"
|
||||
_MY_CSS = ".my-module-example { color: red; }"
|
||||
|
||||
|
||||
class _HeadExtrasPlugin:
|
||||
"""Test plugin that injects tags pointing at its own Flask routes."""
|
||||
|
||||
@hookimpl
|
||||
def get_html_head_extras(self):
|
||||
css_url = url_for('test_plugin_my_module_content_css')
|
||||
js_url = url_for('test_plugin_my_module_content_js')
|
||||
return (
|
||||
f'<link rel="stylesheet" id="test-head-extra-css" href="{css_url}">\n'
|
||||
f'<script id="test-head-extra-js" src="{js_url}" defer></script>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def plugin_routes(live_server):
|
||||
"""Register plugin asset routes once per module (Flask routes can't be added twice)."""
|
||||
app = live_server.app
|
||||
|
||||
@app.route('/test-plugin/my_module_content/css')
|
||||
def test_plugin_my_module_content_css():
|
||||
return Response(_MY_CSS, mimetype='text/css',
|
||||
headers={'Cache-Control': 'max-age=3600'})
|
||||
|
||||
@app.route('/test-plugin/my_module_content/js')
|
||||
def test_plugin_my_module_content_js():
|
||||
return Response(_MY_JS, mimetype='application/javascript',
|
||||
headers={'Cache-Control': 'max-age=3600'})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def head_extras_plugin(plugin_routes):
|
||||
"""Register the hookimpl for one test then unregister it — function-scoped for clean isolation."""
|
||||
plugin = _HeadExtrasPlugin()
|
||||
plugin_manager.register(plugin, name="test_head_extras")
|
||||
yield plugin
|
||||
plugin_manager.unregister(name="test_head_extras")
|
||||
|
||||
|
||||
def test_plugin_html_injected_into_head(client, live_server, measure_memory_usage, datastore_path, head_extras_plugin):
|
||||
"""get_html_head_extras output must appear inside <head> in the rendered page."""
|
||||
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
assert b'id="test-head-extra-css"' in res.data, "Plugin <link> tag missing from rendered page"
|
||||
assert b'id="test-head-extra-js"' in res.data, "Plugin <script> tag missing from rendered page"
|
||||
|
||||
head_end = res.data.find(b'</head>')
|
||||
assert head_end != -1
|
||||
for marker in (b'id="test-head-extra-css"', b'id="test-head-extra-js"'):
|
||||
pos = res.data.find(marker)
|
||||
assert pos != -1 and pos < head_end, f"{marker} must appear before </head>"
|
||||
|
||||
|
||||
def test_plugin_js_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
|
||||
"""The plugin-registered JS route must return JS with the right Content-Type."""
|
||||
res = client.get(url_for('test_plugin_my_module_content_js'))
|
||||
assert res.status_code == 200
|
||||
assert 'javascript' in res.content_type
|
||||
assert _MY_JS.encode() in res.data
|
||||
|
||||
|
||||
def test_plugin_css_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
|
||||
"""The plugin-registered CSS route must return CSS with the right Content-Type."""
|
||||
res = client.get(url_for('test_plugin_my_module_content_css'))
|
||||
assert res.status_code == 200
|
||||
assert 'css' in res.content_type
|
||||
assert _MY_CSS.encode() in res.data
|
||||
|
||||
|
||||
def test_no_extras_without_plugin(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""With no hookimpl registered the markers must not appear (isolation check)."""
|
||||
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
assert b'id="test-head-extra-css"' not in res.data
|
||||
assert b'id="test-head-extra-js"' not in res.data
|
||||
@@ -11,10 +11,10 @@ from changedetectionio.tests.util import set_original_response, set_modified_res
|
||||
set_longer_modified_response, delete_all_watches
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
# NOTE - RELIES ON mailserver as hostname running, see github build recipes
|
||||
smtp_test_server = os.getenv('SMTP_TEST_MAILSERVER', 'mailserver')
|
||||
smtp_test_server = 'mailserver'
|
||||
|
||||
ALL_MARKUP_TOKENS = ''.join(f"TOKEN: '{t}'\n{{{{{t}}}}}\n" for t in NotificationContextData().keys())
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for auto-applying tags to watches by URL pattern matching.
|
||||
|
||||
Verifies:
|
||||
- A tag with url_match_pattern shows on the watch overview list (via get_all_tags_for_watch)
|
||||
- The auto-applied tag appears on the watch edit page
|
||||
- A watch whose URL does NOT match the pattern does not get the tag
|
||||
"""
|
||||
|
||||
import json
|
||||
from flask import url_for
|
||||
from .util import set_original_response, live_server_setup
|
||||
|
||||
|
||||
def test_tag_url_pattern_shows_in_overview(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Tag with a matching url_match_pattern must appear in the watch overview row."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
|
||||
# Create a tag with a URL match pattern
|
||||
res = client.post(
|
||||
url_for("tag"),
|
||||
data=json.dumps({"title": "Auto GitHub", "url_match_pattern": "*github.com*"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
tag_uuid = res.json['uuid']
|
||||
|
||||
# Add a watch that matches the pattern
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": "https://github.com/someuser/repo"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
matching_watch_uuid = res.json['uuid']
|
||||
|
||||
# Add a watch that does NOT match
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": "https://example.com/page"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
non_matching_watch_uuid = res.json['uuid']
|
||||
|
||||
# Watch overview — the tag label must appear in the matching watch's row
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert res.status_code == 200
|
||||
html = res.get_data(as_text=True)
|
||||
|
||||
# The tag title should appear somewhere on the page (it's rendered per-watch via get_all_tags_for_watch)
|
||||
assert "Auto GitHub" in html, "Auto-matched tag title must appear in watch overview"
|
||||
|
||||
# Verify via the datastore directly that get_all_tags_for_watch returns the pattern-matched tag
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
matching_tags = datastore.get_all_tags_for_watch(matching_watch_uuid)
|
||||
assert tag_uuid in matching_tags, "Pattern-matched tag must be returned for matching watch"
|
||||
|
||||
non_matching_tags = datastore.get_all_tags_for_watch(non_matching_watch_uuid)
|
||||
assert tag_uuid not in non_matching_tags, "Pattern-matched tag must NOT appear for non-matching watch"
|
||||
|
||||
|
||||
def test_auto_applied_tag_shows_on_watch_edit(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The watch edit page must show auto-applied tags (from URL pattern) separately."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
|
||||
res = client.post(
|
||||
url_for("tag"),
|
||||
data=json.dumps({"title": "Auto Docs", "url_match_pattern": "*docs.example.com*"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": "https://docs.example.com/guide"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
watch_uuid = res.json['uuid']
|
||||
|
||||
# Watch edit page must mention the auto-applied tag
|
||||
res = client.get(url_for("ui.ui_edit.edit_page", uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
html = res.get_data(as_text=True)
|
||||
|
||||
assert "Auto Docs" in html, "Auto-applied tag name must appear on watch edit page"
|
||||
assert "automatically applied" in html.lower() or "auto" in html.lower(), \
|
||||
"Watch edit page must indicate the tag is auto-applied by pattern"
|
||||
|
||||
|
||||
def test_multiple_pattern_tags_all_applied(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""A watch matching multiple tag patterns must receive all of them, not just the first."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
|
||||
# Two tags with different patterns that both match the same URL
|
||||
res = client.post(
|
||||
url_for("tag"),
|
||||
data=json.dumps({"title": "Org Docs", "url_match_pattern": "*docs.*"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
tag_docs_uuid = res.json['uuid']
|
||||
|
||||
res = client.post(
|
||||
url_for("tag"),
|
||||
data=json.dumps({"title": "Org Python", "url_match_pattern": "*python*"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
tag_python_uuid = res.json['uuid']
|
||||
|
||||
# A third tag whose pattern does NOT match
|
||||
res = client.post(
|
||||
url_for("tag"),
|
||||
data=json.dumps({"title": "Org Rust", "url_match_pattern": "*rust-lang*"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
tag_rust_uuid = res.json['uuid']
|
||||
|
||||
# Watch URL matches both "docs" and "python" patterns but not "rust"
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": "https://docs.python.org/3/library/fnmatch.html"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201, res.data
|
||||
watch_uuid = res.json['uuid']
|
||||
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
resolved = datastore.get_all_tags_for_watch(watch_uuid)
|
||||
|
||||
assert tag_docs_uuid in resolved, "First matching tag must be included"
|
||||
assert tag_python_uuid in resolved, "Second matching tag must be included"
|
||||
assert tag_rust_uuid not in resolved, "Non-matching tag must NOT be included"
|
||||
@@ -610,11 +610,6 @@ def test_xpath_blocked_functions_unit():
|
||||
"unparsed-text-available('file:///etc/passwd')",
|
||||
"doc('file:///etc/passwd')",
|
||||
"doc-available('file:///etc/passwd')",
|
||||
"json-doc('file:///datastore/changedetection.json')",
|
||||
"collection('file:///datastore/')",
|
||||
"uri-collection('file:///datastore/')",
|
||||
"transform(map{})",
|
||||
"load-xquery-module('foo')",
|
||||
"environment-variable('PATH')",
|
||||
"available-environment-variables()",
|
||||
]
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
Static analysis test: verify @login_optionally_required is always applied
|
||||
AFTER (inner to) @blueprint.route(), not before it.
|
||||
|
||||
In Flask, @route() must be the outermost decorator because it registers
|
||||
whatever function it receives. If @login_optionally_required is placed
|
||||
above @route(), the raw unprotected function gets registered and auth is
|
||||
silently bypassed (GHSA-jmrh-xmgh-x9j4).
|
||||
|
||||
Correct order (route outermost, auth inner):
|
||||
@blueprint.route('/path')
|
||||
@login_optionally_required
|
||||
def view(): ...
|
||||
|
||||
Wrong order (auth never called):
|
||||
@login_optionally_required ← registered by route, then discarded
|
||||
@blueprint.route('/path')
|
||||
def view(): ...
|
||||
"""
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parents[3] # …/changedetection.io/
|
||||
SOURCE_ROOT = REPO_ROOT / "changedetectionio"
|
||||
|
||||
|
||||
def _is_route_decorator(node: ast.expr) -> bool:
|
||||
"""Return True if the decorator looks like @something.route(...)."""
|
||||
return (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "route"
|
||||
)
|
||||
|
||||
|
||||
def _is_auth_decorator(node: ast.expr) -> bool:
|
||||
"""Return True if the decorator is @login_optionally_required."""
|
||||
return isinstance(node, ast.Name) and node.id == "login_optionally_required"
|
||||
|
||||
|
||||
def collect_violations() -> list[str]:
|
||||
violations = []
|
||||
|
||||
for path in SOURCE_ROOT.rglob("*.py"):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
except SyntaxError:
|
||||
continue
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
|
||||
decorators = node.decorator_list
|
||||
auth_indices = [i for i, d in enumerate(decorators) if _is_auth_decorator(d)]
|
||||
route_indices = [i for i, d in enumerate(decorators) if _is_route_decorator(d)]
|
||||
|
||||
# Bad order: auth decorator appears at a lower index (higher up) than a route decorator
|
||||
for auth_idx in auth_indices:
|
||||
for route_idx in route_indices:
|
||||
if auth_idx < route_idx:
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
violations.append(
|
||||
f"{rel}:{node.lineno} — `{node.name}`: "
|
||||
f"@login_optionally_required (line {decorators[auth_idx].lineno}) "
|
||||
f"is above @route (line {decorators[route_idx].lineno}); "
|
||||
f"auth wrapper will never be called"
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def test_auth_decorator_order():
|
||||
violations = collect_violations()
|
||||
if violations:
|
||||
msg = (
|
||||
"\n\nFound routes where @login_optionally_required is placed ABOVE @blueprint.route().\n"
|
||||
"This silently disables authentication — @route() registers the raw function\n"
|
||||
"and the auth wrapper is never called.\n\n"
|
||||
"Fix: move @blueprint.route() to be the outermost (topmost) decorator.\n\n"
|
||||
+ "\n".join(f" • {v}" for v in violations)
|
||||
)
|
||||
pytest.fail(msg)
|
||||
@@ -64,7 +64,7 @@ class TestTriggerConditions(unittest.TestCase):
|
||||
"conditions": [
|
||||
{"operator": ">=", "field": "extracted_number", "value": "10"},
|
||||
{"operator": "<=", "field": "extracted_number", "value": "5000"},
|
||||
{"operator": "in", "field": "page_filtered_text", "value": "rock"},
|
||||
{"operator": "in", "field": "page_text", "value": "rock"},
|
||||
#{"operator": "starts_with", "field": "page_text", "value": "I saw"},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ from changedetectionio.diff import (
|
||||
CHANGED_PLACEMARKER_OPEN,
|
||||
CHANGED_PLACEMARKER_CLOSED,
|
||||
CHANGED_INTO_PLACEMARKER_OPEN,
|
||||
CHANGED_INTO_PLACEMARKER_CLOSED,
|
||||
extract_changed_from,
|
||||
extract_changed_to,
|
||||
CHANGED_INTO_PLACEMARKER_CLOSED
|
||||
)
|
||||
|
||||
|
||||
@@ -383,84 +381,5 @@ Line 3 with tabs and spaces"""
|
||||
self.assertNotIn('[-Line 2-]', output)
|
||||
self.assertNotIn('[+Line 2+]', output)
|
||||
|
||||
def test_diff_changed_from_to_word_level(self):
|
||||
"""Primary use case: extract just the old/new value from a changed line (e.g. price monitoring)"""
|
||||
before = "Widget costs $99.99 per month"
|
||||
after = "Widget costs $109.99 per month"
|
||||
|
||||
raw = diff.render_diff(before, after, word_diff=True)
|
||||
|
||||
self.assertEqual(extract_changed_from(raw), "$99.99")
|
||||
self.assertEqual(extract_changed_to(raw), "$109.99")
|
||||
|
||||
def test_diff_changed_from_to_multiple_changes(self):
|
||||
"""Multiple changed fragments on different lines are joined with newline.
|
||||
An unchanged line between the two changes ensures each is a 1-to-1 replace,
|
||||
so word_diff fires per line rather than falling back to multi-line block mode."""
|
||||
before = "Price $99\nunchanged\nTax $5"
|
||||
after = "Price $149\nunchanged\nTax $12"
|
||||
|
||||
raw = diff.render_diff(before, after, word_diff=True)
|
||||
|
||||
self.assertEqual(extract_changed_from(raw), "$99\n$5")
|
||||
self.assertEqual(extract_changed_to(raw), "$149\n$12")
|
||||
|
||||
def test_diff_changed_from_to_pure_insert_delete(self):
|
||||
"""Pure line additions/deletions (no inline word diff) are also captured"""
|
||||
before = "old line"
|
||||
after = "new line"
|
||||
|
||||
# word_diff=False forces line-level CHANGED markers
|
||||
raw = diff.render_diff(before, after, word_diff=False)
|
||||
|
||||
self.assertEqual(extract_changed_from(raw), "old line")
|
||||
self.assertEqual(extract_changed_to(raw), "new line")
|
||||
|
||||
def test_diff_changed_from_to_similar_numbers(self):
|
||||
"""$90.00 → $9.00 must not produce a partial match like '0.00'.
|
||||
The tokenizer splits on whitespace only, so '$90.00' and '$9.00' are
|
||||
each a single atomic token — diff never sees their internal characters."""
|
||||
before = "for sale $90.00"
|
||||
after = "for sale $9.00"
|
||||
|
||||
raw = diff.render_diff(before, after, word_diff=True)
|
||||
|
||||
self.assertEqual(extract_changed_from(raw), "$90.00")
|
||||
self.assertEqual(extract_changed_to(raw), "$9.00")
|
||||
|
||||
def test_diff_changed_from_to_whole_line_replaced(self):
|
||||
"""When every token on the line changed (no common tokens), render_inline_word_diff
|
||||
takes the whole_line_replaced path using CHANGED/CHANGED_INTO markers instead of
|
||||
REMOVED/ADDED. Extraction must still work via the alternation in the regex."""
|
||||
before = "$99"
|
||||
after = "$109"
|
||||
|
||||
raw = diff.render_diff(before, after, word_diff=True)
|
||||
|
||||
self.assertEqual(extract_changed_from(raw), "$99")
|
||||
self.assertEqual(extract_changed_to(raw), "$109")
|
||||
|
||||
def test_diff_changed_from_to_multiple_words_same_line(self):
|
||||
"""When multiple words change on the same line all fragments are joined with newline.
|
||||
'quick brown fox jumps' -> 'slow brown fox hops' gives 'quick\njumps' / 'slow\nhops'.
|
||||
These tokens work best when a single value changes per line."""
|
||||
before = "quick brown fox jumps"
|
||||
after = "slow brown fox hops"
|
||||
|
||||
raw = diff.render_diff(before, after, word_diff=True)
|
||||
|
||||
self.assertEqual(extract_changed_from(raw), "quick\njumps")
|
||||
self.assertEqual(extract_changed_to(raw), "slow\nhops")
|
||||
|
||||
def test_diff_changed_from_to_no_change(self):
|
||||
"""No changes → empty string"""
|
||||
content = "nothing changed here"
|
||||
|
||||
raw = diff.render_diff(content, content, word_diff=True)
|
||||
|
||||
self.assertEqual(extract_changed_from(raw), "")
|
||||
self.assertEqual(extract_changed_to(raw), "")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# run from dir above changedetectionio/ dir
|
||||
# python3 -m unittest changedetectionio.tests.unit.test_tag_url_match
|
||||
|
||||
import unittest
|
||||
from changedetectionio.model.Tag import model as TagModel
|
||||
|
||||
|
||||
def make_tag(pattern):
|
||||
"""Minimal Tag instance for testing matches_url — skips datastore wiring."""
|
||||
tag = TagModel.__new__(TagModel)
|
||||
dict.__init__(tag)
|
||||
tag['url_match_pattern'] = pattern
|
||||
return tag
|
||||
|
||||
|
||||
class TestTagUrlMatch(unittest.TestCase):
|
||||
|
||||
def test_wildcard_matches(self):
|
||||
tag = make_tag('*example.com*')
|
||||
self.assertTrue(tag.matches_url('https://example.com/page'))
|
||||
self.assertTrue(tag.matches_url('https://www.example.com/shop/item'))
|
||||
self.assertFalse(tag.matches_url('https://other.com/page'))
|
||||
|
||||
def test_wildcard_case_insensitive(self):
|
||||
tag = make_tag('*EXAMPLE.COM*')
|
||||
self.assertTrue(tag.matches_url('https://example.com/page'))
|
||||
|
||||
def test_substring_match(self):
|
||||
tag = make_tag('github.com/myorg')
|
||||
self.assertTrue(tag.matches_url('https://github.com/myorg/repo'))
|
||||
self.assertFalse(tag.matches_url('https://github.com/otherorg/repo'))
|
||||
|
||||
def test_substring_case_insensitive(self):
|
||||
tag = make_tag('GitHub.com/MyOrg')
|
||||
self.assertTrue(tag.matches_url('https://github.com/myorg/repo'))
|
||||
|
||||
def test_empty_pattern_never_matches(self):
|
||||
tag = make_tag('')
|
||||
self.assertFalse(tag.matches_url('https://example.com'))
|
||||
|
||||
def test_empty_url_never_matches(self):
|
||||
tag = make_tag('*example.com*')
|
||||
self.assertFalse(tag.matches_url(''))
|
||||
|
||||
def test_question_mark_wildcard(self):
|
||||
tag = make_tag('https://example.com/item-?')
|
||||
self.assertTrue(tag.matches_url('https://example.com/item-1'))
|
||||
self.assertFalse(tag.matches_url('https://example.com/item-12'))
|
||||
|
||||
def test_substring_is_broad(self):
|
||||
"""Plain substring matching is intentionally broad — 'evil.com' matches anywhere
|
||||
in the URL string, including 'notevil.com'. Users who need precise domain matching
|
||||
should use a wildcard pattern like '*://evil.com/*' instead."""
|
||||
tag = make_tag('evil.com')
|
||||
self.assertTrue(tag.matches_url('https://evil.com/page'))
|
||||
self.assertTrue(tag.matches_url('https://notevil.com')) # substring match — expected
|
||||
|
||||
def test_precise_domain_match_with_wildcard(self):
|
||||
"""Use wildcard pattern for precise domain matching to avoid substring surprises."""
|
||||
tag = make_tag('*://evil.com/*')
|
||||
self.assertTrue(tag.matches_url('https://evil.com/page'))
|
||||
self.assertFalse(tag.matches_url('https://notevil.com/page'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -76,9 +76,7 @@ These commands read settings from `../../setup.cfg` automatically.
|
||||
- `en_US` - English (US)
|
||||
- `fr` - French (Français)
|
||||
- `it` - Italian (Italiano)
|
||||
- `ja` - Japanese (日本語)
|
||||
- `ko` - Korean (한국어)
|
||||
- `pt_BR` - Portuguese (Brasil)
|
||||
- `zh` - Chinese Simplified (中文简体)
|
||||
- `zh_Hant_TW` - Chinese Traditional (繁體中文)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -369,7 +369,7 @@ msgstr "Protokol ladění oznámení"
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "General"
|
||||
msgstr "Obecné"
|
||||
msgstr "Generál"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Fetching"
|
||||
@@ -393,7 +393,7 @@ msgstr "RSS"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Backups"
|
||||
msgstr "Zálohy"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Time & Date"
|
||||
@@ -409,7 +409,7 @@ msgstr "Info"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Default recheck time for all watches, current system minimum is"
|
||||
msgstr "Výchozí čas opětovné kontroly pro všechna sledování, aktuální systémové minimum je"
|
||||
msgstr "Výchozí čas opětovné kontroly pro všechny monitory, aktuální systémové minimum je"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "more info"
|
||||
@@ -445,7 +445,9 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Allow access to the watch change history page when password is enabled (Good for sharing the diff page)"
|
||||
msgstr "Povolit přístup na stránku historie změn monitoru, když je povoleno heslo (Vhodné pro sdílení stránky rozdílů)"
|
||||
msgstr ""
|
||||
"Povolit přístup na stránku historie změn monitoru, když je povoleno heslo (Vhodné pro sdílení stránky rozdílů)Povolit"
|
||||
" anonymní přístup na stránku historie sledování, když je povoleno heslo"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
@@ -453,7 +455,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
msgstr "Vyberte výchozí proxy pro všechna sledování"
|
||||
msgstr "Vyberte výchozí proxy pro všechny monitory"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
@@ -477,7 +479,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Use the"
|
||||
msgstr "Použít"
|
||||
msgstr "Použijte"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Basic"
|
||||
@@ -503,7 +505,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "This will wait"
|
||||
msgstr "Toto počká"
|
||||
msgstr "Tohle počká"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "seconds before extracting the text."
|
||||
@@ -863,7 +865,7 @@ msgstr "povoleny adresy URL pro upozornění v celém systému"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "this form will override notification settings for this watch only"
|
||||
msgstr "tento formulář přepíše nastavení oznámení pouze pro tato sledování"
|
||||
msgstr "tento formulář přepíše nastavení oznámení pouze pro tyto monitory"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "an empty Notification URL list here will still send notifications."
|
||||
@@ -880,7 +882,7 @@ msgstr "Přidejte novou značku organizace"
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Watch group / tag"
|
||||
msgstr "Sledovat skupinu / Značka"
|
||||
msgstr "Skupina / Značka"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
|
||||
@@ -888,15 +890,15 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "# Watches"
|
||||
msgstr "# Sledování"
|
||||
msgstr "# monitorů"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "Tag / Label name"
|
||||
msgstr "Tag / Název štítku"
|
||||
msgstr "Název štítku / štítku"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "No website organisational tags/groups configured"
|
||||
msgstr "Žádné skupiny/značky zatím nebyly nastaveny"
|
||||
msgstr "Žádné skupiny/značky"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
@@ -906,7 +908,7 @@ msgstr "Upravit"
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Recheck"
|
||||
msgstr "Znovu zkontrolovat"
|
||||
msgstr "Znovu zkontrolujte"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "Delete Group?"
|
||||
@@ -920,7 +922,7 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Delete"
|
||||
msgstr "Smazat"
|
||||
msgstr "Vymazat"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "Deletes and removes tag"
|
||||
@@ -943,36 +945,36 @@ msgstr "Odpojit"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html
|
||||
msgid "Keep the tag but unlink any watches"
|
||||
msgstr "Ponechte štítek, ale odpojte všechna sledování"
|
||||
msgstr "Ponechte štítek, ale odpojte všechny monitory"
|
||||
|
||||
#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "RSS Feed for this watch"
|
||||
msgstr "RSS kanál pro toto sledování"
|
||||
msgstr "RSS kanál pro tyto monitory"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches deleted"
|
||||
msgstr "{} sledování smazáno"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches paused"
|
||||
msgstr "{} sledování pozastaveno"
|
||||
msgstr "{} monitorů pozastaveno"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches unpaused"
|
||||
msgstr "{} sledování opět spuštěno"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches updated"
|
||||
msgstr "{} sledování aktualizováno"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "{} watches muted"
|
||||
msgstr "{} sledování ztlumeno"
|
||||
msgstr "{} monitorů ztlumeno"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1011,7 +1013,7 @@ msgstr "Sledujte tuto adresu URL!"
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Cleared snapshot history for watch {}"
|
||||
msgstr "Historie snímků vymazána pro sledování {}"
|
||||
msgstr "Historie snímků vymazána pro monitor {}"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "History clearing started in background"
|
||||
@@ -1028,7 +1030,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Deleted."
|
||||
msgstr "Smazáno"
|
||||
msgstr "Vymazat"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Cloned, you are editing the new watch."
|
||||
@@ -1045,7 +1047,7 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "Do fronty přidáno {} sledování k opětovné kontrole ({} již ve frontě nebo běží)."
|
||||
msgstr "Do fronty přidáno {} monitorů k opětovné kontrole ({} již ve frontě nebo běží)."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1054,7 +1056,7 @@ msgstr "Do fronty přidáno {} sledování k opětovné kontrole."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
msgid "Queueing watches for rechecking in background..."
|
||||
msgstr "Přidává se sledování do fronty pro opětovnou kontrolu na pozadí..."
|
||||
msgstr "Přidávání monitorů do fronty pro opětovnou kontrolu na pozadí..."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -1103,7 +1105,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/edit.py
|
||||
msgid "Updated watch."
|
||||
msgstr "Sledování aktualizováno."
|
||||
msgstr "Smazat monitory?"
|
||||
|
||||
#: changedetectionio/blueprint/ui/preview.py
|
||||
msgid "Preview unavailable - No fetch/check completed or triggers not reached"
|
||||
@@ -1119,7 +1121,7 @@ msgstr "Možná budete chtít použít"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
|
||||
msgid "BACKUP"
|
||||
msgstr "ZÁLOHA"
|
||||
msgstr "BACKUP"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html
|
||||
msgid "link first."
|
||||
@@ -1159,11 +1161,11 @@ msgstr "Sdílet jako obrázek"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html
|
||||
msgid "Ignore any lines matching"
|
||||
msgstr "Ignorovat všechny odpovídající řádky"
|
||||
msgstr "Ignorujte všechny odpovídající řádky"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html
|
||||
msgid "Ignore any lines matching excluding digits"
|
||||
msgstr "Ignorovat všechny odpovídající řádky kromě číslic"
|
||||
msgstr "Ignorujte všechny odpovídající řádky kromě číslic"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html
|
||||
msgid "From"
|
||||
@@ -1183,7 +1185,7 @@ msgstr "Řádky"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html
|
||||
msgid "Ignore Whitespace"
|
||||
msgstr "Ignorovat mezery"
|
||||
msgstr "Ignorujte mezery"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html
|
||||
msgid "Same/non-changed"
|
||||
@@ -1207,7 +1209,7 @@ msgstr "Klávesnice:"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
|
||||
msgid "Previous"
|
||||
msgstr "Předchozí"
|
||||
msgstr "Náhled"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html
|
||||
msgid "Next"
|
||||
@@ -1239,7 +1241,7 @@ msgstr "Aktuální snímek obrazovky"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html
|
||||
msgid "Extract Data"
|
||||
msgstr "Extrahovat data"
|
||||
msgstr "Extrahujte data"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html
|
||||
msgid "seconds ago."
|
||||
@@ -1267,7 +1269,7 @@ msgstr "NASTAVENÍ"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html
|
||||
msgid "Goto single snapshot"
|
||||
msgstr "Přejít na samotný snímek"
|
||||
msgstr "Přejít na jeden snímek"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/diff.html
|
||||
msgid "Highlight text to share or add to ignore lists."
|
||||
@@ -1357,15 +1359,15 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Check/Scan all"
|
||||
msgstr "Vše znovu zkontrolovat"
|
||||
msgstr "Znovu zkontrolujte vše"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Choose a proxy for this watch"
|
||||
msgstr "Vybrat proxy pro toto sledování"
|
||||
msgstr "RSS kanál pro tyto monitory"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Using the current global default settings"
|
||||
msgstr "Aktuálně je použito globální výchozí nastavení"
|
||||
msgstr "Použití aktuálního globálního výchozího nastavení"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Show advanced options"
|
||||
@@ -1389,7 +1391,7 @@ msgstr "Proměnné jsou podporovány v hodnotách hlavičky požadavku"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Alert! Extra headers file found and will be added to this watch!"
|
||||
msgstr "Upozornění! Byl nalezen další soubor záhlaví a bude přidán do těchto sledování!"
|
||||
msgstr "Upozornění! Byl nalezen další soubor záhlaví a bude přidán do těchto monitorů!"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Headers can be also read from a file in your data-directory"
|
||||
@@ -1425,7 +1427,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Visual Selector data is not ready, watch needs to be checked atleast once."
|
||||
msgstr "Data Visual Selector nejsou připravena, sledování je třeba alespoň jednou zkontrolovat."
|
||||
msgstr "Data Visual Selector nejsou připravena, monitory je třeba alespoň jednou zkontrolovat."
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid ""
|
||||
@@ -1631,11 +1633,11 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Delete Watch?"
|
||||
msgstr "Smazat sledování?"
|
||||
msgstr "Smazat monitory?"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Are you sure you want to delete the watch for:"
|
||||
msgstr "Opravdu chcete smazat sledování pro:"
|
||||
msgstr "Opravdu chcete smazat monitory pro:"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "This action cannot be undone."
|
||||
@@ -1659,15 +1661,15 @@ msgstr "Vymazat historii"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Clone & Edit"
|
||||
msgstr "Duplikovat a upravit"
|
||||
msgstr "Klonovat a upravovat"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html
|
||||
msgid "Select timestamp"
|
||||
msgstr "Vybrat časové razítko"
|
||||
msgstr "Vyberte časové razítko"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html
|
||||
msgid "Go"
|
||||
msgstr "Přejít"
|
||||
msgstr "Jít"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/preview.html
|
||||
msgid "Current erroring screenshot from most recent request"
|
||||
@@ -1713,7 +1715,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Add a new web page change detection watch"
|
||||
msgstr "Přidejte nové sledování zjišťování změn webové stránky"
|
||||
msgstr "Přidejte nové monitory zjišťování změn webové stránky"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Watch this URL!"
|
||||
@@ -1721,7 +1723,7 @@ msgstr "Monitorovat tuto URL!"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Edit first then Watch"
|
||||
msgstr "Nejdříve upravit, poté sledovat"
|
||||
msgstr "Upravit a monitorovat"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Pause"
|
||||
@@ -1745,7 +1747,7 @@ msgstr "Štítek"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Mark viewed"
|
||||
msgstr "Označit jako shlédnuté"
|
||||
msgstr "Mark zobrazil"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Use default notification"
|
||||
@@ -1773,7 +1775,7 @@ msgstr "Vymazat/resetovat historii"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Delete Watches?"
|
||||
msgstr "Smazat sledování?"
|
||||
msgstr "Smazat monitory?"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
|
||||
@@ -1821,7 +1823,7 @@ msgstr "importovat seznam"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Detecting restock and price"
|
||||
msgstr "Kontrola zásob a ceny"
|
||||
msgstr "Detekce zásob a ceny"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "In stock"
|
||||
@@ -1874,7 +1876,7 @@ msgstr "Nepřečtený"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Recheck all"
|
||||
msgstr "Znovu zkontrolovat vše"
|
||||
msgstr "Znovu zkontrolujte vše"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
#, python-format
|
||||
@@ -2024,7 +2026,7 @@ msgstr "neděle"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Weeks"
|
||||
msgstr "Týdny"
|
||||
msgstr "týdny"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Should contain zero or more seconds"
|
||||
@@ -2044,7 +2046,7 @@ msgstr "Minuty"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Seconds"
|
||||
msgstr "Sekundy"
|
||||
msgstr "sekundy"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Notification Body and Title is required when a Notification URL is used"
|
||||
@@ -2149,7 +2151,7 @@ msgstr "Nahrajte soubor .xlsx"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Must be .xlsx file!"
|
||||
msgstr "Musí být soubor .xlsx!"
|
||||
msgstr "Musí to být soubor .xlsx!"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "File mapping"
|
||||
@@ -2173,7 +2175,7 @@ msgstr "Interval mezi kontrolami"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use global settings for time between check and scheduler."
|
||||
msgstr "Použít globální nastavení pro čas mezi kontrolou a plánovačem."
|
||||
msgstr "Použijte globální nastavení pro čas mezi kontrolou a plánovačem."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "CSS/JSONPath/JQ/XPath Filters"
|
||||
@@ -2282,7 +2284,7 @@ msgstr "Připojte snímek obrazovky k oznámení (pokud je to možné)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Match"
|
||||
msgstr "Shoda"
|
||||
msgstr "# monitory"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Match all of the following"
|
||||
@@ -2353,11 +2355,11 @@ msgstr "Výchozí proxy"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Random jitter seconds ± check"
|
||||
msgstr "Náhodný rozptyl kontrol ± sekund"
|
||||
msgstr "Náhodné jitter sekundy ± kontrola"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Number of fetch workers"
|
||||
msgstr "Počet procesů kontrol"
|
||||
msgstr "Počet pracovníků aportů"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Should be between 1 and 50"
|
||||
@@ -2365,15 +2367,15 @@ msgstr "Mělo by být mezi 1 a 50"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Requests timeout in seconds"
|
||||
msgstr "Časový limit vypršení kontrol v sekundách"
|
||||
msgstr "Požaduje časový limit v sekundách"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Should be between 1 and 999"
|
||||
msgstr "Nastavit mezi 1 a 999"
|
||||
msgstr "Mělo by být mezi 1 a 999"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Default User-Agent overrides"
|
||||
msgstr "Změna výchozího nastavení hodnoty User-Agent"
|
||||
msgstr "Výchozí přepisy User-Agent"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Both a name, and a Proxy URL is required."
|
||||
@@ -2385,11 +2387,11 @@ msgstr "Otevřete stránku „Historie“ na nové kartě"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Realtime UI Updates Enabled"
|
||||
msgstr "Aktualizace UI v reálném čase"
|
||||
msgstr "Aktualizace v reálném čase offline"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Povolit favikony"
|
||||
msgstr "zvážit povolení"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
@@ -2425,7 +2427,7 @@ msgstr "Heslo"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Pager size"
|
||||
msgstr "Počet položek na stránku"
|
||||
msgstr "Velikost pageru"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Should be atleast zero (disabled)"
|
||||
@@ -2457,7 +2459,7 @@ msgstr "Povolit anonymní přístup na stránku historie sledování, když je p
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Hide muted watches from RSS feed"
|
||||
msgstr "Skrýt ztlumená sledování pro RSS zdroje"
|
||||
msgstr "Skrýt ztlumené monitory ze zdroje RSS"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable RSS reader mode "
|
||||
|
||||
Binary file not shown.
@@ -1617,7 +1617,7 @@ msgstr "Bereich zeichnen"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "Clear selection"
|
||||
msgstr "Auswahl löschen"
|
||||
msgstr "Klare Auswahl"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "One moment, fetching screenshot and element information.."
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ services:
|
||||
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
|
||||
#
|
||||
#
|
||||
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well, Can't handle custom headers etc)
|
||||
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well)
|
||||
# - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub
|
||||
#
|
||||
# WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy,
|
||||
|
||||
@@ -725,13 +725,6 @@ components:
|
||||
- true: Tag settings override watch settings
|
||||
- false: Tag settings do not override (watches use their own settings)
|
||||
- null: Not decided yet / inherit default behavior
|
||||
url_match_pattern:
|
||||
type: string
|
||||
description: |
|
||||
Automatically apply this tag to any watch whose URL matches this pattern.
|
||||
Supports fnmatch wildcards (* and ?): e.g. *://example.com/* or github.com/myorg.
|
||||
Plain strings are matched as case-insensitive substrings.
|
||||
Leave empty to disable auto-matching.
|
||||
# Future: Aggregated statistics from all watches with this tag
|
||||
# check_count:
|
||||
# type: integer
|
||||
|
||||
@@ -98,7 +98,7 @@ pytest-flask ~=1.3
|
||||
pytest-mock ~=3.15
|
||||
|
||||
# OpenAPI validation support
|
||||
openapi-core[flask] ~= 0.23
|
||||
openapi-core[flask] ~= 0.22
|
||||
|
||||
loguru
|
||||
|
||||
|
||||
Reference in New Issue
Block a user