import json import re from functools import wraps from calendar import Calendar from datetime import date 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, UserPreferenceForm, UserSettingsForm from .models import ApiKey, Attachment, Item, Tag, UserPreference COMMAND_RE = re.compile(r'/(todo|note|link|journal|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.JOURNAL if 'journal' 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 attach_files_and_replace_tokens(item, files): replacements = {} for file in files: attachment = Attachment.objects.create(item=item, file=file) replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]' if replacements: content = item.content comment = item.comment for old, new in replacements.items(): content = content.replace(old, new) comment = comment.replace(old, new) if content != item.content or comment != item.comment: item.content = content item.comment = comment item.save(update_fields=['content', 'comment', 'updated_at']) 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) attach_files_and_replace_tokens(item, request.FILES.getlist('files')) item.sync_metadata() 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') day = request.GET.get('day') 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) applied_preferences = False if tag: items = items.filter(tags__name=tag) if kind in dict(Item.Kind.choices): items = items.filter(kind=kind) elif not any([tag, status, q, day]): applied_preferences = True prefs, _ = UserPreference.objects.get_or_create(user=request.user) 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) 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) 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() prefs, _ = UserPreference.objects.get_or_create(user=request.user) journal_days = [] if kind == Item.Kind.JOURNAL: today = date.today() year = int(request.GET.get('year', today.year)) month = int(request.GET.get('month', today.month)) journal_qs = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month) days_with_entries = set(journal_qs.dates('created_at', 'day')) journal_days = [ {'day': d, 'date': date(year, month, d).isoformat(), 'has_entries': date(year, month, d) in days_with_entries} for week in Calendar(firstweekday=0).monthdayscalendar(year, month) for d in week if d ] return render(request, 'core/index.html', { 'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'journal_days': journal_days, 'overview_lines': prefs.overview_lines, }) @login_required def new_item(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']) item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url) attach_files_and_replace_tokens(item, request.FILES.getlist('files')) item.sync_metadata() return redirect(item.get_absolute_url()) return render(request, 'core/new_item.html', {'form': QuickItemForm(), 'tags': Tag.objects.all()}) @login_required def journal(request): today = date.today() year = int(request.GET.get('year', today.year)) month = int(request.GET.get('month', today.month)) entries = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month) by_day = {} for item in entries: by_day.setdefault(item.created_at.day, []).append(item) weeks = [] for week in Calendar(firstweekday=0).monthdayscalendar(year, month): weeks.append([(day, by_day.get(day, [])) for day in week]) prev_month, prev_year = (12, year - 1) if month == 1 else (month - 1, year) next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year) return render(request, 'core/journal.html', { 'weeks': weeks, 'year': year, 'month': month, 'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, }) @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') == 'preferences': prefs, _ = UserPreference.objects.get_or_create(user=request.user) pref_form = UserPreferenceForm(request.POST, instance=prefs) if pref_form.is_valid(): pref_form.save() messages.success(request, 'Standardansicht 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') prefs, _ = UserPreference.objects.get_or_create(user=request.user) return render(request, 'core/settings.html', { 'profile_form': UserSettingsForm(instance=request.user), 'preference_form': UserPreferenceForm(instance=prefs), '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() attach_files_and_replace_tokens(item, request.FILES.getlist('files')) item.sync_metadata() 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()}) @login_required def item_editor(request, pk): item = get_object_or_404(Item, pk=pk, owner=request.user) next_url = request.GET.get('next') or item.get_absolute_url() if request.method == 'POST': form = EditItemForm(request.POST, request.FILES, instance=item) if form.is_valid(): item = form.save() attach_files_and_replace_tokens(item, request.FILES.getlist('files')) item.sync_metadata() return redirect(next_url) return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url}) @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_attachment_suggestions(request): item = get_object_or_404(Item, pk=request.GET.get('item'), owner=request.user) data = [{'id': a.id, 'name': a.file.name.split('/')[-1], 'url': a.file.url} for a in item.attachments.all()] return JsonResponse({'attachments': data}) @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))