diff --git a/core/templates/core/_edit_form.html b/core/templates/core/_edit_form.html index e4c5748..1c71b43 100644 --- a/core/templates/core/_edit_form.html +++ b/core/templates/core/_edit_form.html @@ -12,7 +12,7 @@
- +
@@ -142,6 +142,7 @@ function quickComposer(tags, itemId=null){ text:'', active:0, query:'', + queryStart:0, currentEl:null, preview:null, linkSuggestions:[], @@ -158,32 +159,39 @@ 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" %}'}, - {token:'[[',label:'{% trans "Search internal task/note" %}'}, - {token:'![[',label:'{% trans "Insert image from attachments" %}'} + {token:'/todo',label:'{% trans "Create task" %}',group:'{% trans "Type" %}'}, + {token:'/note',label:'{% trans "Create note" %}',group:'{% trans "Type" %}'}, + {token:'/link',label:'{% trans "Save link" %}',group:'{% trans "Type" %}'}, + {token:'/journal',label:'{% trans "Journal entry" %}',group:'{% trans "Type" %}'}, + {token:'/public',label:'{% trans "public" %}',group:'{% trans "Visibility" %}'}, + {token:'/private',label:'{% trans "private" %}',group:'{% trans "Visibility" %}'}, + {token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}',group:'{% trans "Date" %}'}, + {token:'/morgen',label:'{% trans "Tomorrow" %}',group:'{% trans "Date" %}'}, + {token:'/übermorgen',label:'{% trans "Day after tomorrow" %}',group:'{% trans "Date" %}'}, + {token:'/nächstewoche',label:'{% trans "Next week" %}',group:'{% trans "Date" %}'}, + {token:'/code',label:'{% trans "Insert Markdown code block" %}',group:'{% trans "Formatting" %}'}, + {token:'[[',label:'{% trans "Search internal task/note" %}',group:'{% trans "References" %}'}, + {token:'![[',label:'{% trans "Insert image from attachments" %}',group:'{% trans "References" %}'} ], 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 selectedType=(this.text.match(/^\/(todo|note|link|journal)(?=\s)/i)||[])[1]?.toLowerCase(); + let typeTokens=['/todo','/note','/link','/journal']; + let dateTokens=['/datum','/morgen','/übermorgen','/nächstewoche']; + let commands=this.commands.filter(command=>{ + if(typeTokens.includes(command.token)) return !selectedType&&this.queryStart===0; + if(this.queryStart===0&&!selectedType) return false; + if(selectedType&&selectedType!=='todo'&&dateTokens.includes(command.token)) return false; + return true; + }); let list=q.startsWith('#') - ? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'})) + ? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag',group:'{% trans "Tags" %}'})) : 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}`}))) + ? (this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`,group:'{% trans "References" %}'})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`,group:'{% trans "References" %}'}))) : q.startsWith('[[') - ? this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`})) + ? this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`,group:'{% trans "References" %}`})) : commands.filter(c=>!q||c.token.toLowerCase().startsWith(q)); - return list.length?list:[{token:this.query,label:'neu'}]; + return list.length?list:[{token:this.query,label:'neu',group:''}]; }, isContentField(el){return el && (el.name==='content' || el.id==='id_content')}, onInput(e){this.update(e.target)}, @@ -244,6 +252,7 @@ function quickComposer(tags, itemId=null){ let before=el.value.slice(0,el.selectionStart); let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2]; this.query=fragment; + this.queryStart=el.selectionStart-fragment.length; this.active=0; this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||fragment.startsWith('![['); if(fragment.startsWith('![[')&&this.itemId){ diff --git a/core/templates/core/item_editor.html b/core/templates/core/item_editor.html index 92bb4b7..d28dd77 100644 --- a/core/templates/core/item_editor.html +++ b/core/templates/core/item_editor.html @@ -18,7 +18,7 @@
- +
diff --git a/core/templates/core/journal.html b/core/templates/core/journal.html index 8511170..55b6ac7 100644 --- a/core/templates/core/journal.html +++ b/core/templates/core/journal.html @@ -28,7 +28,7 @@
{% trans "Add tags" %}
{% for tag in tags %}{% endfor %}
{{ form.content }} -
+
diff --git a/core/templates/core/new_item.html b/core/templates/core/new_item.html index 3b7bdd3..ea72453 100644 --- a/core/templates/core/new_item.html +++ b/core/templates/core/new_item.html @@ -17,7 +17,7 @@
- +
diff --git a/core/templatetags/markdown_extras.py b/core/templatetags/markdown_extras.py index d5b71fe..0a2bd17 100644 --- a/core/templatetags/markdown_extras.py +++ b/core/templatetags/markdown_extras.py @@ -12,11 +12,31 @@ ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | { } ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']} FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]') +FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})') +HASH_TAG_AT_LINE_START_RE = re.compile(r'^(#{1,6})(?=\S)') + + +def require_space_after_markdown_heading(text): + """Keep #tag at line start as text; Markdown headings require '# '.""" + lines = [] + fence = None + for line in (text or '').splitlines(): + match = FENCE_RE.match(line) + if fence: + lines.append(line) + if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence): + fence = None + elif match: + fence = match.group(1) + lines.append(line) + else: + lines.append(HASH_TAG_AT_LINE_START_RE.sub(r'\\\1', line)) + return '\n'.join(lines) @register.filter def markdown(text): - html = md.markdown(text or '', extensions=['extra', 'sane_lists', 'nl2br']) + html = md.markdown(require_space_after_markdown_heading(text), extensions=['extra', 'sane_lists', 'nl2br']) return mark_safe(bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS)) diff --git a/core/tests.py b/core/tests.py index dffc109..ea0d7e3 100644 --- a/core/tests.py +++ b/core/tests.py @@ -3,6 +3,7 @@ from django.test import TestCase from django.urls import reverse from .models import Item, Tag, UserPreference +from .templatetags.markdown_extras import markdown from .views import parse_due_command, parse_quick_content @@ -75,7 +76,33 @@ class DueDateCommandTests(TestCase): response = self.client.get(reverse('index')) self.assertContains(response, "{token:'/note'") self.assertContains(response, "{token:'/datum'") - self.assertContains(response, "let hasType=") + self.assertContains(response, 'queryStart') + self.assertContains(response, "group:'Typ'") + + +class SlashMenuContextTests(TestCase): + def test_type_command_only_counts_at_start(self): + leading_kind, _, leading_content, _ = parse_quick_content('/todo Leading') + later_kind, _, later_content, _ = parse_quick_content('Text /todo later') + self.assertEqual(leading_kind, Item.Kind.TODO) + self.assertEqual(leading_content, 'Leading') + self.assertEqual(later_kind, Item.Kind.NOTE) + self.assertEqual(later_content, 'Text /todo later') + + +class MarkdownHeadingTests(TestCase): + def test_tag_at_line_start_is_not_a_heading(self): + rendered = str(markdown('#project starts here')) + self.assertIn('

#project starts here

', rendered) + self.assertNotIn('

', rendered) + + def test_heading_with_space_remains_a_heading(self): + self.assertIn('

Project

', str(markdown('# Project'))) + + def test_hash_in_fenced_code_is_unchanged(self): + rendered = str(markdown('```python\n# comment\n```')) + self.assertIn('# comment', rendered) + self.assertNotIn('\\# comment', rendered) class CopyButtonTests(TestCase): diff --git a/core/views.py b/core/views.py index 1a24e9d..e365e04 100644 --- a/core/views.py +++ b/core/views.py @@ -21,7 +21,8 @@ from django.views.decorators.http import require_http_methods, require_POST from .forms import ApiKeyForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference -COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I) +TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I) +VISIBILITY_COMMAND_RE = re.compile(r'/(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) @@ -44,20 +45,24 @@ def fetch_page_title(url): def parse_quick_content(raw, default_kind=Item.Kind.NOTE): content = raw.strip() - commands = {c.lower() for c in COMMAND_RE.findall(content)} - content = COMMAND_RE.sub('', content).strip() + type_match = TYPE_COMMAND_RE.match(content) + type_command = type_match.group(1).lower() if type_match else None + visibility_commands = {command.lower() for command in VISIBILITY_COMMAND_RE.findall(content)} + if type_match: + content = content[type_match.end():].strip() + content = VISIBILITY_COMMAND_RE.sub('', content).strip() only_url = bool(ONLY_URL_RE.match(content)) - if 'todo' in commands: + if type_command == 'todo': kind = Item.Kind.TODO - elif 'journal' in commands: + elif type_command == 'journal': kind = Item.Kind.JOURNAL - elif 'link' in commands or only_url: + elif type_command == 'link' or only_url: kind = Item.Kind.LINK - elif 'note' in commands: + elif type_command == 'note': kind = Item.Kind.NOTE else: kind = default_kind if default_kind in Item.Kind.values else Item.Kind.NOTE - visibility = Item.Visibility.PUBLIC if 'public' in commands else Item.Visibility.PRIVATE + visibility = Item.Visibility.PUBLIC if 'public' in visibility_commands else Item.Visibility.PRIVATE url_match = URL_RE.search(content) url = url_match.group(0) if kind == Item.Kind.LINK and url_match else '' if only_url: @@ -287,7 +292,7 @@ def new_item(request): form = QuickItemForm(request.POST, request.FILES) if form.is_valid(): 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): + if requested_kind in {'todo', 'link', 'journal'} and not TYPE_COMMAND_RE.match(raw.strip()): raw = f'/{requested_kind} {raw}' raw, due_at, _ = parse_due_command(raw) kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind) @@ -307,7 +312,7 @@ def journal(request): form = QuickItemForm(request.POST, request.FILES) if form.is_valid(): raw = form.cleaned_data['content'] - if not re.search(r'/(todo|note|link)\b', raw, re.I): + if not TYPE_COMMAND_RE.match(raw.strip()): raw = f'/journal {raw}' raw, due_at, _ = parse_due_command(raw) kind, visibility, content, url = parse_quick_content(raw) diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index d8b1785..6a490b4 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -644,6 +644,26 @@ 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 "Type" +msgstr "Typ" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "Visibility" +msgstr "Sichtbarkeit" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "Date" +msgstr "Datum" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "Formatting" +msgstr "Formatierung" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "References" +msgstr "Referenzen" + #: core/templates/core/base.html core/templates/core/index.html msgid "Due date (DD.MM.YY)" msgstr "Fälligkeitsdatum (TT.MM.JJ)" diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index fc46a0e..ee53317 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -638,6 +638,26 @@ msgstr "" msgid "You are now a member of team %(team)s." msgstr "" +#: core/templates/core/base.html core/templates/core/index.html +msgid "Type" +msgstr "Type" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "Visibility" +msgstr "Visibility" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "Date" +msgstr "Date" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "Formatting" +msgstr "Formatting" + +#: core/templates/core/base.html core/templates/core/index.html +msgid "References" +msgstr "References" + #: core/templates/core/base.html core/templates/core/index.html msgid "Due date (DD.MM.YY)" msgstr "Due date (DD.MM.YY)"