Groups - UI fix for #4451 and tweaks

This commit is contained in:
dgtlmoon committed 2026-09-20 08:29:21 +02:00
1 parent 7086a1769b
commit 04bf87683c
4 files changed
+104 -16

No files matched your search

@@ -30,13 +30,13 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<form class="pure-form" action="{{ url_for('tags.form_tag_add') }}" method="POST" id="new-watch-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" >
<fieldset>
<legend>{{ _('Add a new organisational tag') }}</legend>
<legend>{{ _('Add a new organisational tag / group name.') }}</legend>
<div id="watch-add-wrapper-zone">
<div>
{{ render_simple_field(form.name, placeholder=_("Watch group / tag")) }}
</div>
<div>
{{ render_simple_field(form.save_button, title=_("Save") ) }}
{{ render_simple_field(form.save_button, title=_("Save"), label=false ) }}
</div>
</div>
<br>
+3 -12
View File
@@ -671,27 +671,18 @@ def _jinja2_filter_fetcher_status_icons(fetcher_name):
return ''
_RE_SANITIZE_TAG = re.compile(r'[^a-zA-Z0-9]')
@app.template_filter('sanitize_tag_class')
def _jinja2_filter_sanitize_tag_class(tag_title):
"""Sanitize a tag title to create a valid CSS class name.
Removes all non-alphanumeric characters and converts to lowercase.
Args:
tag_title: The tag title string
Returns:
str: A sanitized string suitable for use as a CSS class name
"""
# Remove all non-alphanumeric characters and convert to lowercase
sanitized = _RE_SANITIZE_TAG.sub('', tag_title).lower()
# Ensure it starts with a letter (CSS requirement)
if sanitized and not sanitized[0].isalpha():
sanitized = 'tag' + sanitized
return sanitized if sanitized else 'tag'
#
tag_class_name = hashlib.sha256(tag_title.encode('utf-8')).hexdigest()[:16]
return tag_class_name if tag_class_name else 'tag'
# Import login_optionally_required from auth_decorator
+6 -2
View File
@@ -74,9 +74,13 @@
</div>
{% endmacro %}
{# label - false - Dont render, None - use default field.label.text #}
{% macro render_simple_field(field, label=None) %}
<span class="label {% if field.errors %}error{% endif %}"><label for="{{ field.id }}">{{ (label if label is not none else field.label.text) | string | forceescape }}</label></span>
{%- if label is not false -%}
<span class="label {% if field.errors %}error{% endif %}">
<label for="{{ field.id }}">{{ (label if label is not none else field.label.text) | string | forceescape }}</label>
</span>
{%- endif -%}
<span {% if field.errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""A group/tag title must map to its own CSS class, whatever alphabet it is written in.
The auto colour for a group is emitted as a stylesheet rule keyed on the class name that
`sanitize_tag_class` derives from the title (see groups-overview.html / watch-overview.html).
The original filter built that class by stripping every non-alphanumeric character, which for
an ASCII title was fine but for a title written entirely in Japanese, Chinese, Korean, Cyrillic
(etc.) stripped the whole string - so every one of those groups fell through to the literal
`tag-tag` class, shared one stylesheet rule, and rendered in the same colour (#4451).
Hashing the title instead keeps the output in [0-9a-f] (always a valid class name) while giving
every distinct title a distinct class.
run from dir above changedetectionio/ dir
python3 -m unittest changedetectionio.tests.unit.test_sanitize_tag_class
"""
import re
import unittest
from changedetectionio.flask_app import _jinja2_filter_sanitize_tag_class as sanitize_tag_class
# The class is used as `tag-{{ class_name }}`, so it may only contain characters that are legal
# in a CSS identifier - and must never be empty.
CSS_SAFE = re.compile(r'^[0-9a-f]+$')
class TestSanitizeTagClass(unittest.TestCase):
def test_output_is_always_a_safe_css_identifier(self):
"""Whatever goes in, what comes out must be usable as `.tag-<name>`"""
titles = [
'Price drops',
'price drops',
'価格変動',
'가격 변동',
'价格变动',
'Цена',
'Ελλάδα',
'🚨 alerts',
'a b\tc\nd',
'has "quotes" and {braces}',
'../../etc/passwd',
'} .foo { background-color: red;',
'-',
'0',
' ',
'',
]
for title in titles:
with self.subTest(title=title):
class_name = sanitize_tag_class(title)
self.assertTrue(class_name, f"{title!r} must not produce an empty class name")
self.assertRegex(class_name, CSS_SAFE,
f"{title!r} produced a class name that would break the stylesheet")
def test_non_ascii_titles_get_their_own_class(self):
"""#4451 - these all collapsed to the same class, so they all got the same auto colour"""
titles = ['価格変動', '在庫状況', '空室情報']
class_names = [sanitize_tag_class(t) for t in titles]
self.assertEqual(len(set(class_names)), len(titles),
f"non-ASCII group titles must not share a class name, got {class_names}")
for class_name in class_names:
self.assertNotEqual(class_name, 'tag',
"non-ASCII titles must not fall back to the shared 'tag' class")
def test_titles_that_differ_only_in_punctuation_get_their_own_class(self):
"""Stripping non-alphanumerics also merged these; the auto colour must still differ"""
self.assertNotEqual(sanitize_tag_class('re-stock'), sanitize_tag_class('restock'))
self.assertNotEqual(sanitize_tag_class('My Tag'), sanitize_tag_class('my tag'))
def test_is_stable(self):
"""The rule and the element that uses it are rendered by separate calls - they must agree"""
for title in ['Price drops', '価格変動', '']:
with self.subTest(title=title):
self.assertEqual(sanitize_tag_class(title), sanitize_tag_class(title))
def test_is_deterministic_across_runs(self):
"""Not salted/randomised per process, so a class name can be relied on in a test or CSS"""
self.assertEqual(sanitize_tag_class('Price drops'), '03d0b363b6f919cc')
self.assertEqual(sanitize_tag_class('価格変動'), 'd42fc3593d9c3c5a')
def test_many_titles_stay_unique(self):
titles = [f"group {i}" for i in range(500)] + [f"グループ{i}" for i in range(500)]
class_names = {sanitize_tag_class(t) for t in titles}
self.assertEqual(len(class_names), len(titles), "class names must not collide")
if __name__ == '__main__':
unittest.main()