@@ -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)"