From 78290ee5dc03715adca1e6d42b15d45d7f066dfa Mon Sep 17 00:00:00 2001 From: rucki Date: Thu, 27 Aug 2026 09:51:06 +0200 Subject: [PATCH] Improve slash commands and due dates --- core/templates/core/base.html | 4 +-- core/templates/core/index.html | 9 ++++- core/tests.py | 27 ++++++++++++++- core/views.py | 59 +++++++++++++++++++++++++++++---- locale/de/LC_MESSAGES/django.po | 16 +++++++++ locale/en/LC_MESSAGES/django.po | 16 +++++++++ 6 files changed, 120 insertions(+), 11 deletions(-) diff --git a/core/templates/core/base.html b/core/templates/core/base.html index 1f406a3..150486f 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -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)}, diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 4190422..ab4346d 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -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')}, diff --git a/core/tests.py b/core/tests.py index 4ac9349..dffc109 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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') diff --git a/core/views.py b/core/views.py index 748ee5f..1a24e9d 100644 --- a/core/views.py +++ b/core/views.py @@ -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']*>(.*?)', 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() diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 287776c..d8b1785 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -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" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 4b843eb..fc46a0e 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -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"