Improve collection management UX
This commit is contained in:
+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'),
|
||||
|
||||
Reference in New Issue
Block a user