Add markdown exports and tighten tag visibility

This commit is contained in:
rucki
2026-09-09 16:31:01 +02:00
parent c655b82c9b
commit 7f203a7117
6 changed files with 294 additions and 15 deletions
+157 -11
View File
@@ -13,6 +13,7 @@ from django.db import transaction
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.http import url_has_allowed_host_and_scheme
from django.utils import timezone
from django.utils import translation
from django.utils.translation import gettext as _
@@ -21,6 +22,7 @@ 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, TAG_RE, without_markdown_code
from .templatetags.markdown_extras import strip_tags_outside_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)
@@ -209,6 +211,13 @@ def user_can_edit_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
def visible_tags(user):
"""Tags from items the user may see; avoids leaking private tags from other users."""
if not user.is_authenticated:
return Tag.objects.filter(items__visibility=Item.Visibility.PUBLIC).distinct().order_by('name')
return Tag.objects.filter(items__in=visible_items(user)).distinct().order_by('name')
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())
@@ -316,6 +325,137 @@ def index_filter_tags(request, user, prefs, active_tags=None):
return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name')
def index_filtered_items(request, prefs):
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
active_tag_mode = 'all' if request.GET.get('tag_mode') == 'all' else 'any'
tag = active_tags[0] if active_tags else None
kind = request.GET.get('kind')
status = request.GET.get('status')
day = request.GET.get('day')
q = request.GET.get('q', '').strip()
items = visible_items(request.user)
applied_preferences = False
if active_tags:
if active_tag_mode == 'all':
for tag_name in active_tags:
items = items.filter(tags__name=tag_name)
else:
items = items.filter(tags__name__in=active_tags).distinct()
if kind in dict(Item.Kind.choices):
items = items.filter(kind=kind)
elif not any([tag, status, q, day]):
applied_preferences = True
type_filter = Q()
if prefs.show_notes:
type_filter |= Q(kind=Item.Kind.NOTE)
if prefs.show_links:
type_filter |= Q(kind=Item.Kind.LINK)
if prefs.show_journal:
type_filter |= Q(kind=Item.Kind.JOURNAL)
if prefs.show_open_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=False)
if prefs.show_done_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=True)
items = items.filter(type_filter) if type_filter else items.none()
if day:
items = items.filter(created_at__date=day)
upcoming_end = timezone.make_aware(datetime.combine(timezone.localdate() + timedelta(days=7), time(23, 59, 59)))
if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'due':
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__isnull=False, due_at__lte=upcoming_end).order_by('due_at', '-created_at')
elif status == 'archive':
items = items.filter(kind=Item.Kind.TODO, is_done=True)
elif not applied_preferences:
items = items.exclude(kind=Item.Kind.TODO, is_done=True)
if q:
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
return items, active_tags, active_tag_mode, tag, kind, status, day, q
def export_item_line(item, hide_tags=False):
content = strip_tags_outside_code(item.content) if hide_tags else (item.content or '')
lines = [line.rstrip() for line in content.strip().splitlines()]
first = lines[0] if lines else _('Untitled')
prefix = '- [x] ' if item.kind == Item.Kind.TODO and item.is_done else '- [ ] ' if item.kind == Item.Kind.TODO else '- '
output = [f'{prefix}{first}']
output.extend(f' {line}' if line else '' for line in lines[1:])
if item.comment:
output.append('')
output.extend(f' > {line}' if line else ' >' for line in item.comment.strip().splitlines())
return '\n'.join(output)
def markdown_response(filename, markdown_text):
response = HttpResponse(markdown_text, content_type='text/markdown; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@login_required
def export_items(request):
prefs, created = UserPreference.objects.get_or_create(user=request.user)
items, active_tags, active_tag_mode, tag, kind, status, day, q = index_filtered_items(request, prefs)
title_parts = [_('my2dos export')]
if kind:
title_parts.append(kind)
if active_tags:
title_parts.append(' '.join(f'#{tag_name}' for tag_name in active_tags))
if status:
title_parts.append(status)
if q:
title_parts.append(f'“{q}”')
lines = [f'# {" – ".join(title_parts)}', '']
lines.extend(export_item_line(item) for item in items)
return markdown_response('my2dos-export.md', '\n\n'.join(lines).strip() + '\n')
def collection_item_export(item, level=2):
content = strip_tags_outside_code(item.content)
if item.kind == Item.Kind.TODO:
return export_item_line(item, hide_tags=True)
if re.match(r'^#{1,6}\s+', content.lstrip()):
body = content
else:
body = f'{"#" * level} {content}'
if item.comment:
body = f'{body}\n\n> ' + item.comment.strip().replace('\n', '\n> ')
return body
def collection_sections_export(sections, level=2):
lines = []
for section in sections:
if section.item:
heading = section.title.strip() if section.title else ''
if heading:
lines.append(f'{"#" * level} {heading}')
lines.append(collection_item_export(section.item, level + 1))
else:
lines.append(collection_item_export(section.item, level))
elif section.sub_collection:
title = section.title or section.sub_collection.title
lines.append(f'{"#" * level} {title}')
if section.sub_collection.description:
lines.append(section.sub_collection.description.strip())
lines.append(collection_sections_export(section.sub_collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments'), level + 1))
return '\n\n'.join(line for line in lines if line).strip()
def collection_export(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
lines = [f'# {collection.title}', '']
if collection.description:
lines.extend([collection.description.strip(), ''])
if collection.mode == Collection.Mode.TAGS:
lines.extend(export_item_line(item, hide_tags=True) for item in collection_tag_items(collection))
else:
body = collection_sections_export(collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection'))
if body:
lines.append(body)
return markdown_response(f'collection-{collection.pk}.md', '\n\n'.join(line for line in lines if line).strip() + '\n')
def index(request):
if not request.user.is_authenticated:
if request.method == 'POST':
@@ -400,7 +540,7 @@ def index(request):
default=Value(1), output_field=IntegerField(),
)).order_by('due_rank', 'due_at', '-created_at')
return render(request, 'core/index.html', {
'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': Tag.objects.all(), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines,
'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': visible_tags(request.user), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines,
})
@@ -435,7 +575,7 @@ def new_item(request):
add_collection_section(collection, item)
return redirect(next_url)
return render(request, 'core/new_item.html', {
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
'form': QuickItemForm(), 'tags': visible_tags(request.user), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
})
@@ -474,7 +614,7 @@ def journal(request):
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
prefs, created = UserPreference.objects.get_or_create(user=request.user)
return render(request, 'core/journal.html', {
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day,
'form': QuickItemForm(), 'items': items, 'tags': visible_tags(request.user), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day,
'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, 'overview_lines': prefs.overview_lines,
})
@@ -515,7 +655,7 @@ def settings_view(request):
messages.success(request, _('Default view saved.'))
return redirect('settings')
elif request.POST.get('action') == 'apikey':
key_form = ApiKeyForm(request.POST)
key_form = ApiKeyForm(request.POST, user=request.user)
if key_form.is_valid():
api_key = key_form.save(commit=False)
api_key.owner = request.user
@@ -535,7 +675,7 @@ def settings_view(request):
return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user),
'preference_form': UserPreferenceForm(instance=prefs),
'key_form': ApiKeyForm(),
'key_form': ApiKeyForm(user=request.user),
'user_invite_form': UserInviteForm(),
'user_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True),
'api_keys': request.user.api_keys.prefetch_related('tags'),
@@ -914,10 +1054,13 @@ def collection_edit(request, pk):
def collection_detail(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
tag_items = list(collection_tag_items(collection)) if collection.mode == Collection.Mode.TAGS else []
for item in tag_items:
item.can_toggle_done = item.kind == Item.Kind.TODO and user_can_edit_item(request.user, item)
return render(request, 'core/collection_detail.html', {
'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') if collection.is_structured else [],
'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [],
'tag_items': tag_items,
'can_edit': user_can_edit_collection(request.user, collection),
})
@@ -1034,6 +1177,9 @@ def toggle_done(request, pk):
item.is_done = not item.is_done
item.completed_at = timezone.now() if item.is_done else None
item.save(update_fields=['is_done', 'completed_at', 'updated_at'])
next_url = request.POST.get('next') or request.GET.get('next')
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
return redirect(next_url)
if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive':
return HttpResponse('')
return render(request, 'core/_item.html', {'item': item})
@@ -1060,7 +1206,7 @@ def edit_item(request, pk):
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)
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user)}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
@@ -1071,7 +1217,7 @@ def edit_item(request, pk):
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
response['HX-Trigger'] = 'tagsChanged'
return response
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()})
return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user)})
@login_required
@@ -1090,7 +1236,7 @@ def item_editor(request, pk):
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)
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user), 'next_url': next_url}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
@@ -1099,7 +1245,7 @@ def item_editor(request, pk):
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
return redirect(next_url)
return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url})
return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user), 'next_url': next_url})
@login_required
@@ -1188,7 +1334,7 @@ def set_default_item_kind(request):
@login_required
def api_tags(request):
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
return JsonResponse({'tags': list(visible_tags(request.user).values_list('name', flat=True))})
@login_required