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
+54 -13
View File
@@ -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'),