From 28e17a4b21a60a03e22a63b8f6a5e8b080d8bc08 Mon Sep 17 00:00:00 2001 From: rucki Date: Sun, 30 Aug 2026 19:51:19 +0200 Subject: [PATCH] Improve collection management UX --- core/templates/core/collection_manage.html | 4 +- core/tests.py | 28 ++++++++- core/views.py | 67 +++++++++++++++++----- locale/de/LC_MESSAGES/django.po | 28 +++++++++ 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/core/templates/core/collection_manage.html b/core/templates/core/collection_manage.html index 13e2c25..5a1c5c6 100644 --- a/core/templates/core/collection_manage.html +++ b/core/templates/core/collection_manage.html @@ -60,11 +60,11 @@

{% trans "Notes" %}

- {% for item in candidates %}
{{ item.content|truncatechars:180 }}
#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}
{% csrf_token %}
{% empty %}
{% trans "No matching notes." %}
{% endfor %} + {% for item in candidates %}
{{ item.content|truncatechars:180 }}
#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}{% if item.collection_use_count %} · {% blocktrans count count=item.collection_use_count %}used once{% plural %}used {{ count }} times{% endblocktrans %}{% endif %}
{% if item.used_in_current_collection %}{% trans "Already in this collection" %}{% endif %}
{% if item.used_in_current_collection %}{% else %}
{% csrf_token %}
{% endif %}
{% empty %}
{% trans "No matching notes." %}
{% endfor %}

{% trans "Collections" %}

