Improve collection editing and rendering

This commit is contained in:
rucki
2026-08-30 13:05:57 +02:00
parent 5aeb12899b
commit d5917d411a
5 changed files with 172 additions and 14 deletions
+3 -3
View File
@@ -22,15 +22,15 @@
{% if sections or tag_items %}
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div>
<ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|truncatechars:90 }}</a></li>{% endfor %}{% endif %}</ol>
<ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% endif %}</ol>
</nav>
{% endif %}
<div class="collection-flow content">
{% if collection.mode == 'tags' %}
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No notes match the selected tags yet." %}</div>{% endfor %}
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|collection_item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No notes match the selected tags yet." %}</div>{% endfor %}
{% else %}
{% for section in sections %}<section id="section-{{ section.id }}" class="collection-flow-section">{% if section.title %}<h2>{{ section.title }}</h2>{% endif %}{{ section.item|item_markdown }}{% if section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
{% for section in sections %}<section id="section-{{ section.id }}" class="collection-flow-section">{% if section.title %}<h2>{{ section.title }}</h2>{% endif %}{{ section.item|collection_item_markdown }}{% if section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
{% endif %}
</div>
</article>
+3 -3
View File
@@ -18,7 +18,7 @@
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
{% if sections %}
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|truncatechars:90 }}</a></li>{% endfor %}</ol></div></div>
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}</ol></div></div>
{% endif %}
{% if pending_item %}
@@ -39,8 +39,8 @@
{% for section in sections %}
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<h2 class="h3 mb-0">{{ section.title|default:section.item.content|truncatechars:100 }}</h2>
<a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>
<h2 class="h3 mb-0">{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}</h2>
<div class="d-flex gap-2 align-items-center"><a class="btn btn-sm btn-outline-secondary" href="{% url 'item_editor' section.item.id %}?next={% url 'collection_manage' collection.id %}">{% trans "Edit note" %}</a><a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a></div>
</div>
<div class="content">{{ section.item|item_markdown }}</div>
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}
+43 -3
View File
@@ -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'(?<!\w)#([\wäöüÄÖÜß-]+)')
def require_space_after_markdown_heading(text):
@@ -34,6 +35,26 @@ def require_space_after_markdown_heading(text):
return '\n'.join(lines)
def strip_tags_outside_code(text):
"""Remove #tags for collection reading view while preserving code."""
lines = []
fence = None
for line in (text or '').splitlines():
match = FENCE_RE.match(line)
if fence:
lines.append(line)
if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= 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'))
+92 -4
View File
@@ -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('<h1>Project</h1>', str(markdown('# Project')))
self.assertIn('<h2>Section</h2>', 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('<h1>', rendered)
def test_hash_in_inline_code_is_not_a_heading(self):
rendered = str(markdown('```# Das ist Kommentar im Code```'))
self.assertIn('<code># Das ist Kommentar im Code</code>', rendered)
self.assertNotIn('<h1>', rendered)
def test_underline_is_allowed(self):
self.assertIn('<u>underlined</u>', str(markdown('<u>underlined</u>')))
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, '<option value="team" selected>', html=False)
def test_team_item_can_be_made_public_even_with_team_tag(self):
item = Item.objects.create(
owner=self.user, team=self.team, content='Original #docs', visibility=Item.Visibility.TEAM,
)
item.sync_metadata()
response = self.client.post(reverse('item_editor', args=[item.id]), {
'content': 'Updated #docs', 'comment': '', 'visibility': Item.Visibility.PUBLIC, 'url': '',
})
self.assertRedirects(response, item.get_absolute_url())
item.refresh_from_db()
self.assertEqual(item.visibility, Item.Visibility.PUBLIC)
self.assertIsNone(item.team)
class CollectionTests(TestCase):
def setUp(self):
@@ -274,8 +299,10 @@ class CollectionTests(TestCase):
response = self.client.get(collection.get_absolute_url())
html = response.content.decode()
self.assertContains(response, 'Tag-Sammlung')
self.assertLess(html.index('First #guide'), html.index('Second #guide'))
self.assertNotContains(response, 'Ignored #guide')
self.assertLess(html.index('First'), html.index('Second'))
self.assertNotContains(response, 'First #guide')
self.assertNotContains(response, 'Second #guide')
self.assertNotContains(response, 'Ignored')
self.assertContains(response, reverse('collection_edit', args=[collection.id]))
self.assertNotContains(response, reverse('collection_manage', args=[collection.id]))
@@ -294,6 +321,26 @@ class CollectionTests(TestCase):
self.assertNotContains(response, 'Add existing note')
self.assertNotContains(response, 'section_id')
def test_collection_reading_view_hides_note_tags(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Section #tutorial\nText #my2dos and `#code`')
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.get(collection.get_absolute_url())
self.assertContains(response, '<h2>Section</h2>', html=True)
self.assertContains(response, '<code>#code</code>', html=True)
self.assertNotContains(response, '#tutorial')
self.assertNotContains(response, '#my2dos')
def test_collection_manage_links_note_editor_back_to_collection(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Section')
CollectionSection.objects.create(collection=collection, item=note, position=0)
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.get(manage_url)
self.assertContains(response, f'{reverse("item_editor", args=[note.id])}?next={manage_url}')
self.assertContains(response, '>Section</')
self.assertNotContains(response, '>## Section</')
def test_public_collection_is_visible_without_login(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout()
@@ -325,6 +372,47 @@ class CollectionTests(TestCase):
self.assertEqual(note.visibility, Item.Visibility.PUBLIC)
class ApiItemFilterTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='api-filter', password='secret')
self.help_tag = Tag.objects.create(name='help')
self.api_key = ApiKey.objects.create(owner=self.user, name='Help CLI')
self.api_key.tags.add(self.help_tag)
def create_item(self, content):
item = Item.objects.create(owner=self.user, content=content)
item.sync_metadata()
return item
def test_api_key_scope_and_request_tags_are_combined(self):
matching = self.create_item('Keyboard shortcuts #help #tasten')
self.create_item('Git help #help #git')
self.create_item('Private keyboard note #tasten')
response = self.client.get(
reverse('api_items'), {'tag': 'tasten', 'tag_mode': 'all'},
HTTP_X_API_KEY=self.api_key.token,
)
self.assertEqual(response.status_code, 200)
ids = [item['id'] for item in response.json()['items']]
self.assertEqual(ids, [matching.id])
def test_api_items_supports_limit_and_search(self):
self.create_item('First #help #shell')
second = self.create_item('Second #help #shell keyboard')
response = self.client.get(
reverse('api_items'), {'tag': 'shell', 'q': 'keyboard', 'limit': '1'},
HTTP_X_API_KEY=self.api_key.token,
)
self.assertEqual(response.status_code, 200)
items = response.json()['items']
self.assertEqual(len(items), 1)
self.assertEqual(items[0]['id'], second.id)
class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self):
user = get_user_model().objects.create_user(username='tester', password='secret')
+31 -1
View File
@@ -163,6 +163,8 @@ def user_can_delete_item(user, item):
def desired_item_scope(item, user, force_private=False):
if force_private:
return Item.Visibility.PRIVATE, None
if item.visibility == Item.Visibility.PUBLIC:
return Item.Visibility.PUBLIC, None
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', item.content)}
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership:
@@ -187,6 +189,10 @@ def apply_team_from_tags(item, user, force_private=False):
item.visibility = Item.Visibility.PRIVATE
item.save(update_fields=['team', 'visibility', 'updated_at'])
return
if item.visibility == Item.Visibility.PUBLIC:
item.team = None
item.save(update_fields=['team', 'updated_at'])
return
tag_names = list(item.tags.values_list('name', flat=True))
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership:
@@ -980,6 +986,29 @@ def api_item_suggestions(request):
return JsonResponse({'items': data})
def api_items_queryset(request, user, api_key=None):
items = visible_items(user, api_key)
tags = [tag.strip().lstrip('#').lower() for tag in request.GET.getlist('tag') if tag.strip()]
if tags:
if request.GET.get('tag_mode') == 'all':
for tag in tags:
items = items.filter(tags__name=tag)
else:
items = items.filter(tags__name__in=tags).distinct()
q = request.GET.get('q', '').strip()
if q:
items = items.filter(Q(content__icontains=q) | Q(comment__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
return items
def api_items_limit(request):
try:
limit = int(request.GET.get('limit', 200))
except (TypeError, ValueError):
limit = 200
return max(1, min(limit, 500))
def serialize_item(item):
return {
'id': item.id,
@@ -1006,7 +1035,8 @@ def serialize_item(item):
def api_items(request):
user = request.api_key.owner if request.api_key else request.user
if request.method == 'GET':
return JsonResponse({'items': [serialize_item(i) for i in visible_items(user, request.api_key)[:200]]})
items = api_items_queryset(request, user, request.api_key)[:api_items_limit(request)]
return JsonResponse({'items': [serialize_item(i) for i in items]})
data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
raw = data.get('content', '')
raw, command_due_at, _ = parse_due_command(raw)