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