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 %} {% if sections or tag_items %}
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}"> <nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div> <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> </nav>
{% endif %} {% endif %}
<div class="collection-flow content"> <div class="collection-flow content">
{% if collection.mode == 'tags' %} {% 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 %} {% 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 %} {% endif %}
</div> </div>
</article> </article>
+3 -3
View File
@@ -18,7 +18,7 @@
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %} {% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
{% if sections %} {% 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 %} {% endif %}
{% if pending_item %} {% if pending_item %}
@@ -39,8 +39,8 @@
{% for section in sections %} {% for section in sections %}
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body"> <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"> <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> <h2 class="h3 mb-0">{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}</h2>
<a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a> <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>
<div class="content">{{ section.item|item_markdown }}</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 %} {% 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']} ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]') FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]')
FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})') 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): def require_space_after_markdown_heading(text):
@@ -34,6 +35,26 @@ def require_space_after_markdown_heading(text):
return '\n'.join(lines) 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 @register.filter
def markdown(text): def markdown(text):
html = md.markdown(require_space_after_markdown_heading(text), extensions=['extra', 'sane_lists', 'nl2br']) html = md.markdown(require_space_after_markdown_heading(text), extensions=['extra', 'sane_lists', 'nl2br'])
@@ -41,8 +62,17 @@ def markdown(text):
@register.filter @register.filter
def item_markdown(item): def markdown_title(text):
text = item.content or '' """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()} attachments = {str(a.id): a for a in item.attachments.all()}
def replace(match): def replace(match):
@@ -54,6 +84,16 @@ def item_markdown(item):
return markdown(FILE_RE.sub(replace, text)) 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 @register.filter
def is_image(file_name): def is_image(file_name):
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg')) 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.test import TestCase
from django.urls import reverse from django.urls import reverse
from .models import Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference from .models import ApiKey, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
from .templatetags.markdown_extras import markdown from .templatetags.markdown_extras import markdown, markdown_title
from .views import parse_due_command, parse_quick_content 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): def test_heading_with_space_remains_a_heading(self):
self.assertIn('<h1>Project</h1>', str(markdown('# Project'))) 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): def test_hash_in_fenced_code_is_unchanged(self):
rendered = str(markdown('```python\n# comment\n```')) rendered = str(markdown('```python\n# comment\n```'))
self.assertIn('# comment', rendered) self.assertIn('# comment', rendered)
self.assertNotIn('\\# 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): def test_underline_is_allowed(self):
self.assertIn('<u>underlined</u>', str(markdown('<u>underlined</u>'))) 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): class FormattingToolbarTests(TestCase):
def test_toolbar_and_shortcuts_are_available(self): 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])) response = self.client.get(reverse('item_editor', args=[item.id]))
self.assertContains(response, '<option value="team" selected>', html=False) 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): class CollectionTests(TestCase):
def setUp(self): def setUp(self):
@@ -274,8 +299,10 @@ class CollectionTests(TestCase):
response = self.client.get(collection.get_absolute_url()) response = self.client.get(collection.get_absolute_url())
html = response.content.decode() html = response.content.decode()
self.assertContains(response, 'Tag-Sammlung') self.assertContains(response, 'Tag-Sammlung')
self.assertLess(html.index('First #guide'), html.index('Second #guide')) self.assertLess(html.index('First'), html.index('Second'))
self.assertNotContains(response, 'Ignored #guide') self.assertNotContains(response, 'First #guide')
self.assertNotContains(response, 'Second #guide')
self.assertNotContains(response, 'Ignored')
self.assertContains(response, reverse('collection_edit', args=[collection.id])) self.assertContains(response, reverse('collection_edit', args=[collection.id]))
self.assertNotContains(response, reverse('collection_manage', 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, 'Add existing note')
self.assertNotContains(response, 'section_id') 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): def test_public_collection_is_visible_without_login(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC) collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout() self.client.logout()
@@ -325,6 +372,47 @@ class CollectionTests(TestCase):
self.assertEqual(note.visibility, Item.Visibility.PUBLIC) 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): class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self): def test_item_has_copy_button_with_original_text(self):
user = get_user_model().objects.create_user(username='tester', password='secret') 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): def desired_item_scope(item, user, force_private=False):
if force_private: if force_private:
return Item.Visibility.PRIVATE, None 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)} 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() membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership: if membership:
@@ -187,6 +189,10 @@ def apply_team_from_tags(item, user, force_private=False):
item.visibility = Item.Visibility.PRIVATE item.visibility = Item.Visibility.PRIVATE
item.save(update_fields=['team', 'visibility', 'updated_at']) item.save(update_fields=['team', 'visibility', 'updated_at'])
return 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)) 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() membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership: if membership:
@@ -980,6 +986,29 @@ def api_item_suggestions(request):
return JsonResponse({'items': data}) 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): def serialize_item(item):
return { return {
'id': item.id, 'id': item.id,
@@ -1006,7 +1035,8 @@ def serialize_item(item):
def api_items(request): def api_items(request):
user = request.api_key.owner if request.api_key else request.user user = request.api_key.owner if request.api_key else request.user
if request.method == 'GET': 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 data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
raw = data.get('content', '') raw = data.get('content', '')
raw, command_due_at, _ = parse_due_command(raw) raw, command_due_at, _ = parse_due_command(raw)