From d5917d411a05659b27b18528642860c1d29b59c3 Mon Sep 17 00:00:00 2001 From: rucki Date: Sun, 30 Aug 2026 13:05:57 +0200 Subject: [PATCH] Improve collection editing and rendering --- core/templates/core/collection_detail.html | 6 +- core/templates/core/collection_manage.html | 6 +- core/templatetags/markdown_extras.py | 46 ++++++++++- core/tests.py | 96 +++++++++++++++++++++- core/views.py | 32 +++++++- 5 files changed, 172 insertions(+), 14 deletions(-) diff --git a/core/templates/core/collection_detail.html b/core/templates/core/collection_detail.html index 069e0ef..0fa9008 100644 --- a/core/templates/core/collection_detail.html +++ b/core/templates/core/collection_detail.html @@ -22,15 +22,15 @@ {% if sections or tag_items %} {% endif %}
{% if collection.mode == 'tags' %} - {% for item in tag_items %}
{{ item|item_markdown }}{% if item.comment %}
{{ item.comment|markdown }}
{% endif %}
{% empty %}
{% trans "No notes match the selected tags yet." %}
{% endfor %} + {% for item in tag_items %}
{{ item|collection_item_markdown }}{% if item.comment %}
{{ item.comment|markdown }}
{% endif %}
{% empty %}
{% trans "No notes match the selected tags yet." %}
{% endfor %} {% else %} - {% for section in sections %}
{% if section.title %}

{{ section.title }}

{% endif %}{{ section.item|item_markdown }}{% if section.item.comment %}
{{ section.item.comment|markdown }}
{% endif %}
{% empty %}
{% trans "This collection has no sections yet." %}
{% endfor %} + {% for section in sections %}
{% if section.title %}

{{ section.title }}

{% endif %}{{ section.item|collection_item_markdown }}{% if section.item.comment %}
{{ section.item.comment|markdown }}
{% endif %}
{% empty %}
{% trans "This collection has no sections yet." %}
{% endfor %} {% endif %}
diff --git a/core/templates/core/collection_manage.html b/core/templates/core/collection_manage.html index bb22d1e..5b1a529 100644 --- a/core/templates/core/collection_manage.html +++ b/core/templates/core/collection_manage.html @@ -18,7 +18,7 @@ {% if collection.description %}
{{ collection.description|markdown }}
{% endif %} {% if sections %} -
{% trans "Contents" %}
    {% for section in sections %}
  1. {{ section.title|default:section.item.content|truncatechars:90 }}
  2. {% endfor %}
+
{% trans "Contents" %}
    {% for section in sections %}
  1. {{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}
  2. {% endfor %}
{% endif %} {% if pending_item %} @@ -39,8 +39,8 @@ {% for section in sections %}
-

{{ section.title|default:section.item.content|truncatechars:100 }}

- #{{ section.item.id }} +

{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}

+
{{ section.item|item_markdown }}
{% if section.item.comment %}
{{ section.item.comment|markdown }}
{% endif %} diff --git a/core/templatetags/markdown_extras.py b/core/templatetags/markdown_extras.py index 3a39a0e..45950fc 100644 --- a/core/templatetags/markdown_extras.py +++ b/core/templatetags/markdown_extras.py @@ -13,7 +13,8 @@ ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | { ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']} FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]') FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})') -HASH_TAG_AT_LINE_START_RE = re.compile(r'^(#{1,6})(?=\S)') +HASH_TAG_AT_LINE_START_RE = re.compile(r'^(#{1,6})(?!#)(?=\S)') +TAG_RE = re.compile(r'(?= len(fence): + fence = None + elif match: + fence = match.group(1) + lines.append(line) + else: + parts = re.split(r'(`[^`]*`)', line) + cleaned = ''.join(part if part.startswith('`') and part.endswith('`') else TAG_RE.sub('', part) for part in parts) + lines.append(re.sub(r'[ \t]{2,}', ' ', cleaned).strip()) + return '\n'.join(lines).strip() + + @register.filter def markdown(text): html = md.markdown(require_space_after_markdown_heading(text), extensions=['extra', 'sane_lists', 'nl2br']) @@ -41,8 +62,17 @@ def markdown(text): @register.filter -def item_markdown(item): - text = item.content or '' +def markdown_title(text): + """Return a readable title for Markdown content without heading markers.""" + for line in (text or '').splitlines(): + title = line.strip() + if title: + return strip_tags_outside_code(re.sub(r'^#{1,6}\s+', '', title)).strip() + return '' + + +def render_item_markdown(item, hide_tags=False): + text = strip_tags_outside_code(item.content) if hide_tags else (item.content or '') attachments = {str(a.id): a for a in item.attachments.all()} def replace(match): @@ -54,6 +84,16 @@ def item_markdown(item): return markdown(FILE_RE.sub(replace, text)) +@register.filter +def item_markdown(item): + return render_item_markdown(item) + + +@register.filter +def collection_item_markdown(item): + return render_item_markdown(item, hide_tags=True) + + @register.filter def is_image(file_name): return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg')) diff --git a/core/tests.py b/core/tests.py index 6564025..13b66e6 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2,8 +2,8 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse -from .models import Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference -from .templatetags.markdown_extras import markdown +from .models import ApiKey, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference +from .templatetags.markdown_extras import markdown, markdown_title from .views import parse_due_command, parse_quick_content @@ -98,15 +98,25 @@ class MarkdownHeadingTests(TestCase): def test_heading_with_space_remains_a_heading(self): self.assertIn('

Project

', str(markdown('# Project'))) + self.assertIn('

Section

', str(markdown('## Section'))) def test_hash_in_fenced_code_is_unchanged(self): rendered = str(markdown('```python\n# comment\n```')) self.assertIn('# comment', rendered) self.assertNotIn('\\# comment', rendered) + self.assertNotIn('

', rendered) + + def test_hash_in_inline_code_is_not_a_heading(self): + rendered = str(markdown('```# Das ist Kommentar im Code```')) + self.assertIn('# Das ist Kommentar im Code', rendered) + self.assertNotIn('

', rendered) def test_underline_is_allowed(self): self.assertIn('underlined', str(markdown('underlined'))) + def test_markdown_title_strips_heading_marker(self): + self.assertEqual(markdown_title('## Ziel von my2dos\n\nText'), 'Ziel von my2dos') + class FormattingToolbarTests(TestCase): def test_toolbar_and_shortcuts_are_available(self): @@ -179,6 +189,21 @@ class TeamItemEditTests(TestCase): response = self.client.get(reverse('item_editor', args=[item.id])) self.assertContains(response, '