Improve collection management UX
This commit is contained in:
@@ -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>
|
||||
<h3 class="h6">{% trans "Notes" %}</h3>
|
||||
<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>
|
||||
<h3 class="h6">{% trans "Collections" %}</h3>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
+26
-2
@@ -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</')
|
||||
self.assertNotContains(response, '>## Section</')
|
||||
self.assertContains(response, 'href="#section-1">Section')
|
||||
|
||||
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')
|
||||
|
||||
+54
-13
@@ -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'<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)
|
||||
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'(?<!\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()
|
||||
if membership:
|
||||
return Item.Visibility.TEAM, membership.team
|
||||
@@ -686,14 +721,20 @@ def collection_detail_context(request, collection, pending_item=None):
|
||||
candidates = Item.objects.none()
|
||||
collection_candidates = Collection.objects.none()
|
||||
if can_edit:
|
||||
candidates = editable_collection_notes(request.user).exclude(collection_sections__collection=collection)
|
||||
collection_candidates = visible_collections(request.user).filter(mode=Collection.Mode.MANUAL).exclude(pk=collection.pk).exclude(containing_sections__collection=collection)
|
||||
used_item_ids = set(collection.sections.filter(item__isnull=False).values_list('item_id', flat=True))
|
||||
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)]
|
||||
if q:
|
||||
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()]
|
||||
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]
|
||||
for candidate in collection_candidates:
|
||||
candidate.used_in_current_collection = candidate.id in used_collection_ids
|
||||
return {
|
||||
'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'),
|
||||
|
||||
@@ -968,3 +968,31 @@ msgstr "Text kopieren"
|
||||
|
||||
#~ msgid "Create API key"
|
||||
#~ 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"
|
||||
|
||||
Reference in New Issue
Block a user