Compare commits

..

10 Commits

Author SHA1 Message Date
dgtlmoon
0dbfb02e17 UI - URL field should be just a string field (Not type=url) because URLs with Jinja2 macros could cause false errors #3777
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
ChangeDetection.io App Test / test-application-3-14 (push) Blocked by required conditions
2026-04-11 04:49:28 +02:00
hekwert
caa393d5b9 Add complete Turkish translation (#4044) 2026-04-11 10:29:46 +10:00
Jaroslav Lichtblau
17ed9536a3 Czech l12n updates (#4043)
* feat: adding missing Czech translation strings

* feat: adding more missing Czech translation strings

* feat: adding more missing Czech translation strings

* feat: adding more missing Czech translation strings
2026-04-11 10:29:24 +10:00
chaoliang yan
b403b08895 fix: XLSX import error messages report wrong row number after failed rows + test (#4036)
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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-10 13:08:28 +02:00
dgtlmoon
9df2e172f4 Test - word-level diff - Re #4037 - adding test (#4042) 2026-04-10 12:32:02 +02:00
dgtlmoon
dc037f85ab Fix/step failure notification crash (#4041) 2026-04-10 12:15:47 +02:00
dgtlmoon
90f157abde Groups - Set custom colour for tag/group/label background (#4040) 2026-04-10 11:48:58 +02:00
chaoliang yan
4294b461c7 fix: pass include_change_type_prefix to word-level diff (#4037) 2026-04-10 11:46:55 +02:00
Maicon Strey
77116f5203 Add Portuguese (Brasil) translation (#4033)
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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-10 02:46:37 +02:00
dgtlmoon
238d6ba72d Feature - Groups/tag - Apply a group by specifying a wildcard, ie *.mysite.com* (#4032) 2026-04-10 02:45:23 +02:00
29 changed files with 7425 additions and 152 deletions

View File

@@ -160,8 +160,7 @@ class import_xlsx_wachete(Importer):
flash(gettext("Unable to read export XLSX file, something wrong with the file?"), 'error')
return
row_id = 2
for row in wb.active.iter_rows(min_row=row_id):
for row_id, row in enumerate(wb.active.iter_rows(min_row=2), start=2):
try:
extras = {}
data = {}
@@ -212,8 +211,6 @@ class import_xlsx_wachete(Importer):
except Exception as e:
logger.error(e)
flash(gettext("Error processing row number {}, check all cell data types are correct, row was skipped.").format(row_id), 'error')
else:
row_id += 1
flash(gettext("{} imported from Wachete .xlsx in {:.2f}s").format(len(self.new_uuids), time.time() - now))
@@ -241,10 +238,10 @@ class import_xlsx_custom(Importer):
# @todo cehck atleast 2 rows, same in other method
from changedetectionio.forms import validate_url
row_i = 1
row_i = 0
try:
for row in wb.active.iter_rows():
for row_i, row in enumerate(wb.active.iter_rows(), start=1):
url = None
tags = None
extras = {}
@@ -295,7 +292,5 @@ class import_xlsx_custom(Importer):
except Exception as e:
logger.error(e)
flash(gettext("Error processing row number {}, check all cell data types are correct, row was skipped.").format(row_i), 'error')
else:
row_i += 1
flash(gettext("{} imported from custom .xlsx in {:.2f}s").format(len(self.new_uuids), time.time() - now))

View File

@@ -22,11 +22,14 @@ 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
@@ -208,9 +211,17 @@ 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
)

View File

@@ -10,12 +10,11 @@ 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"})

View File

@@ -43,6 +43,46 @@
<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>

View File

@@ -3,6 +3,26 @@
{% 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">
@@ -48,7 +68,7 @@
<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) }}">{{ tag.title }}</a></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>
<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>

View File

@@ -320,7 +320,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'using_global_webdriver_wait': not default['webdriver_delay'],
'uuid': uuid,
'watch': watch,
'capabilities': capabilities
'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', ''))
},
}
included_content = None

View File

@@ -81,6 +81,14 @@
<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) }}

View File

@@ -92,6 +92,7 @@ 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,

View File

@@ -71,6 +71,13 @@ 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'] }};
@@ -92,6 +99,7 @@ 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">

View File

