Compare commits

...

8 Commits

Author SHA1 Message Date
dgtlmoon
5183203e69 Re #3891 - Handle inline favicons 2026-04-11 07:19:16 +02:00
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 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-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
20 changed files with 3715 additions and 145 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

@@ -29,6 +29,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
form=add_form,
generate_tag_colors=processors.generate_processor_badge_colors,
tag_count=tag_count,
wcag_text_color=processors.wcag_text_color,
)
return output

View File

@@ -12,12 +12,9 @@ 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

@@ -57,6 +57,32 @@
</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

@@ -7,6 +7,9 @@
{%- 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'] }};
@@ -17,6 +20,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
color: {{ colors['dark']['color'] }};
}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
</style>

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

@@ -38,26 +38,39 @@
if (a.size !== b.size) {
return b.size - a.size;
}
// Second priority: apple-touch-icon over regular icon
const isAppleA = /apple-touch-icon/.test(a.rel);
const isAppleB = /apple-touch-icon/.test(b.rel);
if (isAppleA && !isAppleB) return -1;
if (!isAppleA && isAppleB) return 1;
// Third priority: icons with no size attribute (fallback icons) last
const hasNoSizeA = !a.hasSizes;
const hasNoSizeB = !b.hasSizes;
if (hasNoSizeA && !hasNoSizeB) return 1;
if (!hasNoSizeA && hasNoSizeB) return -1;
return 0;
});
const timeoutMs = 2000;
// 1 MB — matches the server-side limit in bump_favicon()
const MAX_BYTES = 1 * 1024 * 1024;
for (const icon of icons) {
try {
// Inline data URI — no network fetch needed, data is already here
if (icon.href.startsWith('data:')) {
const match = icon.href.match(/^data:([^;]+);base64,([A-Za-z0-9+/=]+)$/);
if (!match) continue;
const mime_type = match[1];
const base64 = match[2];
// Rough size check: base64 is ~4/3 the binary size
if (base64.length * 0.75 > MAX_BYTES) continue;
return { url: icon.href, mime_type, base64 };
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -74,12 +87,15 @@
const blob = await resp.blob();
if (blob.size > MAX_BYTES) continue;
// Convert blob to base64
const reader = new FileReader();
return await new Promise(resolve => {
reader.onloadend = () => {
resolve({
url: icon.href,
mime_type: blob.type,
base64: reader.result.split(",")[1]
});
};
@@ -98,4 +114,3 @@
// Auto-execute and return result for page.evaluate()
return await window.getFaviconAsBlob();
})();

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

@@ -798,24 +798,50 @@ class model(EntityPersistenceMixin, watch_base):
# Also in the case that the file didnt exist
return True
def bump_favicon(self, url, favicon_base_64: str) -> None:
def bump_favicon(self, url, favicon_base_64: str, mime_type: str = None) -> None:
from urllib.parse import urlparse
import base64
import binascii
decoded = None
import re
if url:
MAX_FAVICON_BYTES = 1 * 1024 * 1024 # 1 MB
MIME_TO_EXT = {
'image/png': 'png',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/svg+xml': 'svg',
'image/webp': 'webp',
'image/bmp': 'bmp',
}
extension = None
# If the caller already resolved the MIME type (e.g. from blob.type or a data URI),
# use that directly — it's more reliable than guessing from a URL path.
if mime_type:
extension = MIME_TO_EXT.get(mime_type.lower().split(';')[0].strip(), None)
# Fall back to extracting extension from URL path, unless it's a data URI.
if not extension and url and not url.startswith('data:'):
try:
parsed = urlparse(url)
filename = os.path.basename(parsed.path)
(base, extension) = filename.lower().strip().rsplit('.', 1)
(_base, ext) = filename.lower().strip().rsplit('.', 1)
extension = ext
except ValueError:
logger.error(f"UUID: {self.get('uuid')} Cant work out file extension from '{url}'")
return None
else:
# Assume favicon.ico
base = "favicon"
extension = "ico"
logger.warning(f"UUID: {self.get('uuid')} Cant work out file extension from '{url}', defaulting to ico")
# Handle data URIs: extract MIME type from the URI itself when not already known
if not extension and url and url.startswith('data:'):
m = re.match(r'^data:([^;]+);base64,', url)
if m:
extension = MIME_TO_EXT.get(m.group(1).lower(), None)
if not extension:
extension = 'ico'
fname = os.path.join(self.data_dir, f"favicon.{extension}")
@@ -824,22 +850,27 @@ class model(EntityPersistenceMixin, watch_base):
decoded = base64.b64decode(favicon_base_64, validate=True)
except (binascii.Error, ValueError) as e:
logger.warning(f"UUID: {self.get('uuid')} FavIcon save data (Base64) corrupt? {str(e)}")
else:
if decoded:
try:
with open(fname, 'wb') as f:
f.write(decoded)
return None
# Invalidate module-level favicon filename cache for this watch
_FAVICON_FILENAME_CACHE.pop(self.data_dir, None)
if len(decoded) > MAX_FAVICON_BYTES:
logger.warning(f"UUID: {self.get('uuid')} Favicon too large ({len(decoded)} bytes), skipping")
return None
# A signal that could trigger the socket server to update the browser also
watch_check_update = signal('watch_favicon_bump')
if watch_check_update:
watch_check_update.send(watch_uuid=self.get('uuid'))
try:
with open(fname, 'wb') as f:
f.write(decoded)
except Exception as e:
logger.warning(f"UUID: {self.get('uuid')} error saving FavIcon to {fname} - {str(e)}")
# Invalidate module-level favicon filename cache for this watch
_FAVICON_FILENAME_CACHE.pop(self.data_dir, None)
# A signal that could trigger the socket server to update the browser also
watch_check_update = signal('watch_favicon_bump')
if watch_check_update:
watch_check_update.send(watch_uuid=self.get('uuid'))
except Exception as e:
logger.warning(f"UUID: {self.get('uuid')} error saving FavIcon to {fname} - {str(e)}")
return None
# @todo - Store some checksum and only write when its different
logger.debug(f"UUID: {self.get('uuid')} updated favicon to at {fname}")

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

@@ -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

@@ -50,6 +50,85 @@ def test_favicon(client, live_server, measure_memory_usage, datastore_path):
res = client.get(url_for('static_content', group='js', filename='../styles/styles.css'))
assert res.status_code != 200
def test_favicon_inline_data_uri(client, live_server, measure_memory_usage, datastore_path):
"""
bump_favicon() must handle a data URI as the url parameter.
Previously this logged "Cant work out file extension from 'data:image/png;base64,...'" and bailed.
The mime_type from the data URI should be used to pick the correct extension.
"""
import base64
import os
# 1x1 transparent PNG (minimal valid PNG bytes)
PNG_BYTES = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01'
b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
png_b64 = base64.b64encode(PNG_BYTES).decode()
data_uri = f"data:image/png;base64,{png_b64}"
uuid = client.application.config.get('DATASTORE').add_watch(url='https://localhost')
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
# Should NOT raise / bail — must save as favicon.png
watch.bump_favicon(url=data_uri, favicon_base_64=png_b64, mime_type='image/png')
favicon_fname = watch.get_favicon_filename()
assert favicon_fname is not None, "Favicon should have been saved"
assert favicon_fname.endswith('.png'), f"Expected .png extension, got: {favicon_fname}"
full_path = os.path.join(watch.data_dir, favicon_fname)
assert os.path.getsize(full_path) == len(PNG_BYTES)
# Also verify it's served correctly via the static route
res = client.get(url_for('static_content', group='favicon', filename=uuid))
assert res.status_code == 200
assert res.data == PNG_BYTES
def test_favicon_mime_type_overrides_url_extension(client, live_server, measure_memory_usage, datastore_path):
"""
mime_type parameter takes precedence over the URL path extension.
A URL ending in .ico but with mime_type='image/png' should save as .png.
"""
import base64
import os
PNG_BYTES = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01'
b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
png_b64 = base64.b64encode(PNG_BYTES).decode()
uuid = client.application.config.get('DATASTORE').add_watch(url='https://localhost')
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
watch.bump_favicon(url='https://example.com/favicon.ico', favicon_base_64=png_b64, mime_type='image/png')
favicon_fname = watch.get_favicon_filename()
assert favicon_fname is not None
assert favicon_fname.endswith('.png'), f"mime_type should override URL extension, got: {favicon_fname}"
def test_favicon_oversized_rejected(client, live_server, measure_memory_usage, datastore_path):
"""Favicons larger than 1 MB must be silently dropped."""
import base64
import os
oversized = b'\x00' * (1 * 1024 * 1024 + 1)
oversized_b64 = base64.b64encode(oversized).decode()
uuid = client.application.config.get('DATASTORE').add_watch(url='https://localhost')
watch = live_server.app.config['DATASTORE'].data['watching'][uuid]
result = watch.bump_favicon(url='https://example.com/big.png', favicon_base_64=oversized_b64, mime_type='image/png')
assert result is None, "bump_favicon should return None for oversized favicon"
assert watch.get_favicon_filename() is None, "No favicon file should have been written"
def test_bad_access(client, live_server, measure_memory_usage, datastore_path):
res = client.post(

View File

@@ -496,6 +496,27 @@ Line 3 with tabs and spaces"""
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

@@ -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

View File

@@ -484,7 +484,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Store favicon if necessary
if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')
favicon_base_64=update_handler.fetcher.favicon_blob.get('base64'),
mime_type=update_handler.fetcher.favicon_blob.get('mime_type')
)
datastore.update_watch(uuid=uuid, update_obj=final_updates)