Improve slash commands and due dates

This commit is contained in:
rucki
2026-08-27 09:51:06 +02:00
parent ee332a11a8
commit 78290ee5dc
6 changed files with 120 additions and 11 deletions
+52 -7
View File
@@ -2,7 +2,7 @@ import json
import re
from functools import wraps
from calendar import Calendar
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
from html import unescape
from urllib.request import Request, urlopen
from django.conf import settings
@@ -25,6 +25,8 @@ 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'<title[^>]*>(.*?)</title>', re.I | re.S)
DATE_COMMAND_RE = re.compile(r'/datum\s+(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))(?:\s+(\d{1,2}:\d{2}))?\b', re.I)
RELATIVE_DATE_COMMAND_RE = re.compile(r'/(morgen|übermorgen|uebermorgen|nächstewoche|naechstewoche)\b', re.I)
def fetch_page_title(url):
@@ -63,6 +65,38 @@ def parse_quick_content(raw, default_kind=Item.Kind.NOTE):
return kind, visibility, content, url
def parse_due_command(raw):
"""Extract a due date slash command and return cleaned text, date and match state."""
date_match = DATE_COMMAND_RE.search(raw)
relative_match = RELATIVE_DATE_COMMAND_RE.search(raw)
match = date_match or relative_match
if not match:
return raw, None, False
due_time = time(23, 59)
if date_match:
date_format = '%d.%m.%Y' if len(date_match.group(1).rsplit('.', 1)[-1]) == 4 else '%d.%m.%y'
try:
due_date = datetime.strptime(date_match.group(1), date_format).date()
if date_match.group(2):
due_time = time.fromisoformat(date_match.group(2))
except ValueError:
return raw, None, False
else:
today = timezone.localdate()
command = relative_match.group(1).lower()
if command == 'morgen':
due_date = today + timedelta(days=1)
elif command in {'übermorgen', 'uebermorgen'}:
due_date = today + timedelta(days=2)
else:
due_date = today + timedelta(days=(7 - today.weekday()))
cleaned = f'{raw[:match.start()]} {raw[match.end():]}'
cleaned = re.sub(r'[ \t]{2,}', ' ', cleaned).strip()
return cleaned, timezone.make_aware(datetime.combine(due_date, due_time)), True
def parse_due_at_from_post(post):
due_date = (post.get('due_date') or '').strip()
due_time = (post.get('due_time') or '').strip()
@@ -167,11 +201,12 @@ def index(request):
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
raw = form.cleaned_data['content']
raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
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 = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
@@ -254,8 +289,9 @@ def new_item(request):
raw = form.cleaned_data['content']
if requested_kind in {'todo', 'link', 'journal'} and not re.search(r'/(todo|note|link|journal)\b', raw, re.I):
raw = f'/{requested_kind} {raw}'
raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
@@ -273,8 +309,9 @@ def journal(request):
raw = form.cleaned_data['content']
if not re.search(r'/(todo|note|link)\b', raw, re.I):
raw = f'/journal {raw}'
raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw)
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
@@ -507,8 +544,9 @@ def edit_item(request, pk):
form = EditItemForm(request.POST, request.FILES, instance=item)
if form.is_valid():
item = form.save(commit=False)
item.content, command_due_at, has_due_command = parse_due_command(item.content)
if item.kind == Item.Kind.TODO:
item.due_at = parse_due_at_from_post(request.POST)
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
item.save()
form.save_m2m()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
@@ -530,8 +568,9 @@ def item_editor(request, pk):
form = EditItemForm(request.POST, request.FILES, instance=item)
if form.is_valid():
item = form.save(commit=False)
item.content, command_due_at, has_due_command = parse_due_command(item.content)
if item.kind == Item.Kind.TODO:
item.due_at = parse_due_at_from_post(request.POST)
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
item.save()
form.save_m2m()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
@@ -656,9 +695,11 @@ def api_items(request):
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', '')
raw, command_due_at, _ = parse_due_command(raw)
prefs, created = UserPreference.objects.get_or_create(user=user)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
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)
due_at = data['due_at'] if 'due_at' in data else command_due_at
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, due_at=due_at)
item.sync_metadata()
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
return JsonResponse(serialize_item(item), status=201)
@@ -683,6 +724,10 @@ def api_item_detail(request, pk):
for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']:
if field in data:
setattr(item, field, data[field])
if 'content' in data and 'due_at' not in data:
item.content, command_due_at, has_due_command = parse_due_command(item.content)
if has_due_command:
item.due_at = command_due_at
if 'is_done' in data and item.kind == Item.Kind.TODO:
item.completed_at = timezone.now() if item.is_done else None
item.save()