- {% for candidate in collection_candidates %}
{{ candidate.title }}
#{{ candidate.id }} · {{ candidate.get_visibility_display }}{% if candidate.team %} · {{ candidate.team.name }}{% endif %}
{% csrf_token %}
{% empty %}
{% trans "No matching collections." %}
{% endfor %} + {% for candidate in collection_candidates %}
{{ candidate.title }}
#{{ candidate.id }} · {{ candidate.get_visibility_display }}{% if candidate.team %} · {{ candidate.team.name }}{% endif %}{% if candidate.collection_use_count %} · {% blocktrans count count=candidate.collection_use_count %}used once{% plural %}used {{ count }} times{% endblocktrans %}{% endif %}
{% if candidate.used_in_current_collection %}{% trans "Already in this collection" %}{% endif %}
{% if candidate.used_in_current_collection %}{% else %}
{% csrf_token %}
{% endif %}
{% empty %}
{% trans "No matching collections." %}
{% endfor %}
{% endif %} diff --git a/core/tests.py b/core/tests.py index f04a054..7e22070 100644 --- a/core/tests.py +++ b/core/tests.py @@ -72,6 +72,13 @@ class DueDateCommandTests(TestCase): self.assertNotIn('/morgen', item.content) self.assertIsNotNone(item.due_at) + def test_due_commands_inside_code_are_ignored(self): + raw = '/todo Example\n```\n/morgen\n/datum 20.10.26\n```' + cleaned, due_at, found = parse_due_command(raw) + self.assertFalse(found) + self.assertIsNone(due_at) + self.assertEqual(cleaned, raw) + def test_type_and_date_commands_are_available_in_menu(self): response = self.client.get(reverse('index')) self.assertContains(response, "{token:'/note'") @@ -89,6 +96,13 @@ class SlashMenuContextTests(TestCase): self.assertEqual(later_kind, Item.Kind.NOTE) self.assertEqual(later_content, 'Text /todo later') + def test_visibility_commands_inside_code_are_ignored(self): + kind, visibility, content, _ = parse_quick_content('/note Example ` /public `\n```\n/private\n```') + self.assertEqual(kind, Item.Kind.NOTE) + self.assertEqual(visibility, Item.Visibility.PRIVATE) + self.assertIn('/public', content) + self.assertIn('/private', content) + class MarkdownHeadingTests(TestCase): def test_tag_at_line_start_is_not_a_heading(self): @@ -341,8 +355,7 @@ class CollectionTests(TestCase): self.assertContains(response, 'hx-boost="true"') self.assertContains(response, 'hx-swap="outerHTML show:none"') self.assertContains(response, f'{reverse("item_editor", args=[note.id])}?next={manage_url}') - self.assertContains(response, '>Section## SectionSection') def test_collection_manage_preserves_search_when_adding_note(self): collection = Collection.objects.create(owner=self.user, title='Reading view') @@ -353,6 +366,17 @@ class CollectionTests(TestCase): self.assertRedirects(response, f'{manage_url}?q=Findable') + def test_collection_manage_marks_already_added_notes(self): + collection = Collection.objects.create(owner=self.user, title='Reading view') + note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Findable note') + CollectionSection.objects.create(collection=collection, item=note, position=0) + + response = self.client.get(f'{reverse("collection_manage", args=[collection.id])}?q=Findable') + + self.assertContains(response, 'Bereits in dieser Sammlung') + self.assertContains(response, '1× verwendet') + self.assertContains(response, 'disabled') + def test_collection_can_include_collection(self): parent = Collection.objects.create(owner=self.user, title='Parent') child = Collection.objects.create(owner=self.user, title='Child') diff --git a/core/views.py b/core/views.py index 7e2de14..2714ea9 100644 --- a/core/views.py +++ b/core/views.py @@ -10,7 +10,7 @@ from django.contrib import messages from django.contrib.auth import login from django.contrib.auth.decorators import login_required from django.db import transaction -from django.db.models import Case, IntegerField, ProtectedError, Q, Value, When +from django.db.models import Case, Count, IntegerField, ProtectedError, Q, Value, When from django.http import HttpResponse, JsonResponse from django.urls import reverse from django.utils import timezone @@ -20,7 +20,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods, require_POST from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm -from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference +from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, without_markdown_code TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I) VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I) @@ -29,6 +29,34 @@ ONLY_URL_RE = re.compile(r'^https?://\S+$') TITLE_RE = re.compile(r']*>(.*?)', re.I | re.S) DATE_COMMAND_RE = re.compile(r'/datum\s+(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))(?:\s+(\d{1,2}:\d{2}))?\b', re.I) RELATIVE_DATE_COMMAND_RE = re.compile(r'/(morgen|übermorgen|uebermorgen|nächstewoche|naechstewoche)\b', re.I) +CODE_FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})') +INLINE_CODE_RE = re.compile(r'(`+).*?\1', re.S) + + +def mask_markdown_code(text): + """Replace Markdown code with spaces while preserving string positions.""" + masked_lines = [] + fence = None + for line in (text or '').splitlines(keepends=True): + line_without_newline = line.rstrip('\r\n') + newline = line[len(line_without_newline):] + match = CODE_FENCE_RE.match(line_without_newline) + if fence: + masked_lines.append(' ' * len(line_without_newline) + newline) + if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence): + fence = None + elif match: + fence = match.group(1) + masked_lines.append(' ' * len(line_without_newline) + newline) + else: + masked_lines.append(INLINE_CODE_RE.sub(lambda m: ' ' * (m.end() - m.start()), line_without_newline) + newline) + return ''.join(masked_lines) + + +def remove_spans(text, spans): + for start, end in sorted(spans, reverse=True): + text = f'{text[:start]} {text[end:]}' + return re.sub(r'[ \t]{2,}', ' ', text).strip() def fetch_page_title(url): @@ -48,10 +76,17 @@ def parse_quick_content(raw, default_kind=Item.Kind.NOTE): content = raw.strip() type_match = TYPE_COMMAND_RE.match(content) type_command = type_match.group(1).lower() if type_match else None - visibility_commands = {command.lower() for command in VISIBILITY_COMMAND_RE.findall(content)} + masked_content = mask_markdown_code(content) + visibility_matches = list(VISIBILITY_COMMAND_RE.finditer(masked_content)) + visibility_commands = {match.group(1).lower() for match in visibility_matches} + offset = 0 if type_match: - content = content[type_match.end():].strip() - content = VISIBILITY_COMMAND_RE.sub('', content).strip() + remainder = content[type_match.end():] + leading_space = len(remainder) - len(remainder.lstrip()) + offset = type_match.end() + leading_space + content = remainder.strip() + visibility_spans = [(match.start() - offset, match.end() - offset) for match in visibility_matches if match.start() >= offset] + content = remove_spans(content, visibility_spans) only_url = bool(ONLY_URL_RE.match(content)) if type_command == 'todo': kind = Item.Kind.TODO @@ -73,8 +108,9 @@ def parse_quick_content(raw, default_kind=Item.Kind.NOTE): def parse_due_command(raw): """Extract a due date slash command and return cleaned text, date and match state.""" - date_match = DATE_COMMAND_RE.search(raw) - relative_match = RELATIVE_DATE_COMMAND_RE.search(raw) + masked_raw = mask_markdown_code(raw) + date_match = DATE_COMMAND_RE.search(masked_raw) + relative_match = RELATIVE_DATE_COMMAND_RE.search(masked_raw) match = date_match or relative_match if not match: return raw, None, False @@ -98,8 +134,7 @@ def parse_due_command(raw): else: due_date = today + timedelta(days=(7 - today.weekday())) - cleaned = f'{raw[:match.start()]} {raw[match.end():]}' - cleaned = re.sub(r'[ \t]{2,}', ' ', cleaned).strip() + cleaned = remove_spans(raw, [(match.start(), match.end())]) return cleaned, timezone.make_aware(datetime.combine(due_date, due_time)), True @@ -165,7 +200,7 @@ def desired_item_scope(item, user, force_private=False): 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'(?