diff --git a/changedetectionio/api/Notifications.py b/changedetectionio/api/Notifications.py index 8ce4e977478..201fc326b01 100644 --- a/changedetectionio/api/Notifications.py +++ b/changedetectionio/api/Notifications.py @@ -2,27 +2,56 @@ from flask import request from . import auth, validate_openapi_request +_API_PROFILE_NAME = "API Default" + +def _get_api_profile(datastore): + """Return (uuid, profile_dict) for the API-managed system profile, or (None, None).""" + profiles = datastore.data['settings']['application'].get('notification_profile_data', {}) + for uid, p in profiles.items(): + if p.get('name') == _API_PROFILE_NAME: + return uid, p + return None, None + + +def _ensure_api_profile(datastore, urls): + """Create or update the API Default profile and ensure it's linked to system.""" + import uuid as uuid_mod + + app = datastore.data['settings']['application'] + app.setdefault('notification_profile_data', {}) + app.setdefault('notification_profiles', []) + + uid, profile = _get_api_profile(datastore) + if uid is None: + uid = str(uuid_mod.uuid4()) + profile = {'uuid': uid, 'name': _API_PROFILE_NAME, 'type': 'apprise', 'config': {}} + app['notification_profile_data'][uid] = profile + + profile['config']['notification_urls'] = urls + + if uid not in app['notification_profiles']: + app['notification_profiles'].append(uid) + + datastore.needs_write = True + return uid, profile + + class Notifications(Resource): def __init__(self, **kwargs): - # datastore is a black box dependency self.datastore = kwargs['datastore'] @auth.check_token @validate_openapi_request('getNotifications') def get(self): - """Return Notification URL List.""" + """Return Notification URL List (from the API Default profile).""" + _, profile = _get_api_profile(self.datastore) + urls = profile['config'].get('notification_urls', []) if profile else [] + return {'notification_urls': urls}, 200 - notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', []) - - return { - 'notification_urls': notification_urls, - }, 200 - @auth.check_token @validate_openapi_request('addNotifications') def post(self): - """Create Notification URLs.""" - + """Add Notification URLs to the API Default profile.""" json_data = request.get_json() notification_urls = json_data.get("notification_urls", []) @@ -32,23 +61,27 @@ def post(self): except ValidationError as e: return str(e), 400 - added_urls = [] + _, profile = _get_api_profile(self.datastore) + existing = list(profile['config'].get('notification_urls', []) if profile else []) + added = [] for url in notification_urls: - clean_url = url.strip() - added_url = self.datastore.add_notification_url(clean_url) - if added_url: - added_urls.append(added_url) + clean = url.strip() + if clean and clean not in existing: + existing.append(clean) + added.append(clean) - if not added_urls: + if not added: return "No valid notification URLs were added", 400 - return {'notification_urls': added_urls}, 201 - + _ensure_api_profile(self.datastore, existing) + self.datastore.commit() + return {'notification_urls': existing}, 201 + @auth.check_token @validate_openapi_request('replaceNotifications') def put(self): - """Replace Notification URLs.""" + """Replace Notification URLs in the API Default profile.""" json_data = request.get_json() notification_urls = json_data.get("notification_urls", []) @@ -57,47 +90,61 @@ def put(self): validate_notification_urls(notification_urls) except ValidationError as e: return str(e), 400 - + if not isinstance(notification_urls, list): return "Invalid input format", 400 clean_urls = [url.strip() for url in notification_urls if isinstance(url, str)] - self.datastore.data['settings']['application']['notification_urls'] = clean_urls - self.datastore.commit() + if clean_urls: + _ensure_api_profile(self.datastore, clean_urls) + else: + # Empty list: remove the profile entirely + uid, _ = _get_api_profile(self.datastore) + if uid: + app = self.datastore.data['settings']['application'] + app['notification_profile_data'].pop(uid, None) + if uid in app.get('notification_profiles', []): + app['notification_profiles'].remove(uid) + self.datastore.needs_write = True + + self.datastore.commit() return {'notification_urls': clean_urls}, 200 - + @auth.check_token @validate_openapi_request('deleteNotifications') def delete(self): - """Delete Notification URLs.""" - + """Delete specific Notification URLs from the API Default profile.""" json_data = request.get_json() urls_to_delete = json_data.get("notification_urls", []) if not isinstance(urls_to_delete, list): abort(400, message="Expected a list of notification URLs.") - notification_urls = self.datastore.data['settings']['application'].get('notification_urls', []) - deleted = [] + uid, profile = _get_api_profile(self.datastore) + if not profile: + abort(400, message="No matching notification URLs found.") + current = list(profile['config'].get('notification_urls', [])) + deleted = [] for url in urls_to_delete: - clean_url = url.strip() - if clean_url in notification_urls: - notification_urls.remove(clean_url) - deleted.append(clean_url) + clean = url.strip() + if clean in current: + current.remove(clean) + deleted.append(clean) if not deleted: abort(400, message="No matching notification URLs found.") - self.datastore.data['settings']['application']['notification_urls'] = notification_urls + profile['config']['notification_urls'] = current + self.datastore.needs_write = True self.datastore.commit() - return 'OK', 204 - + + def validate_notification_urls(notification_urls): from changedetectionio.forms import ValidateAppRiseServers validator = ValidateAppRiseServers() class DummyForm: pass dummy_form = DummyForm() field = type("Field", (object,), {"data": notification_urls, "gettext": lambda self, x: x})() - validator(dummy_form, field) \ No newline at end of file + validator(dummy_form, field) diff --git a/changedetectionio/blueprint/notification_profiles/__init__.py b/changedetectionio/blueprint/notification_profiles/__init__.py new file mode 100644 index 00000000000..da1de53c142 --- /dev/null +++ b/changedetectionio/blueprint/notification_profiles/__init__.py @@ -0,0 +1,250 @@ +import uuid as uuid_mod +from flask import Blueprint, request, render_template, flash, redirect, url_for, make_response +from flask_babel import gettext +from loguru import logger + +from changedetectionio.store import ChangeDetectionStore +from changedetectionio.auth_decorator import login_optionally_required + + +def construct_blueprint(datastore: ChangeDetectionStore): + bp = Blueprint('notification_profiles', __name__, template_folder="templates") + + def _profiles(): + return datastore.data['settings']['application'].setdefault('notification_profile_data', {}) + + @bp.route("/", methods=['GET']) + @login_optionally_required + def index(): + from changedetectionio.notification_profiles.registry import registry + from changedetectionio.notification_profiles.log import read_profile_log + + profiles = _profiles() + + # Count how many watches/tags reference each profile + usage = {} + for watch in datastore.data['watching'].values(): + for u in watch.get('notification_profiles', []): + usage[u] = usage.get(u, 0) + 1 + for tag in datastore.data['settings']['application'].get('tags', {}).values(): + for u in tag.get('notification_profiles', []): + usage[u] = usage.get(u, 0) + 1 + + # Most-recent log entry per profile (for the Last result column) + last_log = {} + for uid in profiles: + entries = read_profile_log(datastore.datastore_path, uid) + if entries: + last_log[uid] = entries[0] # newest first + + return render_template( + "notification_profiles/list.html", + profiles=profiles, + registry=registry, + usage=usage, + last_log=last_log, + ) + + @bp.route("/new", methods=['GET', 'POST']) + @bp.route("/", methods=['GET', 'POST']) + @login_optionally_required + def edit(profile_uuid=None): + from changedetectionio.notification_profiles.registry import registry + from .forms import NotificationProfileForm + + profiles = _profiles() + existing = profiles.get(profile_uuid, {}) if profile_uuid else {} + + form = NotificationProfileForm( + request.form if request.method == 'POST' else None, + data=existing or None, + ) + + if request.method == 'POST' and form.validate(): + profile_type = form.profile_type.data or 'apprise' + type_handler = registry.get(profile_type) + + # Build type-specific config from submitted form data + config = _extract_config(request.form, profile_type) + + try: + type_handler.validate(config) + except ValueError as e: + flash(str(e), 'error') + return render_template("notification_profiles/edit.html", + form=form, profile_uuid=profile_uuid, + registry=registry, existing=existing) + + uid = profile_uuid or str(uuid_mod.uuid4()) + profiles[uid] = { + 'uuid': uid, + 'name': form.name.data.strip(), + 'type': profile_type, + 'config': config, + } + datastore.commit() + flash(gettext("Notification profile saved."), 'notice') + return redirect(url_for('notification_profiles.index')) + + return render_template( + "notification_profiles/edit.html", + form=form, + profile_uuid=profile_uuid, + registry=registry, + existing=existing, + ) + + @bp.route("//delete", methods=['POST']) + @login_optionally_required + def delete(profile_uuid): + profiles = _profiles() + if profile_uuid not in profiles: + flash(gettext("Profile not found."), 'error') + return redirect(url_for('notification_profiles.index')) + + # Warn if in use — but allow deletion + usage_count = sum( + 1 for w in datastore.data['watching'].values() + if profile_uuid in w.get('notification_profiles', []) + ) + + del profiles[profile_uuid] + datastore.commit() + + if usage_count: + flash(gettext("Profile deleted (was linked to %(n)d watch(es)).", n=usage_count), 'notice') + else: + flash(gettext("Profile deleted."), 'notice') + + return redirect(url_for('notification_profiles.index')) + + @bp.route("//test", methods=['POST']) + @login_optionally_required + def test(profile_uuid): + """Fire a test notification for a saved profile.""" + from changedetectionio.notification_service import NotificationContextData, set_basic_notification_vars + import random + + profiles = _profiles() + profile = profiles.get(profile_uuid) + if not profile: + return make_response("Profile not found", 404) + + from changedetectionio.notification_profiles.registry import registry + type_handler = registry.get(profile.get('type', 'apprise')) + + # Pick a random watch for context variables + watch_uuid = request.form.get('watch_uuid') + if not watch_uuid and datastore.data.get('watching'): + watch_uuid = random.choice(list(datastore.data['watching'].keys())) + + if not watch_uuid: + return make_response("Error: No watches configured for test notification", 400) + + watch = datastore.data['watching'].get(watch_uuid) + prev_snapshot = "Example text: example test\nExample text: change detection is cool\n" + current_snapshot = "Example text: example test\nExample text: change detection is fantastic\n" + + dates = list(watch.history.keys()) if watch else [] + if len(dates) > 1: + prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2]) + current_snapshot = watch.get_history_snapshot(timestamp=dates[-1]) + + n_object = NotificationContextData({'watch_url': watch.get('url', 'https://example.com') if watch else 'https://example.com'}) + n_object.update(set_basic_notification_vars( + current_snapshot=current_snapshot, + prev_snapshot=prev_snapshot, + watch=watch, + triggered_text='', + timestamp_changed=dates[-1] if dates else None, + )) + + from changedetectionio.notification_profiles.log import write_profile_log + try: + type_handler.send(profile.get('config', {}), n_object, datastore) + write_profile_log(datastore.datastore_path, profile_uuid, + watch_url=watch.get('url', '') if watch else '', + watch_uuid=watch_uuid or '', + status='test', message='Manual test') + except Exception as e: + logger.error(f"Test notification failed for profile {profile_uuid}: {e}") + write_profile_log(datastore.datastore_path, profile_uuid, + watch_url=watch.get('url', '') if watch else '', + watch_uuid=watch_uuid or '', + status='error', message=str(e)) + return make_response(str(e), 400) + + return 'OK - Test notification sent' + + @bp.route("/type-defaults/", methods=['GET', 'POST']) + @login_optionally_required + def type_defaults(type_id): + """Edit system-wide defaults for a notification profile type.""" + from changedetectionio.notification_profiles.registry import registry + + handler = registry.get(type_id) + if handler is None or handler.defaults_form_class is None: + flash(gettext("No configurable defaults for this notification type."), 'error') + return redirect(url_for('notification_profiles.index')) + + all_defaults = datastore.data['settings']['application'].setdefault('notification_type_defaults', {}) + existing = all_defaults.get(type_id, {}) + + FormClass = handler.defaults_form_class + form = FormClass( + request.form if request.method == 'POST' else None, + data=existing or None, + ) + + if request.method == 'POST' and form.validate(): + # Collect all non-button, non-hidden fields + all_defaults[type_id] = { + field.name: field.data + for field in form + if field.name not in ('save_button', 'csrf_token') + } + datastore.commit() + flash(gettext("Notification type defaults saved."), 'notice') + return redirect(url_for('notification_profiles.index')) + + template = handler.defaults_template or 'notification_profiles/type_defaults.html' + return render_template( + template, + form=form, + handler=handler, + type_id=type_id, + ) + + @bp.route("//log", methods=['GET']) + @login_optionally_required + def profile_log(profile_uuid): + """Show per-profile send history.""" + from changedetectionio.notification_profiles.log import read_profile_log + profiles = _profiles() + profile = profiles.get(profile_uuid) + if not profile: + flash(gettext("Profile not found."), 'error') + return redirect(url_for('notification_profiles.index')) + + entries = read_profile_log(datastore.datastore_path, profile_uuid) + return render_template('notification_profiles/log.html', + profile=profile, + entries=entries, + profile_uuid=profile_uuid) + + return bp + + +def _extract_config(form_data, profile_type: str) -> dict: + """Extract type-specific config fields from form POST data.""" + if profile_type == 'apprise': + raw = form_data.get('notification_urls', '') + urls = [u.strip() for u in raw.splitlines() if u.strip()] + return { + 'notification_urls': urls, + 'notification_title': form_data.get('notification_title', '').strip() or None, + 'notification_body': form_data.get('notification_body', '').strip() or None, + 'notification_format': form_data.get('notification_format', '').strip() or None, + } + # Other types: plugins populate their own config keys + return dict(form_data) diff --git a/changedetectionio/blueprint/notification_profiles/forms.py b/changedetectionio/blueprint/notification_profiles/forms.py new file mode 100644 index 00000000000..7be4625ca9f --- /dev/null +++ b/changedetectionio/blueprint/notification_profiles/forms.py @@ -0,0 +1,43 @@ +from wtforms import Form, StringField, TextAreaField, HiddenField, SubmitField, validators +from wtforms.fields import SelectField +from flask_babel import lazy_gettext as _l + +from changedetectionio.notification import valid_notification_formats + + +class NotificationProfileForm(Form): + name = StringField(_l('Profile name'), [validators.InputRequired()]) + profile_type = HiddenField(default='apprise') + save_button = SubmitField(_l('Save'), render_kw={"class": "pure-button pure-button-primary"}) + + # Apprise-type config fields + notification_urls = TextAreaField( + _l('Notification URL list'), + validators=[validators.Optional()], + render_kw={"rows": 5, "placeholder": "one URL per line\ne.g. mailtos://user:pass@smtp.example.com?to=you@example.com"}, + ) + notification_title = StringField(_l('Notification title'), validators=[validators.Optional()]) + notification_body = TextAreaField(_l('Notification body'), validators=[validators.Optional()], render_kw={"rows": 5}) + notification_format = SelectField( + _l('Notification format'), + choices=[(k, v) for k, v in valid_notification_formats.items() if k != 'System default'], + ) + + +class AppriseDefaultsForm(Form): + """System-wide defaults for the Apprise notification type.""" + notification_title = StringField( + _l('Default notification title'), + validators=[validators.Optional()], + render_kw={"placeholder": "ChangeDetection.io Notification - {{watch_url}}"}, + ) + notification_body = TextAreaField( + _l('Default notification body'), + validators=[validators.Optional()], + render_kw={"rows": 6, "placeholder": "{{watch_url}} had a change.\n---\n{{diff}}\n---\n"}, + ) + notification_format = SelectField( + _l('Default notification format'), + choices=[(k, v) for k, v in valid_notification_formats.items() if k != 'System default'], + ) + save_button = SubmitField(_l('Save defaults'), render_kw={"class": "pure-button pure-button-primary"}) diff --git a/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/edit.html b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/edit.html new file mode 100644 index 00000000000..5a30bf2af0a --- /dev/null +++ b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/edit.html @@ -0,0 +1,99 @@ +{% extends 'base.html' %} +{% from '_helpers.html' import render_field %} +{% block content %} + + +
+
+

{% if profile_uuid %}{{ _('Edit Notification Profile') }}{% else %}{{ _('New Notification Profile') }}{% endif %}

