mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-04-13 06:28:06 +00:00
Compare commits
8 Commits
4037-word-
...
3891-inlin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5183203e69 | ||
|
|
0dbfb02e17 | ||
|
|
caa393d5b9 | ||
|
|
17ed9536a3 | ||
|
|
b403b08895 | ||
|
|
9df2e172f4 | ||
|
|
dc037f85ab | ||
|
|
90f157abde |
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']
|
||||
@@ -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"
|
||||
|
||||
BIN
changedetectionio/translations/tr/LC_MESSAGES/messages.mo
Normal file
BIN
changedetectionio/translations/tr/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
3231
changedetectionio/translations/tr/LC_MESSAGES/messages.po
Normal file
3231
changedetectionio/translations/tr/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user