diff --git a/core/templates/core/base.html b/core/templates/core/base.html index 001b7fe..0130e54 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -51,20 +51,20 @@ document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'}); window.quickComposer = window.quickComposer || function(tags, itemId=null){ return { - open:false,showTags:false,showFormatting:false,text:'',active:0,query:'',queryStart:0,currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[], + open:false,showTags:false,showFormatting:false,text:'',active:0,query:'',queryStart:0,currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],userSuggestions:[],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" %}',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 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(c=>{if(typeTokens.includes(c.token))return !selectedType&&this.queryStart===0;if(this.queryStart===0&&!selectedType)return false;if(selectedType&&selectedType!=='todo'&&dateTokens.includes(c.token))return false;return true});let list=q.startsWith('#')?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}`,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}`,group:'{% trans "References" %}'})):commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu',group:''}]}, + commands:[{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:'/image',label:'{% trans "Insert uploaded image" %}',group:'{% trans "References" %}'},{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 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(c=>{if(typeTokens.includes(c.token))return !selectedType&&this.queryStart===0;if(this.queryStart===0&&!selectedType)return false;if(selectedType&&selectedType!=='todo'&&dateTokens.includes(c.token))return false;return true});let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag',group:'{% trans "Tags" %}'})):q.startsWith('@')?this.userSuggestions.map(u=>({token:'@'+u.username,label:u.label,group:'{% trans "Users" %}'})):q.startsWith('![[')?(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}`,group:'{% trans "References" %}'})):commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));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)}, 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)}, insertImageToken(){if(this.pendingFiles.length)this.insert(`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`)}, previewFile(e){this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));let file=this.pendingFiles[0];this.preview=file?URL.createObjectURL(file):null}, onKeydown(e){this.currentEl=e.target;let shortcut=(e.ctrlKey||e.metaKey)&&{b:'bold',i:'italic',u:'underline'}[e.key.toLowerCase()];if(shortcut){e.preventDefault();this.formatSelection(shortcut);return}if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();this.open=false;e.target.form.requestSubmit();return}if(this.open&&e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%this.filtered.length;return}if(this.open&&e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+this.filtered.length)%this.filtered.length;return}if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){e.preventDefault();this.insert(this.filtered[this.active].token);return}if(e.key==='Escape'){this.open=false;return}if(e.key===' '){this.open=false;return}setTimeout(()=>this.update(e.target),0)}, - update(el){this.currentEl=el;if(this.isContentField(el))this.text=el.value;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){fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(d=>{this.attachmentSuggestions=d.attachments||[]})}else if(fragment.startsWith('[[')){fetch(`/api/item-suggestions/?q=${encodeURIComponent(fragment.slice(2))}`).then(r=>r.json()).then(d=>{this.linkSuggestions=d.items||[]})}}, + update(el){this.currentEl=el;if(this.isContentField(el))this.text=el.value;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('[[')||fragment.startsWith('![[');if(fragment.startsWith('@')){fetch(`/api/user-suggestions/?q=${encodeURIComponent(fragment.slice(1))}`).then(r=>r.json()).then(d=>{this.userSuggestions=d.users||[]})}else if(fragment.startsWith('![[')&&this.itemId){fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(d=>{this.attachmentSuggestions=d.attachments||[]})}else if(fragment.startsWith('[[')){fetch(`/api/item-suggestions/?q=${encodeURIComponent(fragment.slice(2))}`).then(r=>r.json()).then(d=>{this.linkSuggestions=d.items||[]})}}, formatSelection(kind){let el=this.currentEl&&this.currentEl.matches('textarea')?this.currentEl:this.$el.querySelector('textarea[name="content"],textarea');if(!el)return;let start=el.selectionStart??0,end=el.selectionEnd??start;let selected=el.value.slice(start,end);let wrappers={bold:['**','**','Text'],italic:['*','*','Text'],underline:['','','Text'],code:['`','`','Code']};let value,newStart,newEnd;if(wrappers[kind]){let [before,after,placeholder]=wrappers[kind];let inner=selected||placeholder;value=el.value.slice(0,start)+before+inner+after+el.value.slice(end);newStart=start+before.length;newEnd=newStart+inner.length}else{let prefixes={heading:'## ',quote:'> ',list:'- ',numbered:'1. '};let prefix=prefixes[kind]||'';let inner=selected||'{% trans "Text" %}';let formatted=inner.split('\n').map(line=>prefix+line).join('\n');value=el.value.slice(0,start)+formatted+el.value.slice(end);newStart=start;newEnd=start+formatted.length}el.value=value;if(this.isContentField(el))this.text=value;this.$nextTick(()=>{el.focus();el.setSelectionRange(newStart,newEnd)})}, addToken(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let pos=el.selectionStart??el.value.length;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let prefix=before&& !before.endsWith(' ') && !before.endsWith('\n')?' ':'';let value=before+prefix+token+' '+after;if(this.isContentField(el))this.text=value;el.value=value;this.showTags=false;this.$nextTick(()=>{let p=before.length+prefix.length+token.length+1;el.focus();el.setSelectionRange(p,p)})}, - insert(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let isCode=token==='/code';if(isCode)token='```\n\n```';let pos=el.selectionStart;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let match=before.match(/(^|\s)([^\s]*)$/);let start=match?pos-match[2].length:pos;let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');let suffix=isCode?'': ' ';let value=el.value.slice(0,start)+prefix+token+suffix+after;if(this.isContentField(el))this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;el.focus();el.setSelectionRange(p,p)})} + insert(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let isCode=token==='/code';let isImage=token==='/image';if(isCode)token='```\n\n```';if(isImage)token=this.pendingFiles.length?`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`:'![[';let pos=el.selectionStart;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let match=before.match(/(^|\s)([^\s]*)$/);let start=match?pos-match[2].length:pos;let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');let suffix=isCode?'': ' ';let value=el.value.slice(0,start)+prefix+token+suffix+after;if(this.isContentField(el))this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;el.focus();el.setSelectionRange(p,p)})} } }; window.searchBox = function(tags){return{open:false,active:0,q:'',tags:tags||[],get filtered(){let q=this.q.toLowerCase();return this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q))},onInput(e){this.q=e.target.value;this.open=this.q.startsWith('#')},onKeydown(e){if(!this.open)return;if(e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%Math.max(this.filtered.length,1)}else if(e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+Math.max(this.filtered.length,1))%Math.max(this.filtered.length,1)}else if(e.key==='Enter'||e.key==='Tab'){if(this.filtered.length){e.preventDefault();this.select(this.filtered[this.active])}}else if(e.key==='Escape'){this.open=false}},select(tag){let url=new URL(window.location.href);url.searchParams.set('tag',tag);url.searchParams.delete('q');window.location.href=url.pathname+'?'+url.searchParams.toString()}}} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 7363051..a57e017 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -158,6 +158,7 @@ function quickComposer(tags, itemId=null){ preview:null, linkSuggestions:[], attachmentSuggestions:[], + userSuggestions:[], pendingFiles:[], editingItem:false, itemId:itemId, @@ -185,6 +186,7 @@ function quickComposer(tags, itemId=null){ {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:'/image',label:'{% trans "Insert uploaded image" %}',group:'{% trans "References" %}'}, {token:'[[',label:'{% trans "Search internal task/note" %}',group:'{% trans "References" %}'}, {token:'![[',label:'{% trans "Insert image from attachments" %}',group:'{% trans "References" %}'} ], @@ -201,7 +203,9 @@ function quickComposer(tags, itemId=null){ }); let list=q.startsWith('#') ? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag',group:'{% trans "Tags" %}'})) - : q.startsWith('![[') + : q.startsWith('@') + ? this.userSuggestions.map(u=>({token:'@'+u.username,label:u.label,group:'{% trans "Users" %}'})) + : q.startsWith('![[') ? (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}`,group:'{% trans "References" %}'})) @@ -275,8 +279,10 @@ function quickComposer(tags, itemId=null){ 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){ + this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('@')||fragment.startsWith('[[')||fragment.startsWith('![['); + if(fragment.startsWith('@')){ + fetch(`{% url "api_user_suggestions" %}?q=${encodeURIComponent(fragment.slice(1))}`).then(r=>r.json()).then(data=>{this.userSuggestions=data.users||[]}); + }else if(fragment.startsWith('![[')&&this.itemId){ fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(data=>{this.attachmentSuggestions=data.attachments||[]}); }else if(fragment.startsWith('[[')){ let q=fragment.slice(2); @@ -327,7 +333,9 @@ function quickComposer(tags, itemId=null){ let el=this.currentEl || document.getElementById('id_content'); if(!el) return; let isCode=token==='/code'; + let isImage=token==='/image'; if(isCode) token='```\n\n```'; + if(isImage) token=this.pendingFiles.length?`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`:'![['; let pos=el.selectionStart; let before=el.value.slice(0,pos); let after=el.value.slice(pos); diff --git a/core/tests.py b/core/tests.py index f3ccdea..5f99fdd 100644 --- a/core/tests.py +++ b/core/tests.py @@ -120,6 +120,34 @@ class WorkspaceTenancyTests(TestCase): self.assertContains(response, 'text-bg-danger">1') self.assertNotContains(response, 'Due elsewhere') + def test_user_suggestions_are_limited_to_active_workspace(self): + owner = get_user_model().objects.create_user(username='suggest-owner', password='secret') + alice = get_user_model().objects.create_user(username='alice-suggest', first_name='Alice', password='secret') + outsider = get_user_model().objects.create_user(username='alice-foreign', password='secret') + workspace = Workspace.objects.create(owner=owner, name='Suggest Co', slug='suggest-co') + WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER) + WorkspaceMembership.objects.create(workspace=workspace, user=alice, role=WorkspaceMembership.Role.MEMBER) + get_or_create_personal_workspace(outsider) + self.client.force_login(owner) + self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')}) + + response = self.client.get(reverse('api_user_suggestions'), {'q': 'alice'}) + + self.assertEqual(response.status_code, 200) + usernames = [entry['username'] for entry in response.json()['users']] + self.assertIn('alice-suggest', usernames) + self.assertNotIn('alice-foreign', usernames) + + def test_composer_has_user_mentions_and_image_slash_command(self): + user = get_user_model().objects.create_user(username='composer-menu', password='secret') + self.client.force_login(user) + + response = self.client.get(reverse('index')) + + self.assertContains(response, '/image') + self.assertContains(response, '/api/user-suggestions/') + self.assertContains(response, "fragment.startsWith('@')") + def test_todo_can_be_assigned_to_workspace_member_with_mention(self): owner = get_user_model().objects.create_user(username='assign-owner', password='secret') assignee = get_user_model().objects.create_user(username='alice', password='secret') diff --git a/core/urls.py b/core/urls.py index 246d6a7..37a1614 100644 --- a/core/urls.py +++ b/core/urls.py @@ -38,6 +38,7 @@ urlpatterns = [ path('tags/', views.tag_list, name='tag_list'), path('api/tags/', views.api_tags, name='api_tags'), path('api/item-suggestions/', views.api_item_suggestions, name='api_item_suggestions'), + path('api/user-suggestions/', views.api_user_suggestions, name='api_user_suggestions'), path('api/attachment-suggestions/', views.api_attachment_suggestions, name='api_attachment_suggestions'), path('api/items/', views.api_items, name='api_items'), path('api/items//', views.api_item_detail, name='api_item_detail'), diff --git a/core/views.py b/core/views.py index 3967540..ed7dd97 100644 --- a/core/views.py +++ b/core/views.py @@ -1441,6 +1441,20 @@ def api_tags(request): return JsonResponse({'tags': list(visible_tags(request.user, workspace=active_workspace(request)).values_list('name', flat=True))}) +@login_required +def api_user_suggestions(request): + q = request.GET.get('q', '').strip().lstrip('@').lower() + memberships = WorkspaceMembership.objects.filter(workspace=active_workspace(request)).select_related('user') + if q: + memberships = memberships.filter(Q(user__username__icontains=q) | Q(user__first_name__icontains=q) | Q(user__last_name__icontains=q)) + data = [] + for membership in memberships.order_by('user__username')[:10]: + user = membership.user + label = user.get_full_name() or user.username + data.append({'username': user.username, 'label': label}) + return JsonResponse({'users': data}) + + @login_required def api_attachment_suggestions(request): item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=request.GET.get('item'))