Improve slash commands and due dates
This commit is contained in:
+52
-7
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user