Add desktop note collections

This commit is contained in:
rucki
2026-08-27 14:06:06 +02:00
parent 4b83582e72
commit cd97a6cb62
14 changed files with 731 additions and 10 deletions
+261 -5
View File
@@ -9,7 +9,8 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.db.models import Case, IntegerField, Q, Value, When
from django.db import transaction
from django.db.models import Case, IntegerField, ProtectedError, Q, Value, When
from django.http import HttpResponse, JsonResponse
from django.urls import reverse
from django.utils import timezone
@@ -18,8 +19,8 @@ from django.utils.translation import gettext as _
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, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
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
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
@@ -159,6 +160,22 @@ def user_can_delete_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
def desired_item_scope(item, user, force_private=False):
if force_private:
return Item.Visibility.PRIVATE, None
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', 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
visibility = Item.Visibility.PRIVATE if item.visibility == Item.Visibility.TEAM else item.visibility
return visibility, None
def item_scope_fits_collections(item, visibility, team):
sections = item.collection_sections.select_related('collection') if item.pk else CollectionSection.objects.none()
return not any(section.collection.visibility != visibility or section.collection.team_id != (team.id if team else None) for section in sections)
def apply_team_from_tags(item, user, force_private=False):
if force_private:
item.team = None
@@ -514,6 +531,223 @@ def team_accept_invite(request, token):
return redirect('team_detail', pk=invite.team.pk)
def visible_collections(user):
qs = Collection.objects.select_related('owner', 'team').prefetch_related('tags', 'sections__item')
if user.is_authenticated:
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
return qs.filter(visibility=Item.Visibility.PUBLIC)
def user_can_edit_collection(user, collection):
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user).exists()))
def user_can_delete_collection(user, collection):
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists()))
def collection_item_is_compatible(collection, item):
if collection.visibility == Item.Visibility.TEAM:
return item.visibility == Item.Visibility.TEAM and item.team_id == collection.team_id
return item.visibility == collection.visibility and item.team_id is None
def collection_target_from_form(form, user):
tags = form.cleaned_data.get('tags')
tag_names = list(tags.values_list('name', flat=True)) if tags is not None else []
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership:
return Item.Visibility.TEAM, membership.team
return form.cleaned_data['visibility'], None
def item_can_change_for_collection(user, item, collection):
if not user_can_edit_item(user, item):
return False
for section in item.collection_sections.select_related('collection').exclude(collection=collection):
other = section.collection
if other.visibility != collection.visibility or other.team_id != collection.team_id:
return False
return True
def remove_team_tags(content):
slugs = Team.objects.values_list('slug', flat=True)
for slug in slugs:
content = re.sub(rf'(?<!\w)#{re.escape(slug)}\b', '', content, flags=re.I)
return re.sub(r'[ \t]{2,}', ' ', content).strip()
def adapt_item_for_collection(item, collection):
item.content = remove_team_tags(item.content)
if collection.team:
item.content = f'{item.content} #{collection.team.slug}'.strip()
item.visibility = collection.visibility
item.team = collection.team
item.save(update_fields=['content', 'visibility', 'team', 'updated_at'])
item.sync_metadata()
item.team = collection.team
item.visibility = collection.visibility
item.save(update_fields=['team', 'visibility', 'updated_at'])
return item
def copy_item_for_collection(item, collection, owner):
copied = Item.objects.create(
owner=owner, team=collection.team, kind=Item.Kind.NOTE, content=item.content,
comment=item.comment, visibility=collection.visibility, url=item.url,
)
for attachment in item.attachments.all():
Attachment.objects.create(item=copied, file=attachment.file.name)
return adapt_item_for_collection(copied, collection)
def add_collection_section(collection, item):
last_position = collection.sections.order_by('-position').values_list('position', flat=True).first()
return CollectionSection.objects.get_or_create(
collection=collection, item=item,
defaults={'position': (last_position + 1) if last_position is not None else 0},
)
def collection_detail_context(request, collection, pending_item=None):
can_edit = user_can_edit_collection(request.user, collection)
q = request.GET.get('q', '').strip()
candidates = Item.objects.none()
if can_edit:
candidates = visible_items(request.user).filter(kind=Item.Kind.NOTE).exclude(collection_sections__collection=collection)
if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
candidates = candidates[:30]
return {
'collection': collection,
'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments'),
'can_edit': can_edit,
'can_delete': user_can_delete_collection(request.user, collection),
'candidates': candidates,
'q': q,
'pending_item': pending_item,
'pending_can_convert': pending_item and item_can_change_for_collection(request.user, pending_item, collection),
}
@login_required
def collection_list(request):
collections = visible_collections(request.user)
return render(request, 'core/collection_list.html', {'collections': collections})
@login_required
def collection_create(request):
if request.method == 'POST':
form = CollectionForm(request.POST)
if form.is_valid():
visibility, team = collection_target_from_form(form, request.user)
if visibility == Item.Visibility.TEAM and not team:
form.add_error('visibility', _('Select a team tag for a team collection.'))
else:
collection = form.save(commit=False)
collection.owner = request.user
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
messages.success(request, _('Collection created.'))
return redirect(collection)
else:
form = CollectionForm()
return render(request, 'core/collection_form.html', {'form': form, 'heading': _('Create collection')})
@login_required
def collection_edit(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
if request.method == 'POST':
form = CollectionForm(request.POST, instance=collection)
if form.is_valid():
visibility, team = collection_target_from_form(form, request.user)
incompatible = collection.sections.exclude(item__visibility=visibility)
if team:
incompatible = incompatible | collection.sections.exclude(item__team=team)
else:
incompatible = incompatible | collection.sections.exclude(item__team__isnull=True)
if incompatible.exists():
form.add_error(None, _('The collection visibility cannot change while it contains incompatible notes.'))
elif visibility == Item.Visibility.TEAM and not team:
form.add_error('visibility', _('Select a team tag for a team collection.'))
else:
collection = form.save(commit=False)
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
messages.success(request, _('Collection saved.'))
return redirect(collection)
else:
form = CollectionForm(instance=collection)
return render(request, 'core/collection_form.html', {'form': form, 'collection': collection, 'heading': _('Edit collection')})
def collection_detail(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
if request.method == 'POST':
if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
action = request.POST.get('action')
if action in {'add', 'confirm_add'}:
item = get_object_or_404(visible_items(request.user), pk=request.POST.get('item_id'), kind=Item.Kind.NOTE)
if collection_item_is_compatible(collection, item):
add_collection_section(collection, item)
return redirect(collection)
if action == 'add':
return render(request, 'core/collection_detail.html', collection_detail_context(request, collection, item))
mode = request.POST.get('mode')
if mode == 'convert':
if not item_can_change_for_collection(request.user, item, collection):
messages.error(request, _('This note is used by an incompatible collection or cannot be edited. Create a copy instead.'))
return redirect(collection)
item = adapt_item_for_collection(item, collection)
elif mode == 'copy':
item = copy_item_for_collection(item, collection, request.user)
else:
return redirect(collection)
add_collection_section(collection, item)
return redirect(collection)
section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection)
if action == 'remove':
section.delete()
for index, remaining in enumerate(collection.sections.order_by('position', 'id')):
if remaining.position != index:
CollectionSection.objects.filter(pk=remaining.pk).update(position=index)
elif action == 'title':
section.title = request.POST.get('title', '').strip()[:200]
section.save(update_fields=['title'])
elif action in {'up', 'down'}:
direction = -1 if action == 'up' else 1
other = collection.sections.filter(position=section.position + direction).first()
if other:
with transaction.atomic():
temporary = collection.sections.order_by('-position').values_list('position', flat=True).first() + 1000
CollectionSection.objects.filter(pk=section.pk).update(position=temporary)
CollectionSection.objects.filter(pk=other.pk).update(position=section.position)
CollectionSection.objects.filter(pk=section.pk).update(position=other.position)
return redirect(collection)
return render(request, 'core/collection_detail.html', collection_detail_context(request, collection))
@login_required
@require_POST
def collection_delete(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
if not user_can_delete_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
collection.delete()
messages.success(request, _('Collection deleted.'))
return redirect('collection_list')
@login_required
def item_detail(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk)
@@ -552,6 +786,11 @@ def edit_item(request, pk):
item.content, command_due_at, has_due_command = parse_due_command(item.content)
if item.kind == Item.Kind.TODO:
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': Tag.objects.all()}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
@@ -576,6 +815,11 @@ def item_editor(request, pk):
item.content, command_due_at, has_due_command = parse_due_command(item.content)
if item.kind == Item.Kind.TODO:
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': Tag.objects.all(), 'next_url': next_url}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
@@ -591,7 +835,11 @@ def delete_item(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_delete_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
try:
item.delete()
except ProtectedError:
collections = list(item.collection_sections.select_related('collection').values_list('collection__title', flat=True))
return JsonResponse({'error': _('This note is used in collections and cannot be deleted.'), 'collections': collections}, status=409)
return JsonResponse({'ok': True})
@@ -721,7 +969,11 @@ def api_item_detail(request, pk):
if request.method == 'DELETE':
if not user_can_delete_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
try:
item.delete()
except ProtectedError:
collections = list(item.collection_sections.select_related('collection').values_list('collection__title', flat=True))
return JsonResponse({'error': _('This note is used in collections and cannot be deleted.'), 'collections': collections}, status=409)
return JsonResponse({'ok': True})
if not user_can_edit_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
@@ -735,6 +987,10 @@ def api_item_detail(request, pk):
item.due_at = command_due_at
if 'is_done' in data and item.kind == Item.Kind.TODO:
item.completed_at = timezone.now() if item.is_done else None
target_visibility, target_team = desired_item_scope(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
return JsonResponse({'error': _('This visibility is incompatible with a collection that uses the note.')}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
item.sync_metadata()
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)