import json import re from functools import wraps from html import unescape from urllib.request import Request, urlopen from django.contrib import messages from django.contrib.auth.decorators import login_required from django.db.models import Q from django.http import HttpResponse, JsonResponse 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, QuickItemForm, UserSettingsForm from .models import ApiKey, Attachment, Item, Tag COMMAND_RE = re.compile(r'/(todo|note|link|public|private)\b', re.I) URL_RE = re.compile(r'https?://\S+') ONLY_URL_RE = re.compile(r'^https?://\S+$') TITLE_RE = re.compile(r']*>(.*?)', re.I | re.S) def fetch_page_title(url): try: request = Request(url, headers={'User-Agent': 'my2dos/1.0'}) with urlopen(request, timeout=4) as response: html = response.read(200_000).decode(response.headers.get_content_charset() or 'utf-8', errors='ignore') match = TITLE_RE.search(html) if match: return re.sub(r'\s+', ' ', unescape(match.group(1))).strip() except Exception: pass return '' def parse_quick_content(raw): content = raw.strip() commands = {c.lower() for c in COMMAND_RE.findall(content)} content = COMMAND_RE.sub('', content).strip() only_url = bool(ONLY_URL_RE.match(content)) kind = Item.Kind.TODO if 'todo' in commands else Item.Kind.LINK if 'link' in commands or only_url else Item.Kind.NOTE visibility = Item.Visibility.PUBLIC if 'public' in commands else Item.Visibility.PRIVATE url_match = URL_RE.search(content) url = url_match.group(0) if kind == Item.Kind.LINK and url_match else '' if only_url: content = fetch_page_title(url) or url return kind, visibility, content, url def visible_items(user, api_key=None): qs = Item.objects.select_related('owner').prefetch_related('tags', 'attachments', 'linked_items') if api_key: qs = qs.filter(owner=api_key.owner) allowed_tags = api_key.tags.all() if allowed_tags.exists(): qs = qs.filter(tags__in=allowed_tags).distinct() return qs if user.is_authenticated: return qs.filter(Q(owner=user) | Q(visibility=Item.Visibility.PUBLIC)) return qs.filter(visibility=Item.Visibility.PUBLIC) def get_api_key(request): token = request.headers.get('X-Api-Key') or request.headers.get('Authorization', '').removeprefix('Bearer ').strip() if not token: return None return ApiKey.objects.filter(token=token, is_active=True).select_related('owner').prefetch_related('tags').first() def api_auth_required(view): @wraps(view) def wrapper(request, *args, **kwargs): request.api_key = get_api_key(request) if request.user.is_authenticated or request.api_key: return view(request, *args, **kwargs) return JsonResponse({'error': 'authentication required'}, status=401) return wrapper @login_required def index(request): if request.method == 'POST': form = QuickItemForm(request.POST, request.FILES) if form.is_valid(): kind, visibility, content, url = parse_quick_content(form.cleaned_data['content']) active_tag = request.GET.get('tag') if active_tag and f'#{active_tag.lower()}' not in content.lower(): content = f'{content} #{active_tag}' item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url) item.sync_metadata() if request.FILES.get('files'): Attachment.objects.create(item=item, file=request.FILES['files']) if request.headers.get('HX-Request'): response = render(request, 'core/_create_response.html', {'item': item, 'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')}) response['HX-Trigger'] = 'tagsChanged' return response return redirect('index') tag = request.GET.get('tag') kind = request.GET.get('kind') status = request.GET.get('status') q = request.GET.get('q', '').strip() if q.startswith('#') and len(q) > 1: query = request.GET.copy() query['tag'] = q[1:].strip().lower() query.pop('q', None) return redirect(f'{request.path}?{query.urlencode()}') items = visible_items(request.user) if tag: items = items.filter(tags__name=tag) if kind in dict(Item.Kind.choices): items = items.filter(kind=kind) if status == 'open': items = items.filter(kind=Item.Kind.TODO, is_done=False) elif status == 'archive': items = items.filter(kind=Item.Kind.TODO, is_done=True) else: 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 render(request, 'core/index.html', { 'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'q': q, }) @login_required def settings_view(request): if request.method == 'POST': if request.POST.get('action') == 'profile': form = UserSettingsForm(request.POST, instance=request.user) if form.is_valid(): form.save() messages.success(request, 'Einstellungen gespeichert.') return redirect('settings') elif request.POST.get('action') == 'apikey': key_form = ApiKeyForm(request.POST) if key_form.is_valid(): api_key = key_form.save(commit=False) api_key.owner = request.user api_key.save() key_form.save_m2m() messages.success(request, f'API Key erzeugt: {api_key.token}') return redirect('settings') return render(request, 'core/settings.html', { 'profile_form': UserSettingsForm(instance=request.user), 'key_form': ApiKeyForm(), 'api_keys': request.user.api_keys.prefetch_related('tags'), }) @login_required @require_POST def delete_api_key(request, pk): api_key = get_object_or_404(ApiKey, pk=pk, owner=request.user) api_key.delete() return redirect('settings') @login_required def item_detail(request, pk): item = get_object_or_404(visible_items(request.user), pk=pk) return render(request, 'core/detail.html', {'item': item}) @login_required @require_POST def toggle_done(request, pk): item = get_object_or_404(Item, pk=pk, owner=request.user, kind=Item.Kind.TODO) item.is_done = not item.is_done item.save(update_fields=['is_done', 'updated_at']) 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}) @login_required def item_card(request, pk): item = get_object_or_404(visible_items(request.user), pk=pk) return render(request, 'core/_item.html', {'item': item}) @login_required def edit_item(request, pk): item = get_object_or_404(Item, pk=pk, owner=request.user) if request.method == 'POST': form = EditItemForm(request.POST, request.FILES, instance=item) if form.is_valid(): item = form.save() item.sync_metadata() if request.FILES.get('files'): Attachment.objects.create(item=item, file=request.FILES['files']) response = render(request, 'core/_item.html', {'item': item}) response['HX-Trigger'] = 'tagsChanged' return response return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()}) @login_required @require_POST def delete_item(request, pk): item = get_object_or_404(Item, pk=pk, owner=request.user) item.delete() return JsonResponse({'ok': True}) @login_required def tag_list(request): return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')}) @login_required def api_tags(request): return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))}) @login_required def api_item_suggestions(request): q = request.GET.get('q', '').strip() items = visible_items(request.user) if q: items = items.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct() data = [{'id': i.id, 'kind': i.kind, 'label': i.content[:80]} for i in items[:10]] return JsonResponse({'items': data}) def serialize_item(item): return { 'id': item.id, 'kind': item.kind, 'content': item.content, 'comment': item.comment, 'visibility': item.visibility, 'is_done': item.is_done, 'due_at': item.due_at.isoformat() if item.due_at else None, 'url': item.url, 'tags': [t.name for t in item.tags.all()], 'linked_items': [i.id for i in item.linked_items.all()], 'attachments': [a.file.url for a in item.attachments.all()], 'created_at': item.created_at.isoformat(), 'updated_at': item.updated_at.isoformat(), } @csrf_exempt @api_auth_required @require_http_methods(['GET', 'POST']) def api_items(request): user = request.api_key.owner if request.api_key else request.user if request.method == 'GET': return JsonResponse({'items': [serialize_item(i) for i in visible_items(user, request.api_key)[:200]]}) data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST raw = data.get('content', '') kind, visibility, content, url = parse_quick_content(raw) item = Item.objects.create(owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url) item.sync_metadata() return JsonResponse(serialize_item(item), status=201) @csrf_exempt @api_auth_required @require_http_methods(['GET', 'PATCH', 'DELETE']) def api_item_detail(request, pk): user = request.api_key.owner if request.api_key else request.user item = get_object_or_404(visible_items(user, request.api_key), pk=pk) if request.method == 'GET': return JsonResponse(serialize_item(item)) if request.method == 'DELETE': if item.owner != user: return JsonResponse({'error': 'forbidden'}, status=403) item.delete() return JsonResponse({'ok': True}) if item.owner != user: return JsonResponse({'error': 'forbidden'}, status=403) data = json.loads(request.body or '{}') for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']: if field in data: setattr(item, field, data[field]) item.save() item.sync_metadata() return JsonResponse(serialize_item(item))