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
+2 -2
View File
@@ -30,8 +30,8 @@ window.quickComposer = window.quickComposer || function(tags, itemId=null){
return {
open:false,showTags:false,text:'',active:0,query:'',currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[],
init(){document.body.addEventListener('tagsChanged',()=>{fetch('/api/tags/').then(r=>r.json()).then(d=>{this.tags=d.tags||[]})})},
commands:[{token:'/todo',label:'{% trans "Create task" %}'},{token:'/link',label:'{% trans "Save link" %}'},{token:'/journal',label:'{% trans "Journal entry" %}'},{token:'/public',label:'{% trans "public" %}'},{token:'/private',label:'{% trans "private" %}'},{token:'/code',label:'{% trans "Insert Markdown code block" %}'},{token:'[[',label:'{% trans "Search internal task/note" %}'},{token:'![[',label:'{% trans "Insert image from attachments" %}'}],
get filtered(){let q=this.query.toLowerCase();let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'})):q.startsWith('![[')?(this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`}))):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`})):this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu'}]},
commands:[{token:'/todo',label:'{% trans "Create task" %}'},{token:'/note',label:'{% trans "Create note" %}'},{token:'/link',label:'{% trans "Save link" %}'},{token:'/journal',label:'{% trans "Journal entry" %}'},{token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}'},{token:'/morgen',label:'{% trans "Tomorrow" %}'},{token:'/übermorgen',label:'{% trans "Day after tomorrow" %}'},{token:'/nächstewoche',label:'{% trans "Next week" %}'},{token:'/public',label:'{% trans "public" %}'},{token:'/private',label:'{% trans "private" %}'},{token:'/code',label:'{% trans "Insert Markdown code block" %}'},{token:'[[',label:'{% trans "Search internal task/note" %}'},{token:'![[',label:'{% trans "Insert image from attachments" %}'}],
get filtered(){let q=this.query.toLowerCase();let hasType=/(^|\s)\/(todo|note|link|journal)(?=\s)/i.test(this.text);let commands=this.commands.filter(c=>!hasType||!['/todo','/note','/link','/journal'].includes(c.token));let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'})):q.startsWith('![[')?(this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`}))):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`})):commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu'}]},
isContentField(el){return el && (el.name==='content' || el.id==='id_content')},
onInput(e){this.update(e.target)},
handlePaste(e){let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));if(!item||!this.$refs.fileInput)return;let file=item.getAsFile();let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');let named=new File([file],`paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`,{type:file.type});let dt=new DataTransfer();[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));dt.items.add(named);this.$refs.fileInput.files=dt.files;this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));this.preview=URL.createObjectURL(named)},
+8 -1
View File
@@ -159,8 +159,13 @@ function quickComposer(tags, itemId=null){
},
commands:[
{token:'/todo',label:'{% trans "Create task" %}'},
{token:'/note',label:'{% trans "Create note" %}'},
{token:'/link',label:'{% trans "Save link" %}'},
{token:'/journal',label:'{% trans "Journal entry" %}'},
{token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}'},
{token:'/morgen',label:'{% trans "Tomorrow" %}'},
{token:'/übermorgen',label:'{% trans "Day after tomorrow" %}'},
{token:'/nächstewoche',label:'{% trans "Next week" %}'},
{token:'/public',label:'{% trans "public" %}'},
{token:'/private',label:'{% trans "private" %}'},
{token:'/code',label:'{% trans "Insert Markdown code block" %}'},
@@ -169,13 +174,15 @@ function quickComposer(tags, itemId=null){
],
get filtered(){
let q=this.query.toLowerCase();
let hasType=/(^|\s)\/(todo|note|link|journal)(?=\s)/i.test(this.text);
let commands=this.commands.filter(c=>!hasType||!['/todo','/note','/link','/journal'].includes(c.token));
let list=q.startsWith('#')
? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'}))
: q.startsWith('![[')
? (this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`})))
: q.startsWith('[[')
? this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`}))
: this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));
: commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));
return list.length?list:[{token:this.query,label:'neu'}];
},
isContentField(el){return el && (el.name==='content' || el.id==='id_content')},
+26 -1
View File
@@ -3,7 +3,7 @@ from django.test import TestCase
from django.urls import reverse
from .models import Item, Tag, UserPreference
from .views import parse_quick_content
from .views import parse_due_command, parse_quick_content
class ItemMetadataTests(TestCase):
@@ -53,6 +53,31 @@ class DefaultItemKindTests(TestCase):
self.assertEqual(response.context['requested_kind'], Item.Kind.TODO)
class DueDateCommandTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='dates', password='secret')
self.client.force_login(self.user)
def test_datum_command_is_removed_and_parsed(self):
cleaned, due_at, found = parse_due_command('/todo Call back /datum 20.10.26 14:30 #work')
self.assertTrue(found)
self.assertEqual(cleaned, '/todo Call back #work')
self.assertEqual((due_at.year, due_at.month, due_at.day, due_at.hour, due_at.minute), (2026, 10, 20, 14, 30))
def test_relative_command_sets_due_date_on_quick_create(self):
response = self.client.post(reverse('index'), {'content': '/todo Call back /morgen'})
self.assertRedirects(response, reverse('index'))
item = Item.objects.get()
self.assertNotIn('/morgen', item.content)
self.assertIsNotNone(item.due_at)
def test_type_and_date_commands_are_available_in_menu(self):
response = self.client.get(reverse('index'))
self.assertContains(response, "{token:'/note'")
self.assertContains(response, "{token:'/datum'")
self.assertContains(response, "let hasType=")
class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self):
user = get_user_model().objects.create_user(username='tester', password='secret')
+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()
+16
View File
@@ -644,6 +644,22 @@ msgstr "Team %(team)s erstellt. Nutze #%(slug)s für Team-Einträge."
msgid "You are now a member of team %(team)s."
msgstr "Du bist jetzt Mitglied im Team %(team)s."
#: core/templates/core/base.html core/templates/core/index.html
msgid "Due date (DD.MM.YY)"
msgstr "Fälligkeitsdatum (TT.MM.JJ)"
#: core/templates/core/base.html core/templates/core/index.html
msgid "Tomorrow"
msgstr "Morgen"
#: core/templates/core/base.html core/templates/core/index.html
msgid "Day after tomorrow"
msgstr "Übermorgen"
#: core/templates/core/base.html core/templates/core/index.html
msgid "Next week"
msgstr "Nächste Woche"
#: core/forms.py
msgid "Default type for new entries"
msgstr "Standardtyp für neue Einträge"
+16
View File
@@ -638,6 +638,22 @@ msgstr ""
msgid "You are now a member of team %(team)s."
msgstr ""
#: core/templates/core/base.html core/templates/core/index.html
msgid "Due date (DD.MM.YY)"
msgstr "Due date (DD.MM.YY)"
#: core/templates/core/base.html core/templates/core/index.html
msgid "Tomorrow"
msgstr "Tomorrow"
#: core/templates/core/base.html core/templates/core/index.html
msgid "Day after tomorrow"
msgstr "Day after tomorrow"
#: core/templates/core/base.html core/templates/core/index.html
msgid "Next week"
msgstr "Next week"
#: core/forms.py
msgid "Default type for new entries"
msgstr "Default type for new entries"