@@ -76,7 +76,7 @@ def extract_changed_to(raw_diff: str) -> str:
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]:
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html', include_change_type_prefix: bool = True) -> tuple[str, bool]:
"""
Render word-level differences between two lines inline using diff-match-patch library.
@@ -163,14 +163,20 @@ def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool
if removed_tokens:
removed_full = ''.join(removed_tokens).rstrip()
trailing_removed = ''.join(removed_tokens)[len(removed_full):] if len(''.join(removed_tokens)) > len(removed_full) else ''
result_parts.append(f'{CHANGED_PLACEMARKER_OPEN}{removed_full}{CHANGED_PLACEMARKER_CLOSED}{trailing_removed}')
if include_change_type_prefix:
result_parts.append(f'{CHANGED_PLACEMARKER_OPEN}{removed_full}{CHANGED_PLACEMARKER_CLOSED}{trailing_removed}')
else:
result_parts.append(f'{removed_full}{trailing_removed}')
if added_tokens:
if result_parts: # Add newline between removed and added
result_parts.append('\n')
added_full = ''.join(added_tokens).rstrip()
trailing_added = ''.join(added_tokens)[len(added_full):] if len(''.join(added_tokens)) > len(added_full) else ''
result_parts.append(f'{CHANGED_INTO_PLACEMARKER_OPEN}{added_full}{CHANGED_INTO_PLACEMARKER_CLOSED}{trailing_added}')
if include_change_type_prefix:
result_parts.append(f'{CHANGED_INTO_PLACEMARKER_OPEN}{added_full}{CHANGED_INTO_PLACEMARKER_CLOSED}{trailing_added}')
else:
result_parts.append(f'{added_full}{trailing_added}')
return ''.join(result_parts), has_changes
else:
@@ -180,21 +186,27 @@ def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool
if op == 0: # Equal
result_parts.append(text)
elif op == 1: # Insertion
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{ADDED_PLACEMARKER_OPEN}{content}{ADDED_PLACEMARKER_CLOSED}{trailing}')
if not include_change_type_prefix:
result_parts.append(text)
else:
result_parts.append(trailing)
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{ADDED_PLACEMARKER_OPEN}{content}{ADDED_PLACEMARKER_CLOSED}{trailing}')
else:
result_parts.append(trailing)
elif op == -1: # Deletion
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{REMOVED_PLACEMARKER_OPEN}{content}{REMOVED_PLACEMARKER_CLOSED}{trailing}')
if not include_change_type_prefix:
result_parts.append(text)
else:
result_parts.append(trailing)
# Don't wrap empty content (e.g., whitespace-only tokens after rstrip)
content = text.rstrip()
trailing = text[len(content):] if len(text) > len(content) else ''
if content:
result_parts.append(f'{REMOVED_PLACEMARKER_OPEN}{content}{REMOVED_PLACEMARKER_CLOSED}{trailing}')
else:
result_parts.append(trailing)
return ''.join(result_parts), has_changes
@@ -390,7 +402,7 @@ def customSequenceMatcher(
# Use inline word-level diff for single line replacements when word_diff is enabled
if word_diff and len(before_lines) == 1 and len(after_lines) == 1:
inline_diff, has_changes = render_inline_word_diff(before_lines[0], after_lines[0], ignore_junk=ignore_junk, tokenizer=tokenizer)
inline_diff, has_changes = render_inline_word_diff(before_lines[0], after_lines[0], ignore_junk=ignore_junk, tokenizer=tokenizer, include_change_type_prefix=include_change_type_prefix)
# Check if there are any actual changes (not just whitespace when ignore_junk is enabled)
if ignore_junk and not has_changes:
# No real changes, skip this line

View File

@@ -725,7 +725,7 @@ class ValidateStartsWithRegex(object):
raise ValidationError(self.message or _l("Invalid value."))
class quickWatchForm(Form):
url = fields.URLField(_l('URL'), validators=[validateURL()])
url = StringField(_l('URL'), validators=[validateURL()])
tags = StringTagUUID(_l('Group tag'), validators=[validators.Optional()])
watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"})
processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
@@ -779,7 +779,7 @@ class SingleBrowserStep(Form):
class processor_text_json_diff_form(commonSettingsForm):
url = fields.URLField('Web Page URL', validators=[validateURL()])
url = StringField('Web Page URL', validators=[validateURL()])
tags = StringTagUUID('Group Tag', [validators.Optional()], default='')
time_between_check = EnhancedFormField(

View File

@@ -28,19 +28,20 @@ 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)
'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)
'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'
}
return locale_map.get(flask_locale, flask_locale)
@@ -54,7 +55,8 @@ 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'},
'pt': {'flag': 'fi fi-pt fis', 'name': 'Português (Portugal)'},
'pt_BR': {'flag': 'fi fi-br fis', 'name': 'Português (Brasil)'},
'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'},
'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'},
'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'},

View File

@@ -46,11 +46,26 @@ 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)

View File

@@ -496,7 +496,7 @@ Thanks - Your omniscient changedetection.io installation.
n_object = NotificationContextData({
'notification_title': f"Changedetection.io - Alert - Browser step at position {step} could not be run",
'notification_body': body,
'notification_format': self._check_cascading_vars('notification_format', watch),
'notification_format': _check_cascading_vars(self.datastore, 'notification_format', watch),
})
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')

View File

@@ -341,6 +341,18 @@ 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.

View File

@@ -980,12 +980,20 @@ 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 {}
# Should return a dict of full tag info linked by UUID
if watch:
return dictfilt(self.__data['settings']['application']['tags'], watch.get('tags', []))
# Start with manually assigned tags
result = dictfilt(self.__data['settings']['application']['tags'], watch.get('tags', []))
return {}
# 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
@property
def extra_browsers(self):

View File

@@ -214,3 +214,85 @@ def test_import_watchete_xlsx(client, live_server, measure_memory_usage, datasto
assert watch.get('fetch_backend') == 'system' # uses default if blank
delete_all_watches(client)
def test_import_wachete_xlsx_row_counter(client, live_server, measure_memory_usage, datastore_path):
"""Row counter in Wachete XLSX import must advance even after a failed row.
Regression: row_id was only incremented in the try/else (on success), so
after any failure the counter froze and all subsequent errors cited the
stale number. With the enumerate() fix, row 5 must say "row 5", not "row 3".
"""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
# Header row (row 1)
ws.append(['Name', 'Id', 'Url', 'Interval (min)', 'XPath', 'Dynamic Wachet', 'Portal Wachet', 'Folder'])
# Row 2: valid
ws.append(['Site A', '001', 'https://example.com/a', 60, None, None, None, None])
# Row 3: bad URL — must report row 3
ws.append(['Site B', '002', 'not-a-valid-url', 60, None, None, None, None])
# Row 4: valid
ws.append(['Site C', '003', 'https://example.com/c', 60, None, None, None, None])
# Row 5: bad URL — must report row 5, not "row 3" (the pre-fix stale value)
ws.append(['Site D', '004', 'also-not-valid', 60, None, None, None, None])
xlsx_bytes = io.BytesIO()
wb.save(xlsx_bytes)
xlsx_bytes.seek(0)
res = client.post(
url_for("imports.import_page"),
data={'file_mapping': 'wachete', 'xlsx_file': (xlsx_bytes, 'test.xlsx')},
follow_redirects=True,
)
assert b'2 imported from Wachete .xlsx' in res.data
assert b'Error processing row number 3' in res.data
assert b'Error processing row number 5' in res.data
delete_all_watches(client)
def test_import_custom_xlsx_row_counter(client, live_server, measure_memory_usage, datastore_path):
"""Row counter in custom XLSX import must reflect the actual row, not always row 1.
Regression: row_i was incremented in the else clause of the *outer* try/except
(which only fired once, after the whole loop), so every URL-validation error
inside the loop reported "row 1". With enumerate() the third row must say
"row 3", not "row 1".
"""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
# Row 1: bad URL — must report row 1
ws.append(['not-valid-url-row1'])
# Row 2: valid
ws.append(['https://example.com/b'])
# Row 3: bad URL — must report row 3, not "row 1" (the pre-fix value)
ws.append(['not-valid-url-row3'])
# Row 4: valid
ws.append(['https://example.com/d'])
xlsx_bytes = io.BytesIO()
wb.save(xlsx_bytes)
xlsx_bytes.seek(0)
res = client.post(
url_for("imports.import_page"),
data={
'file_mapping': 'custom',
'custom_xlsx[col_0]': '1',
'custom_xlsx[col_type_0]': 'url',
'xlsx_file': (xlsx_bytes, 'test.xlsx'),
},
follow_redirects=True,
)
assert b'2 imported from custom .xlsx' in res.data
assert b'Error processing row number 1' in res.data
assert b'Error processing row number 3' in res.data
delete_all_watches(client)

View File

@@ -0,0 +1,144 @@
#!/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"

View File

@@ -462,5 +462,61 @@ Line 3 with tabs and spaces"""
self.assertEqual(extract_changed_to(raw), "")
def test_word_diff_no_prefix_whole_line_replaced(self):
"""When include_change_type_prefix=False, word-level diffs for whole-line
replacements must not include placemarkers (issue #3816)."""
before = "73"
after = "100"
raw = diff.render_diff(before, after, word_diff=True, include_change_type_prefix=False)
self.assertNotIn('PLACEMARKER', raw)
# Should contain just the raw values separated by newline
self.assertIn('73', raw)
self.assertIn('100', raw)
def test_word_diff_no_prefix_inline_changes(self):
"""When include_change_type_prefix=False, inline word-level diffs
must not include placemarkers (issue #3816)."""
before = "the price is 50 dollars"
after = "the price is 75 dollars"
raw = diff.render_diff(before, after, word_diff=True, include_change_type_prefix=False)
self.assertNotIn('PLACEMARKER', raw)
self.assertIn('50', raw)
self.assertIn('75', raw)
def test_word_diff_with_prefix_still_wraps(self):
"""Default include_change_type_prefix=True must still wrap tokens."""
before = "73"
after = "100"
raw = diff.render_diff(before, after, word_diff=True, include_change_type_prefix=True)
self.assertIn('PLACEMARKER', raw)
def test_word_diff_no_prefix_exact_output(self):
"""Pin exact output for include_change_type_prefix=False to catch regressions.
Whole-line replacement: old and new values separated by newline, no markers.
Inline partial replacement: equal tokens kept, changed tokens (both old and new)
appended without markers — this means old+new are concatenated in place.
"""
# Whole-line replaced: both values on separate lines, clean
raw = diff.render_diff('73', '100', word_diff=True, include_change_type_prefix=False)
self.assertEqual(raw, '73\n100')
# Inline word replacement: equal context preserved, old+new token concatenated in-place
raw = diff.render_diff('the price is 50 dollars', 'the price is 75 dollars',
word_diff=True, include_change_type_prefix=False)
self.assertEqual(raw, 'the price is 5075 dollars')
# Sanity: with prefix the whole-line case is fully wrapped
raw = diff.render_diff('73', '100', word_diff=True, include_change_type_prefix=True)
self.assertEqual(raw, '@changed_PLACEMARKER_OPEN73@changed_PLACEMARKER_CLOSED\n'
'@changed_into_PLACEMARKER_OPEN100@changed_into_PLACEMARKER_CLOSED')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,68 @@
"""
Unit test for send_step_failure_notification regression.
Before the fix, line 499 called self._check_cascading_vars('notification_format', watch)
which raises AttributeError because _check_cascading_vars is a module-level function,
not a method of NotificationService.
"""
import queue
from unittest.mock import MagicMock
def _make_datastore(watch_uuid, notification_url):
"""Minimal datastore mock that NotificationService and _check_cascading_vars need."""
watch = MagicMock()
watch.get = lambda key, default=None: {
'uuid': watch_uuid,
'url': 'https://example.com',
'notification_urls': [notification_url],
'notification_format': '',
'notification_muted': False,
}.get(key, default)
watch.__getitem__ = lambda self, key: watch.get(key)
datastore = MagicMock()
datastore.data = {
'watching': {watch_uuid: watch},
'settings': {
'application': {
'notification_urls': [],
'notification_format': 'text',
'filter_failure_notification_threshold_attempts': 3,
}
}
}
datastore.get_all_tags_for_watch.return_value = {}
return datastore, watch
def test_send_step_failure_notification_does_not_raise():
"""send_step_failure_notification must not raise AttributeError (wrong self. prefix on module-level function)."""
from changedetectionio.notification_service import NotificationService
watch_uuid = 'test-uuid-1234'
notification_q = queue.Queue()
datastore, _ = _make_datastore(watch_uuid, 'post://localhost/test')
service = NotificationService(datastore=datastore, notification_q=notification_q)
# Before the fix this raised:
# AttributeError: 'NotificationService' object has no attribute '_check_cascading_vars'
service.send_step_failure_notification(watch_uuid=watch_uuid, step_n=0)
def test_send_step_failure_notification_queues_item():
"""A notification object should be placed on the queue when URLs are configured."""
from changedetectionio.notification_service import NotificationService
watch_uuid = 'test-uuid-5678'
notification_q = queue.Queue()
datastore, _ = _make_datastore(watch_uuid, 'post://localhost/test')
service = NotificationService(datastore=datastore, notification_q=notification_q)
service.send_step_failure_notification(watch_uuid=watch_uuid, step_n=1)
assert not notification_q.empty(), "Expected a notification to be queued"
item = notification_q.get_nowait()
assert 'notification_title' in item
assert 'position 2' in item['notification_title']

