Add markdown exports and tighten tag visibility
This commit is contained in:
+157
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user