Improve collection management UX

This commit is contained in:
rucki
2026-08-30 19:51:19 +02:00
parent 7a2ce8e650
commit 28e17a4b21
4 changed files with 110 additions and 17 deletions
+2 -2
View File
@@ -60,11 +60,11 @@
<form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes and collections' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form> <form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes and collections' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form>
<h3 class="h6">{% trans "Notes" %}</h3> <h3 class="h6">{% trans "Notes" %}</h3>
<div class="vstack gap-2 mb-4"> <div class="vstack gap-2 mb-4">
{% for item in candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ item.content|truncatechars:180 }}</div><div class="small text-muted">#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}</div></div><form method="post" hx-on::before-request="window.collectionFocus='__last'">{% csrf_token %}<input type="hidden" name="action" value="add"><input type="hidden" name="item_id" value="{{ item.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching notes." %}</div>{% endfor %} {% for item in candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3{% if item.used_in_current_collection %} bg-light opacity-75{% endif %}"><div><div>{{ item.content|truncatechars:180 }}</div><div class="small text-muted">#{{ 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 %}</div>{% if item.used_in_current_collection %}<span class="badge text-bg-secondary mt-2">{% trans "Already in this collection" %}</span>{% endif %}</div>{% if item.used_in_current_collection %}<button class="btn btn-sm btn-outline-secondary" disabled>{% trans "Added" %}</button>{% else %}<form method="post" hx-on::before-request="window.collectionFocus='__last'">{% csrf_token %}<input type="hidden" name="action" value="add"><input type="hidden" name="item_id" value="{{ item.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form>{% endif %}</div>{% empty %}<div class="text-muted">{% trans "No matching notes." %}</div>{% endfor %}
</div> </div>
<h3 class="h6">{% trans "Collections" %}</h3> <h3 class="h6">{% trans "Collections" %}</h3>
<div class="vstack gap-2"> <div class="vstack gap-2">
{% for candidate in collection_candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ candidate.title }}</div><div class="small text-muted">#{{ candidate.id }} · {{ candidate.get_visibility_display }}{% if candidate.team %} · {{ candidate.team.name }}{% endif %}</div></div><form method="post" hx-on::before-request="window.collectionFocus='__last'">{% csrf_token %}<input type="hidden" name="action" value="add_collection"><input type="hidden" name="collection_id" value="{{ candidate.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching collections." %}</div>{% endfor %} {% for candidate in collection_candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3{% if candidate.used_in_current_collection %} bg-light opacity-75{% endif %}"><div><div>{{ candidate.title }}</div><div class="small text-muted">#{{ 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 %}</div>{% if candidate.used_in_current_collection %}<span class="badge text-bg-secondary mt-2">{% trans "Already in this collection" %}</span>{% endif %}</div>{% if candidate.used_in_current_collection %}<button class="btn btn-sm btn-outline-secondary" disabled>{% trans "Added" %}</button>{% else %}<form method="post" hx-on::before-request="window.collectionFocus='__last'">{% csrf_token %}<input type="hidden" name="action" value="add_collection"><input type="hidden" name="collection_id" value="{{ candidate.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form>{% endif %}</div>{% empty %}<div class="text-muted">{% trans "No matching collections." %}</div>{% endfor %}
</div> </div>
</div></div> </div></div>
{% endif %} {% endif %}
+26 -2
View File
@@ -72,6 +72,13 @@ class DueDateCommandTests(TestCase):
self.assertNotIn('/morgen', item.content) self.assertNotIn('/morgen', item.content)
self.assertIsNotNone(item.due_at) 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): def test_type_and_date_commands_are_available_in_menu(self):
response = self.client.get(reverse('index')) response = self.client.get(reverse('index'))
self.assertContains(response, "{token:'/note'") self.assertContains(response, "{token:'/note'")
@@ -89,6 +96,13 @@ class SlashMenuContextTests(TestCase):
self.assertEqual(later_kind, Item.Kind.NOTE) self.assertEqual(later_kind, Item.Kind.NOTE)
self.assertEqual(later_content, 'Text /todo later') 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): class MarkdownHeadingTests(TestCase):
def test_tag_at_line_start_is_not_a_heading(self): 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-boost="true"')
self.assertContains(response, 'hx-swap="outerHTML show:none"') self.assertContains(response, 'hx-swap="outerHTML show:none"')
self.assertContains(response, f'{reverse("item_editor", args=[note.id])}?next={manage_url}') self.assertContains(response, f'{reverse("item_editor", args=[note.id])}?next={manage_url}')
self.assertContains(response, '>Section</') self.assertContains(response, 'href="#section-1">Section')
self.assertNotContains(response, '>## Section</')
def test_collection_manage_preserves_search_when_adding_note(self): def test_collection_manage_preserves_search_when_adding_note(self):
collection = Collection.objects.create(owner=self.user, title='Reading view') 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') 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): def test_collection_can_include_collection(self):
parent = Collection.objects.create(owner=self.user, title='Parent') parent = Collection.objects.create(owner=self.user, title='Parent')
child = Collection.objects.create(owner=self.user, title='Child') child = Collection.objects.create(owner=self.user, title='Child')
+54 -13
View File
@@ -10,7 +10,7 @@ from django.contrib import messages
from django.contrib.auth import login from django.contrib.auth import login
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import transaction 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.http import HttpResponse, JsonResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST 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 .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) TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\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'<title[^>]*>(.*?)</title>', re.I | re.S) TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', 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) 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) 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): def fetch_page_title(url):
@@ -48,10 +76,17 @@ def parse_quick_content(raw, default_kind=Item.Kind.NOTE):
content = raw.strip() content = raw.strip()
type_match = TYPE_COMMAND_RE.match(content) type_match = TYPE_COMMAND_RE.match(content)
type_command = type_match.group(1).lower() if type_match else None 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: if type_match:
content = content[type_match.end():].strip() remainder = content[type_match.end():]
content = VISIBILITY_COMMAND_RE.sub('', content).strip() 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)) only_url = bool(ONLY_URL_RE.match(content))
if type_command == 'todo': if type_command == 'todo':
kind = Item.Kind.TODO kind = Item.Kind.TODO
@@ -73,8 +108,9 @@ def parse_quick_content(raw, default_kind=Item.Kind.NOTE):
def parse_due_command(raw): def parse_due_command(raw):
"""Extract a due date slash command and return cleaned text, date and match state.""" """Extract a due date slash command and return cleaned text, date and match state."""
date_match = DATE_COMMAND_RE.search(raw) masked_raw = mask_markdown_code(raw)
relative_match = RELATIVE_DATE_COMMAND_RE.search(raw) date_match = DATE_COMMAND_RE.search(masked_raw)
relative_match = RELATIVE_DATE_COMMAND_RE.search(masked_raw)
match = date_match or relative_match match = date_match or relative_match
if not match: if not match:
return raw, None, False return raw, None, False
@@ -98,8 +134,7 @@ def parse_due_command(raw):
else: else:
due_date = today + timedelta(days=(7 - today.weekday())) due_date = today + timedelta(days=(7 - today.weekday()))
cleaned = f'{raw[:match.start()]} {raw[match.end():]}' cleaned = remove_spans(raw, [(match.start(), match.end())])
cleaned = re.sub(r'[ \t]{2,}', ' ', cleaned).strip()
return cleaned, timezone.make_aware(datetime.combine(due_date, due_time)), True 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 return Item.Visibility.PRIVATE, None
if item.visibility == Item.Visibility.PUBLIC: if item.visibility == Item.Visibility.PUBLIC:
return Item.Visibility.PUBLIC, None 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äöüÄÖÜß-]+)', without_markdown_code(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:
return Item.Visibility.TEAM, membership.team return Item.Visibility.TEAM, membership.team
@@ -686,14 +721,20 @@ def collection_detail_context(request, collection, pending_item=None):
candidates = Item.objects.none() candidates = Item.objects.none()
collection_candidates = Collection.objects.none() collection_candidates = Collection.objects.none()
if can_edit: if can_edit:
candidates = editable_collection_notes(request.user).exclude(collection_sections__collection=collection) used_item_ids = set(collection.sections.filter(item__isnull=False).values_list('item_id', flat=True))
collection_candidates = visible_collections(request.user).filter(mode=Collection.Mode.MANUAL).exclude(pk=collection.pk).exclude(containing_sections__collection=collection) used_collection_ids = set(collection.sections.filter(sub_collection__isnull=False).values_list('sub_collection_id', flat=True))
candidates = editable_collection_notes(request.user).annotate(collection_use_count=Count('collection_sections', distinct=True))
collection_candidates = visible_collections(request.user).filter(mode=Collection.Mode.MANUAL).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True))
collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)] collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)]
if q: if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct() candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
collection_candidates = [candidate for candidate in collection_candidates if q.lower() in candidate.title.lower()] collection_candidates = [candidate for candidate in collection_candidates if q.lower() in candidate.title.lower()]
candidates = candidates[:30] candidates = list(candidates[:30])
for candidate in candidates:
candidate.used_in_current_collection = candidate.id in used_item_ids
collection_candidates = collection_candidates[:30] collection_candidates = collection_candidates[:30]
for candidate in collection_candidates:
candidate.used_in_current_collection = candidate.id in used_collection_ids
return { return {
'collection': collection, 'collection': collection,
'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection'), 'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection'),
+28
View File
@@ -968,3 +968,31 @@ msgstr "Text kopieren"
#~ msgid "Create API key" #~ msgid "Create API key"
#~ msgstr "API Key erzeugen" #~ msgstr "API Key erzeugen"
#: core/templates/core/collection_manage.html
msgid "Add existing note or collection"
msgstr "Bestehende Notiz oder Sammlung hinzufügen"
#: core/templates/core/collection_manage.html
msgid "Search notes and collections"
msgstr "Notizen und Sammlungen suchen"
#: core/templates/core/collection_manage.html
msgid "Already in this collection"
msgstr "Bereits in dieser Sammlung"
#: core/templates/core/collection_manage.html
msgid "Added"
msgstr "Hinzugefügt"
#: core/templates/core/collection_manage.html
msgid "No matching collections."
msgstr "Keine passenden Sammlungen."
#: core/templates/core/collection_manage.html
msgid "used once"
msgid_plural "used %(count)s times"
msgstr[0] "1× verwendet"
msgstr[1] "%(count)s× verwendet"