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))