+ +
+ + {{ form.profile_type() }} + +
+
+ {{ render_field(form.name, placeholder=_('e.g. My Slack Alerts')) }} +
+ + {# Type selector — only one type for now but ready for more #} +
+ +
+ {% for type_id, display_name in registry.choices() %} + {% set handler = registry.get(type_id) %} + + {% endfor %} +
+
+ + {# Type-specific config — rendered via the type's template partial #} +
+ {% for type_id, display_label in registry.choices() %} + {% set handler = registry.get(type_id) %} +
+ {% include handler.template %} +
+ {% endfor %} +
+
+ +
+ {{ render_field(form.save_button) }} + {% if profile_uuid %} + {{ _('Send test') }} + + + {% endif %} + {{ _('Cancel') }} +
+
+
+
+ + +{% endblock %} diff --git a/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/list.html b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/list.html new file mode 100644 index 00000000000..5fae489b110 --- /dev/null +++ b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/list.html @@ -0,0 +1,147 @@ +{% extends 'base.html' %} +{% block content %} +
+
+

{{ _('Notification Profiles') }}

+

+ {{ _('Profiles define where and how notifications are sent. Link them to watches or groups.') }} + {{ _('Create new profile') }} +

+ + {% if not profiles %} +
+ {{ _('No notification profiles yet.') }} + {{ _('Create first profile') }} +
+ {% else %} + + + + + + + + + + + + + {% for uuid, profile in profiles.items() %} + {% set type_handler = registry.get(profile.get('type', 'apprise')) %} + + + + + + + + + {% endfor %} + +
{{ _('Name') }}{{ _('Type') }}{{ _('Destination') }}{{ _('Used by') }}{{ _('Last result') }}
{{ profile.get('name', '') }} + + {{ type_handler.display_name }} + {{ type_handler.get_url_hint(profile.get('config', {})) }} + {% set n = usage.get(uuid, 0) %} + {% if n %} + {{ n }} {{ _('watch(es)') }} + {% else %} + {{ _('unused') }} + {% endif %} + + {%- set _last = last_log.get(uuid) -%} + {%- if _last -%} + + {%- if _last.status == 'ok' -%}✓ {{ _('OK') }} + {%- elif _last.status == 'test' -%}▶ {{ _('Test') }} + {%- else -%}✗ {{ _('Error') }} + {%- endif -%} + + {%- else -%} + {{ _('no log') }} + {%- endif -%} + + {{ _('Edit') }} + + +
+ + +
+
+ {% endif %} + +
+ {{ _('+ New profile') }} + + {# Per-type system defaults #} + {% set types_with_defaults = registry.all() | selectattr('defaults_form_class') | list %} + {% if types_with_defaults %} +
+

{{ _('Notification type defaults') }}

+

+ {{ _('Configure system-wide fallback values used when a profile leaves a field blank.') }} +

+ + + + + + + + + {% for handler in types_with_defaults %} + + + + + {% endfor %} + +
{{ _('Type') }}
+ + {{ handler.display_name }} + + {{ _('Configure defaults') }} +
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/log.html b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/log.html new file mode 100644 index 00000000000..1e5500c2776 --- /dev/null +++ b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/log.html @@ -0,0 +1,66 @@ +{% extends 'base.html' %} +{% block content %} +
+
+

{{ _('Notification Log') }}: {{ profile.get('name', '') }}

+

+ ← {{ _('Back to profiles') }} + {{ _('Edit profile') }} +

+ + {% if not entries %} +
+ {{ _('No log entries yet — logs are written each time a notification is attempted.') }} +
+ {% else %} + + + + + + + + + + + {% for e in entries %} + + + + + + + {% endfor %} + +
{{ _('Time') }}{{ _('Status') }}{{ _('Watch') }}{{ _('Detail') }}
{{ e.ts }} + {% if e.status == 'ok' %} + ✓ {{ _('OK') }} + {% elif e.status == 'test' %} + ▶ {{ _('Test') }} + {% else %} + ✗ {{ _('Error') }} + {% endif %} + + {% if e.watch_url %} + {{ e.watch_url[:80] }}{% if e.watch_url|length > 80 %}…{% endif %} + {% else %} + + {% endif %} + + {% if e.message %} + {{ e.message }} + {% endif %} +
+

{{ _('Showing last %(n)d entries (newest first).', n=entries|length) }}

+ {% endif %} +
+
+ + +{% endblock %} diff --git a/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/type_defaults.html b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/type_defaults.html new file mode 100644 index 00000000000..f0b6973514a --- /dev/null +++ b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/type_defaults.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} +{% from '_helpers.html' import render_field %} +{% from '_common_fields.html' import show_token_placeholders %} +{% block content %} +
+
+

+ + {{ handler.display_name }} — {{ _('System defaults') }} +

+

+ {{ _('These values are used when a notification profile leaves a field blank.') }} + {{ _('Back to profiles') }} +

+ +
+ +
+ {% for field in form if field.name not in ('save_button', 'csrf_token') %} +
+ {{ render_field(field) }} +
+ {% endfor %} + {{ show_token_placeholders(extra_notification_token_placeholder_info=None) }} +
+
+ {{ render_field(form.save_button) }} + {{ _('Cancel') }} +
+
+
+
+{% endblock %} diff --git a/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/type_defaults/apprise.html b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/type_defaults/apprise.html new file mode 100644 index 00000000000..cf2459a7d43 --- /dev/null +++ b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/type_defaults/apprise.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} +{% from '_helpers.html' import render_field %} +{% from '_common_fields.html' import show_token_placeholders %} +{% block content %} +
+
+

+ + {{ _('Apprise — System notification defaults') }} +

+

+ {{ _('These are the fallback title, body, and format used when an Apprise notification profile leaves those fields blank.') }} + {{ _('Back to profiles') }} +

+ +
+ +
+
+ {{ render_field(form.notification_title, class="notification-title") }} +
+
+ {{ render_field(form.notification_body, rows=6, class="notification-body") }} + {{ show_token_placeholders(extra_notification_token_placeholder_info=None) }} +
+
+ {{ render_field(form.notification_format, class="notification-format") }} +
+
+
+ {{ render_field(form.save_button) }} + {{ _('Cancel') }} +
+
+
+
+{% endblock %} diff --git a/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/types/apprise.html b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/types/apprise.html new file mode 100644 index 00000000000..c8dd0d3fb50 --- /dev/null +++ b/changedetectionio/blueprint/notification_profiles/templates/notification_profiles/types/apprise.html @@ -0,0 +1,37 @@ +{% from '_helpers.html' import render_field %} +{% from '_common_fields.html' import show_token_placeholders %} + +
+ {{ render_field(form.notification_urls, + rows=5, + placeholder="Examples:\n Slack - slack://TokenA/TokenB/TokenC\n Discord - discord://WebhookID/WebhookToken\n Email - mailtos://user:pass@smtp.host?to=you@example.com\n Telegram- tgram://BotToken/ChatID", + class="notification-urls") }} +
+ {{ _('Tip:') }} {{ _('Use') }} + {{ _('Apprise Notification URLs') }} + {{ _('for notifications to almost any service.') }} + {{ _('Configuration notes') }} +
+
+ + +
+
+ +
+
+ {{ render_field(form.notification_title, + class="notification-title", + placeholder=_('Leave blank to use system default')) }} +
+
+ {{ render_field(form.notification_body, + rows=5, + class="notification-body", + placeholder=_('Leave blank to use system default')) }} + {{ show_token_placeholders(extra_notification_token_placeholder_info=None) }} +
+
+ {{ render_field(form.notification_format, class="notification-format") }} +
+
diff --git a/changedetectionio/blueprint/rss/_util.py b/changedetectionio/blueprint/rss/_util.py index c96f8b41095..666e98c1247 100644 --- a/changedetectionio/blueprint/rss/_util.py +++ b/changedetectionio/blueprint/rss/_util.py @@ -3,7 +3,7 @@ """ from changedetectionio.notification.handler import process_notification -from changedetectionio.notification_service import NotificationContextData, _check_cascading_vars +from changedetectionio.notification_service import NotificationContextData from loguru import logger import datetime import pytz @@ -71,7 +71,14 @@ def validate_rss_token(datastore, request): def get_rss_template(datastore, watch, rss_content_format, default_html, default_plaintext): """Get the appropriate template for RSS content.""" if datastore.data['settings']['application'].get('rss_template_type') == 'notification_body': - return _check_cascading_vars(datastore=datastore, var_name='notification_body', watch=watch) + # Resolve notification body from the profile chain (watch → tag → system) + from changedetectionio.notification_profiles.resolver import resolve_notification_profiles + from changedetectionio.notification import default_notification_body + for profile, _ in resolve_notification_profiles(watch, datastore): + body = profile.get('config', {}).get('notification_body') + if body: + return body + return default_notification_body override = datastore.data['settings']['application'].get('rss_template_override') if override and override.strip(): diff --git a/changedetectionio/blueprint/settings/__init__.py b/changedetectionio/blueprint/settings/__init__.py index 655bba03743..c54a77d63d3 100644 --- a/changedetectionio/blueprint/settings/__init__.py +++ b/changedetectionio/blueprint/settings/__init__.py @@ -48,9 +48,6 @@ def settings_page(): extra_notification_tokens=datastore.get_unique_notification_tokens_available() ) - # Remove the last option 'System default' - form.application.form.notification_format.choices.pop() - if datastore.proxy_list is None: # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead del form.requests.form.proxy @@ -78,6 +75,8 @@ def settings_page(): del (app_update['password']) datastore.data['settings']['application'].update(app_update) + # notification_profiles is submitted as hidden inputs (list of UUIDs), not a form field + datastore.data['settings']['application']['notification_profiles'] = request.form.getlist('notification_profiles') # Handle dynamic worker count adjustment old_worker_count = datastore.data['settings']['requests'].get('workers', 1) @@ -167,6 +166,7 @@ def settings_page(): # Instantiate the form with existing settings plugin_forms[plugin_id] = form_class(data=settings) + from changedetectionio.notification_profiles.registry import registry as notification_registry output = render_template("settings.html", active_plugins=active_plugins, api_key=datastore.data['settings']['application'].get('api_access_token'), @@ -178,6 +178,7 @@ def settings_page(): form=form, hide_remove_pass=os.getenv("SALTED_PASS", False), min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)), + notification_registry=notification_registry, settings_application=datastore.data['settings']['application'], timezone_default_config=datastore.data['settings']['application'].get('scheduler_timezone_default'), utc_time=utc_time, diff --git a/changedetectionio/blueprint/settings/templates/settings.html b/changedetectionio/blueprint/settings/templates/settings.html index 6839355c349..934c4196eba 100644 --- a/changedetectionio/blueprint/settings/templates/settings.html +++ b/changedetectionio/blueprint/settings/templates/settings.html @@ -104,7 +104,18 @@
- {{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} +
+ +

{{ _('Profiles linked here fire for every watch that has no profiles of its own (or its groups).') }} + {{ _('Manage profiles →') }}

+ {% from '_notification_profiles_selector.html' import render_notification_profile_selector %} + {{ render_notification_profile_selector( + own_profiles=settings_application.get('notification_profiles', []), + inherited_profiles=[], + all_profile_data=settings_application.get('notification_profile_data', {}), + registry=notification_registry + ) }} +
{{ render_field(form.application.form.base_url, class="m-d") }} diff --git a/changedetectionio/blueprint/tags/__init__.py b/changedetectionio/blueprint/tags/__init__.py index e11910ff7a9..6abdfad96a7 100644 --- a/changedetectionio/blueprint/tags/__init__.py +++ b/changedetectionio/blueprint/tags/__init__.py @@ -7,6 +7,14 @@ from changedetectionio.flask_app import login_optionally_required +def _get_tag_inherited_notification_profiles(datastore): + """Tags only inherit from system level.""" + result = [] + for uid in datastore.data['settings']['application'].get('notification_profiles', []): + result.append((uid, 'system')) + return result + + def construct_blueprint(datastore: ChangeDetectionStore): tags_blueprint = Blueprint('tags', __name__, template_folder="templates") @@ -175,11 +183,14 @@ def form_tag_edit(uuid): sub_field.data = sub_value break + from changedetectionio.notification_profiles.registry import registry as notification_registry template_args = { 'data': default, 'form': form, 'watch': default, 'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(), + 'notification_registry': notification_registry, + 'inherited_notification_profiles': _get_tag_inherited_notification_profiles(datastore), } included_content = {} @@ -239,6 +250,7 @@ def form_tag_edit_submit(uuid): tag.update(form.data) tag['processor'] = 'restock_diff' + tag['notification_profiles'] = request.form.getlist('notification_profiles') tag.commit() # Clear checksums for all watches using this tag to force reprocessing diff --git a/changedetectionio/blueprint/tags/templates/edit-tag.html b/changedetectionio/blueprint/tags/templates/edit-tag.html index 8a02609039d..f9012a39a81 100644 --- a/changedetectionio/blueprint/tags/templates/edit-tag.html +++ b/changedetectionio/blueprint/tags/templates/edit-tag.html @@ -63,27 +63,17 @@

{{ _('Text filtering') }}

{% endif %}
-
- {{ render_ternary_field(form.notification_muted, BooleanField=True) }} -
- {% if 1 %}
- {{ render_checkbox_field(form.notification_screenshot) }} - - {{ _('Use with caution!') }} {{ _('This will easily fill up your email storage quota or flood other storages.') }} - + {{ render_ternary_field(form.notification_muted, BooleanField=True) }}
- {% endif %} -
- {% if has_default_notification_urls %} -
- {{ _('Look out!') }} - {{ _('There are') }} {{ _('system-wide notification URLs enabled') }}, {{ _('this form will override notification settings for this watch only') }} ‐ {{ _('an empty Notification URL list here will still send notifications.') }} -
- {% endif %} - {{ _('Use system defaults') }} - - {{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} +
+ {% from '_notification_profiles_selector.html' import render_notification_profile_selector %} + {{ render_notification_profile_selector( + own_profiles=watch.get('notification_profiles', []), + inherited_profiles=inherited_notification_profiles, + all_profile_data=settings_application.get('notification_profile_data', {}), + registry=notification_registry + ) }}
diff --git a/changedetectionio/blueprint/ui/__init__.py b/changedetectionio/blueprint/ui/__init__.py index 751b4023edb..c38173f5890 100644 --- a/changedetectionio/blueprint/ui/__init__.py +++ b/changedetectionio/blueprint/ui/__init__.py @@ -83,15 +83,10 @@ def _handle_operations(op, uuids, datastore, worker_pool, update_q, queuedWatchM flash(gettext("{} watches cleared/reset.").format(len(uuids))) elif (op == 'notification-default'): - from changedetectionio.notification import ( - USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH - ) for uuid in uuids: if datastore.data['watching'].get(uuid): - datastore.data['watching'][uuid]['notification_title'] = None - datastore.data['watching'][uuid]['notification_body'] = None - datastore.data['watching'][uuid]['notification_urls'] = [] - datastore.data['watching'][uuid]['notification_format'] = USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH + # Clear watch-level profile overrides so the watch falls back to tag/system profiles + datastore.data['watching'][uuid]['notification_profiles'] = [] datastore.data['watching'][uuid].commit() if emit_flash: flash(gettext("{} watches set to use default notification settings").format(len(uuids))) diff --git a/changedetectionio/blueprint/ui/edit.py b/changedetectionio/blueprint/ui/edit.py index e474e1a755e..49f7ea6c77d 100644 --- a/changedetectionio/blueprint/ui/edit.py +++ b/changedetectionio/blueprint/ui/edit.py @@ -11,9 +11,27 @@ from changedetectionio.time_handler import is_within_schedule from changedetectionio import worker_pool +def _get_inherited_notification_profiles(watch, datastore): + """Return list of (uuid, origin_label) for profiles inherited from groups/system.""" + own = set(watch.get('notification_profiles', [])) + result = [] + seen = set() + tags = datastore.get_all_tags_for_watch(uuid=watch.get('uuid')) or {} + for tag in tags.values(): + for uid in tag.get('notification_profiles', []): + if uid not in own and uid not in seen: + result.append((uid, tag.get('title', 'group'))) + seen.add(uid) + for uid in datastore.data['settings']['application'].get('notification_profiles', []): + if uid not in own and uid not in seen: + result.append((uid, 'system')) + seen.add(uid) + return result + + def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData): edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates") - + def _watch_has_tag_options_set(watch): """This should be fixed better so that Tag is some proper Model, a tag is just a Watch also""" for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items(): @@ -231,6 +249,9 @@ def edit_page(uuid): tag_uuids.append(datastore.add_tag(title=t)) extra_update_obj['tags'] = tag_uuids + # notification_profiles comes from hidden inputs (not a form field), handle separately + extra_update_obj['notification_profiles'] = request.form.getlist('notification_profiles') + datastore.data['watching'][uuid].update(form.data) datastore.data['watching'][uuid].update(extra_update_obj) @@ -335,7 +356,8 @@ def edit_page(uuid): 'extra_processor_config': form.extra_tab_content(), 'extra_title': f" - Edit - {watch.label}", 'form': form, - 'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False, + 'inherited_notification_profiles': _get_inherited_notification_profiles(watch, datastore), + 'notification_registry': __import__('changedetectionio.notification_profiles.registry', fromlist=['registry']).registry, 'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0, 'has_special_tag_options': _watch_has_tag_options_set(watch=watch), 'jq_support': jq_support, diff --git a/changedetectionio/blueprint/ui/notification.py b/changedetectionio/blueprint/ui/notification.py index 3bf1e286e3c..c30e51ab1c7 100644 --- a/changedetectionio/blueprint/ui/notification.py +++ b/changedetectionio/blueprint/ui/notification.py @@ -7,7 +7,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): notification_blueprint = Blueprint('ui_notification', __name__, template_folder="../ui/templates") - + # AJAX endpoint for sending a test @notification_blueprint.route("/notification/send-test/", methods=['POST']) @notification_blueprint.route("/notification/send-test", methods=['POST']) @@ -15,12 +15,10 @@ def construct_blueprint(datastore: ChangeDetectionStore): @login_optionally_required def ajax_callback_send_notification_test(watch_uuid=None): from changedetectionio.notification_service import NotificationContextData, set_basic_notification_vars - # Watch_uuid could be unset in the case it`s used in tag editor, global settings import apprise from changedetectionio.notification.handler import process_notification from changedetectionio.notification.apprise_plugin.assets import apprise_asset from changedetectionio.jinja2_custom import render as jinja_render - from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler apobj = apprise.Apprise(asset=apprise_asset) @@ -38,69 +36,112 @@ def ajax_callback_send_notification_test(watch_uuid=None): return make_response("Error: You must have atleast one watch configured for 'test notification' to work", 400) watch = datastore.data['watching'].get(watch_uuid) - notification_urls = request.form.get('notification_urls','').strip().splitlines() - - if not notification_urls: - logger.debug("Test notification - Trying by group/tag in the edit form if available") - # On an edit page, we should also fire off to the tags if they have notifications - if request.form.get('tags') and request.form['tags'].strip(): - for k in request.form['tags'].split(','): - tag = datastore.tag_exists_by_name(k.strip()) - notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None - - if not notification_urls and not is_global_settings_form and not is_group_settings_form: - # In the global settings, use only what is typed currently in the text box - logger.debug("Test notification - Trying by global system settings notifications") - if datastore.data['settings']['application'].get('notification_urls'): - notification_urls = datastore.data['settings']['application']['notification_urls'] - + notification_urls = [u for u in request.form.get('notification_urls', '').strip().splitlines() if u.strip()] + + # --- Profile-based path: no inline URLs provided, use resolved profiles for the watch --- + if not notification_urls and watch_uuid and not is_global_settings_form and not is_group_settings_form: + from changedetectionio.notification_profiles.resolver import resolve_notification_profiles + if watch: + profiles = resolve_notification_profiles(watch, datastore) + if not profiles: + return make_response('Error: No notification profiles are linked to this watch (check watch, tags, and system settings)', 400) + + prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n" + current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples" + dates = list(watch.history.keys()) + if len(dates) > 1: + prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2]) + current_snapshot = watch.get_history_snapshot(timestamp=dates[-1]) + + errors = [] + sent = 0 + for profile, type_handler in profiles: + n_object = NotificationContextData({'watch_url': watch.get('url', 'https://example.com')}) + n_object.update(set_basic_notification_vars( + current_snapshot=current_snapshot, + prev_snapshot=prev_snapshot, + watch=watch, + triggered_text='', + timestamp_changed=dates[-1] if dates else None, + )) + try: + type_handler.send(profile.get('config', {}), n_object, datastore) + sent += 1 + except Exception as e: + logger.error(f"Test notification profile '{profile.get('name')}' failed: {e}") + errors.append(f"{profile.get('name', '?')}: {e}") + + if errors: + return make_response('; '.join(errors), 400) + return f'OK - Sent test via {sent} profile(s)' + + # --- Legacy path: notification_urls supplied via form (global/group settings test) --- if not notification_urls: - return 'Error: No Notification URLs set/found' - + if is_global_settings_form or is_group_settings_form: + # Try system-level profiles + from changedetectionio.notification_profiles.resolver import resolve_notification_profiles + if watch: + profiles = resolve_notification_profiles(watch, datastore) + if profiles: + prev_snapshot = "Example text: example test\nExample text: change detection is cool\n" + current_snapshot = "Example text: example test\nExample text: change detection is fantastic\n" + dates = list(watch.history.keys()) + if len(dates) > 1: + prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2]) + current_snapshot = watch.get_history_snapshot(timestamp=dates[-1]) + + errors = [] + sent = 0 + for profile, type_handler in profiles: + n_object = NotificationContextData({'watch_url': watch.get('url', 'https://example.com')}) + n_object.update(set_basic_notification_vars( + current_snapshot=current_snapshot, + prev_snapshot=prev_snapshot, + watch=watch, + triggered_text='', + timestamp_changed=dates[-1] if dates else None, + )) + try: + type_handler.send(profile.get('config', {}), n_object, datastore) + sent += 1 + except Exception as e: + errors.append(f"{profile.get('name', '?')}: {e}") + if errors: + return make_response('; '.join(errors), 400) + return f'OK - Sent test via {sent} profile(s)' + + return make_response('Error: No notification profiles or URLs configured', 400) + + # Validate apprise URLs for n_url in notification_urls: - # We are ONLY validating the apprise:// part here, convert all tags to something so as not to break apprise URLs generic_notification_context_data = NotificationContextData() generic_notification_context_data.set_random_for_validation() - n_url = jinja_render(template_str=n_url, **generic_notification_context_data).strip() - if len(n_url.strip()): - if not apobj.add(n_url): - return f'Error: {n_url} is not a valid AppRise URL.' + n_url_rendered = jinja_render(template_str=n_url, **generic_notification_context_data).strip() + if n_url_rendered and not apobj.add(n_url_rendered): + return make_response(f'Error: {n_url} is not a valid AppRise URL.', 400) try: - # use the same as when it is triggered, but then override it with the form test values n_object = NotificationContextData({ 'watch_url': request.form.get('window_url', "https://changedetection.io"), 'notification_urls': notification_urls }) - # Only use if present, if not set in n_object it should use the default system value if 'notification_format' in request.form and request.form['notification_format'].strip(): n_object['notification_format'] = request.form.get('notification_format', '').strip() - else: - n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format') if 'notification_title' in request.form and request.form['notification_title'].strip(): n_object['notification_title'] = request.form.get('notification_title', '').strip() - elif datastore.data['settings']['application'].get('notification_title'): - n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title') else: n_object['notification_title'] = "Test title" if 'notification_body' in request.form and request.form['notification_body'].strip(): n_object['notification_body'] = request.form.get('notification_body', '').strip() - elif datastore.data['settings']['application'].get('notification_body'): - n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body') else: n_object['notification_body'] = "Test body" n_object['as_async'] = False - # Same like in notification service, should be refactored - dates = list(watch.history.keys()) - trigger_text = '' - snapshot_contents = '' - - # Could be called as a 'test notification' with only 1 snapshot available + dates = list(watch.history.keys()) if watch else [] prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n" current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples" @@ -111,22 +152,19 @@ def ajax_callback_send_notification_test(watch_uuid=None): n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot, prev_snapshot=prev_snapshot, watch=watch, - triggered_text=trigger_text, + triggered_text='', timestamp_changed=dates[-1] if dates else None)) - sent_obj = process_notification(n_object, datastore) except Exception as e: logger.error(e) e_str = str(e) - # Remove this text which is not important and floods the container e_str = e_str.replace( "DEBUG - .CustomNotifyPluginWrapper'>", '') - return make_response(e_str, 400) return 'OK - Sent test notifications' - return notification_blueprint \ No newline at end of file + return notification_blueprint diff --git a/changedetectionio/blueprint/ui/templates/edit.html b/changedetectionio/blueprint/ui/templates/edit.html index fa4801bbdb1..c8dddd08712 100644 --- a/changedetectionio/blueprint/ui/templates/edit.html +++ b/changedetectionio/blueprint/ui/templates/edit.html @@ -282,7 +282,7 @@

{{ _('Click here to Start') }}

-
+
{{ render_ternary_field(form.notification_muted, BooleanField=true) }}
{% if capabilities.supports_screenshots %} @@ -293,15 +293,21 @@

{{ _('Click here to Start') }}

{% endif %} -
- {% if has_default_notification_urls %} -
- {{ _('Look out!') }} - {{ _('There are') }} {{ _('system-wide notification URLs enabled') }}, {{ _('this form will override notification settings for this watch only') }} ‐ {{ _('an empty Notification URL list here will still send notifications.') }} +
+ {% from '_notification_profiles_selector.html' import render_notification_profile_selector %} + {{ render_notification_profile_selector( + own_profiles=watch.get('notification_profiles', []), + inherited_profiles=inherited_notification_profiles, + all_profile_data=settings_application.get('notification_profile_data', {}), + registry=notification_registry + ) }} +
+
+ +
+ +
- {% endif %} - {{ _('Use system defaults') }} - {{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html index a12d380b15f..cc971b38d2d 100644 --- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html +++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html @@ -279,9 +279,19 @@ {%- endif -%} {%- endif -%} - {%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%} + {%- set _watch_tags = datastore.get_all_tags_for_watch(watch['uuid']) -%} + {%- for watch_tag_uuid, watch_tag in _watch_tags.items() -%} {{ watch_tag.title }} {%- endfor -%} + {%- for np in get_resolved_notification_profiles(watch) -%} + {%- if np.level == 'direct' -%} + {{ np.name }} + {%- elif np.level == 'group' -%} + {{ np.name }} + {%- else -%} + {{ np.name }} + {%- endif -%} + {%- endfor -%}
diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 2170fe88622..a2a9f5224e0 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -245,6 +245,31 @@ def _get_current_worker_count(): """Get the current number of operational workers""" return worker_pool.get_worker_count() +@app.template_global('get_resolved_notification_profiles') +def _get_resolved_notification_profiles(watch): + """Return list of resolved notification profile info dicts for a watch. + + Each entry: {'name': str, 'level': 'direct'|'group'|'system', 'group_name': str} + Deduplicated by UUID across all levels — same logic as resolve_notification_profiles(). + """ + all_profiles = datastore.data['settings']['application'].get('notification_profile_data', {}) + seen = set() + result = [] + + def _add(uuids, level, group_name=''): + for uid in (uuids or []): + if uid in seen or uid not in all_profiles: + continue + seen.add(uid) + result.append({'name': all_profiles[uid].get('name', ''), 'level': level, 'group_name': group_name}) + + _add(watch.get('notification_profiles', []), 'direct') + for tag in (datastore.get_all_tags_for_watch(uuid=watch.get('uuid')) or {}).values(): + _add(tag.get('notification_profiles', []), 'group', tag.get('title', '')) + _add(datastore.data['settings']['application'].get('notification_profiles', []), 'system') + return result + + @app.template_global('get_worker_status_info') def _get_worker_status_info(): """Get detailed worker status information for display""" @@ -848,6 +873,9 @@ def static_content(group, filename): import changedetectionio.blueprint.tags as tags app.register_blueprint(tags.construct_blueprint(datastore), url_prefix='/tags') + from changedetectionio.blueprint.notification_profiles import construct_blueprint as construct_notification_profiles_blueprint + app.register_blueprint(construct_notification_profiles_blueprint(datastore), url_prefix='/notification-profiles') + import changedetectionio.blueprint.check_proxies as check_proxies app.register_blueprint(check_proxies.construct_blueprint(datastore=datastore), url_prefix='/check_proxy') diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 68b1d553420..7df6e1cb0ef 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -443,20 +443,6 @@ def __call__(self, form, field): # raise ValidationError(message % (field.data, e)) -class ValidateNotificationBodyAndTitleWhenURLisSet(object): - """ - Validates that they entered something in both notification title+body when the URL is set - Due to https://github.com/dgtlmoon/changedetection.io/issues/360 - """ - - def __init__(self, message=None): - self.message = message - - def __call__(self, form, field): - if len(field.data): - if not len(form.notification_title.data) or not len(form.notification_body.data): - message = field.gettext('Notification Body and Title is required when a Notification URL is used') - raise ValidationError(message) class ValidateAppRiseServers(object): """ @@ -736,16 +722,7 @@ class quickWatchForm(Form): class commonSettingsForm(Form): from . import processors - def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs): - super().__init__(formdata, obj, prefix, data, meta, **kwargs) - self.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) - self.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) - self.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) - - notification_body = TextAreaField(_l('Notification Body'), default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()]) - notification_format = SelectField(_l('Notification format'), choices=list(valid_notification_formats.items())) - notification_title = StringField(_l('Notification Title'), default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()]) - notification_urls = StringListField(_l('Notification URL List'), validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()]) + fetch_backend = RadioField(_l('Fetch Method'), choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) processor = RadioField( label=_l("Processor - What do you want to achieve?"), choices=lambda: processors.available_processors(), default=processors.get_default_processor) scheduler_timezone_default = StringField(_l("Default timezone for watch check scheduler"), render_kw={"list": "timezones"}, validators=[validateTimeZoneName()]) webdriver_delay = IntegerField(_l('Wait seconds before extracting text'), validators=[validators.Optional(), validators.NumberRange(min=1, message=_l("Should contain one or more seconds"))]) @@ -1095,12 +1072,6 @@ class globalSettingsForm(Form): # Define these as FormFields/"sub forms", this way it matches the JSON storage # datastore.data['settings']['application'].. # datastore.data['settings']['requests'].. - def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs): - super().__init__(formdata, obj, prefix, data, meta, **kwargs) - self.application.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) - self.application.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) - self.application.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) - requests = FormField(globalSettingsRequestForm) application = FormField(globalSettingsApplicationForm) save_button = SubmitField(_l('Save'), render_kw={"class": "pure-button pure-button-primary"}) diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py index ecaeb8c1a4c..4d5fa672bad 100644 --- a/changedetectionio/model/App.py +++ b/changedetectionio/model/App.py @@ -48,10 +48,9 @@ class model(dict): 'ignore_whitespace': True, 'ignore_status_codes': False, #@todo implement, as ternary. 'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison - 'notification_body': default_notification_body, - 'notification_format': default_notification_format, - 'notification_title': default_notification_title, - 'notification_urls': [], # Apprise URL list + 'notification_profile_data': {}, # uuid → NotificationProfile dict (the actual stored profiles) + 'notification_profiles': [], # System-level linked NotificationProfile UUIDs + 'notification_type_defaults': {}, # type_id → dict of type-specific system defaults 'pager_size': 50, 'password': False, 'render_anchor_tag_content': False, diff --git a/changedetectionio/model/__init__.py b/changedetectionio/model/__init__.py index 3c084d7c140..5de8e959f87 100644 --- a/changedetectionio/model/__init__.py +++ b/changedetectionio/model/__init__.py @@ -208,12 +208,9 @@ def __init__(self, *arg, **kw): 'last_viewed': 0, # history key value of the last viewed via the [diff] link 'method': 'GET', 'notification_alert_count': 0, - 'notification_body': None, - 'notification_format': USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH, 'notification_muted': False, - 'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL - 'notification_title': None, - 'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise) + 'notification_profiles': [], # List of linked NotificationProfile UUIDs + 'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL 'page_title': None, # from the page 'paused': False, 'previous_md5': False, diff --git a/changedetectionio/notification_profiles/registry.py b/changedetectionio/notification_profiles/registry.py index 477b65b201a..2fb932e47c0 100644 --- a/changedetectionio/notification_profiles/registry.py +++ b/changedetectionio/notification_profiles/registry.py @@ -12,13 +12,20 @@ @registry.register class MyProfileType(NotificationProfileType): - type_id = "mytype" - display_name = "My Service" - icon = "bell" - template = "my_plugin/notification_profiles/types/mytype.html" + type_id = "mytype" + display_name = "My Service" + icon = "bell" + template = "my_plugin/notification_profiles/types/mytype.html" + # Optional: declare a WTForms Form class to expose type-wide system defaults in the UI + # defaults_form_class = MyDefaultsForm + # defaults_template = "my_plugin/notification_profiles/type_defaults/mytype.html" def send(self, config: dict, n_object: dict, datastore) -> bool: - requests.post(config['webhook_url'], json={"text": n_object['notification_body']}) + # Use self.get_type_defaults(datastore) to read system-wide defaults + # Use self.resolve(profile_val, system_val, hardcoded_val) for the cascade + system_defaults = self.get_type_defaults(datastore) + body = self.resolve(config.get('body'), system_defaults.get('body'), 'Default body') + requests.post(config['webhook_url'], json={"text": body}) return True """ @@ -26,10 +33,25 @@ def send(self, config: dict, n_object: dict, datastore) -> bool: class NotificationProfileType(ABC): - type_id: str = NotImplemented - display_name: str = NotImplemented - icon: str = "bell" # feather icon name - template: str = NotImplemented # Jinja2 partial rendered in the profile edit form + type_id: str = NotImplemented + display_name: str = NotImplemented + icon: str = "bell" # feather icon name + template: str = NotImplemented # Jinja2 partial rendered in the profile edit form + defaults_form_class: type = None # WTForms Form subclass for type-specific system-wide defaults (None = no defaults UI) + defaults_template: str = None # Optional Jinja2 template for defaults form (falls back to generic) + + def get_type_defaults(self, datastore) -> dict: + """Read this type's system-wide configurable defaults from the datastore.""" + return ( + datastore.data['settings']['application'] + .setdefault('notification_type_defaults', {}) + .get(self.type_id, {}) + ) + + @staticmethod + def resolve(profile_val, system_val, hardcoded_val): + """3-tier cascade: profile config → type system defaults → hardcoded constant.""" + return profile_val or system_val or hardcoded_val @abstractmethod def send(self, config: dict, n_object: dict, datastore) -> bool: @@ -56,10 +78,17 @@ def get_url_hint(self, config: dict) -> str: class AppriseProfileType(NotificationProfileType): """Delivers notifications via Apprise using a raw URL list.""" - type_id = "apprise" - display_name = "Apprise" - icon = "bell" - template = "notification_profiles/types/apprise.html" + type_id = "apprise" + display_name = "Apprise" + icon = "bell" + template = "notification_profiles/types/apprise.html" + defaults_template = "notification_profiles/type_defaults/apprise.html" + + @property + def defaults_form_class(self): + # Imported here to avoid circular imports at module load time + from changedetectionio.blueprint.notification_profiles.forms import AppriseDefaultsForm + return AppriseDefaultsForm def get_apprise_urls(self, config: dict) -> list: return config.get('notification_urls') or [] @@ -67,15 +96,38 @@ def get_apprise_urls(self, config: dict) -> list: def send(self, config: dict, n_object, datastore) -> bool: from changedetectionio.notification.handler import process_notification from changedetectionio.notification_service import NotificationContextData + from changedetectionio.notification import ( + default_notification_body, + default_notification_format, + default_notification_title, + ) urls = self.get_apprise_urls(config) if not urls: return False if not isinstance(n_object, NotificationContextData): n_object = NotificationContextData(n_object) + + system_defaults = self.get_type_defaults(datastore) + + # 4-tier cascade: profile config → type system defaults → pre-set n_object value → hardcoded constants + # n_object may carry a specific alert title/body (e.g. filter-failure, browser-step-failure) + # that is more meaningful than the generic hardcoded default — preserve it as the penultimate fallback. n_object['notification_urls'] = urls - n_object['notification_title'] = config.get('notification_title') or n_object.get('notification_title') - n_object['notification_body'] = config.get('notification_body') or n_object.get('notification_body') - n_object['notification_format'] = config.get('notification_format') or n_object.get('notification_format') + n_object['notification_title'] = self.resolve( + config.get('notification_title'), + system_defaults.get('notification_title'), + n_object.get('notification_title') or default_notification_title, + ) + n_object['notification_body'] = self.resolve( + config.get('notification_body'), + system_defaults.get('notification_body'), + n_object.get('notification_body') or default_notification_body, + ) + n_object['notification_format'] = self.resolve( + config.get('notification_format'), + system_defaults.get('notification_format'), + n_object.get('notification_format') or default_notification_format, + ) process_notification(n_object, datastore) return True diff --git a/changedetectionio/notification_service.py b/changedetectionio/notification_service.py index 3290ff7b548..0757f947e7b 100644 --- a/changedetectionio/notification_service.py +++ b/changedetectionio/notification_service.py @@ -290,13 +290,29 @@ class NotificationService: def __init__(self, datastore, notification_q): self.datastore = datastore self.notification_q = notification_q + + def _log_profile_send(self, profile: dict, watch=None, *, status: str, message: str = ''): + """Write one entry to the per-profile notification log.""" + try: + from changedetectionio.notification_profiles.log import write_profile_log + write_profile_log( + self.datastore.datastore_path, + profile_uuid=profile.get('uuid', ''), + watch_url=watch.get('url', '') if watch else '', + watch_uuid=watch.get('uuid', '') if watch else '', + status=status, + message=message, + ) + except Exception as log_err: + logger.warning(f"Could not write profile log: {log_err}") - def queue_notification_for_watch(self, n_object: NotificationContextData, watch, date_index_from=-2, date_index_to=-1): + def queue_notification_for_watch(self, n_object: NotificationContextData, watch, + profile=None, type_handler=None, + date_index_from=-2, date_index_to=-1): """ - Queue a notification for a watch with full diff rendering and template variables + Build full notification context and either queue it (via type_handler.send) or return it. + profile and type_handler come from resolve_notification_profiles(). """ - from changedetectionio.notification import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH - if not isinstance(n_object, NotificationContextData): raise TypeError(f"Expected NotificationContextData, got {type(n_object)}") @@ -308,17 +324,11 @@ def queue_notification_for_watch(self, n_object: NotificationContextData, watch, dates = list(watch_history.keys()) trigger_text = watch.get('trigger_text', []) - # Add text that was triggered if len(dates): snapshot_contents = watch.get_history_snapshot(timestamp=dates[-1]) else: snapshot_contents = "No snapshot/history available, the watch should fetch atleast once." - # If we ended up here with "System default" - if n_object.get('notification_format') == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH: - n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format') - - triggered_text = '' if len(trigger_text): from . import html_tools @@ -326,7 +336,6 @@ def queue_notification_for_watch(self, n_object: NotificationContextData, watch, if triggered_text: triggered_text = '\n'.join(triggered_text) - # Could be called as a 'test notification' with only 1 snapshot available prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n" current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples" @@ -334,14 +343,19 @@ def queue_notification_for_watch(self, n_object: NotificationContextData, watch, prev_snapshot = watch.get_history_snapshot(timestamp=dates[date_index_from]) current_snapshot = watch.get_history_snapshot(timestamp=dates[date_index_to]) - n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot, prev_snapshot=prev_snapshot, watch=watch, triggered_text=triggered_text, timestamp_changed=dates[date_index_to])) - if self.notification_q: + # Include screenshot if configured on the watch + n_object['notification_screenshot'] = watch.get('notification_screenshot', False) if watch else False + + if type_handler and profile: + logger.debug(f"Sending via profile type_handler {type_handler.type_id}") + type_handler.send(profile.get('config', {}), n_object, self.datastore) + elif self.notification_q: logger.debug("Queued notification for sending") self.notification_q.put(n_object) else: @@ -350,54 +364,83 @@ def queue_notification_for_watch(self, n_object: NotificationContextData, watch, def send_content_changed_notification(self, watch_uuid): """ - Send notification when content changes are detected + Send notification when content changes are detected. + Fires all NotificationProfiles linked to the watch (via watch → tags → system cascade). """ - n_object = NotificationContextData() + from changedetectionio.model.resolver import resolve_setting + from changedetectionio.notification_profiles.resolver import resolve_notification_profiles + watch = self.datastore.data['watching'].get(watch_uuid) if not watch: - return + return False + + # Mute cascade: watch → tag → system + muted = resolve_setting(watch, self.datastore, 'notification_muted', sentinel_values={None}, default=False) + if muted: + return False watch_history = watch.history dates = list(watch_history.keys()) - # Theoretically it's possible that this could be just 1 long, - # - In the case that the timestamp key was not unique if len(dates) == 1: raise ValueError( "History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?" ) - # Should be a better parent getter in the model object - - # Prefer - Individual watch settings > Tag settings > Global settings (in that order) - # this change probably not needed? - n_object['notification_urls'] = _check_cascading_vars(self.datastore, 'notification_urls', watch) - n_object['notification_title'] = _check_cascading_vars(self.datastore,'notification_title', watch) - n_object['notification_body'] = _check_cascading_vars(self.datastore,'notification_body', watch) - n_object['notification_format'] = _check_cascading_vars(self.datastore,'notification_format', watch) + profiles = resolve_notification_profiles(watch, self.datastore) + if not profiles: + return False - # (Individual watch) Only prepare to notify if the rules above matched queued = False - if n_object and n_object.get('notification_urls'): - queued = True - + for profile, type_handler in profiles: + n_object = NotificationContextData() + try: + self.queue_notification_for_watch(n_object=n_object, watch=watch, + profile=profile, type_handler=type_handler) + queued = True + self._log_profile_send(profile, watch, status='ok') + except Exception as e: + err_str = str(e) + logger.error(f"Notification profile '{profile.get('name', profile.get('uuid'))}' failed for watch {watch_uuid}: {e}") + self._log_profile_send(profile, watch, status='error', message=err_str) + self.datastore.update_watch(uuid=watch_uuid, + update_obj={'last_notification_error': "Notification error detected, goto notification log."}) + try: + from changedetectionio.flask_app import notification_debug_log + notification_debug_log += err_str.splitlines() + notification_debug_log[:] = notification_debug_log[-100:] + except Exception: + pass + + if queued: count = watch.get('notification_alert_count', 0) + 1 self.datastore.update_watch(uuid=watch_uuid, update_obj={'notification_alert_count': count}) - self.queue_notification_for_watch(n_object=n_object, watch=watch) - return queued def send_filter_failure_notification(self, watch_uuid): """ - Send notification when CSS/XPath filters fail consecutively + Send notification when CSS/XPath filters fail consecutively. + Fires via the resolved notification profiles for the watch. """ + from changedetectionio.notification_profiles.resolver import resolve_notification_profiles + from changedetectionio.model.resolver import resolve_setting + threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts') watch = self.datastore.data['watching'].get(watch_uuid) if not watch: return + # Mute cascade check + muted = resolve_setting(watch, self.datastore, 'notification_muted', sentinel_values={None}, default=False) + if muted: + return + + profiles = resolve_notification_profiles(watch, self.datastore) + if not profiles: + logger.debug(f"NOT sending filter not found notification for {watch_uuid} - no notification profiles") + return + filter_list = ", ".join(watch['include_filters']) - # @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed body = f"""Hello, Your configured CSS/xPath filters of '{filter_list}' for {{{{watch_url}}}} did not appear on the page after {threshold} attempts. @@ -408,47 +451,55 @@ def send_filter_failure_notification(self, watch_uuid): Thanks - Your omniscient changedetection.io installation. """ - n_object = NotificationContextData({ 'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page', 'notification_body': body, - 'notification_format': _check_cascading_vars(self.datastore, 'notification_format', watch), + 'watch_url': watch['url'], + 'uuid': watch_uuid, + 'watch_uuid': watch_uuid, + 'screenshot': None, }) - n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html') - if len(watch['notification_urls']): - n_object['notification_urls'] = watch['notification_urls'] + # Use the notification_format from the profile config, or fall back to system default + from changedetectionio.notification import default_notification_format + n_object['notification_format'] = default_notification_format + n_object['markup_text_links_to_html_links'] = n_object.get('notification_format', '').startswith('html') - elif len(self.datastore.data['settings']['application']['notification_urls']): - n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls'] + for profile, type_handler in profiles: + try: + type_handler.send(profile.get('config', {}), n_object, self.datastore) + self._log_profile_send(profile, watch, status='ok', message='Filter failure alert') + except Exception as e: + logger.error(f"Filter failure notification via profile '{profile.get('name')}' failed: {e}") + self._log_profile_send(profile, watch, status='error', message=str(e)) - # Only prepare to notify if the rules above matched - if 'notification_urls' in n_object: - n_object.update({ - 'watch_url': watch['url'], - 'uuid': watch_uuid, - 'screenshot': None - }) - self.notification_q.put(n_object) - logger.debug(f"Sent filter not found notification for {watch_uuid}") - else: - logger.debug(f"NOT sending filter not found notification for {watch_uuid} - no notification URLs") + logger.debug(f"Sent filter not found notification for {watch_uuid} via {len(profiles)} profile(s)") def send_step_failure_notification(self, watch_uuid, step_n): """ - Send notification when browser steps fail consecutively + Send notification when browser steps fail consecutively. + Fires via the resolved notification profiles for the watch. """ + from changedetectionio.notification_profiles.resolver import resolve_notification_profiles + from changedetectionio.model.resolver import resolve_setting + watch = self.datastore.data['watching'].get(watch_uuid, False) if not watch: return - threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts') + muted = resolve_setting(watch, self.datastore, 'notification_muted', sentinel_values={None}, default=False) + if muted: + return + + profiles = resolve_notification_profiles(watch, self.datastore) + if not profiles: + return + + threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts') step = step_n + 1 - # @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed - # {{{{ }}}} because this will be Jinja2 {{ }} tokens body = f"""Hello, - + Your configured browser step at position {step} for the web page watch {{{{watch_url}}}} did not appear on the page after {threshold} attempts, did the page change layout? The element may have moved and needs editing, or does it need a delay added? @@ -457,28 +508,26 @@ def send_step_failure_notification(self, watch_uuid, step_n): Thanks - Your omniscient changedetection.io installation. """ - + from changedetectionio.notification import default_notification_format 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': default_notification_format, + 'watch_url': watch['url'], + 'uuid': watch_uuid, + 'watch_uuid': watch_uuid, + 'screenshot': None, }) - n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html') - if len(watch['notification_urls']): - n_object['notification_urls'] = watch['notification_urls'] + for profile, type_handler in profiles: + try: + type_handler.send(profile.get('config', {}), n_object, self.datastore) + self._log_profile_send(profile, watch, status='ok', message='Browser step failure alert') + except Exception as e: + logger.error(f"Step failure notification via profile '{profile.get('name')}' failed: {e}") + self._log_profile_send(profile, watch, status='error', message=str(e)) - elif len(self.datastore.data['settings']['application']['notification_urls']): - n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls'] - - # Only prepare to notify if the rules above matched - if 'notification_urls' in n_object: - n_object.update({ - 'watch_url': watch['url'], - 'uuid': watch_uuid - }) - self.notification_q.put(n_object) - logger.error(f"Sent step not found notification for {watch_uuid}") + logger.error(f"Sent step not found notification for {watch_uuid} via {len(profiles)} profile(s)") # Convenience functions for creating notification service instances diff --git a/changedetectionio/static/js/notifications.js b/changedetectionio/static/js/notifications.js index 98c271b1f14..c7227d9da14 100644 --- a/changedetectionio/static/js/notifications.js +++ b/changedetectionio/static/js/notifications.js @@ -13,40 +13,47 @@ $(document).ready(function () { $('#send-test-notification').click(function (e) { e.preventDefault(); - data = { - notification_urls: $('textarea.notification-urls').val(), - notification_title: $('input.notification-title').val(), - notification_body: $('textarea.notification-body').val(), - notification_format: $('select.notification-format').val(), - tags: $('#tags').val(), + var $btn = $(this); + var $spinner = $('.notifications-wrapper .spinner'); + var $log = $('#notification-test-log'); + + $spinner.fadeIn(); + $log.show(); + $log.find('span').text('Sending...'); + + // Build data: if legacy notification_urls textarea exists (settings/group forms), include them + var data = { window_url: window.location.href, + }; + + var $urlField = $('textarea.notification-urls'); + if ($urlField.length) { + data.notification_urls = $urlField.val(); + data.notification_title = $('input.notification-title').val(); + data.notification_body = $('textarea.notification-body').val(); + data.notification_format = $('select.notification-format').val(); + data.tags = $('#tags').val(); } - $('.notifications-wrapper .spinner').fadeIn(); - $('#notification-test-log').show(); $.ajax({ type: "POST", url: notification_base_url, data: data, statusCode: { 400: function (data) { - $("#notification-test-log>span").text(data.responseText); + $log.find('span').text(data.responseText); }, } }).done(function (data) { - $("#notification-test-log>span").text(data); + $log.find('span').text(data); }).fail(function (jqXHR, textStatus, errorThrown) { - // Handle connection refused or other errors if (textStatus === "error" && errorThrown === "") { - console.error("Connection refused or server unreachable"); - $("#notification-test-log>span").text("Error: Connection refused or server is unreachable."); + $log.find('span').text("Error: Connection refused or server is unreachable."); } else { - console.error("Error:", textStatus, errorThrown); - $("#notification-test-log>span").text("An error occurred: " + textStatus); + $log.find('span').text("An error occurred: " + textStatus); } }).always(function () { - $('.notifications-wrapper .spinner').hide(); - }) + $spinner.hide(); + }); }); }); - diff --git a/changedetectionio/static/styles/scss/parts/_notification_profiles.scss b/changedetectionio/static/styles/scss/parts/_notification_profiles.scss new file mode 100644 index 00000000000..9134557f6af --- /dev/null +++ b/changedetectionio/static/styles/scss/parts/_notification_profiles.scss @@ -0,0 +1,181 @@ +.notification-profile-selector { + position: relative; + + .np-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + min-height: 32px; + padding: 4px 0; + } + + .np-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 3px; + font-size: 0.82em; + line-height: 1.4; + background: var(--color-background-button-tag); + color: var(--color-white); + cursor: default; + max-width: 240px; + + .np-chip-icon svg { + width: 12px; + height: 12px; + flex-shrink: 0; + } + + .np-chip-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.np-chip-own .np-chip-remove { + cursor: pointer; + margin-left: 2px; + opacity: 0.65; + font-size: 1.1em; + line-height: 1; + flex-shrink: 0; + &:hover { opacity: 1; } + } + + &.np-chip-inherited { + opacity: 0.5; + border: 1px dashed var(--color-grey-600); + background: transparent; + color: var(--color-grey-500); + + .np-chip-lock svg { + width: 10px; + height: 10px; + flex-shrink: 0; + } + } + } + + .np-add-wrapper { + position: relative; + display: inline-block; + } + + .np-add-btn { + display: inline-flex; + align-items: center; + gap: 4px; + background: transparent; + border: 1px dashed var(--color-grey-500); + color: var(--color-grey-400); + padding: 2px 8px; + font-size: 0.82em; + cursor: pointer; + border-radius: 3px; + &:hover { + border-color: var(--color-link); + color: var(--color-link); + } + svg { width: 12px; height: 12px; } + } + + .np-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 200; + min-width: 300px; + max-width: 420px; + background: var(--color-background); + border: 1px solid var(--color-border-input); + border-radius: 4px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + overflow: hidden; + + .np-search { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border: none; + border-bottom: 1px solid var(--color-border-input); + outline: none; + font-size: 0.9em; + background: var(--color-background-input); + color: var(--color-text-input); + } + + .np-options { + max-height: 220px; + overflow-y: auto; + } + + .np-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + + &:hover { background: var(--color-grey-900); } + + .np-option-icon svg { width: 14px; height: 14px; flex-shrink: 0; } + + .np-option-text { + display: flex; + flex-direction: column; + gap: 1px; + overflow: hidden; + } + .np-option-name { font-size: 0.88em; font-weight: 600; white-space: nowrap; } + .np-option-hint { font-size: 0.76em; color: var(--color-text-input-description); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + } + + .np-create-new { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--color-border-input); + font-size: 0.88em; + color: var(--color-link); + text-decoration: none; + &:hover { background: var(--color-grey-900); } + svg { width: 13px; height: 13px; } + } + } +} + +// Profile type cards on the edit form +.profile-type-cards { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 6px 0; + + .profile-type-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 16px; + border: 2px solid var(--color-border-input); + border-radius: 6px; + cursor: pointer; + font-size: 0.85em; + color: var(--color-grey-400); + min-width: 80px; + transition: border-color 0.15s, color 0.15s; + + svg { width: 18px; height: 18px; } + + input[type="radio"] { display: none; } + + &.active, &:hover { + border-color: var(--color-link); + color: var(--color-link); + } + } +} diff --git a/changedetectionio/static/styles/scss/styles.scss b/changedetectionio/static/styles/scss/styles.scss index ed7c0ea19ed..6267a3eb946 100644 --- a/changedetectionio/static/styles/scss/styles.scss +++ b/changedetectionio/static/styles/scss/styles.scss @@ -32,6 +32,7 @@ @use "parts/toast"; @use "parts/login_form"; @use "parts/tabs"; +@use "parts/notification_profiles"; // Smooth transitions for theme switching body, @@ -218,6 +219,40 @@ code { font-weight: 900; } +.watch-notif-profile { + @extend .inline-tag; + color: var(--color-white); + background: var(--color-link, #5c6bc0); + opacity: 0.8; + font-size: 0.7em; + cursor: default; + + &.inherited { + opacity: 0.5; + } + + &.system { + background: var(--color-grey-600, #888); + opacity: 0.55; + } +} + +// Per-profile last-result badge in the profiles list +a.notif-last-result { + font-size: 0.82em; + font-weight: bold; + text-decoration: none; + padding: 2px 6px; + border-radius: 3px; + white-space: nowrap; + + &.ok { color: #2a7c2a; background: rgba(42,124,42,0.10); } + &.test { color: #1a6fa8; background: rgba(26,111,168,0.10); } + &.error { color: #c0392b; background: rgba(192,57,43,0.10); } + + &:hover { opacity: 0.75; } +} + .watch-tag-list { color: var(--color-white); background: var(--color-text-watch-tag-list); diff --git a/changedetectionio/static/styles/styles.css b/changedetectionio/static/styles/styles.css index 377ade0fcfc..5b6f8d1e646 100644 --- a/changedetectionio/static/styles/styles.css +++ b/changedetectionio/static/styles/styles.css @@ -1 +1 @@ -:root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100);--highlight-trigger-text-bg-color: #1b98f8;--highlight-ignored-text-bg-color: var(--color-grey-700);--highlight-blocked-text-bg-color: rgb(202, 60, 60)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadow-jump: var(--color-grey-200);--color-icon-github: var(--color-white);--color-icon-github-hover: var(--color-grey-700);--color-watch-table-error: var(--color-light-red);--color-watch-table-row-text: var(--color-grey-800)}html[data-darkmode=true] .icon-spread{filter:hue-rotate(-10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .title-col a[target=_blank]::after,html[data-darkmode=true] .watch-table .current-diff-url::after{filter:invert(0.5) hue-rotate(10deg) brightness(2)}html[data-darkmode=true] .watch-table .status-browsersteps{filter:invert(0.5) hue-rotate(10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .watch-controls .state-off img{opacity:.3}html[data-darkmode=true] .watch-table .watch-controls .state-on img{opacity:1}html[data-darkmode=true] .watch-table .unviewed{color:#fff}html[data-darkmode=true] .watch-table .unviewed.error{color:var(--color-watch-table-error)}.arrow{border:solid #1b98f8;border-width:0 2px 2px 0;display:inline-block;padding:3px}.arrow.right{transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}.arrow.left{transform:rotate(135deg);-webkit-transform:rotate(135deg)}.arrow.up,.arrow.asc{transform:rotate(-135deg);-webkit-transform:rotate(-135deg)}.arrow.down,.arrow.desc{transform:rotate(45deg);-webkit-transform:rotate(45deg)}#browser_steps th{display:none}#browser_steps li{list-style:decimal;padding:5px}#browser_steps li.browser-step-with-error{background-color:#ffd6d6;border-radius:4px}#browser_steps li:not(:first-child):hover{opacity:1}#browser_steps li .control{padding-left:5px;padding-right:5px}#browser_steps li .control a{font-size:70%}#browser_steps li.empty{padding:0px;opacity:.35}#browser_steps li.empty .control{display:none}#browser_steps li:hover{background:#eee}#browser_steps li>label{display:none}@media only screen and (min-width: 760px){#browser-steps .flex-wrapper{display:flex;flex-flow:row;height:70vh;font-size:80%}#browser-steps .flex-wrapper #browser-steps-ui{flex-grow:1;flex-shrink:1;flex-basis:0;background-color:#eee;border-radius:5px}#browser-steps-fieldlist{flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:400px;padding-left:1rem;overflow-y:scroll}#browsersteps-selector-wrapper{height:100% !important}}#browsersteps-selector-wrapper{width:100%;overflow-y:scroll;position:relative;height:80vh}#browsersteps-selector-wrapper>img{position:absolute;max-width:100%}#browsersteps-selector-wrapper>canvas{position:relative;max-width:100%}#browsersteps-selector-wrapper>canvas:hover{cursor:pointer}#browsersteps-selector-wrapper .loader{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);z-index:100;max-width:350px;text-align:center}#browsersteps-selector-wrapper .spinner,#browsersteps-selector-wrapper .spinner:after{width:80px;height:80px;font-size:3px}#browsersteps-selector-wrapper #browsersteps-click-start{color:var(--color-grey-400)}#browsersteps-selector-wrapper #browsersteps-click-start:hover{cursor:pointer}ul#requests-extra_proxies{list-style:none}ul#requests-extra_proxies li>label{display:none}ul#requests-extra_proxies table tr{display:table-row}ul#requests-extra_proxies table tr input[type=text]{width:100%}@media only screen and (min-width: 1024px){ul#requests-extra_proxies table tr{display:inline}}#request label[for=proxy]{display:inline-block}body.proxy-check-active #request .proxy-check-details{font-size:80%;color:#555;display:block;padding-left:2em;max-width:500px}body.proxy-check-active #request .proxy-timing{font-size:80%;padding-left:1rem;color:var(--color-link)}#recommended-proxy{display:grid;gap:2rem;padding-bottom:1em}@media(min-width: 991px){#recommended-proxy{grid-template-columns:repeat(2, 1fr)}}#recommended-proxy>div{border:1px #aaa solid;border-radius:4px;padding:1em}#extra-proxies-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}ul#requests-extra_browsers{list-style:none}ul#requests-extra_browsers li>label{display:none}ul#requests-extra_browsers table tr{display:table-row}ul#requests-extra_browsers table tr input[type=text]{width:100%}@media only screen and (min-width: 1280px){ul#requests-extra_browsers table tr{display:inline}ul#requests-extra_browsers table tr input[type=text]{width:100%}}#extra-browsers-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}.pagination-page-info{text-transform:capitalize}.pagination.menu>*{display:inline-block}.pagination.menu li{display:inline-block}.pagination.menu a{padding:.65rem;margin:3px;border:none;background:#444;border-radius:2px;color:var(--color-text-button)}.pagination.menu a.disabled{display:none}.pagination.menu a.active{font-weight:bold;background:#888}.pagination.menu a:hover{background:#999}.spinner,.spinner:after{border-radius:50%;width:10px;height:10px}.spinner{margin:0px auto;font-size:3px;vertical-align:middle;display:inline-block;text-indent:-9999em;border-top:1.1em solid rgba(38,104,237,.2);border-right:1.1em solid rgba(38,104,237,.2);border-bottom:1.1em solid rgba(38,104,237,.2);border-left:1.1em solid #2668ed;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:load8 1.1s infinite linear;animation:load8 1.1s infinite linear}@-webkit-keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.toggle-light-mode .icon-dark{display:none}html[data-darkmode=true] .toggle-light-mode .icon-light{display:none}html[data-darkmode=true] .toggle-light-mode .icon-dark{display:block}.pure-menu-link{padding:.5rem 1em;line-height:1.2rem}#menu-mute,#menu-pause{padding-left:.3rem;padding-right:.3rem}#menu-mute img,#menu-pause img{height:1.2rem}.pure-menu-item svg{height:1.2rem}.pure-menu-item *{vertical-align:middle}.pure-menu-item .github-link{height:1.8rem;display:block}.pure-menu-item .github-link svg{height:100%}.pure-menu-item .bi-heart:hover{cursor:pointer}.pure-menu-item.active .pure-menu-link{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}#cdio-logo{padding-left:.5em}#inline-menu-extras-group>*{display:inline-block}#overlay{opacity:.95;position:fixed;width:350px;max-width:100%;height:100%;top:0;right:-350px;background-color:var(--color-table-stripe);z-index:2;transform:translateX(0);transition:transform .5s ease}#overlay.visible{transform:translateX(-100%)}#overlay .content{font-size:.875rem;padding:1rem;margin-top:5rem;max-width:400px;color:var(--color-watch-table-row-text)}#heartpath{transition:all ease .3s !important}#heartpath:hover{fill:red !important;transition:all ease .3s !important}.minitabs-wrapper{width:100%}.minitabs-wrapper>div[id]{padding:20px;border:1px solid #ccc;border-top:none}.minitabs-wrapper .minitabs-content{width:100%;display:flex}.minitabs-wrapper .minitabs-content>div{flex:1 1 auto;min-width:0;overflow:scroll}.minitabs-wrapper .minitabs{display:flex;border-bottom:1px solid #ccc}.minitabs-wrapper .minitab{flex:1;text-align:center;padding:12px 0;text-decoration:none;color:#333;background-color:#f1f1f1;border:1px solid #ccc;border-bottom:none;cursor:pointer;transition:background-color .3s}.minitabs-wrapper .minitab:hover{background-color:#ddd}.minitabs-wrapper .minitab.active{background-color:#fff;font-weight:bold}@media(min-width: 800px){body.preview-text-enabled #filters-and-triggers>div{display:flex;gap:20px;position:relative}}body.preview-text-enabled #edit-text-filter,body.preview-text-enabled #text-preview{flex:1;align-self:flex-start}body.preview-text-enabled #edit-text-filter #pro-tips{display:none}body.preview-text-enabled #text-preview{position:sticky;top:20px;padding-top:1rem;padding-bottom:1rem;display:block !important}body.preview-text-enabled #activate-text-preview{background-color:var(--color-grey-500)}body.preview-text-enabled .monospace-preview{background:var(--color-background-input);border:1px solid var(--color-grey-600);padding:1rem;color:var(--color-text-input);font-family:"Courier New",Courier,monospace;font-size:70%;word-break:break-word;white-space:pre-wrap}#activate-text-preview{right:0;position:absolute;z-index:3;box-shadow:1px 1px 4px var(--color-shadow-jump)}#stats_row{display:flex;align-items:center;width:100%;color:#fff;font-size:.85rem}#stats_row>*{padding-bottom:.5rem}#stats_row .left{text-align:left}#stats_row .right{opacity:.5;transition:opacity .6s ease;margin-left:auto;text-align:right}body.has-queue #stats_row .right{opacity:1}.watch-table{width:100%;font-size:80%}.watch-table tr{color:var(--color-watch-table-row-text)}.watch-table tr.unviewed{font-weight:bold}.watch-table td{white-space:nowrap}.watch-table td.title-col{word-break:break-all;white-space:normal}.watch-table td a.external::after{content:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);margin:0 3px 0 5px}.watch-table th{white-space:nowrap}.watch-table th a{font-weight:normal}.watch-table th a.active{font-weight:bolder}.watch-table th a.inactive .arrow{display:none}.watch-table tr.checking-now td:first-child{position:relative}.watch-table tr.checking-now td:first-child::before{content:"";position:absolute;top:0;bottom:0;left:0;width:3px;background-color:#293eff}.watch-table tr.checking-now td.last-checked .spinner-wrapper{display:inline-block !important}.watch-table tr.checking-now td.last-checked .innertext{display:none !important}.watch-table tr.queued a.recheck{display:none !important}.watch-table tr.queued a.already-in-queue-button{display:inline-block !important}.watch-table tr.paused a.pause-toggle.state-on{display:inline !important}.watch-table tr.paused a.pause-toggle.state-off{display:none !important}.watch-table tr.notification_muted a.mute-toggle.state-on{display:inline !important}.watch-table tr.notification_muted a.mute-toggle.state-off{display:none !important}.watch-table tr.has-error{color:var(--color-watch-table-error)}.watch-table tr.has-error .error-text{display:block !important}.watch-table tr.single-history a.preview-link{display:inline-block !important}.watch-table tr.multiple-history a.history-link{display:inline-block !important}#watch-table-wrapper #post-list-buttons{text-align:right;padding:0px;margin:0px}#watch-table-wrapper #post-list-buttons li{display:inline-block}#watch-table-wrapper #post-list-buttons a{border-top-left-radius:initial;border-top-right-radius:initial;border-bottom-left-radius:5px;border-bottom-right-radius:5px}#watch-table-wrapper.has-error #post-list-buttons #post-list-with-errors{display:inline-block !important}#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-mark-views,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread{display:inline-block !important}@media(max-width: 767px){.watch-table thead{display:block}.watch-table thead tr th{display:inline-block}}@media(max-width: 767px)and (max-width: 768px){.watch-table thead tr th .hide-on-mobile{display:none}}@media(max-width: 767px){.watch-table thead .empty-cell{display:none}.watch-table .last-checked{margin-left:calc(20px + .5rem)}.watch-table .last-checked>span{vertical-align:middle}.watch-table .last-changed{margin-left:calc(20px + .5rem)}.watch-table .last-checked::before{color:var(--color-text);content:"Last Checked "}.watch-table .last-changed::before{color:var(--color-text);content:"Last Changed "}.watch-table td.inline{display:inline-block}.watch-table .pure-table td,.watch-table .pure-table th{border:none}.watch-table td{border:none;border-bottom:1px solid var(--color-border-watch-table-cell);vertical-align:middle}.watch-table td:before{top:6px;left:6px;width:45%;padding-right:10px;white-space:nowrap}.watch-table.pure-table-striped tr{background-color:var(--color-table-background)}.watch-table.pure-table-striped tr:nth-child(2n-1){background-color:var(--color-table-stripe)}.watch-table.pure-table-striped tr:nth-child(2n-1) td{background-color:inherit}}@media(max-width: 767px){.watch-table tbody tr{padding-bottom:10px;padding-top:10px;display:grid;grid-template-columns:20px 1fr 100px;grid-template-rows:auto auto auto auto;gap:.5rem}.watch-table tbody tr .counter-i{display:none}.watch-table tbody tr td.checkbox-uuid{display:grid;place-items:center}.watch-table tbody tr>td{border-bottom:none}.watch-table tbody tr>td[colspan]{grid-column:1/-1}.watch-table tbody tr>td.title-col{grid-column:1/-1;grid-row:1}.watch-table tbody tr>td.title-col .watch-title{font-size:.92rem}.watch-table tbody tr>td.title-col .link-spread{display:none}.watch-table tbody tr>td.last-checked{grid-column:1/-1;grid-row:2}.watch-table tbody tr>td.last-changed{grid-column:1/-1;grid-row:3}.watch-table tbody tr>td.checkbox-uuid{grid-column:1;grid-row:4}.watch-table tbody tr>td.buttons{grid-column:2;grid-row:4;display:flex;align-items:center;justify-content:flex-start}.watch-table tbody tr>td.watch-controls{grid-column:3;grid-row:4;display:grid;place-items:center}.watch-table tbody tr>td.watch-controls a img{padding:10px}.pure-table td{padding:3px !important}}ul#conditions_match_logic{list-style:none}ul#conditions_match_logic input,ul#conditions_match_logic label,ul#conditions_match_logic li{display:inline-block}ul#conditions_match_logic li{padding-right:1em}.fieldlist_formfields{width:100%;background-color:var(--color-background, #fff);border-radius:4px;border:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header{display:flex;background-color:var(--color-background-table-thead, #e0e0e0);font-weight:bold;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header-cell{flex:1;padding:.5em 1em;text-align:left}.fieldlist_formfields .fieldlist-header-cell:last-child{flex:0 0 120px}.fieldlist_formfields .fieldlist-body{display:flex;flex-direction:column}.fieldlist_formfields .fieldlist-row{display:flex;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-row:last-child{border-bottom:none}.fieldlist_formfields .fieldlist-row:nth-child(2n-1){background-color:var(--color-table-stripe, #f2f2f2)}.fieldlist_formfields .fieldlist-row.error-row{background-color:var(--color-error-input, #ffdddd)}.fieldlist_formfields .fieldlist-cell{flex:1;padding:.5em 1em;display:flex;flex-direction:column;justify-content:center}.fieldlist_formfields .fieldlist-cell input,.fieldlist_formfields .fieldlist-cell select{width:100%}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:0 0 120px;display:flex;flex-direction:row;align-items:center;gap:4px}.fieldlist_formfields ul.errors{margin-top:.5em;margin-bottom:0;padding:.5em;background-color:var(--color-error-background-snapshot-age, #ffdddd);border-radius:4px;list-style-position:inside}@media only screen and (max-width: 760px){.fieldlist_formfields .fieldlist-header,.fieldlist_formfields .fieldlist-row{flex-direction:column}.fieldlist_formfields .fieldlist-header-cell{display:none}.fieldlist_formfields .fieldlist-row{padding:.5em 0;border-bottom:2px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-cell{padding:.25em .5em}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:1;justify-content:flex-start;padding-top:.5em}.fieldlist_formfields .fieldlist-cell:not(:last-child){margin-bottom:.5em}.fieldlist_formfields .fieldlist-cell::before{content:attr(data-label);font-weight:bold;margin-bottom:.25em}}.fieldlist_formfields .addRuleRow,.fieldlist_formfields .removeRuleRow,.fieldlist_formfields .verifyRuleRow{cursor:pointer;border:none;padding:4px 8px;border-radius:3px;font-weight:bold;background-color:#aaa;color:var(--color-foreground-text, #fff)}.fieldlist_formfields .addRuleRow:hover,.fieldlist_formfields .removeRuleRow:hover,.fieldlist_formfields .verifyRuleRow:hover{background-color:#999}.watch-table.favicon-not-enabled tr .favicon{display:none}.watch-table tr td.inline.title-col .flex-wrapper{display:flex;align-items:center;gap:4px}.watch-table td,.watch-table th{vertical-align:middle}.watch-table tr.has-favicon.unviewed img.favicon{opacity:1 !important}.watch-table .status-icons{white-space:nowrap;display:flex;align-items:center;gap:4px}.watch-table .status-icons>*{vertical-align:middle}.title-col{padding:10px}.title-wrapper{display:flex;align-items:center;gap:10px}.title-col-inner{display:inline-block;vertical-align:middle}.watch-table img.favicon{vertical-align:middle;max-width:25px;max-height:25px;height:25px;padding-right:4px}body.checking-now #checking-now-fixed-tab{display:block !important}#checking-now-fixed-tab{background:#ccc;border-radius:5px;bottom:0;color:var(--color-text);display:none;font-size:.8rem;left:0;padding:5px;position:fixed}#selector-wrapper{height:100%;text-align:center;max-height:70vh;overflow-y:scroll;position:relative}#selector-wrapper>img{position:absolute;z-index:4;max-width:100%}#selector-wrapper>canvas{position:relative;z-index:5;max-width:100%}#selector-wrapper>canvas:hover{cursor:pointer}#selector-current-xpath{font-size:80%}.ternary-radio-group{display:flex;gap:0;border:1px solid var(--color-grey-750);border-radius:4px;overflow:hidden;width:fit-content;background:var(--color-background)}.ternary-radio-group .ternary-radio-option{position:relative;cursor:pointer;margin:0;display:flex;align-items:center}.ternary-radio-group .ternary-radio-option input[type=radio]{position:absolute;opacity:0;width:0;height:0}.ternary-radio-group .ternary-radio-option .ternary-radio-label{padding:8px 16px;background:var(--color-grey-900);border:none;border-right:1px solid var(--color-grey-750);font-size:13px;font-weight:500;color:var(--color-text);transition:all .2s ease;cursor:pointer;display:block;text-align:center}.ternary-radio-group .ternary-radio-option:last-child .ternary-radio-label{border-right:none}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button);font-weight:600}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600);color:var(--color-text-button)}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}.ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-800)}@media(max-width: 480px){.ternary-radio-group{width:100%}.ternary-radio-group .ternary-radio-label{flex:1;min-width:auto}}input[type=radio].pure-radio:checked+label,input[type=radio].pure-radio:checked{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option .ternary-radio-label{background:var(--color-grey-350)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-400)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}body.processor-image_ssim_diff #edit-text-filter .text-filtering{display:none}body.processor-image_ssim_diff #conditions-tab{display:none}.modal-dialog{border:none;border-radius:10px;padding:0;background:var(--color-background);color:var(--color-text);box-shadow:0 5px 20px rgba(0,0,0,.3);max-width:500px;width:90%}.modal-dialog::backdrop{background:rgba(0,0,0,.6);backdrop-filter:blur(3px);animation:fadeIn .2s ease-out}.modal-dialog[open]{animation:slideIn .25s ease-out}.modal-dialog .modal-header{padding:1.5rem;border-bottom:1px solid var(--color-border-table-cell);display:flex;align-items:center;gap:1rem}.modal-dialog .modal-header .modal-icon{font-size:2rem;line-height:1;flex-shrink:0}.modal-dialog .modal-header .modal-icon.warning{color:var(--color-warning)}.modal-dialog .modal-header .modal-icon.danger{color:var(--color-background-button-error)}.modal-dialog .modal-header .modal-icon.info{color:var(--color-background-button-primary)}.modal-dialog .modal-header .modal-title{font-size:1.3rem;font-weight:bold;margin:0;color:var(--color-text)}.modal-dialog .modal-body{padding:1.5rem;line-height:1.6}.modal-dialog .modal-body p{margin:0 0 1rem 0}.modal-dialog .modal-body p:last-child{margin-bottom:0}.modal-dialog .modal-body strong{color:var(--color-text);font-weight:600}.modal-dialog .modal-footer{padding:1rem 1.5rem;border-top:1px solid var(--color-border-table-cell);display:flex;gap:.75rem;justify-content:flex-end;background:var(--color-grey-900)}.modal-dialog .modal-footer button{padding:.6rem 1.5rem;border:none;border-radius:4px;cursor:pointer;font-weight:500;transition:all .2s ease;font-size:.95rem}.modal-dialog .modal-footer button:hover{transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,.15)}.modal-dialog .modal-footer button:active{transform:translateY(0)}.modal-dialog .modal-footer button.modal-btn-cancel{background:var(--color-background-button-cancel);color:var(--color-grey-200)}.modal-dialog .modal-footer button.modal-btn-cancel:hover{background:var(--color-grey-700)}.modal-dialog .modal-footer button.modal-btn-confirm{background:var(--color-background-button-primary);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-confirm:hover{opacity:.9}.modal-dialog .modal-footer button.modal-btn-danger{background:var(--color-background-button-error);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-danger:hover{background:var(--color-dark-red)}.modal-dialog .modal-footer button.modal-btn-warning{background:var(--color-background-button-warning);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-warning:hover{opacity:.9}html[data-darkmode=true] .modal-dialog{box-shadow:0 5px 30px rgba(0,0,0,.7)}html[data-darkmode=true] .modal-dialog .modal-footer{background:var(--color-grey-200)}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideIn{from{opacity:0;transform:translateY(-20px) scale(0.95)}to{opacity:1;transform:translateY(0) scale(1)}}@media only screen and (max-width: 760px){.modal-dialog{width:95%;max-width:none}.modal-dialog .modal-header{padding:1rem}.modal-dialog .modal-header .modal-title{font-size:1.1rem}.modal-dialog .modal-body{padding:1rem;font-size:.95rem}.modal-dialog .modal-footer{padding:.75rem 1rem;flex-wrap:wrap}.modal-dialog .modal-footer button{flex:1;min-width:120px}}#language-selector-flag{display:inline-block;width:1.2em;height:1.2em;vertical-align:middle;border-radius:50%;overflow:hidden;opacity:.6}#language-selector-flag:hover{opacity:1}.language-list{display:flex;flex-direction:column;gap:.5rem;padding:.5rem 0}.language-option{display:flex;align-items:center;gap:1rem;padding:.25rem;border-radius:4px;transition:background-color .2s ease;text-decoration:none;color:var(--color-text);border:1px solid rgba(0,0,0,0)}.language-option:hover{background-color:var(--color-background-menu-link-hover);border-color:var(--color-border-table-cell)}.language-option.active{background-color:var(--color-link);color:var(--color-text-button);font-weight:600}.language-option .flag{font-size:1.5rem;flex-shrink:0}.language-option .language-name{flex-grow:1;font-size:1rem}#language-modal .language-list .lang-option{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;margin-right:.5em;border-radius:50%;overflow:hidden}.content-wrapper{display:flex;gap:0;width:100%;max-width:100%;position:relative}@media only screen and (max-width: 900px){.content-wrapper{flex-direction:column}}.action-sidebar{position:sticky;top:100px;flex-shrink:0;width:80px;height:fit-content;background:rgba(0,0,0,0);padding:1.5rem 0;display:flex;flex-direction:column;gap:.5rem;align-items:center;z-index:0}@media only screen and (max-width: 900px){.action-sidebar{position:relative;top:0;width:100%;flex-direction:row;justify-content:space-around;padding:0;overflow-x:auto}}.action-sidebar-item{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;padding:.75rem .5rem;min-width:64px;text-decoration:none;opacity:.8;transition:opacity .2s ease}.action-sidebar-item:hover{opacity:1}.action-sidebar-item.active{opacity:1}.action-sidebar-item.active .action-icon{stroke:#fff;stroke-width:2.5}.action-sidebar-item.active .action-label{color:#fff;font-weight:700}.action-icon{width:28px;height:28px;stroke:#fff;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s ease}.action-label{font-size:.65rem;font-weight:500;text-align:center;line-height:1.1;letter-spacing:.02em;text-transform:uppercase;color:#fff;transition:color .2s ease;max-width:60px;word-wrap:break-word}.content-main{flex:0 1 auto;width:100%;min-width:0;padding:0;display:flex;flex-direction:column;align-items:center}.hamburger-menu{display:none;background:rgba(0,0,0,0);border:none;cursor:pointer;padding:.5rem;z-index:10001;position:relative}@media only screen and (max-width: 980px){.hamburger-menu{display:flex;flex-direction:column;justify-content:center;align-items:center}}.hamburger-icon{width:24px;height:20px;position:relative;display:flex;flex-direction:column;justify-content:space-between}.hamburger-icon span{display:block;height:3px;width:100%;background:var(--color-text);border-radius:2px;transition:all .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);transform-origin:center}.hamburger-menu.active .hamburger-icon span:nth-child(1){transform:translateY(8.5px) rotate(45deg)}.hamburger-menu.active .hamburger-icon span:nth-child(2){opacity:0;transform:translateX(-10px)}.hamburger-menu.active .hamburger-icon span:nth-child(3){transform:translateY(-8.5px) rotate(-45deg)}.mobile-menu-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:9999;opacity:0;transition:opacity .3s ease}.mobile-menu-overlay.active{display:block;opacity:1}.mobile-menu-drawer{position:fixed;top:0;right:-280px;width:280px;height:100%;background:var(--color-background);opacity:1;box-shadow:-2px 0 8px rgba(0,0,0,.15);z-index:10000;transition:right .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);overflow-y:auto;padding-top:60px}.mobile-menu-drawer.active{right:0}.mobile-menu-drawer .mobile-menu-items{list-style:none;padding:1rem 0;margin:0}.mobile-menu-drawer .mobile-menu-items li{border-bottom:1px solid var(--color-border-table-cell)}.mobile-menu-drawer .mobile-menu-items li>*{display:block;padding:1rem 1.5rem;color:var(--color-text);text-decoration:none;font-weight:500;transition:background .2s ease}.mobile-menu-drawer .mobile-menu-items li>*:hover{background:var(--color-background-menu-link-hover)}.mobile-menu-drawer .mobile-menu-items li#menu-pause,.mobile-menu-drawer .mobile-menu-items li#menu-mute{display:none}.logo-cdio{font-weight:bold;font-size:1.1rem}.logo-cdio .logo-cd{color:var(--color-grey-500)}.logo-cdio .logo-io{color:var(--color-text)}.menu-always-visible{display:flex;align-items:center;gap:.5rem;margin-left:auto}@media only screen and (max-width: 980px){#top-right-menu .menu-collapsible{display:none !important}.pure-menu-horizontal{overflow-x:visible !important}#nav-menu{overflow-x:visible !important}}@media only screen and (min-width: 1025px){.hamburger-menu,.mobile-menu-drawer,.mobile-menu-overlay{display:none !important}}html[data-darkmode=true] .mobile-menu-drawer{box-shadow:-2px 0 8px rgba(0,0,0,.4)}#search-modal .modal-body{padding:2rem 1.5rem}#search-modal .modal-body .pure-control-group{padding-bottom:0}#search-modal .modal-body .pure-control-group label{display:block;margin-bottom:.5rem;font-size:.9rem;font-weight:600;color:var(--color-text)}#search-modal .modal-body .pure-control-group #search-modal-input{width:100%;max-width:100%;box-sizing:border-box;padding:.6rem .8rem;font-size:1rem;border:1px solid var(--color-border-input);border-radius:4px;background-color:var(--color-background-input);color:var(--color-text-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);transition:border-color .2s ease,box-shadow .2s ease}#search-modal .modal-body .pure-control-group #search-modal-input:focus{outline:none;border-color:var(--color-link);box-shadow:0 0 0 3px rgba(27,152,248,.1)}#search-modal .modal-body .pure-control-group #search-modal-input::placeholder{color:var(--color-text-input-placeholder);opacity:.7}html[data-darkmode=true] #search-modal #search-modal-input:focus{box-shadow:0 0 0 3px rgba(89,189,251,.15)}.action-sidebar-item{position:relative}.action-sidebar-item .notification-bubble{position:absolute;top:8px;left:8px;min-width:18px;height:18px;background:#f44;color:#fff;font-size:10px;font-weight:700;line-height:18px;text-align:center;border-radius:9px;padding:0 2px;box-shadow:0 2px 4px rgba(0,0,0,.3);pointer-events:none;transition:all .2s ease;display:none}.action-sidebar-item .notification-bubble.red-bubble{background:#f44}.action-sidebar-item .notification-bubble.blue-bubble{background:#4a9eff;color:#fff}.action-sidebar-item .notification-bubble.visible{display:block}.action-sidebar-item .notification-bubble.pulse{animation:bubblePulse .4s ease-out}.action-sidebar-item .notification-bubble.large-number{font-size:8px;min-width:20px;height:20px;line-height:20px;border-radius:10px}@keyframes bubblePulse{0%{transform:scale(1)}50%{transform:scale(1.3)}100%{transform:scale(1)}}html[data-darkmode=true] .notification-bubble{box-shadow:0 2px 6px rgba(0,0,0,.6)}.toast-container{position:fixed;display:flex;flex-direction:column;gap:.75rem;pointer-events:none;z-index:10000}.toast-container.toast-top-right{top:20px;right:20px}.toast-container.toast-top-center{top:100px;left:50%;transform:translateX(-50%)}.toast-container.toast-top-left{top:20px;left:20px}.toast-container.toast-bottom-right{bottom:20px;right:20px}.toast-container.toast-bottom-center{bottom:20px;left:50%;transform:translateX(-50%)}.toast-container.toast-bottom-left{bottom:20px;left:20px}.toast{position:relative;display:flex;align-items:center;gap:.75rem;min-width:300px;max-width:500px;padding:1rem 1.25rem;background:var(--color-background);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15),0 0 0 1px rgba(0,0,0,.05);pointer-events:auto;overflow:hidden;opacity:0;transform:translateY(-50px);transition:all .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);font-family:inherit}.toast.toast-show{opacity:1;transform:translateY(0)}.toast.toast-hide{opacity:0;transform:translateY(-50px) scale(0.95)}.toast.toast-success{border-left:4px solid #10b981}.toast.toast-success .toast-icon{color:#10b981}.toast.toast-error{border-left:4px solid #ef4444}.toast.toast-error .toast-icon{color:#ef4444}.toast.toast-warning{border-left:4px solid #f59e0b}.toast.toast-warning .toast-icon{color:#f59e0b}.toast.toast-info{border-left:4px solid #3b82f6}.toast.toast-info .toast-icon{color:#3b82f6}.toast.toast-default{border-left:4px solid var(--color-grey-500)}.toast-icon{flex-shrink:0;width:24px;height:24px}.toast-icon svg{width:100%;height:100%}.toast-message{flex:1;font-size:.875rem;line-height:1.5;color:var(--color-text);word-break:break-word;font-family:inherit}.toast-close{flex-shrink:0;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0);border:none;border-radius:4px;color:var(--color-grey-500);font-size:1.5rem;line-height:1;cursor:pointer;transition:all .2s ease;padding:0;margin-left:.25rem}.toast-close:hover{background:var(--color-grey-800);color:var(--color-text)}.toast-close:active{transform:scale(0.95)}.toast-progress{position:absolute;bottom:0;left:0;right:0;height:3px;background:currentColor;opacity:.3;transform-origin:left;transition:transform linear}html[data-darkmode=true] .toast{background:var(--color-grey-300);box-shadow:0 4px 12px rgba(0,0,0,.4),0 0 0 1px hsla(0,0%,100%,.05)}html[data-darkmode=true] .toast-close:hover{background:var(--color-grey-400)}@media only screen and (max-width: 768px){.toast-container{left:50% !important;right:auto !important;top:80px !important;transform:translateX(-50%) !important;align-items:center}.toast-container.toast-bottom-right,.toast-container.toast-bottom-center,.toast-container.toast-bottom-left{top:auto !important;bottom:80px !important}.toast{min-width:auto;max-width:none;width:80vw;transform:translateY(-100px)}.toast.toast-show{transform:translateY(0)}.toast.toast-hide{transform:translateY(-100px) scale(0.95)}}@media(prefers-reduced-motion: reduce){.toast{transition:opacity .2s ease;transform:none !important}.toast.toast-show{opacity:1}.toast.toast-hide{opacity:0}}.login-form{min-height:52vh;display:flex;align-items:center;justify-content:center;padding:2rem 1rem}.login-form .inner{background:var(--color-background);border-radius:16px;box-shadow:0 10px 40px rgba(0,0,0,.08),0 2px 8px rgba(0,0,0,.04);padding:3rem 2.5rem;width:100%;max-width:420px;position:relative;overflow:hidden;transition:transform .3s ease,box-shadow .3s ease}.login-form .inner:hover{box-shadow:0 15px 50px rgba(0,0,0,.12),0 5px 15px rgba(0,0,0,.06)}.login-form form{margin:0}.login-form fieldset{border:none;padding:0;margin:0}.login-form .pure-control-group{margin-bottom:1.75rem}.login-form .pure-control-group:last-of-type{margin-bottom:0;margin-top:2rem}.login-form label{display:block;margin-bottom:.5rem;font-weight:600;font-size:.9rem;color:var(--color-text);letter-spacing:.01em}.login-form input[type=password]{width:100%;padding:.875rem 1rem;border:2px solid var(--color-grey-800);border-radius:8px;font-size:1rem;background:var(--color-background-input);color:var(--color-text-input);transition:all .2s ease;box-sizing:border-box}.login-form input[type=password]:focus{outline:none;border-color:var(--color-link);box-shadow:0 0 0 3px rgba(27,152,248,.1);transform:translateY(-1px)}.login-form input[type=password]::placeholder{color:var(--color-text-input-placeholder)}.login-form button[type=submit]{width:100%;padding:.875rem 1.5rem;font-size:1rem;font-weight:600;border-radius:8px;border:none;background:var(--color-background-button-primary);color:var(--color-text-button);cursor:pointer;transition:all .2s ease;box-shadow:0 2px 8px rgba(27,152,248,.2)}.login-form button[type=submit]:hover{box-shadow:0 4px 12px rgba(27,152,248,.3);background:#06c}.login-form button[type=submit]:active{transform:translateY(0);box-shadow:0 2px 4px rgba(27,152,248,.2)}.content-main>ul.messages{position:fixed;top:120px;left:50%;transform:translateX(-50%);list-style:none;padding:0;margin:0;z-index:1000;min-width:300px;max-width:500px}.content-main>ul.messages li{padding:1rem 1.25rem;border-radius:8px;font-size:.95rem;line-height:1.5;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,.15);animation:slideDown .3s ease-out;border:2px solid rgba(0,0,0,0)}.content-main>ul.messages li.error{background:#fee;border:2px solid #ef4444;color:#991b1b;font-weight:600}.content-main>ul.messages li.success{background:#f0fdf4;border:2px solid #10b981;color:#166534}.content-main>ul.messages li.info,.content-main>ul.messages li.message{background:#eff6ff;border:2px solid #3b82f6;color:#1e40af}@keyframes slideDown{from{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}html[data-darkmode=true] .login-form .inner{box-shadow:0 10px 40px rgba(0,0,0,.4),0 2px 8px rgba(0,0,0,.2)}html[data-darkmode=true] .login-form .inner:hover{box-shadow:0 15px 50px rgba(0,0,0,.5),0 5px 15px rgba(0,0,0,.3)}html[data-darkmode=true] .login-form input[type=password]{border-color:var(--color-grey-400)}html[data-darkmode=true] .login-form input[type=password]:focus{border-color:var(--color-link)}html[data-darkmode=true] .content-main>ul.messages li{box-shadow:0 4px 12px rgba(0,0,0,.4)}html[data-darkmode=true] .content-main>ul.messages li.error{background:#4a1d1d;border-color:#ef4444;color:#fca5a5}html[data-darkmode=true] .content-main>ul.messages li.success{background:#1a3a2a;border-color:#10b981;color:#86efac}html[data-darkmode=true] .content-main>ul.messages li.info,html[data-darkmode=true] .content-main>ul.messages li.message{background:#1e3a5f;border-color:#3b82f6;color:#93c5fd}@media only screen and (max-width: 768px){.login-form{min-height:auto;padding:1rem .5rem;padding-top:5rem}.login-form .inner{padding:2rem 1.5rem;border-radius:12px}.content-main>ul.messages{top:70px;left:10px;right:10px;transform:none;min-width:auto}}body.wrapped-tabs .tabs ul{grid-template-columns:repeat(auto-fill, minmax(var(--tab-width, 180px), 1fr));grid-auto-flow:row;grid-auto-columns:unset;gap:0;column-gap:5px}body.wrapped-tabs .tabs ul li{border-radius:0}.tabs ul{margin:0px;padding:0px;display:grid;grid-auto-flow:column;grid-auto-columns:max-content;gap:5px;list-style:none}.tabs ul li{white-space:nowrap;color:var(--color-text-tab);border-top-left-radius:5px;border-top-right-radius:5px;background-color:var(--color-background-tab)}.tabs ul li:not(.active):hover{background-color:var(--color-background-tab-hover)}.tabs ul li.active,.tabs ul li :target{background-color:var(--color-background)}.tabs ul li.active a,.tabs ul li :target a{color:var(--color-text-tab-active);font-weight:bold}.tabs ul li a{display:block;padding:.7em;color:var(--color-text-tab)}body,.pure-table,.pure-table thead,.pure-table td,.pure-table th,.pure-form input,.pure-form textarea,.pure-form select,.edit-form .inner,.pure-menu-horizontal,footer,.sticky-tab,#diff-jump,.button-tag,#new-watch-form,#new-watch-form input:not(.pure-button),code,.messages li,#checkbox-operations,.inline-warning,a,.watch-controls img{transition:color .4s ease,background-color .4s ease,background .4s ease,border-color .4s ease,box-shadow .4s ease}body{color:var(--color-text);background:var(--color-background-page);font-family:Helvetica Neue,Helvetica,Lucida Grande,Arial,Ubuntu,Cantarell,Fira Sans,sans-serif}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}.status-icon{display:inline-block;height:1rem;vertical-align:middle}.pure-table-even{background:var(--color-background)}a{text-decoration:none;color:var(--color-link)}a.github-link{color:var(--color-icon-github);margin:0 1rem 0 .5rem}a.github-link svg{fill:currentColor}a.github-link:hover{color:var(--color-icon-github-hover)}#search-result-info{color:#fff}button.toggle-button{vertical-align:middle;background:rgba(0,0,0,0);border:none;cursor:pointer;color:var(--color-icon-github)}button.toggle-button:hover{color:var(--color-icon-github-hover)}button.toggle-button svg{fill:currentColor}button.toggle-button .icon-light{display:block}.pure-menu-horizontal{background:var(--color-background);padding:5px;display:flex;justify-content:space-between;align-items:center}#pure-menu-horizontal-spinner{height:3px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;width:100%;animation:gradient 200s ease infinite}body.spinner-active #pure-menu-horizontal-spinner{animation:gradient 1s ease infinite}@keyframes gradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}.pure-menu-heading{color:var(--color-text-menu-heading)}.pure-menu-link{color:var(--color-text-menu-link)}.pure-menu-link:hover{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}.tab-pane-inner{scroll-margin-top:200px}section.content{padding-bottom:1em;flex-direction:column;display:flex;align-items:center;justify-content:center}@media only screen and (max-width: 980px){section.content{padding-top:80px}}@media only screen and (min-width: 980px){section.content{padding-top:100px}}code{background:var(--color-background-code);color:var(--color-text)}.inline-tag,.restock-label,.tracking-ldjson-price-data,.watch-tag-list,.processor-badge{white-space:nowrap;border-radius:5px;padding:2px 5px;margin-right:4px;line-height:1.2rem}.processor-badge{font-weight:900}.watch-tag-list{color:var(--color-white);background:var(--color-text-watch-tag-list);text-decoration:none}.watch-tag-list:hover{text-decoration:none;opacity:.8;cursor:pointer}.watch-tag-list:visited{color:var(--color-white)}@media(min-width: 768px){.box{margin:0 1em !important}}.box{max-width:100%;margin:0 .3em;flex-direction:column;display:flex;justify-content:center}body:after{content:"";background:linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%)}body:after,body:before{display:block;height:650px;position:absolute;top:0;left:0;width:100%;z-index:-1}body::after{opacity:.91}body::before{content:""}body:after,body:before{-webkit-clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%);clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%)}.button-small{font-size:85%}.button-xsmall{font-size:70%}.fetch-error{padding-top:1em;font-size:80%;max-width:400px;display:block}.pure-button-primary,a.pure-button-primary,.pure-button-selected,a.pure-button-selected{background-color:var(--color-background-button-primary)}.button-secondary{color:var(--color-text-button);border-radius:4px;text-shadow:0 1px 1px rgba(0,0,0,.2)}.button-success{background:var(--color-background-button-success)}.button-tag{background:var(--color-background-button-tag);color:var(--color-text-button);font-size:65%;border-bottom-left-radius:initial;border-bottom-right-radius:initial;margin-right:4px}.button-tag.active{background:var(--color-background-button-tag-active);font-weight:bold}.button-error{background:var(--color-background-button-error);color:var(--color-text-button-error)}.button-warning{background:var(--color-background-button-warning);color:var(--color-text-button-warning)}.button-secondary{background:var(--color-background-button-secondary)}.button-cancel{background:var(--color-background-button-cancel)}.messages li{list-style:none;padding:1em;border-radius:10px;color:var(--color-text-messages);font-weight:bold}.messages li.message{background:var(--color-background-messages-message)}.messages li.error{background:var(--color-background-messages-error)}.messages li.notice{background:var(--color-background-messages-notice)}.messages.with-share-link>*:hover{cursor:pointer}.notifications-wrapper{padding-top:.5rem}.notifications-wrapper #notification-test-log{margin-top:1rem;padding:1rem;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-width:100%;box-sizing:border-box;max-height:12rem;overflow-y:scroll;border:1px solid var(--color-border-notification);border-radius:5px}label:hover{cursor:pointer}.grey-form-border{border:1px solid var(--color-border-notification);padding:.5rem;border-radius:5px}#notification-error-log{border:1px solid var(--color-border-notification);padding:1rem;border-radius:5px;overflow-wrap:break-word}#token-table.pure-table td,#token-table.pure-table th{font-size:80%}.pure-form input[type=text].transparent-field{background-color:var(--color-background-new-watch-input-transparent) !important;color:var(--color-white) !important;border:1px solid hsla(0,0%,100%,.2) !important;box-shadow:none !important;-webkit-box-shadow:none !important}.pure-form input[type=text].transparent-field::placeholder{opacity:.5;color:hsla(0,0%,100%,.7);font-weight:lighter}#new-watch-form{background:var(--color-background-new-watch-form);padding:1em;border-radius:10px;margin-bottom:1em;max-width:100%}#new-watch-form #url::placeholder{font-weight:bold}#new-watch-form input{display:inline-block;margin-bottom:5px}#new-watch-form input:not(.pure-button){background-color:var(--color-background-new-watch-input);color:var(--color-text-new-watch-input)}#new-watch-form .label{display:none}#new-watch-form legend{color:var(--color-text-legend);font-weight:bold}@media only screen and (min-width: 760px){#new-watch-form #watch-add-wrapper-zone{display:flex;gap:.3rem;flex-direction:row;min-width:70vw}}#new-watch-form #watch-add-wrapper-zone>span{flex-grow:0}#new-watch-form #watch-add-wrapper-zone>span input{width:100%;padding-right:1em}#new-watch-form #watch-add-wrapper-zone>span:first-child{flex-grow:1}@media only screen and (max-width: 760px){#new-watch-form #watch-add-wrapper-zone #url{width:100%}}#new-watch-form #watch-group-tag{font-size:.9rem;padding:.3rem;display:flex;align-items:center;gap:.5rem;color:var(--color-white)}#new-watch-form #watch-group-tag label,#new-watch-form #watch-group-tag input{margin:0}#new-watch-form #watch-group-tag input{flex:1}#diff-col{padding-left:40px}#diff-jump{position:fixed;left:0px;top:120px;background:var(--color-background);padding:10px;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}#diff-jump a{color:var(--color-link);cursor:pointer;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-o-user-select:none}footer{padding:10px;background:var(--color-background);color:var(--color-text-footer);text-align:center}#feed-icon{vertical-align:middle}.sticky-tab{position:absolute;top:60px;font-size:65%;background:var(--color-background);padding:10px}@media only screen and (max-width: 980px){.sticky-tab{display:none}}.sticky-tab#left-sticky{left:0;position:fixed;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}.sticky-tab#right-sticky{right:0px}.sticky-tab#hosted-sticky{right:0px;top:100px;font-weight:bold}#new-version-text a{color:var(--color-link-new-version)}.watch-controls{color:#f8321b}.watch-controls .state-on img{opacity:.8}.watch-controls img{opacity:.2}.watch-controls img:hover{transition:opacity .3s;opacity:.8}.monospaced-textarea textarea{width:100%;font-family:monospace;white-space:pre;overflow-wrap:normal;overflow-x:auto}.pure-form fieldset{padding-top:0px}.pure-form fieldset ul{padding-bottom:0px;margin-bottom:0px}.pure-form .pure-control-group,.pure-form .pure-group,.pure-form .pure-controls{padding-bottom:1em}.pure-form .pure-control-group div,.pure-form .pure-group div,.pure-form .pure-controls div{margin:0px}.pure-form .pure-control-group .checkbox>*,.pure-form .pure-group .checkbox>*,.pure-form .pure-controls .checkbox>*{display:inline;vertical-align:middle}.pure-form .pure-control-group .checkbox>label,.pure-form .pure-group .checkbox>label,.pure-form .pure-controls .checkbox>label{padding-left:5px}.pure-form .pure-control-group legend,.pure-form .pure-group legend,.pure-form .pure-controls legend{color:var(--color-text-legend)}.pure-form .error input{background-color:var(--color-error-input)}.pure-form ul.errors{padding:.5em .6em;border:1px solid var(--color-error-list);border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form ul.errors li{margin-left:1em;color:var(--color-error-list)}.pure-form label{font-weight:bold}.pure-form textarea{width:100%}.pure-form .inline-radio ul{margin:0px;list-style:none}.pure-form .inline-radio ul li{display:flex;align-items:center;gap:1em}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 980px){.edit-form{padding:.5em;margin:0}#nav-menu{overflow-x:scroll}}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 980px){input[type=text]{width:100%}}.pure-table{border-color:var(--color-border-table-cell)}.pure-table thead{background-color:var(--color-background-table-thead);color:var(--color-text);border-bottom:1px solid var(--color-background-table-thead)}.pure-table td,.pure-table th{border-left-color:var(--color-border-table-cell)}.pure-table-striped tr:nth-child(2n-1) td{background-color:var(--color-table-stripe)}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{border:var(--color-border-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);background-color:var(--color-background-input);color:var(--color-text-input)}.pure-form input[type=color]:active,.pure-form input[type=date]:active,.pure-form input[type=datetime-local]:active,.pure-form input[type=datetime]:active,.pure-form input[type=email]:active,.pure-form input[type=month]:active,.pure-form input[type=number]:active,.pure-form input[type=password]:active,.pure-form input[type=search]:active,.pure-form input[type=tel]:active,.pure-form input[type=text]:active,.pure-form input[type=time]:active,.pure-form input[type=url]:active,.pure-form input[type=week]:active,.pure-form select:active,.pure-form textarea:active{background-color:var(--color-background-input)}input::placeholder,textarea::placeholder{color:var(--color-text-input-placeholder)}.m-d{min-width:100%}@media only screen and (min-width: 761px){.m-d{min-width:80%}}.pure-form-stacked>div:first-child{display:block}.tab-pane-inner{padding:0px}.tab-pane-inner:not(:target){display:none}.tab-pane-inner:target{display:block}.beta-logo{height:50px;right:-3px;top:-3px;position:absolute}#selector-header{padding-bottom:1em}body.full-width .edit-form{width:95%}.edit-form{min-width:70%;max-width:95%}.edit-form .box-wrap{position:relative}.edit-form .inner{background:var(--color-background);padding:20px}.edit-form #actions{display:block;background:var(--color-background)}.edit-form #actions .pure-control-group{display:flex;gap:.625em;flex-wrap:wrap}.edit-form .pure-form-message-inline{padding-left:0;color:var(--color-text-input-description)}.edit-form .pure-form-message-inline code{font-size:.875em}.border-fieldset{border:1px solid #ccc;padding:1rem;border-radius:5px;margin-bottom:1rem}.border-fieldset h3{margin-top:0}.border-fieldset fieldset:last-of-type{padding-bottom:0}.border-fieldset fieldset:last-of-type .pure-control-group{padding-bottom:0}ul{padding-left:1em;padding-top:0px;margin-top:4px}.time-check-widget tr{display:inline}.time-check-widget tr input[type=number]{width:5em}@media only screen and (max-width: 760px){.time-check-widget tbody{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:.625em .3125em;align-items:center}.time-check-widget tr{display:contents}.time-check-widget tr th{text-align:right;padding-right:5px}.time-check-widget tr input[type=number]{width:100%;max-width:5em}}#webdriver_delay{width:5em}#api-key:hover{cursor:pointer}#api-key-copy{color:var(--color-api-key)}.button-green{background-color:var(--color-background-button-green)}.button-red{background-color:var(--color-background-button-red)}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#checkbox-operations{background:var(--color-background-checkbox-operations);padding:1em;border-radius:10px;margin-bottom:1em;display:none}#checkbox-operations button{margin-bottom:3px;margin-top:3px;display:inline-flex;align-items:center}.checkbox-uuid>*{vertical-align:middle}.inline-warning{border:1px solid var(--color-border-warning);padding:.5rem;border-radius:5px;color:var(--color-warning)}.inline-warning>span{display:inline-block;vertical-align:middle}.inline-warning img.inline-warning-icon{display:inline;height:26px;vertical-align:middle}.tracking-ldjson-price-data{background-color:var(--color-background-button-green);color:#000;opacity:.6}.ldjson-price-track-offer{font-weight:bold;font-style:italic}.ldjson-price-track-offer a.pure-button{border-radius:3px;padding:3px;background-color:var(--color-background-button-green)}.price-follow-tag-icon{display:inline-block;height:.8rem;vertical-align:middle}#quick-watch-processor-type ul#processor{color:#fff;padding-left:0px}#quick-watch-processor-type ul#processor li{list-style:none;font-size:.9rem;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.5rem;margin-bottom:.5rem}#quick-watch-processor-type label,#quick-watch-processor-type input{padding:0;margin:0}.restock-label.in-stock{background-color:var(--color-background-button-green);color:#fff}.restock-label.not-in-stock{background-color:var(--color-background-button-cancel);color:#777}.restock-label.error{background-color:var(--color-background-button-error);color:#fff;opacity:.7}.restock-label svg{vertical-align:middle}#chrome-extension-link{padding:9px;border:1px solid var(--color-grey-800);border-radius:10px;vertical-align:middle}#chrome-extension-link img{height:21px;padding:2px;vertical-align:middle}#realtime-conn-error{position:fixed;bottom:0;left:0;background:var(--color-warning);padding:10px;font-size:.8rem;color:#fff;opacity:.8}#bottom-horizontal-offscreen{position:fixed;bottom:0;left:0;right:0;width:100%;min-height:50px;max-height:50vh;background:hsla(0,0%,100%,.7215686275);border-top:1px solid var(--color-border-table-cell);padding:10px;box-shadow:0 -2px 10px rgba(0,0,0,.2);z-index:100;overflow-y:auto;transition:opacity .3s ease-in-out;scroll-margin-bottom:10px;display:flex;justify-content:center;align-items:center}ul#highlightSnippetActions{list-style:none}ul#highlightSnippetActions li{display:inline-block} +:root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100);--highlight-trigger-text-bg-color: #1b98f8;--highlight-ignored-text-bg-color: var(--color-grey-700);--highlight-blocked-text-bg-color: rgb(202, 60, 60)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadow-jump: var(--color-grey-200);--color-icon-github: var(--color-white);--color-icon-github-hover: var(--color-grey-700);--color-watch-table-error: var(--color-light-red);--color-watch-table-row-text: var(--color-grey-800)}html[data-darkmode=true] .icon-spread{filter:hue-rotate(-10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .title-col a[target=_blank]::after,html[data-darkmode=true] .watch-table .current-diff-url::after{filter:invert(0.5) hue-rotate(10deg) brightness(2)}html[data-darkmode=true] .watch-table .status-browsersteps{filter:invert(0.5) hue-rotate(10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .watch-controls .state-off img{opacity:.3}html[data-darkmode=true] .watch-table .watch-controls .state-on img{opacity:1}html[data-darkmode=true] .watch-table .unviewed{color:#fff}html[data-darkmode=true] .watch-table .unviewed.error{color:var(--color-watch-table-error)}.arrow{border:solid #1b98f8;border-width:0 2px 2px 0;display:inline-block;padding:3px}.arrow.right{transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}.arrow.left{transform:rotate(135deg);-webkit-transform:rotate(135deg)}.arrow.up,.arrow.asc{transform:rotate(-135deg);-webkit-transform:rotate(-135deg)}.arrow.down,.arrow.desc{transform:rotate(45deg);-webkit-transform:rotate(45deg)}#browser_steps th{display:none}#browser_steps li{list-style:decimal;padding:5px}#browser_steps li.browser-step-with-error{background-color:#ffd6d6;border-radius:4px}#browser_steps li:not(:first-child):hover{opacity:1}#browser_steps li .control{padding-left:5px;padding-right:5px}#browser_steps li .control a{font-size:70%}#browser_steps li.empty{padding:0px;opacity:.35}#browser_steps li.empty .control{display:none}#browser_steps li:hover{background:#eee}#browser_steps li>label{display:none}@media only screen and (min-width: 760px){#browser-steps .flex-wrapper{display:flex;flex-flow:row;height:70vh;font-size:80%}#browser-steps .flex-wrapper #browser-steps-ui{flex-grow:1;flex-shrink:1;flex-basis:0;background-color:#eee;border-radius:5px}#browser-steps-fieldlist{flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:400px;padding-left:1rem;overflow-y:scroll}#browsersteps-selector-wrapper{height:100% !important}}#browsersteps-selector-wrapper{width:100%;overflow-y:scroll;position:relative;height:80vh}#browsersteps-selector-wrapper>img{position:absolute;max-width:100%}#browsersteps-selector-wrapper>canvas{position:relative;max-width:100%}#browsersteps-selector-wrapper>canvas:hover{cursor:pointer}#browsersteps-selector-wrapper .loader{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);z-index:100;max-width:350px;text-align:center}#browsersteps-selector-wrapper .spinner,#browsersteps-selector-wrapper .spinner:after{width:80px;height:80px;font-size:3px}#browsersteps-selector-wrapper #browsersteps-click-start{color:var(--color-grey-400)}#browsersteps-selector-wrapper #browsersteps-click-start:hover{cursor:pointer}ul#requests-extra_proxies{list-style:none}ul#requests-extra_proxies li>label{display:none}ul#requests-extra_proxies table tr{display:table-row}ul#requests-extra_proxies table tr input[type=text]{width:100%}@media only screen and (min-width: 1024px){ul#requests-extra_proxies table tr{display:inline}}#request label[for=proxy]{display:inline-block}body.proxy-check-active #request .proxy-check-details{font-size:80%;color:#555;display:block;padding-left:2em;max-width:500px}body.proxy-check-active #request .proxy-timing{font-size:80%;padding-left:1rem;color:var(--color-link)}#recommended-proxy{display:grid;gap:2rem;padding-bottom:1em}@media(min-width: 991px){#recommended-proxy{grid-template-columns:repeat(2, 1fr)}}#recommended-proxy>div{border:1px #aaa solid;border-radius:4px;padding:1em}#extra-proxies-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}ul#requests-extra_browsers{list-style:none}ul#requests-extra_browsers li>label{display:none}ul#requests-extra_browsers table tr{display:table-row}ul#requests-extra_browsers table tr input[type=text]{width:100%}@media only screen and (min-width: 1280px){ul#requests-extra_browsers table tr{display:inline}ul#requests-extra_browsers table tr input[type=text]{width:100%}}#extra-browsers-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}.pagination-page-info{text-transform:capitalize}.pagination.menu>*{display:inline-block}.pagination.menu li{display:inline-block}.pagination.menu a{padding:.65rem;margin:3px;border:none;background:#444;border-radius:2px;color:var(--color-text-button)}.pagination.menu a.disabled{display:none}.pagination.menu a.active{font-weight:bold;background:#888}.pagination.menu a:hover{background:#999}.spinner,.spinner:after{border-radius:50%;width:10px;height:10px}.spinner{margin:0px auto;font-size:3px;vertical-align:middle;display:inline-block;text-indent:-9999em;border-top:1.1em solid rgba(38,104,237,.2);border-right:1.1em solid rgba(38,104,237,.2);border-bottom:1.1em solid rgba(38,104,237,.2);border-left:1.1em solid #2668ed;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:load8 1.1s infinite linear;animation:load8 1.1s infinite linear}@-webkit-keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.toggle-light-mode .icon-dark{display:none}html[data-darkmode=true] .toggle-light-mode .icon-light{display:none}html[data-darkmode=true] .toggle-light-mode .icon-dark{display:block}.pure-menu-link{padding:.5rem 1em;line-height:1.2rem}#menu-mute,#menu-pause{padding-left:.3rem;padding-right:.3rem}#menu-mute img,#menu-pause img{height:1.2rem}.pure-menu-item svg{height:1.2rem}.pure-menu-item *{vertical-align:middle}.pure-menu-item .github-link{height:1.8rem;display:block}.pure-menu-item .github-link svg{height:100%}.pure-menu-item .bi-heart:hover{cursor:pointer}.pure-menu-item.active .pure-menu-link{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}#cdio-logo{padding-left:.5em}#inline-menu-extras-group>*{display:inline-block}#overlay{opacity:.95;position:fixed;width:350px;max-width:100%;height:100%;top:0;right:-350px;background-color:var(--color-table-stripe);z-index:2;transform:translateX(0);transition:transform .5s ease}#overlay.visible{transform:translateX(-100%)}#overlay .content{font-size:.875rem;padding:1rem;margin-top:5rem;max-width:400px;color:var(--color-watch-table-row-text)}#heartpath{transition:all ease .3s !important}#heartpath:hover{fill:red !important;transition:all ease .3s !important}.minitabs-wrapper{width:100%}.minitabs-wrapper>div[id]{padding:20px;border:1px solid #ccc;border-top:none}.minitabs-wrapper .minitabs-content{width:100%;display:flex}.minitabs-wrapper .minitabs-content>div{flex:1 1 auto;min-width:0;overflow:scroll}.minitabs-wrapper .minitabs{display:flex;border-bottom:1px solid #ccc}.minitabs-wrapper .minitab{flex:1;text-align:center;padding:12px 0;text-decoration:none;color:#333;background-color:#f1f1f1;border:1px solid #ccc;border-bottom:none;cursor:pointer;transition:background-color .3s}.minitabs-wrapper .minitab:hover{background-color:#ddd}.minitabs-wrapper .minitab.active{background-color:#fff;font-weight:bold}@media(min-width: 800px){body.preview-text-enabled #filters-and-triggers>div{display:flex;gap:20px;position:relative}}body.preview-text-enabled #edit-text-filter,body.preview-text-enabled #text-preview{flex:1;align-self:flex-start}body.preview-text-enabled #edit-text-filter #pro-tips{display:none}body.preview-text-enabled #text-preview{position:sticky;top:20px;padding-top:1rem;padding-bottom:1rem;display:block !important}body.preview-text-enabled #activate-text-preview{background-color:var(--color-grey-500)}body.preview-text-enabled .monospace-preview{background:var(--color-background-input);border:1px solid var(--color-grey-600);padding:1rem;color:var(--color-text-input);font-family:"Courier New",Courier,monospace;font-size:70%;word-break:break-word;white-space:pre-wrap}#activate-text-preview{right:0;position:absolute;z-index:3;box-shadow:1px 1px 4px var(--color-shadow-jump)}#stats_row{display:flex;align-items:center;width:100%;color:#fff;font-size:.85rem}#stats_row>*{padding-bottom:.5rem}#stats_row .left{text-align:left}#stats_row .right{opacity:.5;transition:opacity .6s ease;margin-left:auto;text-align:right}body.has-queue #stats_row .right{opacity:1}.watch-table{width:100%;font-size:80%}.watch-table tr{color:var(--color-watch-table-row-text)}.watch-table tr.unviewed{font-weight:bold}.watch-table td{white-space:nowrap}.watch-table td.title-col{word-break:break-all;white-space:normal}.watch-table td a.external::after{content:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);margin:0 3px 0 5px}.watch-table th{white-space:nowrap}.watch-table th a{font-weight:normal}.watch-table th a.active{font-weight:bolder}.watch-table th a.inactive .arrow{display:none}.watch-table tr.checking-now td:first-child{position:relative}.watch-table tr.checking-now td:first-child::before{content:"";position:absolute;top:0;bottom:0;left:0;width:3px;background-color:#293eff}.watch-table tr.checking-now td.last-checked .spinner-wrapper{display:inline-block !important}.watch-table tr.checking-now td.last-checked .innertext{display:none !important}.watch-table tr.queued a.recheck{display:none !important}.watch-table tr.queued a.already-in-queue-button{display:inline-block !important}.watch-table tr.paused a.pause-toggle.state-on{display:inline !important}.watch-table tr.paused a.pause-toggle.state-off{display:none !important}.watch-table tr.notification_muted a.mute-toggle.state-on{display:inline !important}.watch-table tr.notification_muted a.mute-toggle.state-off{display:none !important}.watch-table tr.has-error{color:var(--color-watch-table-error)}.watch-table tr.has-error .error-text{display:block !important}.watch-table tr.single-history a.preview-link{display:inline-block !important}.watch-table tr.multiple-history a.history-link{display:inline-block !important}#watch-table-wrapper #post-list-buttons{text-align:right;padding:0px;margin:0px}#watch-table-wrapper #post-list-buttons li{display:inline-block}#watch-table-wrapper #post-list-buttons a{border-top-left-radius:initial;border-top-right-radius:initial;border-bottom-left-radius:5px;border-bottom-right-radius:5px}#watch-table-wrapper.has-error #post-list-buttons #post-list-with-errors{display:inline-block !important}#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-mark-views,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread{display:inline-block !important}@media(max-width: 767px){.watch-table thead{display:block}.watch-table thead tr th{display:inline-block}}@media(max-width: 767px)and (max-width: 768px){.watch-table thead tr th .hide-on-mobile{display:none}}@media(max-width: 767px){.watch-table thead .empty-cell{display:none}.watch-table .last-checked{margin-left:calc(20px + .5rem)}.watch-table .last-checked>span{vertical-align:middle}.watch-table .last-changed{margin-left:calc(20px + .5rem)}.watch-table .last-checked::before{color:var(--color-text);content:"Last Checked "}.watch-table .last-changed::before{color:var(--color-text);content:"Last Changed "}.watch-table td.inline{display:inline-block}.watch-table .pure-table td,.watch-table .pure-table th{border:none}.watch-table td{border:none;border-bottom:1px solid var(--color-border-watch-table-cell);vertical-align:middle}.watch-table td:before{top:6px;left:6px;width:45%;padding-right:10px;white-space:nowrap}.watch-table.pure-table-striped tr{background-color:var(--color-table-background)}.watch-table.pure-table-striped tr:nth-child(2n-1){background-color:var(--color-table-stripe)}.watch-table.pure-table-striped tr:nth-child(2n-1) td{background-color:inherit}}@media(max-width: 767px){.watch-table tbody tr{padding-bottom:10px;padding-top:10px;display:grid;grid-template-columns:20px 1fr 100px;grid-template-rows:auto auto auto auto;gap:.5rem}.watch-table tbody tr .counter-i{display:none}.watch-table tbody tr td.checkbox-uuid{display:grid;place-items:center}.watch-table tbody tr>td{border-bottom:none}.watch-table tbody tr>td[colspan]{grid-column:1/-1}.watch-table tbody tr>td.title-col{grid-column:1/-1;grid-row:1}.watch-table tbody tr>td.title-col .watch-title{font-size:.92rem}.watch-table tbody tr>td.title-col .link-spread{display:none}.watch-table tbody tr>td.last-checked{grid-column:1/-1;grid-row:2}.watch-table tbody tr>td.last-changed{grid-column:1/-1;grid-row:3}.watch-table tbody tr>td.checkbox-uuid{grid-column:1;grid-row:4}.watch-table tbody tr>td.buttons{grid-column:2;grid-row:4;display:flex;align-items:center;justify-content:flex-start}.watch-table tbody tr>td.watch-controls{grid-column:3;grid-row:4;display:grid;place-items:center}.watch-table tbody tr>td.watch-controls a img{padding:10px}.pure-table td{padding:3px !important}}ul#conditions_match_logic{list-style:none}ul#conditions_match_logic input,ul#conditions_match_logic label,ul#conditions_match_logic li{display:inline-block}ul#conditions_match_logic li{padding-right:1em}.fieldlist_formfields{width:100%;background-color:var(--color-background, #fff);border-radius:4px;border:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header{display:flex;background-color:var(--color-background-table-thead, #e0e0e0);font-weight:bold;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header-cell{flex:1;padding:.5em 1em;text-align:left}.fieldlist_formfields .fieldlist-header-cell:last-child{flex:0 0 120px}.fieldlist_formfields .fieldlist-body{display:flex;flex-direction:column}.fieldlist_formfields .fieldlist-row{display:flex;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-row:last-child{border-bottom:none}.fieldlist_formfields .fieldlist-row:nth-child(2n-1){background-color:var(--color-table-stripe, #f2f2f2)}.fieldlist_formfields .fieldlist-row.error-row{background-color:var(--color-error-input, #ffdddd)}.fieldlist_formfields .fieldlist-cell{flex:1;padding:.5em 1em;display:flex;flex-direction:column;justify-content:center}.fieldlist_formfields .fieldlist-cell input,.fieldlist_formfields .fieldlist-cell select{width:100%}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:0 0 120px;display:flex;flex-direction:row;align-items:center;gap:4px}.fieldlist_formfields ul.errors{margin-top:.5em;margin-bottom:0;padding:.5em;background-color:var(--color-error-background-snapshot-age, #ffdddd);border-radius:4px;list-style-position:inside}@media only screen and (max-width: 760px){.fieldlist_formfields .fieldlist-header,.fieldlist_formfields .fieldlist-row{flex-direction:column}.fieldlist_formfields .fieldlist-header-cell{display:none}.fieldlist_formfields .fieldlist-row{padding:.5em 0;border-bottom:2px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-cell{padding:.25em .5em}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:1;justify-content:flex-start;padding-top:.5em}.fieldlist_formfields .fieldlist-cell:not(:last-child){margin-bottom:.5em}.fieldlist_formfields .fieldlist-cell::before{content:attr(data-label);font-weight:bold;margin-bottom:.25em}}.fieldlist_formfields .addRuleRow,.fieldlist_formfields .removeRuleRow,.fieldlist_formfields .verifyRuleRow{cursor:pointer;border:none;padding:4px 8px;border-radius:3px;font-weight:bold;background-color:#aaa;color:var(--color-foreground-text, #fff)}.fieldlist_formfields .addRuleRow:hover,.fieldlist_formfields .removeRuleRow:hover,.fieldlist_formfields .verifyRuleRow:hover{background-color:#999}.watch-table.favicon-not-enabled tr .favicon{display:none}.watch-table tr td.inline.title-col .flex-wrapper{display:flex;align-items:center;gap:4px}.watch-table td,.watch-table th{vertical-align:middle}.watch-table tr.has-favicon.unviewed img.favicon{opacity:1 !important}.watch-table .status-icons{white-space:nowrap;display:flex;align-items:center;gap:4px}.watch-table .status-icons>*{vertical-align:middle}.title-col{padding:10px}.title-wrapper{display:flex;align-items:center;gap:10px}.title-col-inner{display:inline-block;vertical-align:middle}.watch-table img.favicon{vertical-align:middle;max-width:25px;max-height:25px;height:25px;padding-right:4px}body.checking-now #checking-now-fixed-tab{display:block !important}#checking-now-fixed-tab{background:#ccc;border-radius:5px;bottom:0;color:var(--color-text);display:none;font-size:.8rem;left:0;padding:5px;position:fixed}#selector-wrapper{height:100%;text-align:center;max-height:70vh;overflow-y:scroll;position:relative}#selector-wrapper>img{position:absolute;z-index:4;max-width:100%}#selector-wrapper>canvas{position:relative;z-index:5;max-width:100%}#selector-wrapper>canvas:hover{cursor:pointer}#selector-current-xpath{font-size:80%}.ternary-radio-group{display:flex;gap:0;border:1px solid var(--color-grey-750);border-radius:4px;overflow:hidden;width:fit-content;background:var(--color-background)}.ternary-radio-group .ternary-radio-option{position:relative;cursor:pointer;margin:0;display:flex;align-items:center}.ternary-radio-group .ternary-radio-option input[type=radio]{position:absolute;opacity:0;width:0;height:0}.ternary-radio-group .ternary-radio-option .ternary-radio-label{padding:8px 16px;background:var(--color-grey-900);border:none;border-right:1px solid var(--color-grey-750);font-size:13px;font-weight:500;color:var(--color-text);transition:all .2s ease;cursor:pointer;display:block;text-align:center}.ternary-radio-group .ternary-radio-option:last-child .ternary-radio-label{border-right:none}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button);font-weight:600}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600);color:var(--color-text-button)}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}.ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-800)}@media(max-width: 480px){.ternary-radio-group{width:100%}.ternary-radio-group .ternary-radio-label{flex:1;min-width:auto}}input[type=radio].pure-radio:checked+label,input[type=radio].pure-radio:checked{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option .ternary-radio-label{background:var(--color-grey-350)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-400)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}body.processor-image_ssim_diff #edit-text-filter .text-filtering{display:none}body.processor-image_ssim_diff #conditions-tab{display:none}.modal-dialog{border:none;border-radius:10px;padding:0;background:var(--color-background);color:var(--color-text);box-shadow:0 5px 20px rgba(0,0,0,.3);max-width:500px;width:90%}.modal-dialog::backdrop{background:rgba(0,0,0,.6);backdrop-filter:blur(3px);animation:fadeIn .2s ease-out}.modal-dialog[open]{animation:slideIn .25s ease-out}.modal-dialog .modal-header{padding:1.5rem;border-bottom:1px solid var(--color-border-table-cell);display:flex;align-items:center;gap:1rem}.modal-dialog .modal-header .modal-icon{font-size:2rem;line-height:1;flex-shrink:0}.modal-dialog .modal-header .modal-icon.warning{color:var(--color-warning)}.modal-dialog .modal-header .modal-icon.danger{color:var(--color-background-button-error)}.modal-dialog .modal-header .modal-icon.info{color:var(--color-background-button-primary)}.modal-dialog .modal-header .modal-title{font-size:1.3rem;font-weight:bold;margin:0;color:var(--color-text)}.modal-dialog .modal-body{padding:1.5rem;line-height:1.6}.modal-dialog .modal-body p{margin:0 0 1rem 0}.modal-dialog .modal-body p:last-child{margin-bottom:0}.modal-dialog .modal-body strong{color:var(--color-text);font-weight:600}.modal-dialog .modal-footer{padding:1rem 1.5rem;border-top:1px solid var(--color-border-table-cell);display:flex;gap:.75rem;justify-content:flex-end;background:var(--color-grey-900)}.modal-dialog .modal-footer button{padding:.6rem 1.5rem;border:none;border-radius:4px;cursor:pointer;font-weight:500;transition:all .2s ease;font-size:.95rem}.modal-dialog .modal-footer button:hover{transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,.15)}.modal-dialog .modal-footer button:active{transform:translateY(0)}.modal-dialog .modal-footer button.modal-btn-cancel{background:var(--color-background-button-cancel);color:var(--color-grey-200)}.modal-dialog .modal-footer button.modal-btn-cancel:hover{background:var(--color-grey-700)}.modal-dialog .modal-footer button.modal-btn-confirm{background:var(--color-background-button-primary);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-confirm:hover{opacity:.9}.modal-dialog .modal-footer button.modal-btn-danger{background:var(--color-background-button-error);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-danger:hover{background:var(--color-dark-red)}.modal-dialog .modal-footer button.modal-btn-warning{background:var(--color-background-button-warning);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-warning:hover{opacity:.9}html[data-darkmode=true] .modal-dialog{box-shadow:0 5px 30px rgba(0,0,0,.7)}html[data-darkmode=true] .modal-dialog .modal-footer{background:var(--color-grey-200)}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideIn{from{opacity:0;transform:translateY(-20px) scale(0.95)}to{opacity:1;transform:translateY(0) scale(1)}}@media only screen and (max-width: 760px){.modal-dialog{width:95%;max-width:none}.modal-dialog .modal-header{padding:1rem}.modal-dialog .modal-header .modal-title{font-size:1.1rem}.modal-dialog .modal-body{padding:1rem;font-size:.95rem}.modal-dialog .modal-footer{padding:.75rem 1rem;flex-wrap:wrap}.modal-dialog .modal-footer button{flex:1;min-width:120px}}#language-selector-flag{display:inline-block;width:1.2em;height:1.2em;vertical-align:middle;border-radius:50%;overflow:hidden;opacity:.6}#language-selector-flag:hover{opacity:1}.language-list{display:flex;flex-direction:column;gap:.5rem;padding:.5rem 0}.language-option{display:flex;align-items:center;gap:1rem;padding:.25rem;border-radius:4px;transition:background-color .2s ease;text-decoration:none;color:var(--color-text);border:1px solid rgba(0,0,0,0)}.language-option:hover{background-color:var(--color-background-menu-link-hover);border-color:var(--color-border-table-cell)}.language-option.active{background-color:var(--color-link);color:var(--color-text-button);font-weight:600}.language-option .flag{font-size:1.5rem;flex-shrink:0}.language-option .language-name{flex-grow:1;font-size:1rem}#language-modal .language-list .lang-option{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;margin-right:.5em;border-radius:50%;overflow:hidden}.content-wrapper{display:flex;gap:0;width:100%;max-width:100%;position:relative}@media only screen and (max-width: 900px){.content-wrapper{flex-direction:column}}.action-sidebar{position:sticky;top:100px;flex-shrink:0;width:80px;height:fit-content;background:rgba(0,0,0,0);padding:1.5rem 0;display:flex;flex-direction:column;gap:.5rem;align-items:center;z-index:0}@media only screen and (max-width: 900px){.action-sidebar{position:relative;top:0;width:100%;flex-direction:row;justify-content:space-around;padding:0;overflow-x:auto}}.action-sidebar-item{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;padding:.75rem .5rem;min-width:64px;text-decoration:none;opacity:.8;transition:opacity .2s ease}.action-sidebar-item:hover{opacity:1}.action-sidebar-item.active{opacity:1}.action-sidebar-item.active .action-icon{stroke:#fff;stroke-width:2.5}.action-sidebar-item.active .action-label{color:#fff;font-weight:700}.action-icon{width:28px;height:28px;stroke:#fff;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s ease}.action-label{font-size:.65rem;font-weight:500;text-align:center;line-height:1.1;letter-spacing:.02em;text-transform:uppercase;color:#fff;transition:color .2s ease;max-width:60px;word-wrap:break-word}.content-main{flex:0 1 auto;width:100%;min-width:0;padding:0;display:flex;flex-direction:column;align-items:center}.hamburger-menu{display:none;background:rgba(0,0,0,0);border:none;cursor:pointer;padding:.5rem;z-index:10001;position:relative}@media only screen and (max-width: 980px){.hamburger-menu{display:flex;flex-direction:column;justify-content:center;align-items:center}}.hamburger-icon{width:24px;height:20px;position:relative;display:flex;flex-direction:column;justify-content:space-between}.hamburger-icon span{display:block;height:3px;width:100%;background:var(--color-text);border-radius:2px;transition:all .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);transform-origin:center}.hamburger-menu.active .hamburger-icon span:nth-child(1){transform:translateY(8.5px) rotate(45deg)}.hamburger-menu.active .hamburger-icon span:nth-child(2){opacity:0;transform:translateX(-10px)}.hamburger-menu.active .hamburger-icon span:nth-child(3){transform:translateY(-8.5px) rotate(-45deg)}.mobile-menu-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:9999;opacity:0;transition:opacity .3s ease}.mobile-menu-overlay.active{display:block;opacity:1}.mobile-menu-drawer{position:fixed;top:0;right:-280px;width:280px;height:100%;background:var(--color-background);opacity:1;box-shadow:-2px 0 8px rgba(0,0,0,.15);z-index:10000;transition:right .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);overflow-y:auto;padding-top:60px}.mobile-menu-drawer.active{right:0}.mobile-menu-drawer .mobile-menu-items{list-style:none;padding:1rem 0;margin:0}.mobile-menu-drawer .mobile-menu-items li{border-bottom:1px solid var(--color-border-table-cell)}.mobile-menu-drawer .mobile-menu-items li>*{display:block;padding:1rem 1.5rem;color:var(--color-text);text-decoration:none;font-weight:500;transition:background .2s ease}.mobile-menu-drawer .mobile-menu-items li>*:hover{background:var(--color-background-menu-link-hover)}.mobile-menu-drawer .mobile-menu-items li#menu-pause,.mobile-menu-drawer .mobile-menu-items li#menu-mute{display:none}.logo-cdio{font-weight:bold;font-size:1.1rem}.logo-cdio .logo-cd{color:var(--color-grey-500)}.logo-cdio .logo-io{color:var(--color-text)}.menu-always-visible{display:flex;align-items:center;gap:.5rem;margin-left:auto}@media only screen and (max-width: 980px){#top-right-menu .menu-collapsible{display:none !important}.pure-menu-horizontal{overflow-x:visible !important}#nav-menu{overflow-x:visible !important}}@media only screen and (min-width: 1025px){.hamburger-menu,.mobile-menu-drawer,.mobile-menu-overlay{display:none !important}}html[data-darkmode=true] .mobile-menu-drawer{box-shadow:-2px 0 8px rgba(0,0,0,.4)}#search-modal .modal-body{padding:2rem 1.5rem}#search-modal .modal-body .pure-control-group{padding-bottom:0}#search-modal .modal-body .pure-control-group label{display:block;margin-bottom:.5rem;font-size:.9rem;font-weight:600;color:var(--color-text)}#search-modal .modal-body .pure-control-group #search-modal-input{width:100%;max-width:100%;box-sizing:border-box;padding:.6rem .8rem;font-size:1rem;border:1px solid var(--color-border-input);border-radius:4px;background-color:var(--color-background-input);color:var(--color-text-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);transition:border-color .2s ease,box-shadow .2s ease}#search-modal .modal-body .pure-control-group #search-modal-input:focus{outline:none;border-color:var(--color-link);box-shadow:0 0 0 3px rgba(27,152,248,.1)}#search-modal .modal-body .pure-control-group #search-modal-input::placeholder{color:var(--color-text-input-placeholder);opacity:.7}html[data-darkmode=true] #search-modal #search-modal-input:focus{box-shadow:0 0 0 3px rgba(89,189,251,.15)}.action-sidebar-item{position:relative}.action-sidebar-item .notification-bubble{position:absolute;top:8px;left:8px;min-width:18px;height:18px;background:#f44;color:#fff;font-size:10px;font-weight:700;line-height:18px;text-align:center;border-radius:9px;padding:0 2px;box-shadow:0 2px 4px rgba(0,0,0,.3);pointer-events:none;transition:all .2s ease;display:none}.action-sidebar-item .notification-bubble.red-bubble{background:#f44}.action-sidebar-item .notification-bubble.blue-bubble{background:#4a9eff;color:#fff}.action-sidebar-item .notification-bubble.visible{display:block}.action-sidebar-item .notification-bubble.pulse{animation:bubblePulse .4s ease-out}.action-sidebar-item .notification-bubble.large-number{font-size:8px;min-width:20px;height:20px;line-height:20px;border-radius:10px}@keyframes bubblePulse{0%{transform:scale(1)}50%{transform:scale(1.3)}100%{transform:scale(1)}}html[data-darkmode=true] .notification-bubble{box-shadow:0 2px 6px rgba(0,0,0,.6)}.toast-container{position:fixed;display:flex;flex-direction:column;gap:.75rem;pointer-events:none;z-index:10000}.toast-container.toast-top-right{top:20px;right:20px}.toast-container.toast-top-center{top:100px;left:50%;transform:translateX(-50%)}.toast-container.toast-top-left{top:20px;left:20px}.toast-container.toast-bottom-right{bottom:20px;right:20px}.toast-container.toast-bottom-center{bottom:20px;left:50%;transform:translateX(-50%)}.toast-container.toast-bottom-left{bottom:20px;left:20px}.toast{position:relative;display:flex;align-items:center;gap:.75rem;min-width:300px;max-width:500px;padding:1rem 1.25rem;background:var(--color-background);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15),0 0 0 1px rgba(0,0,0,.05);pointer-events:auto;overflow:hidden;opacity:0;transform:translateY(-50px);transition:all .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);font-family:inherit}.toast.toast-show{opacity:1;transform:translateY(0)}.toast.toast-hide{opacity:0;transform:translateY(-50px) scale(0.95)}.toast.toast-success{border-left:4px solid #10b981}.toast.toast-success .toast-icon{color:#10b981}.toast.toast-error{border-left:4px solid #ef4444}.toast.toast-error .toast-icon{color:#ef4444}.toast.toast-warning{border-left:4px solid #f59e0b}.toast.toast-warning .toast-icon{color:#f59e0b}.toast.toast-info{border-left:4px solid #3b82f6}.toast.toast-info .toast-icon{color:#3b82f6}.toast.toast-default{border-left:4px solid var(--color-grey-500)}.toast-icon{flex-shrink:0;width:24px;height:24px}.toast-icon svg{width:100%;height:100%}.toast-message{flex:1;font-size:.875rem;line-height:1.5;color:var(--color-text);word-break:break-word;font-family:inherit}.toast-close{flex-shrink:0;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0);border:none;border-radius:4px;color:var(--color-grey-500);font-size:1.5rem;line-height:1;cursor:pointer;transition:all .2s ease;padding:0;margin-left:.25rem}.toast-close:hover{background:var(--color-grey-800);color:var(--color-text)}.toast-close:active{transform:scale(0.95)}.toast-progress{position:absolute;bottom:0;left:0;right:0;height:3px;background:currentColor;opacity:.3;transform-origin:left;transition:transform linear}html[data-darkmode=true] .toast{background:var(--color-grey-300);box-shadow:0 4px 12px rgba(0,0,0,.4),0 0 0 1px hsla(0,0%,100%,.05)}html[data-darkmode=true] .toast-close:hover{background:var(--color-grey-400)}@media only screen and (max-width: 768px){.toast-container{left:50% !important;right:auto !important;top:80px !important;transform:translateX(-50%) !important;align-items:center}.toast-container.toast-bottom-right,.toast-container.toast-bottom-center,.toast-container.toast-bottom-left{top:auto !important;bottom:80px !important}.toast{min-width:auto;max-width:none;width:80vw;transform:translateY(-100px)}.toast.toast-show{transform:translateY(0)}.toast.toast-hide{transform:translateY(-100px) scale(0.95)}}@media(prefers-reduced-motion: reduce){.toast{transition:opacity .2s ease;transform:none !important}.toast.toast-show{opacity:1}.toast.toast-hide{opacity:0}}.login-form{min-height:52vh;display:flex;align-items:center;justify-content:center;padding:2rem 1rem}.login-form .inner{background:var(--color-background);border-radius:16px;box-shadow:0 10px 40px rgba(0,0,0,.08),0 2px 8px rgba(0,0,0,.04);padding:3rem 2.5rem;width:100%;max-width:420px;position:relative;overflow:hidden;transition:transform .3s ease,box-shadow .3s ease}.login-form .inner:hover{box-shadow:0 15px 50px rgba(0,0,0,.12),0 5px 15px rgba(0,0,0,.06)}.login-form form{margin:0}.login-form fieldset{border:none;padding:0;margin:0}.login-form .pure-control-group{margin-bottom:1.75rem}.login-form .pure-control-group:last-of-type{margin-bottom:0;margin-top:2rem}.login-form label{display:block;margin-bottom:.5rem;font-weight:600;font-size:.9rem;color:var(--color-text);letter-spacing:.01em}.login-form input[type=password]{width:100%;padding:.875rem 1rem;border:2px solid var(--color-grey-800);border-radius:8px;font-size:1rem;background:var(--color-background-input);color:var(--color-text-input);transition:all .2s ease;box-sizing:border-box}.login-form input[type=password]:focus{outline:none;border-color:var(--color-link);box-shadow:0 0 0 3px rgba(27,152,248,.1);transform:translateY(-1px)}.login-form input[type=password]::placeholder{color:var(--color-text-input-placeholder)}.login-form button[type=submit]{width:100%;padding:.875rem 1.5rem;font-size:1rem;font-weight:600;border-radius:8px;border:none;background:var(--color-background-button-primary);color:var(--color-text-button);cursor:pointer;transition:all .2s ease;box-shadow:0 2px 8px rgba(27,152,248,.2)}.login-form button[type=submit]:hover{box-shadow:0 4px 12px rgba(27,152,248,.3);background:#06c}.login-form button[type=submit]:active{transform:translateY(0);box-shadow:0 2px 4px rgba(27,152,248,.2)}.content-main>ul.messages{position:fixed;top:120px;left:50%;transform:translateX(-50%);list-style:none;padding:0;margin:0;z-index:1000;min-width:300px;max-width:500px}.content-main>ul.messages li{padding:1rem 1.25rem;border-radius:8px;font-size:.95rem;line-height:1.5;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,.15);animation:slideDown .3s ease-out;border:2px solid rgba(0,0,0,0)}.content-main>ul.messages li.error{background:#fee;border:2px solid #ef4444;color:#991b1b;font-weight:600}.content-main>ul.messages li.success{background:#f0fdf4;border:2px solid #10b981;color:#166534}.content-main>ul.messages li.info,.content-main>ul.messages li.message{background:#eff6ff;border:2px solid #3b82f6;color:#1e40af}@keyframes slideDown{from{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}html[data-darkmode=true] .login-form .inner{box-shadow:0 10px 40px rgba(0,0,0,.4),0 2px 8px rgba(0,0,0,.2)}html[data-darkmode=true] .login-form .inner:hover{box-shadow:0 15px 50px rgba(0,0,0,.5),0 5px 15px rgba(0,0,0,.3)}html[data-darkmode=true] .login-form input[type=password]{border-color:var(--color-grey-400)}html[data-darkmode=true] .login-form input[type=password]:focus{border-color:var(--color-link)}html[data-darkmode=true] .content-main>ul.messages li{box-shadow:0 4px 12px rgba(0,0,0,.4)}html[data-darkmode=true] .content-main>ul.messages li.error{background:#4a1d1d;border-color:#ef4444;color:#fca5a5}html[data-darkmode=true] .content-main>ul.messages li.success{background:#1a3a2a;border-color:#10b981;color:#86efac}html[data-darkmode=true] .content-main>ul.messages li.info,html[data-darkmode=true] .content-main>ul.messages li.message{background:#1e3a5f;border-color:#3b82f6;color:#93c5fd}@media only screen and (max-width: 768px){.login-form{min-height:auto;padding:1rem .5rem;padding-top:5rem}.login-form .inner{padding:2rem 1.5rem;border-radius:12px}.content-main>ul.messages{top:70px;left:10px;right:10px;transform:none;min-width:auto}}body.wrapped-tabs .tabs ul{grid-template-columns:repeat(auto-fill, minmax(var(--tab-width, 180px), 1fr));grid-auto-flow:row;grid-auto-columns:unset;gap:0;column-gap:5px}body.wrapped-tabs .tabs ul li{border-radius:0}.tabs ul{margin:0px;padding:0px;display:grid;grid-auto-flow:column;grid-auto-columns:max-content;gap:5px;list-style:none}.tabs ul li{white-space:nowrap;color:var(--color-text-tab);border-top-left-radius:5px;border-top-right-radius:5px;background-color:var(--color-background-tab)}.tabs ul li:not(.active):hover{background-color:var(--color-background-tab-hover)}.tabs ul li.active,.tabs ul li :target{background-color:var(--color-background)}.tabs ul li.active a,.tabs ul li :target a{color:var(--color-text-tab-active);font-weight:bold}.tabs ul li a{display:block;padding:.7em;color:var(--color-text-tab)}.notification-profile-selector{position:relative}.notification-profile-selector .np-chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:32px;padding:4px 0}.notification-profile-selector .np-chip{display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:3px;font-size:.82em;line-height:1.4;background:var(--color-background-button-tag);color:var(--color-white);cursor:default;max-width:240px}.notification-profile-selector .np-chip .np-chip-icon svg{width:12px;height:12px;flex-shrink:0}.notification-profile-selector .np-chip .np-chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.notification-profile-selector .np-chip.np-chip-own .np-chip-remove{cursor:pointer;margin-left:2px;opacity:.65;font-size:1.1em;line-height:1;flex-shrink:0}.notification-profile-selector .np-chip.np-chip-own .np-chip-remove:hover{opacity:1}.notification-profile-selector .np-chip.np-chip-inherited{opacity:.5;border:1px dashed var(--color-grey-600);background:rgba(0,0,0,0);color:var(--color-grey-500)}.notification-profile-selector .np-chip.np-chip-inherited .np-chip-lock svg{width:10px;height:10px;flex-shrink:0}.notification-profile-selector .np-add-wrapper{position:relative;display:inline-block}.notification-profile-selector .np-add-btn{display:inline-flex;align-items:center;gap:4px;background:rgba(0,0,0,0);border:1px dashed var(--color-grey-500);color:var(--color-grey-400);padding:2px 8px;font-size:.82em;cursor:pointer;border-radius:3px}.notification-profile-selector .np-add-btn:hover{border-color:var(--color-link);color:var(--color-link)}.notification-profile-selector .np-add-btn svg{width:12px;height:12px}.notification-profile-selector .np-dropdown{position:absolute;top:calc(100% + 4px);left:0;z-index:200;min-width:300px;max-width:420px;background:var(--color-background);border:1px solid var(--color-border-input);border-radius:4px;box-shadow:0 4px 14px rgba(0,0,0,.18);overflow:hidden}.notification-profile-selector .np-dropdown .np-search{width:100%;box-sizing:border-box;padding:8px 10px;border:none;border-bottom:1px solid var(--color-border-input);outline:none;font-size:.9em;background:var(--color-background-input);color:var(--color-text-input)}.notification-profile-selector .np-dropdown .np-options{max-height:220px;overflow-y:auto}.notification-profile-selector .np-dropdown .np-option{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer}.notification-profile-selector .np-dropdown .np-option:hover{background:var(--color-grey-900)}.notification-profile-selector .np-dropdown .np-option .np-option-icon svg{width:14px;height:14px;flex-shrink:0}.notification-profile-selector .np-dropdown .np-option .np-option-text{display:flex;flex-direction:column;gap:1px;overflow:hidden}.notification-profile-selector .np-dropdown .np-option .np-option-name{font-size:.88em;font-weight:600;white-space:nowrap}.notification-profile-selector .np-dropdown .np-option .np-option-hint{font-size:.76em;color:var(--color-text-input-description);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.notification-profile-selector .np-dropdown .np-create-new{display:flex;align-items:center;gap:6px;padding:8px 12px;border-top:1px solid var(--color-border-input);font-size:.88em;color:var(--color-link);text-decoration:none}.notification-profile-selector .np-dropdown .np-create-new:hover{background:var(--color-grey-900)}.notification-profile-selector .np-dropdown .np-create-new svg{width:13px;height:13px}.profile-type-cards{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0}.profile-type-cards .profile-type-card{display:flex;flex-direction:column;align-items:center;gap:4px;padding:10px 16px;border:2px solid var(--color-border-input);border-radius:6px;cursor:pointer;font-size:.85em;color:var(--color-grey-400);min-width:80px;transition:border-color .15s,color .15s}.profile-type-cards .profile-type-card svg{width:18px;height:18px}.profile-type-cards .profile-type-card input[type=radio]{display:none}.profile-type-cards .profile-type-card.active,.profile-type-cards .profile-type-card:hover{border-color:var(--color-link);color:var(--color-link)}body,.pure-table,.pure-table thead,.pure-table td,.pure-table th,.pure-form input,.pure-form textarea,.pure-form select,.edit-form .inner,.pure-menu-horizontal,footer,.sticky-tab,#diff-jump,.button-tag,#new-watch-form,#new-watch-form input:not(.pure-button),code,.messages li,#checkbox-operations,.inline-warning,a,.watch-controls img{transition:color .4s ease,background-color .4s ease,background .4s ease,border-color .4s ease,box-shadow .4s ease}body{color:var(--color-text);background:var(--color-background-page);font-family:Helvetica Neue,Helvetica,Lucida Grande,Arial,Ubuntu,Cantarell,Fira Sans,sans-serif}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}.status-icon{display:inline-block;height:1rem;vertical-align:middle}.pure-table-even{background:var(--color-background)}a{text-decoration:none;color:var(--color-link)}a.github-link{color:var(--color-icon-github);margin:0 1rem 0 .5rem}a.github-link svg{fill:currentColor}a.github-link:hover{color:var(--color-icon-github-hover)}#search-result-info{color:#fff}button.toggle-button{vertical-align:middle;background:rgba(0,0,0,0);border:none;cursor:pointer;color:var(--color-icon-github)}button.toggle-button:hover{color:var(--color-icon-github-hover)}button.toggle-button svg{fill:currentColor}button.toggle-button .icon-light{display:block}.pure-menu-horizontal{background:var(--color-background);padding:5px;display:flex;justify-content:space-between;align-items:center}#pure-menu-horizontal-spinner{height:3px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;width:100%;animation:gradient 200s ease infinite}body.spinner-active #pure-menu-horizontal-spinner{animation:gradient 1s ease infinite}@keyframes gradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}.pure-menu-heading{color:var(--color-text-menu-heading)}.pure-menu-link{color:var(--color-text-menu-link)}.pure-menu-link:hover{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}.tab-pane-inner{scroll-margin-top:200px}section.content{padding-bottom:1em;flex-direction:column;display:flex;align-items:center;justify-content:center}@media only screen and (max-width: 980px){section.content{padding-top:80px}}@media only screen and (min-width: 980px){section.content{padding-top:100px}}code{background:var(--color-background-code);color:var(--color-text)}.inline-tag,.restock-label,.tracking-ldjson-price-data,.watch-tag-list,.watch-notif-profile,.processor-badge{white-space:nowrap;border-radius:5px;padding:2px 5px;margin-right:4px;line-height:1.2rem}.processor-badge{font-weight:900}.watch-notif-profile{color:var(--color-white);background:var(--color-link, #5c6bc0);opacity:.8;font-size:.7em;cursor:default}.watch-notif-profile.inherited{opacity:.5}.watch-notif-profile.system{background:var(--color-grey-600, #888);opacity:.55}a.notif-last-result{font-size:.82em;font-weight:bold;text-decoration:none;padding:2px 6px;border-radius:3px;white-space:nowrap}a.notif-last-result.ok{color:#2a7c2a;background:rgba(42,124,42,.1)}a.notif-last-result.test{color:#1a6fa8;background:rgba(26,111,168,.1)}a.notif-last-result.error{color:#c0392b;background:rgba(192,57,43,.1)}a.notif-last-result:hover{opacity:.75}.watch-tag-list{color:var(--color-white);background:var(--color-text-watch-tag-list);text-decoration:none}.watch-tag-list:hover{text-decoration:none;opacity:.8;cursor:pointer}.watch-tag-list:visited{color:var(--color-white)}@media(min-width: 768px){.box{margin:0 1em !important}}.box{max-width:100%;margin:0 .3em;flex-direction:column;display:flex;justify-content:center}body:after{content:"";background:linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%)}body:after,body:before{display:block;height:650px;position:absolute;top:0;left:0;width:100%;z-index:-1}body::after{opacity:.91}body::before{content:""}body:after,body:before{-webkit-clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%);clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%)}.button-small{font-size:85%}.button-xsmall{font-size:70%}.fetch-error{padding-top:1em;font-size:80%;max-width:400px;display:block}.pure-button-primary,a.pure-button-primary,.pure-button-selected,a.pure-button-selected{background-color:var(--color-background-button-primary)}.button-secondary{color:var(--color-text-button);border-radius:4px;text-shadow:0 1px 1px rgba(0,0,0,.2)}.button-success{background:var(--color-background-button-success)}.button-tag{background:var(--color-background-button-tag);color:var(--color-text-button);font-size:65%;border-bottom-left-radius:initial;border-bottom-right-radius:initial;margin-right:4px}.button-tag.active{background:var(--color-background-button-tag-active);font-weight:bold}.button-error{background:var(--color-background-button-error);color:var(--color-text-button-error)}.button-warning{background:var(--color-background-button-warning);color:var(--color-text-button-warning)}.button-secondary{background:var(--color-background-button-secondary)}.button-cancel{background:var(--color-background-button-cancel)}.messages li{list-style:none;padding:1em;border-radius:10px;color:var(--color-text-messages);font-weight:bold}.messages li.message{background:var(--color-background-messages-message)}.messages li.error{background:var(--color-background-messages-error)}.messages li.notice{background:var(--color-background-messages-notice)}.messages.with-share-link>*:hover{cursor:pointer}.notifications-wrapper{padding-top:.5rem}.notifications-wrapper #notification-test-log{margin-top:1rem;padding:1rem;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-width:100%;box-sizing:border-box;max-height:12rem;overflow-y:scroll;border:1px solid var(--color-border-notification);border-radius:5px}label:hover{cursor:pointer}.grey-form-border{border:1px solid var(--color-border-notification);padding:.5rem;border-radius:5px}#notification-error-log{border:1px solid var(--color-border-notification);padding:1rem;border-radius:5px;overflow-wrap:break-word}#token-table.pure-table td,#token-table.pure-table th{font-size:80%}.pure-form input[type=text].transparent-field{background-color:var(--color-background-new-watch-input-transparent) !important;color:var(--color-white) !important;border:1px solid hsla(0,0%,100%,.2) !important;box-shadow:none !important;-webkit-box-shadow:none !important}.pure-form input[type=text].transparent-field::placeholder{opacity:.5;color:hsla(0,0%,100%,.7);font-weight:lighter}#new-watch-form{background:var(--color-background-new-watch-form);padding:1em;border-radius:10px;margin-bottom:1em;max-width:100%}#new-watch-form #url::placeholder{font-weight:bold}#new-watch-form input{display:inline-block;margin-bottom:5px}#new-watch-form input:not(.pure-button){background-color:var(--color-background-new-watch-input);color:var(--color-text-new-watch-input)}#new-watch-form .label{display:none}#new-watch-form legend{color:var(--color-text-legend);font-weight:bold}@media only screen and (min-width: 760px){#new-watch-form #watch-add-wrapper-zone{display:flex;gap:.3rem;flex-direction:row;min-width:70vw}}#new-watch-form #watch-add-wrapper-zone>span{flex-grow:0}#new-watch-form #watch-add-wrapper-zone>span input{width:100%;padding-right:1em}#new-watch-form #watch-add-wrapper-zone>span:first-child{flex-grow:1}@media only screen and (max-width: 760px){#new-watch-form #watch-add-wrapper-zone #url{width:100%}}#new-watch-form #watch-group-tag{font-size:.9rem;padding:.3rem;display:flex;align-items:center;gap:.5rem;color:var(--color-white)}#new-watch-form #watch-group-tag label,#new-watch-form #watch-group-tag input{margin:0}#new-watch-form #watch-group-tag input{flex:1}#diff-col{padding-left:40px}#diff-jump{position:fixed;left:0px;top:120px;background:var(--color-background);padding:10px;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}#diff-jump a{color:var(--color-link);cursor:pointer;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-o-user-select:none}footer{padding:10px;background:var(--color-background);color:var(--color-text-footer);text-align:center}#feed-icon{vertical-align:middle}.sticky-tab{position:absolute;top:60px;font-size:65%;background:var(--color-background);padding:10px}@media only screen and (max-width: 980px){.sticky-tab{display:none}}.sticky-tab#left-sticky{left:0;position:fixed;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}.sticky-tab#right-sticky{right:0px}.sticky-tab#hosted-sticky{right:0px;top:100px;font-weight:bold}#new-version-text a{color:var(--color-link-new-version)}.watch-controls{color:#f8321b}.watch-controls .state-on img{opacity:.8}.watch-controls img{opacity:.2}.watch-controls img:hover{transition:opacity .3s;opacity:.8}.monospaced-textarea textarea{width:100%;font-family:monospace;white-space:pre;overflow-wrap:normal;overflow-x:auto}.pure-form fieldset{padding-top:0px}.pure-form fieldset ul{padding-bottom:0px;margin-bottom:0px}.pure-form .pure-control-group,.pure-form .pure-group,.pure-form .pure-controls{padding-bottom:1em}.pure-form .pure-control-group div,.pure-form .pure-group div,.pure-form .pure-controls div{margin:0px}.pure-form .pure-control-group .checkbox>*,.pure-form .pure-group .checkbox>*,.pure-form .pure-controls .checkbox>*{display:inline;vertical-align:middle}.pure-form .pure-control-group .checkbox>label,.pure-form .pure-group .checkbox>label,.pure-form .pure-controls .checkbox>label{padding-left:5px}.pure-form .pure-control-group legend,.pure-form .pure-group legend,.pure-form .pure-controls legend{color:var(--color-text-legend)}.pure-form .error input{background-color:var(--color-error-input)}.pure-form ul.errors{padding:.5em .6em;border:1px solid var(--color-error-list);border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form ul.errors li{margin-left:1em;color:var(--color-error-list)}.pure-form label{font-weight:bold}.pure-form textarea{width:100%}.pure-form .inline-radio ul{margin:0px;list-style:none}.pure-form .inline-radio ul li{display:flex;align-items:center;gap:1em}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 980px){.edit-form{padding:.5em;margin:0}#nav-menu{overflow-x:scroll}}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 980px){input[type=text]{width:100%}}.pure-table{border-color:var(--color-border-table-cell)}.pure-table thead{background-color:var(--color-background-table-thead);color:var(--color-text);border-bottom:1px solid var(--color-background-table-thead)}.pure-table td,.pure-table th{border-left-color:var(--color-border-table-cell)}.pure-table-striped tr:nth-child(2n-1) td{background-color:var(--color-table-stripe)}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{border:var(--color-border-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);background-color:var(--color-background-input);color:var(--color-text-input)}.pure-form input[type=color]:active,.pure-form input[type=date]:active,.pure-form input[type=datetime-local]:active,.pure-form input[type=datetime]:active,.pure-form input[type=email]:active,.pure-form input[type=month]:active,.pure-form input[type=number]:active,.pure-form input[type=password]:active,.pure-form input[type=search]:active,.pure-form input[type=tel]:active,.pure-form input[type=text]:active,.pure-form input[type=time]:active,.pure-form input[type=url]:active,.pure-form input[type=week]:active,.pure-form select:active,.pure-form textarea:active{background-color:var(--color-background-input)}input::placeholder,textarea::placeholder{color:var(--color-text-input-placeholder)}.m-d{min-width:100%}@media only screen and (min-width: 761px){.m-d{min-width:80%}}.pure-form-stacked>div:first-child{display:block}.tab-pane-inner{padding:0px}.tab-pane-inner:not(:target){display:none}.tab-pane-inner:target{display:block}.beta-logo{height:50px;right:-3px;top:-3px;position:absolute}#selector-header{padding-bottom:1em}body.full-width .edit-form{width:95%}.edit-form{min-width:70%;max-width:95%}.edit-form .box-wrap{position:relative}.edit-form .inner{background:var(--color-background);padding:20px}.edit-form #actions{display:block;background:var(--color-background)}.edit-form #actions .pure-control-group{display:flex;gap:.625em;flex-wrap:wrap}.edit-form .pure-form-message-inline{padding-left:0;color:var(--color-text-input-description)}.edit-form .pure-form-message-inline code{font-size:.875em}.border-fieldset{border:1px solid #ccc;padding:1rem;border-radius:5px;margin-bottom:1rem}.border-fieldset h3{margin-top:0}.border-fieldset fieldset:last-of-type{padding-bottom:0}.border-fieldset fieldset:last-of-type .pure-control-group{padding-bottom:0}ul{padding-left:1em;padding-top:0px;margin-top:4px}.time-check-widget tr{display:inline}.time-check-widget tr input[type=number]{width:5em}@media only screen and (max-width: 760px){.time-check-widget tbody{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:.625em .3125em;align-items:center}.time-check-widget tr{display:contents}.time-check-widget tr th{text-align:right;padding-right:5px}.time-check-widget tr input[type=number]{width:100%;max-width:5em}}#webdriver_delay{width:5em}#api-key:hover{cursor:pointer}#api-key-copy{color:var(--color-api-key)}.button-green{background-color:var(--color-background-button-green)}.button-red{background-color:var(--color-background-button-red)}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#checkbox-operations{background:var(--color-background-checkbox-operations);padding:1em;border-radius:10px;margin-bottom:1em;display:none}#checkbox-operations button{margin-bottom:3px;margin-top:3px;display:inline-flex;align-items:center}.checkbox-uuid>*{vertical-align:middle}.inline-warning{border:1px solid var(--color-border-warning);padding:.5rem;border-radius:5px;color:var(--color-warning)}.inline-warning>span{display:inline-block;vertical-align:middle}.inline-warning img.inline-warning-icon{display:inline;height:26px;vertical-align:middle}.tracking-ldjson-price-data{background-color:var(--color-background-button-green);color:#000;opacity:.6}.ldjson-price-track-offer{font-weight:bold;font-style:italic}.ldjson-price-track-offer a.pure-button{border-radius:3px;padding:3px;background-color:var(--color-background-button-green)}.price-follow-tag-icon{display:inline-block;height:.8rem;vertical-align:middle}#quick-watch-processor-type ul#processor{color:#fff;padding-left:0px}#quick-watch-processor-type ul#processor li{list-style:none;font-size:.9rem;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.5rem;margin-bottom:.5rem}#quick-watch-processor-type label,#quick-watch-processor-type input{padding:0;margin:0}.restock-label.in-stock{background-color:var(--color-background-button-green);color:#fff}.restock-label.not-in-stock{background-color:var(--color-background-button-cancel);color:#777}.restock-label.error{background-color:var(--color-background-button-error);color:#fff;opacity:.7}.restock-label svg{vertical-align:middle}#chrome-extension-link{padding:9px;border:1px solid var(--color-grey-800);border-radius:10px;vertical-align:middle}#chrome-extension-link img{height:21px;padding:2px;vertical-align:middle}#realtime-conn-error{position:fixed;bottom:0;left:0;background:var(--color-warning);padding:10px;font-size:.8rem;color:#fff;opacity:.8}#bottom-horizontal-offscreen{position:fixed;bottom:0;left:0;right:0;width:100%;min-height:50px;max-height:50vh;background:hsla(0,0%,100%,.7215686275);border-top:1px solid var(--color-border-table-cell);padding:10px;box-shadow:0 -2px 10px rgba(0,0,0,.2);z-index:100;overflow-y:auto;transition:opacity .3s ease-in-out;scroll-margin-bottom:10px;display:flex;justify-content:center;align-items:center}ul#highlightSnippetActions{list-style:none}ul#highlightSnippetActions li{display:inline-block} diff --git a/changedetectionio/store/updates.py b/changedetectionio/store/updates.py index 78b7173ca4d..8afc4876253 100644 --- a/changedetectionio/store/updates.py +++ b/changedetectionio/store/updates.py @@ -914,3 +914,117 @@ def update_30(self): tag.commit() logger.info(f"update_30: migrated tag {tag_uuid} restock_settings → processor_config_restock_diff") + def update_31(self): + """Migrate embedded notification settings to NotificationProfile objects. + + Creates NotificationProfile entries in settings.application.notification_profile_data + from any existing notification_urls/title/body/format fields on watches, tags, and + system settings. Deduplicates identical configs to avoid redundant profiles. + Cleans up the old flat fields afterwards. + + Safe to re-run: skips if notification_profile_data already exists. + """ + import uuid as uuid_mod + + app = self.data['settings']['application'] + + # Idempotency: if we already ran, skip + if app.get('notification_profile_data'): + logger.info("update_31: notification_profile_data already exists, skipping") + return + + app.setdefault('notification_profile_data', {}) + app.setdefault('notification_profiles', []) + + def _find_or_create(name, urls, title, body, fmt): + """Return UUID of a matching existing profile or create a new one.""" + for existing_uuid, p in app['notification_profile_data'].items(): + c = p.get('config', {}) + if (c.get('notification_urls') == urls + and c.get('notification_title') == title + and c.get('notification_body') == body + and c.get('notification_format') == fmt): + return existing_uuid + new_uuid = str(uuid_mod.uuid4()) + app['notification_profile_data'][new_uuid] = { + 'uuid': new_uuid, + 'name': name, + 'type': 'apprise', + 'config': { + 'notification_urls': urls, + 'notification_title': title, + 'notification_body': body, + 'notification_format': fmt, + }, + } + logger.info(f"update_31: created profile '{name}' ({new_uuid})") + return new_uuid + + # 1. System-wide settings + sys_urls = app.get('notification_urls', []) + if sys_urls: + uid = _find_or_create( + name="System Default", + urls=sys_urls, + title=app.get('notification_title'), + body=app.get('notification_body'), + fmt=app.get('notification_format'), + ) + if uid not in app['notification_profiles']: + app['notification_profiles'].append(uid) + + # 2. Tags + for tag_uuid, tag in app.get('tags', {}).items(): + tag_urls = tag.get('notification_urls', []) + if not tag_urls: + continue + uid = _find_or_create( + name=f"{tag.get('title', 'Group')} notifications", + urls=tag_urls, + title=tag.get('notification_title'), + body=tag.get('notification_body'), + fmt=tag.get('notification_format'), + ) + tag.setdefault('notification_profiles', []) + if uid not in tag['notification_profiles']: + tag['notification_profiles'].append(uid) + tag.commit() + + # 3. Watches + for watch_uuid, watch in self.data['watching'].items(): + watch_urls = watch.get('notification_urls', []) + if not watch_urls: + continue + label = watch.get('title') or watch.get('url', watch_uuid) + uid = _find_or_create( + name=f"{label[:60]} notifications", + urls=watch_urls, + title=watch.get('notification_title'), + body=watch.get('notification_body'), + fmt=watch.get('notification_format'), + ) + watch.setdefault('notification_profiles', []) + if uid not in watch['notification_profiles']: + watch['notification_profiles'].append(uid) + watch.commit() + + # 4. Remove old flat fields from system settings + for key in ('notification_urls', 'notification_title', 'notification_body', 'notification_format'): + app.pop(key, None) + + # 5. Strip old flat fields from tags + for tag in app.get('tags', {}).values(): + for key in ('notification_urls', 'notification_title', 'notification_body', 'notification_format'): + tag.pop(key, None) + tag.commit() + + # 6. Strip old flat fields from watches + for watch in self.data['watching'].values(): + for key in ('notification_urls', 'notification_title', 'notification_body', 'notification_format'): + watch.pop(key, None) + watch.commit() + + created = len(app['notification_profile_data']) + logger.success(f"update_31: migrated {created} notification profile(s)") + self.commit() + diff --git a/changedetectionio/templates/menu.html b/changedetectionio/templates/menu.html index d6c0b5f36c7..a92f72e263d 100644 --- a/changedetectionio/templates/menu.html +++ b/changedetectionio/templates/menu.html @@ -7,6 +7,9 @@ <li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('tags.') %}active{% endif %}"> <a href="{{ url_for('tags.tags_overview_page') }}" class="pure-menu-link">{{ _('GROUPS') }}</a> </li> + <li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('notification_profiles.') %}active{% endif %}"> + <a href="{{ url_for('notification_profiles.index') }}" class="pure-menu-link">{{ _('NOTIFICATIONS') }}</a> + </li> <li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('settings.') %}active{% endif %}"> <a href="{{ url_for('settings.settings_page') }}" class="pure-menu-link">{{ _('SETTINGS') }}</a> </li> diff --git a/changedetectionio/tests/test_filter_failure_notification.py b/changedetectionio/tests/test_filter_failure_notification.py index 40ef43f46f2..d2a2bbba178 100644 --- a/changedetectionio/tests/test_filter_failure_notification.py +++ b/changedetectionio/tests/test_filter_failure_notification.py @@ -1,7 +1,9 @@ import os import time from flask import url_for -from .util import set_original_response, wait_for_all_checks, wait_for_notification_endpoint_output, delete_all_watches +from .util import (set_original_response, wait_for_all_checks, wait_for_notification_endpoint_output, + delete_all_watches, add_notification_profile, set_watch_notification_profile, + clear_notification_profiles) from ..notification import valid_notification_formats @@ -25,36 +27,67 @@ def run_filter_test(client, live_server, content_filter, app_notification_format # Response WITHOUT the filter ID element set_original_response(datastore_path=datastore_path) - live_server.app.config['DATASTORE'].data['settings']['application']['notification_format'] = app_notification_format - # Goto the edit page, add our ignore text notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'post') - - # Add our URL to the import page test_url = url_for('test_endpoint', _external=True) # cleanup for the next - client.get( - url_for("ui.form_delete", uuid="all"), - follow_redirects=True - ) + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) notification_file = os.path.join(datastore_path, "notification.txt") if os.path.isfile(notification_file): os.unlink(notification_file) - uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) - res = client.get(url_for("watchlist.index")) + datastore = client.application.config.get('DATASTORE') + clear_notification_profiles(datastore) + uuid = datastore.add_watch(url=test_url) + res = client.get(url_for("watchlist.index")) assert b'No web page change detection watches configured' not in res.data - client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) wait_for_all_checks(client) uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) - assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure" +<<<<<<< HEAD + # Create a notification profile for this watch and link it + profile_uuid = add_notification_profile( + datastore, + notification_url=notification_url, + notification_title="New ChangeDetection.io Notification - {{watch_url}}", + notification_body=( + "BASE URL: {{base_url}}\n" + "Watch URL: {{watch_url}}\n" + "Watch UUID: {{watch_uuid}}\n" + "Watch title: {{watch_title}}\n" + "Watch tag: {{watch_tag}}\n" + "Preview: {{preview_url}}\n" + "Diff URL: {{diff_url}}\n" + "Snapshot: {{current_snapshot}}\n" + "Diff: {{diff}}\n" + "Diff Full: {{diff_full}}\n" + "Diff as Patch: {{diff_patch}}\n" + ":-)" + ), + notification_format=app_notification_format, + name="Filter Failure Test", + ) + set_watch_notification_profile(datastore, uuid, profile_uuid) + + # Update watch: set tags, title, filter_failure_notification_send + watch_data = { + "fetch_backend": "html_requests", + "filter_failure_notification_send": 'y', + "time_between_check_use_default": "y", + "headers": "", + "tags": "my tag", + "title": "my title 123", + "time_between_check-hours": 5, + "url": test_url, + "notification_profiles": profile_uuid, + } +======= watch_data = {"notification_urls": notification_url, "notification_title": "New ChangeDetection.io Notification - {{watch_url}}", "notification_body": "BASE URL: {{base_url}}\n" @@ -79,13 +112,13 @@ def run_filter_test(client, live_server, content_filter, app_notification_format "time_between_check-hours": 5, # So that the queue runner doesnt also put it in "url": test_url, } +>>>>>>> dev res = client.post( url_for("ui.ui_edit.edit_page", uuid=uuid), data=watch_data, follow_redirects=True ) - assert b"Updated watch." in res.data wait_for_all_checks(client) assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure" @@ -99,16 +132,13 @@ def run_filter_test(client, live_server, content_filter, app_notification_format ) assert b"Updated watch." in res.data - # It should have checked once so far and given this error (because we hit SAVE) - wait_for_all_checks(client) assert not os.path.isfile(notification_file) # Hitting [save] would have triggered a recheck, and we have a filter, so this would be ONE failure assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 1, "Should have been checked once" - # recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented) - # Add 4 more checks + # recheck it up to just before the threshold checked = 0 ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0) for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2): @@ -137,24 +167,18 @@ def run_filter_test(client, live_server, content_filter, app_notification_format assert 'Your configured CSS/xPath filters' in notification - # Text (or HTML conversion) markup to make the notifications a little nicer should have worked if app_notification_format.startswith('html'): - # apprise should have used sax-escape (' instead of ", " etc), lets check it worked - from apprise.conversion import convert_between from apprise.common import NotifyFormat escaped_filter = convert_between(NotifyFormat.TEXT, NotifyFormat.HTML, content_filter) - assert escaped_filter in notification or escaped_filter.replace('"', '"') in notification - assert 'a href="' in notification # Quotes should still be there so the link works - + assert 'a href="' in notification else: assert 'a href' not in notification assert content_filter in notification # Remove it and prove that it doesn't trigger when not expected - # It should register a change, but no 'filter not found' os.unlink(notification_file) set_response_with_filter(datastore_path) @@ -164,9 +188,7 @@ def run_filter_test(client, live_server, content_filter, app_notification_format wait_for_all_checks(client) wait_for_notification_endpoint_output(datastore_path=datastore_path) - # It should have sent a notification, but.. assert os.path.isfile(notification_file) - # but it should not contain the info about a failed filter (because there was none in this case) with open(notification_file, 'r') as f: notification = f.read() assert not 'CSS/xPath filter was not present in the page' in notification @@ -175,23 +197,19 @@ def run_filter_test(client, live_server, content_filter, app_notification_format assert uuid in notification # cleanup for the next - client.get( - url_for("ui.form_delete", uuid="all"), - follow_redirects=True - ) + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) os.unlink(notification_file) delete_all_watches(client) + clear_notification_profiles(datastore) def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage, datastore_path): - # # live_server_setup(live_server) # Setup on conftest per function run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('htmlcolor'), datastore_path=datastore_path) # Check markup send conversion didnt affect plaintext preference run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('text'), datastore_path=datastore_path) delete_all_watches(client) def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage, datastore_path): - # # live_server_setup(live_server) # Setup on conftest per function run_filter_test(client=client, live_server=live_server, content_filter='//*[@id="nope-doesnt-exist"]', app_notification_format=valid_notification_formats.get('htmlcolor'), datastore_path=datastore_path) delete_all_watches(client) diff --git a/changedetectionio/tests/test_notification.py b/changedetectionio/tests/test_notification.py index 96b6a62adb3..1f55bd891e8 100644 --- a/changedetectionio/tests/test_notification.py +++ b/changedetectionio/tests/test_notification.py @@ -5,8 +5,11 @@ from flask import url_for from loguru import logger -from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output -from . util import extract_UUID_from_client +from .util import (set_original_response, set_modified_response, set_more_modified_response, + live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output, + add_notification_profile, set_watch_notification_profile, set_system_notification_profile, + clear_notification_profiles) +from .util import extract_UUID_from_client import logging import base64 @@ -16,44 +19,32 @@ default_notification_title, valid_notification_formats ) from ..diff import HTML_CHANGED_STYLE -from ..model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH from ..notification_service import FormattableTimestamp # Hard to just add more live server URLs when one test is already running (I think) # So we add our test here (was in a different file) def test_check_notification(client, live_server, measure_memory_usage, datastore_path): - - set_original_response(datastore_path=datastore_path) - - # Re 360 - new install should have defaults set - res = client.get(url_for("settings.settings_page")) - notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')+"?status_code=204" - assert default_notification_body.encode() in res.data - assert default_notification_title.encode() in res.data - - ##################### - # Set this up for when we remove the notification from the watch, it should fallback with these details - res = client.post( - url_for("settings.settings_page"), - data={"application-notification_urls": notification_url, - "application-notification_title": "fallback-title "+default_notification_title, - "application-notification_body": "fallback-body "+default_notification_body, - "application-notification_format": default_notification_format, - "requests-time_between_check-minutes": 180}, - follow_redirects=True - ) + set_original_response(datastore_path=datastore_path) - assert b"Settings updated." in res.data + notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') + "?status_code=204" + datastore = client.application.config.get('DATASTORE') + # Settings page should load OK with no inline notification fields (they're now in profiles) res = client.get(url_for("settings.settings_page")) - for k,v in valid_notification_formats.items(): - if k == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH: - continue - assert f'value="{k}"'.encode() in res.data # Should be by key NOT value - assert f'value="{v}"'.encode() not in res.data # Should be by key NOT value - + assert res.status_code == 200 + + # Create a system-level fallback profile + sys_profile_uuid = add_notification_profile( + datastore, + notification_url=notification_url, + notification_title="fallback-title " + default_notification_title, + notification_body="fallback-body " + default_notification_body, + notification_format=default_notification_format, + name="System Fallback", + ) + set_system_notification_profile(datastore, sys_profile_uuid) # When test mode is in BASE_URL env mode, we should see this already configured env_base_url = os.getenv('BASE_URL', '').strip() @@ -64,7 +55,6 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore else: logging.debug(">>> SKIPPING BASE_URL check") - # re #242 - when you edited an existing new entry, it would not correctly show the notification settings # Add our URL to the import page test_url = url_for('test_endpoint', _external=True) res = client.post( @@ -78,66 +68,66 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore wait_for_all_checks(client) # We write the PNG to disk, but a JPEG should appear in the notification - # Write the last screenshot png testimage_png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' - uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) screenshot_dir = os.path.join(datastore_path, str(uuid)) os.makedirs(screenshot_dir, exist_ok=True) with open(os.path.join(screenshot_dir, 'last-screenshot.png'), 'wb') as f: f.write(base64.b64decode(testimage_png)) - # Goto the edit page, add our ignore text - # Add our URL to the import page - - print (">>>> Notification URL: "+notification_url) - - notification_form_data = {"notification_urls": notification_url, - "notification_title": "New ChangeDetection.io Notification - {{watch_url}}", - "notification_body": "BASE URL: {{base_url}}\n" - "Watch URL: {{watch_url}}\n" - "Watch UUID: {{watch_uuid}}\n" - "Watch title: {{watch_title}}\n" - "Watch tag: {{watch_tag}}\n" - "Preview: {{preview_url}}\n" - "Diff URL: {{diff_url}}\n" - "Snapshot: {{current_snapshot}}\n" - "Diff: {{diff}}\n" - "Diff Added: {{diff_added}}\n" - "Diff Removed: {{diff_removed}}\n" - "Diff Full: {{diff_full}}\n" - "Diff with args: {{diff(context=3)}}" - "Diff as Patch: {{diff_patch}}\n" - "Change datetime: {{change_datetime}}\n" - "Change datetime format: Weekday {{change_datetime(format='%A')}}\n" - "Change datetime format: {{change_datetime(format='%Y-%m-%dT%H:%M:%S%z')}}\n" - ":-)", - "notification_screenshot": True, - "notification_format": 'text'} - - notification_form_data.update({ - "url": test_url, - "tags": "my tag, my second tag", - "title": "my title", - "headers": "", - "browser_profile": "direct_http_requests", - "time_between_check_use_default": "y"}) + print(">>>> Notification URL: " + notification_url) + + # Create a watch-level notification profile with the full body template + watch_notification_body = ( + "BASE URL: {{base_url}}\n" + "Watch URL: {{watch_url}}\n" + "Watch UUID: {{watch_uuid}}\n" + "Watch title: {{watch_title}}\n" + "Watch tag: {{watch_tag}}\n" + "Preview: {{preview_url}}\n" + "Diff URL: {{diff_url}}\n" + "Snapshot: {{current_snapshot}}\n" + "Diff: {{diff}}\n" + "Diff Added: {{diff_added}}\n" + "Diff Removed: {{diff_removed}}\n" + "Diff Full: {{diff_full}}\n" + "Diff with args: {{diff(context=3)}}" + "Diff as Patch: {{diff_patch}}\n" + "Change datetime: {{change_datetime}}\n" + "Change datetime format: Weekday {{change_datetime(format='%A')}}\n" + "Change datetime format: {{change_datetime(format='%Y-%m-%dT%H:%M:%S%z')}}\n" + ":-)" + ) + watch_profile_uuid = add_notification_profile( + datastore, + notification_url=notification_url, + notification_title="New ChangeDetection.io Notification - {{watch_url}}", + notification_body=watch_notification_body, + notification_format='text', + name="Watch Profile", + ) + # Update the watch: set tags, title, screenshot, and link the profile res = client.post( url_for("ui.ui_edit.edit_page", uuid="first"), - data=notification_form_data, + data={ + "url": test_url, + "tags": "my tag, my second tag", + "title": "my title", + "headers": "", + "fetch_backend": "html_requests", + "notification_screenshot": True, + "time_between_check_use_default": "y", + "notification_profiles": watch_profile_uuid, + }, follow_redirects=True ) assert b"Updated watch." in res.data - - # Hit the edit page, be sure that we saved it - # Re #242 - wasnt saving? - res = client.get( - url_for("ui.ui_edit.edit_page", uuid="first")) - assert bytes(notification_url.encode('utf-8')) in res.data - assert bytes("New ChangeDetection.io Notification".encode('utf-8')) in res.data + # Hit the edit page — profile name should appear + res = client.get(url_for("ui.ui_edit.edit_page", uuid="first")) + assert b"Watch Profile" in res.data ## Now recheck, and it should have sent the notification wait_for_all_checks(client) @@ -152,15 +142,11 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore res = client.get(url_for("watchlist.index")) assert b'notification-error' not in res.data - - # Verify what was sent as a notification, this file should exist + # Verify what was sent as a notification with open(os.path.join(datastore_path, "notification.txt"), "r") as f: notification_submission = f.read() os.unlink(os.path.join(datastore_path, "notification.txt")) - # Did we see the URL that had a change, in the notification? - # Diff was correctly executed - assert "Diff Full: Some initial text" in notification_submission assert "Diff: (changed) Which is across multiple lines" in notification_submission assert "(into) which has this one new line" in notification_submission @@ -176,57 +162,44 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore assert test_url in notification_submission assert ':-)' in notification_submission - # Check the attachment was added, and that it is a JPEG from the original PNG + # Check the attachment was added notification_submission_object = json.loads(notification_submission) assert notification_submission_object import time - # Could be from a few seconds ago (when the notification was fired vs in this test checking), so check for any times_possible = [str(FormattableTimestamp(int(time.time()) - i)) for i in range(15)] assert any(t in notification_submission for t in times_possible) txt = f"Weekday {FormattableTimestamp(int(time.time()))(format='%A')}" assert txt in notification_submission - - - # We keep PNG screenshots for now - # IF THIS FAILS YOU SHOULD BE TESTING WITH ENV VAR REMOVE_REQUESTS_OLD_SCREENSHOTS=False assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.png' assert len(notification_submission_object['attachments'][0]['base64']) assert notification_submission_object['attachments'][0]['mimetype'] == 'image/png' jpeg_in_attachment = base64.b64decode(notification_submission_object['attachments'][0]['base64']) - # Assert that the JPEG is readable (didn't get chewed up somewhere) from PIL import Image import io assert Image.open(io.BytesIO(jpeg_in_attachment)) if env_base_url: - # Re #65 - did we see our BASE_URl ? - logging.debug (">>> BASE_URL checking in notification: %s", env_base_url) + logging.debug(">>> BASE_URL checking in notification: %s", env_base_url) assert env_base_url in notification_submission else: logging.debug(">>> Skipping BASE_URL check") - # This should insert the {current_snapshot} set_more_modified_response(datastore_path=datastore_path) client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) wait_for_all_checks(client) wait_for_notification_endpoint_output(datastore_path=datastore_path) - # Verify what was sent as a notification, this file should exist with open(os.path.join(datastore_path, "notification.txt"), "r") as f: notification_submission = f.read() assert "Ohh yeah awesome" in notification_submission - # Prove that "content constantly being marked as Changed with no Updating causes notification" is not a thing - # https://github.com/dgtlmoon/changedetection.io/discussions/192 os.unlink(os.path.join(datastore_path, "notification.txt")) - - # Trigger a check client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) wait_for_all_checks(client) client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) @@ -236,30 +209,18 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore assert os.path.exists(os.path.join(datastore_path, "notification.txt")) == False res = client.get(url_for("settings.notification_logs")) - # be sure we see it in the output log assert b'New ChangeDetection.io Notification - ' + test_url.encode('utf-8') in res.data + # Now unlink the watch profile — it should fall back to the system profile set_original_response(datastore_path=datastore_path) - res = client.post( - url_for("ui.ui_edit.edit_page", uuid="first"), - data={ - "url": test_url, - "tags": "my tag", - "title": "my title", - "notification_urls": '', - "notification_title": '', - "notification_body": '', - "notification_format": default_notification_format, - "browser_profile": "direct_http_requests", - "time_between_check_use_default": "y"}, - follow_redirects=True - ) - assert b"Updated watch." in res.data + watch = datastore.data['watching'][uuid] + watch['notification_profiles'] = [] + watch.commit() + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) wait_for_all_checks(client) wait_for_notification_endpoint_output(datastore_path=datastore_path) - # Verify what was sent as a notification, this file should exist with open(os.path.join(datastore_path, "notification.txt"), "r") as f: notification_submission = f.read() assert "fallback-title" in notification_submission @@ -270,58 +231,51 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore url_for("ui.form_delete", uuid="all"), follow_redirects=True ) + clear_notification_profiles(datastore) def test_notification_urls_jinja2_apprise_integration(client, live_server, measure_memory_usage, datastore_path): - # # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation test_notification_url = "hassio://127.0.0.1/longaccesstoken?verify=no&nid={{watch_uuid}}" - - res = client.post( - url_for("settings.settings_page"), - data={ - "application-minutes_between_check": 180, - "application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了", "another": "{{diff|truncate(1500)}}" }', - "application-notification_format": default_notification_format, - "application-notification_urls": test_notification_url, - # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation - "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }} {{diff|truncate(200)}} ", - }, - follow_redirects=True + datastore = client.application.config.get('DATASTORE') + + profile_uuid = add_notification_profile( + datastore, + notification_url=test_notification_url, + notification_body='{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了", "another": "{{diff|truncate(1500)}}" }', + notification_format=default_notification_format, + notification_title="New ChangeDetection.io Notification - {{ watch_url }} {{diff|truncate(200)}} ", + name="Jinja2 Integration Test", ) - assert b'Settings updated' in res.data - assert '网站监测'.encode() in res.data - assert b'{{diff|truncate(1500)}}' in res.data - assert b'{{diff|truncate(200)}}' in res.data + set_system_notification_profile(datastore, profile_uuid) + # Verify settings page loads OK + res = client.get(url_for("settings.settings_page")) + assert res.status_code == 200 + clear_notification_profiles(datastore) def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_memory_usage, datastore_path): - # test_endpoint - that sends the contents of a file # test_notification_endpoint - that takes a POST and writes it to file (test-datastore/notification.txt) # CUSTOM JSON BODY CHECK for POST:// set_original_response(datastore_path=datastore_path) - # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation - test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?status_code=204&watch_uuid={{ watch_uuid }}&xxx={{ watch_url }}&now={% now 'Europe/London', '%Y-%m-%d' %}&+custom-header=123&+second=hello+world%20%22space%22" - - res = client.post( - url_for("settings.settings_page"), - data={ - "application-minutes_between_check": 180, - "application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了" }', - "application-notification_format": default_notification_format, - "application-notification_urls": test_notification_url, - # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation - "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }} ", - }, - follow_redirects=True + test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?status_code=204&watch_uuid={{ watch_uuid }}&xxx={{ watch_url }}&now={% now 'Europe/London', '%Y-%m-%d' %}&+custom-header=123&+second=hello+world%20%22space%22" + + datastore = client.application.config.get('DATASTORE') + profile_uuid = add_notification_profile( + datastore, + notification_url=test_notification_url, + notification_body='{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了" }', + notification_format=default_notification_format, + notification_title="New ChangeDetection.io Notification - {{ watch_url }} ", + name="Custom Endpoint Test", ) - assert b'Settings updated' in res.data + set_system_notification_profile(datastore, profile_uuid) # Add a watch and trigger a HTTP POST test_url = url_for('test_endpoint', _external=True) @@ -336,7 +290,6 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me wait_for_notification_endpoint_output(datastore_path=datastore_path) - # Check no errors were recorded, because we asked for 204 which is slightly uncommon but is still OK res = client.get(url_for("watchlist.index")) assert b'notification-error' not in res.data @@ -348,7 +301,6 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me assert j['secret'] == 444 assert j['somebug'] == '网站监测 内容更新了' - # URL check, this will always be converted to lowercase assert os.path.isfile(os.path.join(datastore_path, "notification-url.txt")) with open(os.path.join(datastore_path, "notification-url.txt"), 'r') as f: @@ -361,13 +313,11 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me # Check our watch_uuid appeared assert f'watch_uuid={watch_uuid}' in notification_url - with open(os.path.join(datastore_path, "notification-headers.txt"), 'r') as f: notification_headers = f.read() assert 'custom-header: 123' in notification_headers.lower() assert 'second: hello world "space"' in notification_headers.lower() - # Should always be automatically detected as JSON content type even when we set it as 'Plain Text' (default) assert os.path.isfile(os.path.join(datastore_path, "notification-content-type.txt")) with open(os.path.join(datastore_path, "notification-content-type.txt"), 'r') as f: @@ -379,6 +329,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me url_for("ui.form_delete", uuid="all"), follow_redirects=True ) + clear_notification_profiles(datastore) #2510 @@ -387,24 +338,22 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage set_original_response(datastore_path=datastore_path) if os.path.isfile(os.path.join(datastore_path, "notification.txt")): - os.unlink(os.path.join(datastore_path, "notification.txt")) \ + os.unlink(os.path.join(datastore_path, "notification.txt")) - # 1995 UTF-8 content should be encoded test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}' + datastore = client.application.config.get('DATASTORE') - # otherwise other settings would have already existed from previous tests in this file - res = client.post( - url_for("settings.settings_page"), - data={ - "application-minutes_between_check": 180, - "application-notification_body": test_body, - "application-notification_format": default_notification_format, - "application-notification_urls": "", - "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", - }, - follow_redirects=True + test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}&+custom-header=123" + + profile_uuid = add_notification_profile( + datastore, + notification_url=test_notification_url, + notification_body=test_body, + notification_format=default_notification_format, + notification_title="New ChangeDetection.io Notification - {{ watch_url }}", + name="Global Test Profile", ) - assert b'Settings updated' in res.data + set_system_notification_profile(datastore, profile_uuid) test_url = url_for('test_endpoint', _external=True) res = client.post( @@ -412,15 +361,14 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage data={"url": test_url, "tags": 'nice one'}, follow_redirects=True ) - assert b"Watch added" in res.data + wait_for_all_checks(client) - test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123" - - ######### Test global/system settings + ######### Test using the resolved profiles endpoint + uuid = next(iter(datastore.data['watching'])) res = client.post( - url_for("ui.ui_notification.ajax_callback_send_notification_test")+"?mode=global-settings", - data={"notification_urls": test_notification_url}, + url_for("ui.ui_notification.ajax_callback_send_notification_test", watch_uuid=uuid), + data={}, follow_redirects=True ) @@ -431,7 +379,6 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage x = f.read() assert 'change detection is cool 网站监测 内容更新了' in x if 'html' in default_notification_format: - # this should come from default text when in global/system mode here changedetectionio/notification_service.py assert 'title="Changed into">Example text:' in x else: assert 'title="Changed into">Example text:' not in x @@ -440,31 +387,21 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage os.unlink(os.path.join(datastore_path, "notification.txt")) - ######### Test group/tag settings - res = client.post( - url_for("ui.ui_notification.ajax_callback_send_notification_test")+"?mode=group-settings", - data={"notification_urls": test_notification_url}, - follow_redirects=True + ## Check that 'test' catches errors with a bad profile + bad_profile_uuid = add_notification_profile( + datastore, + notification_url='post://akjsdfkjasdkfjasdkfjasdkjfas232323/should-error', + name="Bad Profile", ) + set_watch_notification_profile(datastore, uuid, bad_profile_uuid) + # Remove system profile from watch so only bad profile fires + watch = datastore.data['watching'][uuid] + watch['notification_profiles'] = [bad_profile_uuid] + watch.commit() - assert res.status_code != 400 - assert res.status_code != 500 - - # Give apprise time to fire - wait_for_notification_endpoint_output(datastore_path=datastore_path) - - with open(os.path.join(datastore_path, "notification.txt"), 'r') as f: - x = f.read() - # Should come from notification.py default handler when there is no notification body to pull from - assert 'change detection is cool 网站监测 内容更新了' in x - - ## Check that 'test' catches errors - test_notification_url = 'post://akjsdfkjasdkfjasdkfjasdkjfas232323/should-error' - - ######### Test global/system settings res = client.post( - url_for("ui.ui_notification.ajax_callback_send_notification_test")+"?mode=global-settings", - data={"notification_urls": test_notification_url}, + url_for("ui.ui_notification.ajax_callback_send_notification_test", watch_uuid=uuid), + data={}, follow_redirects=True ) assert res.status_code == 400 @@ -477,47 +414,54 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage b"Connection error occurred" in res.data or b"net::ERR_NAME_NOT_RESOLVED" in res.data ) - + client.get( url_for("ui.form_delete", uuid="all"), follow_redirects=True ) - ######### Test global/system settings - When everything is deleted it should give a helpful error - # See #2727 + # When everything is deleted with no watches, expect helpful error res = client.post( - url_for("ui.ui_notification.ajax_callback_send_notification_test")+"?mode=global-settings", - data={"notification_urls": test_notification_url}, + url_for("ui.ui_notification.ajax_callback_send_notification_test"), + data={}, follow_redirects=True ) assert res.status_code == 400 assert b"Error: You must have atleast one watch configured for 'test notification' to work" in res.data + clear_notification_profiles(datastore) + #2510 def test_single_send_test_notification_on_watch(client, live_server, measure_memory_usage, datastore_path): set_original_response(datastore_path=datastore_path) if os.path.isfile(os.path.join(datastore_path, "notification.txt")): - os.unlink(os.path.join(datastore_path, "notification.txt")) \ - + os.unlink(os.path.join(datastore_path, "notification.txt")) test_url = url_for('test_endpoint', _external=True) uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) wait_for_all_checks(client) - test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123" - # 1995 UTF-8 content should be encoded + test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}&+custom-header=123" test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}\n\nCurrent snapshot: {{current_snapshot}}' - ######### Test global/system settings + datastore = client.application.config.get('DATASTORE') + + profile_uuid = add_notification_profile( + datastore, + notification_url=test_notification_url, + notification_body=test_body, + notification_format=default_notification_format, + notification_title="New ChangeDetection.io Notification - {{ watch_url }}", + name="Single Watch Test", + ) + set_watch_notification_profile(datastore, uuid, profile_uuid) + + ######### Test single-watch notification via resolved profiles res = client.post( - url_for("ui.ui_notification.ajax_callback_send_notification_test")+f"/{uuid}", - data={"notification_urls": test_notification_url, - "notification_body": test_body, - "notification_format": default_notification_format, - "notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", - }, + url_for("ui.ui_notification.ajax_callback_send_notification_test", watch_uuid=uuid), + data={}, follow_redirects=True ) @@ -528,15 +472,16 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem x = f.read() assert 'change detection is cool 网站监测 内容更新了' in x if 'html' in default_notification_format: - # this should come from default text when in global/system mode here changedetectionio/notification_service.py assert 'title="Changed into">Example text:' in x else: assert 'title="Changed into">Example text:' not in x assert 'span' not in x assert 'Example text:' in x - #3720 current_snapshot check, was working but lets test it exactly. + #3720 current_snapshot check assert 'Current snapshot: Example text: example test' in x os.unlink(os.path.join(datastore_path, "notification.txt")) + clear_notification_profiles(datastore) + def _test_color_notifications(client, notification_body_token, datastore_path): @@ -545,23 +490,18 @@ def _test_color_notifications(client, notification_body_token, datastore_path): if os.path.isfile(os.path.join(datastore_path, "notification.txt")): os.unlink(os.path.join(datastore_path, "notification.txt")) + test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}&+custom-header=123" - test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123" - - - # otherwise other settings would have already existed from previous tests in this file - res = client.post( - url_for("settings.settings_page"), - data={ - "application-minutes_between_check": 180, - "application-notification_body": notification_body_token, - "application-notification_format": "htmlcolor", - "application-notification_urls": test_notification_url, - "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", - }, - follow_redirects=True + datastore = client.application.config.get('DATASTORE') + profile_uuid = add_notification_profile( + datastore, + notification_url=test_notification_url, + notification_body=notification_body_token, + notification_format="htmlcolor", + notification_title="New ChangeDetection.io Notification - {{ watch_url }}", + name="Color Notification Test", ) - assert b'Settings updated' in res.data + set_system_notification_profile(datastore, profile_uuid) test_url = url_for('test_endpoint', _external=True) res = client.post( @@ -569,14 +509,11 @@ def _test_color_notifications(client, notification_body_token, datastore_path): data={"url": test_url, "tags": 'nice one'}, follow_redirects=True ) - assert b"Watch added" in res.data wait_for_all_checks(client) - set_modified_response(datastore_path=datastore_path) - res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) assert b'Queued 1 watch for rechecking.' in res.data @@ -592,9 +529,10 @@ def _test_color_notifications(client, notification_body_token, datastore_path): url_for("ui.form_delete", uuid="all"), follow_redirects=True ) + clear_notification_profiles(datastore) + # Just checks the format of the colour notifications was correct def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path): - _test_color_notifications(client, '{{diff}}',datastore_path=datastore_path) - _test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path) - + _test_color_notifications(client, '{{diff}}', datastore_path=datastore_path) + _test_color_notifications(client, '{{diff_full}}', datastore_path=datastore_path) diff --git a/changedetectionio/tests/test_notification_errors.py b/changedetectionio/tests/test_notification_errors.py index 71274480eb3..83483c278b9 100644 --- a/changedetectionio/tests/test_notification_errors.py +++ b/changedetectionio/tests/test_notification_errors.py @@ -1,15 +1,16 @@ import os import time from flask import url_for -from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, delete_all_watches +from .util import (set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, + delete_all_watches, add_notification_profile, set_watch_notification_profile, + clear_notification_profiles) import logging def test_check_notification_error_handling(client, live_server, measure_memory_usage, datastore_path): - # live_server_setup(live_server) # Setup on conftest per function set_original_response(datastore_path=datastore_path) - # Set a URL and fetch it, then set a notification URL which is going to give errors + # Set a URL and fetch it, then set notification profiles — one broken, one working test_url = url_for('test_endpoint', _external=True) res = client.post( url_for("ui.ui_views.form_quick_watch_add"), @@ -24,6 +25,19 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u working_notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') broken_notification_url = "jsons://broken-url-xxxxxxxx123/test" +<<<<<<< HEAD + datastore = client.application.config.get('DATASTORE') + uuid = next(iter(datastore.data['watching'])) + + # A broken URL in a profile should not block a working profile from firing + broken_profile_uuid = add_notification_profile( + datastore, + notification_url=broken_notification_url, + notification_title="xxx", + notification_body="xxxxx", + notification_format='text', + name="Broken Profile", +======= res = client.post( url_for("ui.ui_edit.edit_page", uuid="first"), # A URL with errors should not block the one that is working @@ -39,34 +53,37 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u "browser_profile": "direct_http_requests", "time_between_check_use_default": "y"}, follow_redirects=True +>>>>>>> dev ) - assert b"Updated watch." in res.data - + working_profile_uuid = add_notification_profile( + datastore, + notification_url=working_notification_url, + notification_title="xxx", + notification_body="xxxxx", + notification_format='text', + name="Working Profile", + ) + set_watch_notification_profile(datastore, uuid, broken_profile_uuid) + set_watch_notification_profile(datastore, uuid, working_profile_uuid) + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) wait_for_all_checks(client) - found=False + found = False for i in range(1, 10): - logging.debug("Fetching watch overview....") - res = client.get( - url_for("watchlist.index")) - + res = client.get(url_for("watchlist.index")) if bytes("Notification error detected".encode('utf-8')) in res.data: - found=True + found = True break - time.sleep(1) assert found - # The error should show in the notification logs - res = client.get( - url_for("settings.notification_logs")) - # Check for various DNS/connection error patterns that may appear in different environments + res = client.get(url_for("settings.notification_logs")) found_name_resolution_error = ( - b"No address found" in res.data or + b"No address found" in res.data or b"Name or service not known" in res.data or b"nodename nor servname provided" in res.data or b"Temporary failure in name resolution" in res.data or @@ -76,10 +93,11 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u ) assert found_name_resolution_error - # And the working one, which is after the 'broken' one should still have fired + # And the working one should still have fired with open(os.path.join(datastore_path, "notification.txt"), "r") as f: notification_submission = f.read() os.unlink(os.path.join(datastore_path, "notification.txt")) assert 'xxxxx' in notification_submission delete_all_watches(client) + clear_notification_profiles(datastore) diff --git a/changedetectionio/tests/test_notification_profile_custom_type.py b/changedetectionio/tests/test_notification_profile_custom_type.py new file mode 100644 index 00000000000..e2e15347224 --- /dev/null +++ b/changedetectionio/tests/test_notification_profile_custom_type.py @@ -0,0 +1,111 @@ +""" +Test registering a custom NotificationProfileType via registry.register(). + +Verifies that: +- A third-party type can be registered alongside the built-in Apprise type +- The registry resolves it correctly by type_id +- A watch linked to a profile of that type fires send() when a change is detected +- The custom send() receives a populated NotificationContextData object +""" + +import uuid as uuid_mod +from flask import url_for + +from changedetectionio.tests.util import ( + set_original_response, + set_modified_response, + live_server_setup, + wait_for_all_checks, + wait_for_notification_endpoint_output, +) + + +def test_custom_notification_profile_type_registration(client, live_server, measure_memory_usage, datastore_path): + """ + Register a custom NotificationProfileType that POSTs to the test endpoint, + link it to a watch via a profile, trigger a change, and confirm the custom + send() was called. + """ + from changedetectionio.notification_profiles.registry import registry, NotificationProfileType + + # ── 1. Define and register a custom type ───────────────────────────────── + + class WebhookProfileType(NotificationProfileType): + """Simple webhook type: POSTs watch_url + watch_title JSON to a webhook_url.""" + type_id = 'test_webhook' + display_name = 'Test Webhook' + icon = 'send' + template = 'notification_profiles/types/apprise.html' # reuse apprise template for UI + + def send(self, config: dict, n_object, datastore) -> bool: + import requests as req + webhook_url = config.get('webhook_url') + if not webhook_url: + return False + payload = { + 'watch_url': n_object.get('watch_url', ''), + 'watch_title': n_object.get('watch_title', ''), + 'diff': n_object.get('diff', ''), + } + req.post(webhook_url, json=payload, timeout=5) + return True + + def validate(self, config: dict) -> None: + if not config.get('webhook_url'): + raise ValueError("webhook_url is required") + + # Register — idempotent if test runs more than once in a session + registry.register(WebhookProfileType) + assert registry.get('test_webhook') is not None, "Custom type should be in registry after register()" + assert registry.get('test_webhook').type_id == 'test_webhook' + + # ── 2. Set up live server and test content ──────────────────────────────── + + live_server_setup(live_server) + set_original_response(datastore_path=datastore_path) + + datastore = client.application.config.get('DATASTORE') + webhook_url = url_for('test_notification_endpoint', _external=True) + + # ── 3. Create a profile using the custom type ───────────────────────────── + + uid = str(uuid_mod.uuid4()) + datastore.data['settings']['application'].setdefault('notification_profile_data', {})[uid] = { + 'uuid': uid, + 'name': 'Custom Webhook Profile', + 'type': 'test_webhook', + 'config': {'webhook_url': webhook_url}, + } + + # ── 4. Add a watch ──────────────────────────────────────────────────────── + + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, "tags": ''}, + follow_redirects=True, + ) + assert b"Watch added" in res.data + wait_for_all_checks(client) + + watch_uuid = next(iter(datastore.data['watching'])) + + # Link the custom profile to the watch + datastore.data['watching'][watch_uuid]['notification_profiles'] = [uid] + datastore.data['watching'][watch_uuid].commit() + + # ── 5. Trigger a change ─────────────────────────────────────────────────── + + set_modified_response(datastore_path=datastore_path) + + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + wait_for_all_checks(client) + + # ── 6. Verify the custom send() was called ──────────────────────────────── + + assert wait_for_notification_endpoint_output(datastore_path), \ + "Custom WebhookProfileType.send() should have POSTed to the test notification endpoint" + + # ── 7. Cleanup: unregister the test type so it doesn't bleed into other tests ── + + registry._types.pop('test_webhook', None) diff --git a/changedetectionio/tests/util.py b/changedetectionio/tests/util.py index 6c30aeba1f2..10b9a079e9e 100644 --- a/changedetectionio/tests/util.py +++ b/changedetectionio/tests/util.py @@ -115,6 +115,55 @@ def set_empty_text_response(datastore_path): return None +def add_notification_profile(datastore, notification_url, notification_title='', notification_body='', + notification_format='text', name='Test Profile'): + """Create a notification profile in the datastore and return its UUID.""" + import uuid as uuid_mod + uid = str(uuid_mod.uuid4()) + urls = [notification_url] if isinstance(notification_url, str) else notification_url + datastore.data['settings']['application'].setdefault('notification_profile_data', {})[uid] = { + 'uuid': uid, + 'name': name, + 'type': 'apprise', + 'config': { + 'notification_urls': urls, + 'notification_title': notification_title or None, + 'notification_body': notification_body or None, + 'notification_format': notification_format or None, + }, + } + return uid + + +def set_watch_notification_profile(datastore, watch_uuid, profile_uuid): + """Link a notification profile UUID to a specific watch.""" + watch = datastore.data['watching'][watch_uuid] + profiles = list(watch.get('notification_profiles', [])) + if profile_uuid not in profiles: + profiles.append(profile_uuid) + watch['notification_profiles'] = profiles + watch.commit() + + +def set_system_notification_profile(datastore, profile_uuid): + """Link a notification profile UUID to system settings.""" + app = datastore.data['settings']['application'] + profiles = list(app.get('notification_profiles', [])) + if profile_uuid not in profiles: + profiles.append(profile_uuid) + app['notification_profiles'] = profiles + + +def clear_notification_profiles(datastore): + """Remove all notification profiles and links.""" + app = datastore.data['settings']['application'] + app['notification_profile_data'] = {} + app['notification_profiles'] = [] + for watch in datastore.data['watching'].values(): + watch['notification_profiles'] = [] + watch.commit() + + def wait_for_notification_endpoint_output(datastore_path): '''Apprise can take a few seconds to fire''' #@todo - could check the apprise object directly instead of looking for this file