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"