View File

@@ -0,0 +1,68 @@
#!/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()

View File

@@ -78,6 +78,7 @@ These commands read settings from `../../setup.cfg` automatically.
- `it` - Italian (Italiano)
- `ja` - Japanese (日本語)
- `ko` - Korean (한국어)
- `pt_BR` - Portuguese (Brasil)
- `zh` - Chinese Simplified (中文简体)
- `zh_Hant_TW` - Chinese Traditional (繁體中文)

View File

@@ -20,85 +20,85 @@ msgstr ""
#: changedetectionio/blueprint/backups/__init__.py
msgid "A backup is already running, check back in a few minutes"
msgstr ""
msgstr "Tvorba souboru zálohy běží, vraťte se za pár minut"
#: changedetectionio/blueprint/backups/__init__.py
msgid "Maximum number of backups reached, please remove some"
msgstr ""
msgstr "Maximální počet souborů záloh dosažen, některé prosím smažte"
#: changedetectionio/blueprint/backups/__init__.py
msgid "Backup building in background, check back in a few minutes."
msgstr ""
msgstr "Tvorba souboru zálohy běží na pozadí, vraťte se za pár minut."
#: changedetectionio/blueprint/backups/__init__.py
msgid "Backups were deleted."
msgstr ""
msgstr "Zálohy byly smazány"
#: changedetectionio/blueprint/backups/restore.py
msgid "Backup zip file"
msgstr ""
msgstr "Záložní zip soubor"
#: changedetectionio/blueprint/backups/restore.py
msgid "Must be a .zip backup file!"
msgstr ""
msgstr "Musí být .zip soubor zálohy!"
#: changedetectionio/blueprint/backups/restore.py
msgid "Include groups"
msgstr ""
msgstr "Zahrnout skupiny"
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing groups of the same UUID"
msgstr ""
msgstr "Nahradit existující skupiny obsahující stejné UUID"
#: changedetectionio/blueprint/backups/restore.py
msgid "Include watches"
msgstr ""
msgstr "Zahrnout sledování"
#: changedetectionio/blueprint/backups/restore.py
msgid "Replace existing watches of the same UUID"
msgstr ""
msgstr "Nahradit existující sledování obsahující stejne UUID"
#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore backup"
msgstr ""
msgstr "Obnovit zálohu"
#: changedetectionio/blueprint/backups/restore.py
msgid "A restore is already running, check back in a few minutes"
msgstr ""
msgstr "Obnova již probíhá, vraťte se za pár minut"
#: changedetectionio/blueprint/backups/restore.py
msgid "No file uploaded"
msgstr ""
msgstr "Nebyl nahrán žádný soubor"
#: changedetectionio/blueprint/backups/restore.py
msgid "File must be a .zip backup file"
msgstr ""
msgstr "Soubor musí být .zip soubor zálohy!"
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
msgstr ""
msgstr "Neplatný nebo poškozený zip soubor"
#: changedetectionio/blueprint/backups/restore.py
msgid "Restore started in background, check back in a few minutes."
msgstr ""
msgstr "Obnova běží na pozadí, vraťte se za pár minut."
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Create"
msgstr ""
msgstr "Vytvořit"
#: changedetectionio/blueprint/backups/templates/backup_create.html
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore"
msgstr ""
msgstr "Obnovit"
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "A backup is running!"
msgstr "A backup is running!"
msgstr "Probíhá zálohování!"
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgstr ""
msgstr "Zde můžete stáhnout a vyžádat vytvoření zálohy, dostupné vytvořené zálohy jsou vypsané níže."
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "Mb"
@@ -106,7 +106,7 @@ msgstr "Mb"
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "No backups found."
msgstr "No backups found."
msgstr "Nenalezeny žádné zálohy."
#: changedetectionio/blueprint/backups/templates/backup_create.html
msgid "Create backup"
@@ -118,77 +118,77 @@ msgstr "Odstranit zálohy"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "A restore is running!"
msgstr ""
msgstr "Probíhá obnova!"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout)."
msgstr ""
msgstr "Obnovit ze zálohy. Musí být .zip soubor zálohy vytvořený nejméně v0.53.1 (nové rozvržení databáze)"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Note: This does not override the main application settings, only watches and groups."
msgstr ""
msgstr "Pozn.: Nepřepíše hlavní nastavení aplikaci, pouze sledování a skupiny."
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
msgstr ""
msgstr "Zahrnout všechny skupiny nalezené v záloze?"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing groups of the same UUID?"
msgstr ""
msgstr "Nahradit všechny existující skupiny se stejným UUID?"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all watches found in backup?"
msgstr ""
msgstr "Zahrnout všechna sledování nalezená v záloze?"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Replace any existing watches of the same UUID?"
msgstr ""
msgstr "Nahradit všechna existující sledování se stejným UUID?"
#: changedetectionio/blueprint/imports/importer.py
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr ""
msgstr "Importuje se prvních 5000 URL adres, další lze načíst opakovaným importem."
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} Imported from list in {:.2f}s, {} Skipped."
msgstr ""
msgstr "{} importováno ze seznamu za {:.2f}s, {} přeskočeno."
#: changedetectionio/blueprint/imports/importer.py
msgid "Unable to read JSON file, was it broken?"
msgstr ""
msgstr "Nelze načíst JSON soubor, byl poškozen?"
#: changedetectionio/blueprint/imports/importer.py
msgid "JSON structure looks invalid, was it broken?"
msgstr ""
msgstr "Strukturovaný JSON text je neplatný, byl poškozen?"
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} Imported from Distill.io in {:.2f}s, {} Skipped."
msgstr ""
msgstr "{} importováno z Distill.io za {:.2f}s, {} přeskočeno."
#: changedetectionio/blueprint/imports/importer.py
msgid "Unable to read export XLSX file, something wrong with the file?"
msgstr ""
msgstr "Nelze přečíst vyexportovaný XLSX soubor, je s ním něco v nepořádku?"
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "Error processing row number {}, URL value was incorrect, row was skipped."
msgstr ""
msgstr "Chyba při zpracování řádku {}, hodnota URL neplatná, řádek přeskočen."
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
msgstr ""
msgstr "Chyba při zpracování řádku {}, zkontrolujte že všechny typy dat v buňkách jsou správné, řádka přeskočena."
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} imported from Wachete .xlsx in {:.2f}s"
msgstr ""
msgstr "{} importováno z Wachete .xlsx za {:.2f}s"
#: changedetectionio/blueprint/imports/importer.py
#, python-brace-format
msgid "{} imported from custom .xlsx in {:.2f}s"
msgstr ""
msgstr "{} importováno z vlastního .xlsx za {:.2f}s"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "URL List"
@@ -204,15 +204,15 @@ msgstr ".XLSX a Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
msgstr ""
msgstr "Možnost obnovení changedetection.io zálohy je v"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "backups section"
msgstr ""
msgstr "sekci záloh"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgstr ""
msgstr "Vložte jednu URL na řádek a případně přidejte tagy pro každou URL odsazené mezerníkem, oddělené čárkou (,):"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Example:"
@@ -224,7 +224,7 @@ msgstr "URL, které neprojdou validací, zůstanou v textové oblasti."
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
msgstr ""
msgstr "Nakopírujte a vložte exportovaný Distill.io soubor, měl by být ve formátu JSON."
#: changedetectionio/blueprint/imports/templates/import.html
msgid "This is"
@@ -252,9 +252,7 @@ msgstr "Jak exportovat?"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Be sure to set your default fetcher to Chrome if required."
msgstr ""
"V případě potřeby nezapomeňte nastavit výchozí nástroj pro načítání na Chrome.V případě potřeby nezapomeňte nastavit "
"výchozí načítání na Chrome.V případě potřeby nezapomeňte nastavit výchozí nástroj pro načítání na Chrome."
msgstr "V případě potřeby nastavte výchozí nástroj pro načítání na Chrome."
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Table of custom column and data types mapping for the"
@@ -286,73 +284,73 @@ msgstr "CSS/xPath filtr"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Group / Tag name(s)"
msgstr "Sledovat skupinu / tag"
msgstr "Skupina / tag"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Recheck time (minutes)"
msgstr "Znovu zkontrolovat čas (minuty)"
msgstr "Znovu zkontrolovat po (minuty)"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Import"
msgstr "IMPORTOVAT"
msgstr "Importovat"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch with UUID %(uuid)s not found"
msgstr ""
msgstr "Sledování s UUID %(uuid)s nenalezeno"
#: changedetectionio/blueprint/rss/single_watch.py
#, python-format
msgid "Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)"
msgstr ""
msgstr "Sledování %(uuid)s nemá dostatečný počet historických záznamů pro zobrazení změn (třeba alespoň 2)"
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection removed."
msgstr ""
msgstr "Ochrana heslem odebrána."
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
msgstr "Upozornění: Počet workerů ({}) se blíží nebo překračuje dostupné CPU jádra ({})"
msgstr "Upozornění: Počet pracovních procesů ({}) se blíží nebo překračuje počet CPU jader ({})"
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Worker count adjusted: {}"
msgstr ""
msgstr "Počet pracovních procesů upraven: {}"
#: changedetectionio/blueprint/settings/__init__.py
msgid "Dynamic worker adjustment not supported for sync workers"
msgstr ""
msgstr "Dynamická úprava počtu pracovních procesů není u synchronizace podporována"
#: changedetectionio/blueprint/settings/__init__.py
#, python-brace-format
msgid "Error adjusting workers: {}"
msgstr ""
msgstr "Došlo k chybě při úpravě pracovních procesů: {}"
#: changedetectionio/blueprint/settings/__init__.py
msgid "Password protection enabled."
msgstr ""
msgstr "Ochrana heslem povolena."
#: changedetectionio/blueprint/settings/__init__.py
msgid "Settings updated."
msgstr "NASTAVENÍ"
msgstr "Nastavení upraveno."
#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py
#: changedetectionio/processors/extract.py
msgid "An error occurred, please see below."
msgstr ""
msgstr "Došlo k chybě, více informací níže."
#: changedetectionio/blueprint/settings/__init__.py
msgid "API Key was regenerated."
msgstr ""
msgstr "API klíč byl znovu vygenerován."
#: changedetectionio/blueprint/settings/__init__.py
msgid "Automatic scheduling paused - checks will not be queued."
msgstr ""
msgstr "Automatické plánování pozastaveno - kontroly nejsou řazeny do fronty."
#: changedetectionio/blueprint/settings/__init__.py
msgid "Automatic scheduling resumed - checks will be queued normally."
msgstr ""
msgstr "Automatické plánování spuštěno - kontroly budou opět řazeny do fronty."
#: changedetectionio/blueprint/settings/__init__.py
msgid "All notifications muted."
@@ -425,7 +423,7 @@ msgstr "Nastavit na"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "to disable"
msgstr ""
msgstr "vypnuto"
#: 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."
@@ -1761,7 +1759,7 @@ msgstr "Vymazat historie"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
msgstr ""
msgstr "<p>Opravdu chcete vyčistit historii u vybraných položek?</p><p>Tuto akci nelze vzít zpět.</p>"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "OK"
@@ -1777,7 +1775,7 @@ msgstr "Smazat sledování?"
#: 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>"
msgstr ""
msgstr "<p>Opravdu chcete smazat vybraná sledování?</p><p>Tuto akci nelze vzít zpět.</p>"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Queued size"
@@ -1888,59 +1886,59 @@ msgstr "Ještě ne"
#: changedetectionio/flask_app.py
msgid "0 seconds"
msgstr ""
msgstr "0 sekund"
#: changedetectionio/flask_app.py
msgid "year"
msgstr ""
msgstr "rok"
#: changedetectionio/flask_app.py
msgid "years"
msgstr ""
msgstr "roky"
#: changedetectionio/flask_app.py
msgid "month"
msgstr ""
msgstr "měsíc"
#: changedetectionio/flask_app.py
msgid "months"
msgstr ""
msgstr "měsíce"
#: changedetectionio/flask_app.py
msgid "week"
msgstr ""
msgstr "týden"
#: changedetectionio/flask_app.py
msgid "weeks"
msgstr ""
msgstr "týdny"
#: changedetectionio/flask_app.py
msgid "day"
msgstr ""
msgstr "den"
#: changedetectionio/flask_app.py
msgid "days"
msgstr ""
msgstr "dny"
#: changedetectionio/flask_app.py
msgid "hour"
msgstr ""
msgstr "hodina"
#: changedetectionio/flask_app.py
msgid "hours"
msgstr ""
msgstr "hodiny"
#: changedetectionio/flask_app.py
msgid "minute"
msgstr ""
msgstr "minuta"
#: changedetectionio/flask_app.py
msgid "minutes"
msgstr ""
msgstr "minut"
#: changedetectionio/flask_app.py
msgid "second"
msgstr ""
msgstr "sekunda"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py
msgid "seconds"
@@ -1948,11 +1946,11 @@ msgstr "sekundy"
#: changedetectionio/flask_app.py
msgid "Already logged in"
msgstr ""
msgstr "Již přihlášen(a)"
#: changedetectionio/flask_app.py
msgid "You must be logged in, please log in."
msgstr ""
msgstr "Je třeba být přihlášen(a), přihlas se."
#: changedetectionio/flask_app.py
msgid "Incorrect password"
@@ -1960,11 +1958,11 @@ msgstr "Nesprávné heslo"
#: changedetectionio/forms.py
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
msgstr ""
msgstr "Je třeba zadat alespoň jeden časový interval (týdny, dny, hodiny, minuty nebo sekundy)."
#: changedetectionio/forms.py
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
msgstr ""
msgstr "Je třeba zadat alespoň jeden časový interval (týdny, dny, hodiny, minuty nebo sekundy) pokud nejsou použita globální nastavení."
#: changedetectionio/forms.py
msgid "Invalid time format. Use HH:MM."
@@ -1980,7 +1978,7 @@ msgstr "nenastaveno"
#: changedetectionio/forms.py
msgid "Start At"
msgstr "Začíná v"
msgstr "Spustit v"
#: changedetectionio/forms.py
msgid "Run duration"
@@ -2028,7 +2026,7 @@ msgstr "Týdny"
#: changedetectionio/forms.py
msgid "Should contain zero or more seconds"
msgstr "Mělo by obsahovat nula nebo více sekund"
msgstr "Je třeba zadat nula nebo více sekund"
#: changedetectionio/forms.py
msgid "Days"
@@ -2177,15 +2175,15 @@ msgstr "Použít globální nastavení pro čas mezi kontrolou a plánovačem."
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "CSS/xPath filtr"
msgstr "CSS/JSONPath/JQ/xPath filtry"
#: changedetectionio/forms.py
msgid "Remove elements"
msgstr "Vyberte podle prvku"
msgstr "Odstranit prvky"
#: changedetectionio/forms.py
msgid "Extract text"
msgstr "Extrahujte data"
msgstr "Extrahovat text"
#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py
msgid "Title"
@@ -2193,15 +2191,15 @@ msgstr "Titul"
#: changedetectionio/forms.py
msgid "Ignore lines containing"
msgstr "Ignorujte všechny odpovídající řádky"
msgstr "Ignorovat řádky obsahující"
#: changedetectionio/forms.py
msgid "Request body"
msgstr "Žádost"
msgstr "Tělo žádosti"
#: changedetectionio/forms.py
msgid "Request method"
msgstr "Žádost"
msgstr "Metoda žádosti"
#: changedetectionio/forms.py
msgid "Ignore status codes (process non-2xx status codes as normal)"
@@ -2209,11 +2207,11 @@ msgstr "Ignorovat stavové kódy (zpracovat stavové kódy jiné než 2xx jako n
#: changedetectionio/forms.py
msgid "Only trigger when unique lines appear in all history"
msgstr "Spustit pouze tehdy, když se objeví jedinečné čáry"
msgstr "Zpracovat pouze tehdy, když se objeví jedinečné řádky"
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py
msgid "Remove duplicate lines of text"
msgstr "Odstraňte duplicitní řádky textu"
msgstr "Odstranit duplicitní řádky textu"
#: changedetectionio/forms.py
msgid "Sort text alphabetically"
@@ -2221,11 +2219,11 @@ msgstr "Seřadit text podle abecedy"
#: changedetectionio/forms.py
msgid "Strip ignored lines"
msgstr "Odstraňte ignorované řádky"
msgstr "Odstranit ignorované řádky"
#: changedetectionio/forms.py
msgid "Trim whitespace before and after text"
msgstr "Odstraňte všechny mezery před a za každým řádkem textu"
msgstr "Odstranit všechny mezery před a za každým řádkem textu"
#: changedetectionio/forms.py
msgid "Added lines"
@@ -2233,11 +2231,11 @@ msgstr "Přidané řádky"
#: changedetectionio/forms.py
msgid "Replaced/changed lines"
msgstr "Vyměněny/změněny řádky"
msgstr "Nahrazené/upravené řádky"
#: changedetectionio/forms.py
msgid "Removed lines"
msgstr "Odebráno"
msgstr "Odstraněné řádky"
#: changedetectionio/forms.py
msgid "Keyword triggers - Trigger/wait for text"
@@ -2261,7 +2259,7 @@ msgstr "Proxy"
#: changedetectionio/forms.py
msgid "Send a notification when the filter can no longer be found on the page"
msgstr "Odeslat upozornění, když filtr již na stránce nelze najít"
msgstr "Odeslat upozornění pokud filtr již na stránce nelze najít"
#: changedetectionio/forms.py
msgid "Muted"
@@ -2278,7 +2276,7 @@ msgstr "Oznámení"
#: changedetectionio/forms.py
msgid "Attach screenshot to notification (where possible)"
msgstr "Připojte snímek obrazovky k oznámení (pokud je to možné)"
msgstr "Připojit snímek obrazovky k oznámení (pokud je to možné)"
#: changedetectionio/forms.py
msgid "Match"
@@ -2286,7 +2284,7 @@ msgstr "Shoda"
#: changedetectionio/forms.py
msgid "Match all of the following"
msgstr "Spojte všechny následující položky"
msgstr "Spojit všechny následující položky"
#: changedetectionio/forms.py
msgid "Match any of the following"
@@ -2298,7 +2296,7 @@ msgstr "V seznamu použijte stránku <title>"
#: changedetectionio/forms.py
msgid "Number of history items per watch to keep"
msgstr ""
msgstr "Počet historických záznamů pro sledování"
#: changedetectionio/forms.py
msgid "Body must be empty when Request Method is set to GET"
@@ -2317,7 +2315,7 @@ msgstr "Neplatná syntaxe šablony: %(error)s"
#: changedetectionio/forms.py
#, python-format
msgid "Invalid template syntax in \"%(header)s\" header: %(error)s"
msgstr ""
msgstr "Neplatná syntax šablony v \"%(header)s\" hlavička: %(error)s"
#: changedetectionio/forms.py
msgid "Name"
@@ -2441,7 +2439,7 @@ msgstr "RSS <description> tělo sestavené z"
#: changedetectionio/forms.py
msgid "RSS \"System default\" template override"
msgstr ""
msgstr "Úprava \"Systémové výchozí\" RSS šablony"
#: changedetectionio/forms.py
msgid "Remove password"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -725,6 +725,13 @@ 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

View File

@@ -51,7 +51,7 @@ linkify-it-py
# - Requires extra wheel for rPi, adds build time for arm/v8 which is not in piwheels
# Pinned to 44.x for ARM compatibility and sslyze compatibility (sslyze requires <45) and (45.x may not have pre-built ARM wheels)
# Also pinned because dependabot wants specific versions
cryptography==46.0.7
cryptography==44.0.0
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
# use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814