Refine slash menu and Markdown tags

This commit is contained in:
rucki
2026-08-27 10:32:19 +02:00
parent 78290ee5dc
commit fd2a3de4ac
11 changed files with 142 additions and 41 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
<textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea> <textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
<textarea class="form-control" name="comment" rows="3" placeholder="{% trans 'Comment, e.g. same task already captured [[12]]' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea> <textarea class="form-control" name="comment" rows="3" placeholder="{% trans 'Comment, e.g. same task already captured [[12]]' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak> <div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span><span x-show="idx === 0 || filtered[idx-1].group !== cmd.group" class="float-end text-muted text-uppercase slash-group" x-text="cmd.group"></span></button></template>
</div> </div>
<div class="row g-2"> <div class="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility"> <div class="col-md-4"><select class="form-select" name="visibility">
+5 -5
View File
@@ -17,7 +17,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style> <style>
body{background:linear-gradient(180deg,#f8fafc 0,#f7f7f4 18rem);color:#1f2937}.shell{max-width:960px}.navbar{box-shadow:0 .35rem 1.25rem rgba(15,23,42,.06)}.navbar a{text-decoration:none}.nav-soft{color:#475569!important;font-weight:500;padding:.2rem .35rem;border-radius:.5rem}.nav-soft:hover{color:#0d6efd!important;background:#eff6ff}.user-menu-btn{min-width:9rem}.card{border:0}.item-card{border:0;border-radius:1.1rem;box-shadow:0 .45rem 1.4rem rgba(15,23,42,.07)!important}.filter-panel{padding:.25rem .15rem}.border{border-color:rgba(15,23,42,.10)!important}.text-bg-light{background:#f1f5f9!important;color:#475569!important}.btn{border-radius:.7rem}.form-control,.form-select{border-color:#e2e8f0;border-radius:.75rem}.content p:last-child{margin-bottom:0}.tag{font-size:.85rem}.slash-menu{position:absolute;z-index:20;top:100%;left:0;width:18rem}.done{opacity:.6}.done .content{text-decoration:line-through}.content img{max-width:100%;max-height:420px;border-radius:.5rem;border:1px solid #ddd;padding:.25rem;background:white}.overview-content{max-height:calc(var(--overview-lines) * 1.55em);overflow:hidden}.overview-content.expanded{max-height:none}.content{min-width:0;max-width:100%;overflow-wrap:anywhere}.content pre{position:relative;display:block;width:100%;max-width:100%;box-sizing:border-box;background:#1f2937;color:#f9fafb;border-radius:.6rem;padding:1rem;overflow-x:auto;overflow-y:auto;white-space:pre}.content pre code{display:block;color:inherit;white-space:pre;min-width:0}.copy-code{position:absolute;top:.4rem;right:.4rem;line-height:1}.copy-item{position:absolute;right:.75rem;bottom:.5rem;line-height:1;opacity:0;transition:opacity .15s}.item-card:hover .copy-item,.copy-item:focus{opacity:.7}.copy-item:hover{opacity:1!important}.mobile-create-buttons,.mobile-create-nav,.mobile-tags,.mobile-media,.mobile-tag-picker,.mobile-bottom-nav{display:none}.mobile-tag-popup{position:fixed;inset:auto .75rem 1rem .75rem;z-index:1050;max-height:55vh;overflow:auto}.mobile-tag-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.25);z-index:1040}.camera-input{max-width:13rem}@media (max-width: 767.98px){body{padding-bottom:4.25rem}.shell{max-width:100%}main.container{padding:.25rem .75rem 1rem}.navbar .container{gap:.5rem}.navbar-brand{font-size:1rem}.navbar .d-flex{gap:.5rem!important}.user-menu-btn{min-width:auto;max-width:9rem}.item-card{border-radius:.75rem}.item-actions{width:100%;justify-content:stretch!important}.item-action-btn{flex:1 1 0;min-width:0}.mobile-create-buttons,.mobile-tags,.mobile-media{display:flex!important}.mobile-bottom-nav{display:flex!important;position:fixed;left:.75rem;right:.75rem;bottom:.75rem;z-index:1060;background:white;border-radius:1rem;box-shadow:0 .5rem 1.5rem rgba(15,23,42,.18);padding:.35rem}.mobile-bottom-nav .btn{font-size:.8rem;padding:.45rem .25rem}.mobile-tag-picker{display:block!important}.desktop-quick-create{display:none!important}.mobile-create-nav{display:none!important}.desktop-file{display:none!important}.mobile-create-buttons{position:sticky;top:57px;z-index:10;background:#f7f7f4;padding:.35rem 0}.toolbar-filter{display:block;background:#f7f7f4;border-top:4px solid #fff;padding:.35rem .75rem .5rem;box-shadow:0 .4rem .9rem rgba(15,23,42,.05)}.mobile-filter-sidebar{display:none}.filter-panel{background:#f7f7f4;padding:0}.filter-panel-title{display:block;margin:0 0 .3rem!important;font-size:.82rem;color:#64748b;line-height:1}.filter-row{display:grid;grid-template-columns:repeat(5,minmax(0,1fr)) 2.25rem;gap:.25rem;width:100%}.filter-row .btn,.filter-row summary{width:100%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-left:.1rem;padding-right:.1rem}.mobile-filter-toggle{position:absolute;opacity:0;pointer-events:none}.mobile-filter-more{grid-column:6;grid-row:1;position:relative;border:0;background:transparent;color:#475569;font-size:1.2rem;line-height:1;text-align:center;cursor:pointer}.mobile-filter-more .badge{position:absolute;top:-.15rem;right:.15rem;font-size:.55rem;line-height:1;padding:.12rem .25rem}.mobile-filter-details{display:none;grid-column:1/-1;grid-row:2;margin-top:.45rem;padding:.75rem;background:white;border-radius:1rem;box-shadow:0 .35rem 1rem rgba(15,23,42,.12)}.mobile-filter-toggle:checked ~ .mobile-filter-details{display:block}.quick-actions{flex-wrap:wrap}.quick-actions .form-control{min-width:100%}.slash-menu{width:calc(100vw - 1.5rem)}textarea.form-control-lg{font-size:1rem}.btn{touch-action:manipulation}.item-row{flex-direction:column}.item-actions{align-self:flex-end}.item-main{width:100%;min-width:0}.copy-item{opacity:.65}} body{background:linear-gradient(180deg,#f8fafc 0,#f7f7f4 18rem);color:#1f2937}.shell{max-width:960px}.navbar{box-shadow:0 .35rem 1.25rem rgba(15,23,42,.06)}.navbar a{text-decoration:none}.nav-soft{color:#475569!important;font-weight:500;padding:.2rem .35rem;border-radius:.5rem}.nav-soft:hover{color:#0d6efd!important;background:#eff6ff}.user-menu-btn{min-width:9rem}.card{border:0}.item-card{border:0;border-radius:1.1rem;box-shadow:0 .45rem 1.4rem rgba(15,23,42,.07)!important}.filter-panel{padding:.25rem .15rem}.border{border-color:rgba(15,23,42,.10)!important}.text-bg-light{background:#f1f5f9!important;color:#475569!important}.btn{border-radius:.7rem}.form-control,.form-select{border-color:#e2e8f0;border-radius:.75rem}.content p:last-child{margin-bottom:0}.tag{font-size:.85rem}.slash-menu{position:absolute;z-index:20;top:100%;left:0;width:18rem}.slash-group{font-size:.65rem;letter-spacing:.04em;margin-left:.5rem}.done{opacity:.6}.done .content{text-decoration:line-through}.content img{max-width:100%;max-height:420px;border-radius:.5rem;border:1px solid #ddd;padding:.25rem;background:white}.overview-content{max-height:calc(var(--overview-lines) * 1.55em);overflow:hidden}.overview-content.expanded{max-height:none}.content{min-width:0;max-width:100%;overflow-wrap:anywhere}.content pre{position:relative;display:block;width:100%;max-width:100%;box-sizing:border-box;background:#1f2937;color:#f9fafb;border-radius:.6rem;padding:1rem;overflow-x:auto;overflow-y:auto;white-space:pre}.content pre code{display:block;color:inherit;white-space:pre;min-width:0}.copy-code{position:absolute;top:.4rem;right:.4rem;line-height:1}.copy-item{position:absolute;right:.75rem;bottom:.5rem;line-height:1;opacity:0;transition:opacity .15s}.item-card:hover .copy-item,.copy-item:focus{opacity:.7}.copy-item:hover{opacity:1!important}.mobile-create-buttons,.mobile-create-nav,.mobile-tags,.mobile-media,.mobile-tag-picker,.mobile-bottom-nav{display:none}.mobile-tag-popup{position:fixed;inset:auto .75rem 1rem .75rem;z-index:1050;max-height:55vh;overflow:auto}.mobile-tag-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.25);z-index:1040}.camera-input{max-width:13rem}@media (max-width: 767.98px){body{padding-bottom:4.25rem}.shell{max-width:100%}main.container{padding:.25rem .75rem 1rem}.navbar .container{gap:.5rem}.navbar-brand{font-size:1rem}.navbar .d-flex{gap:.5rem!important}.user-menu-btn{min-width:auto;max-width:9rem}.item-card{border-radius:.75rem}.item-actions{width:100%;justify-content:stretch!important}.item-action-btn{flex:1 1 0;min-width:0}.mobile-create-buttons,.mobile-tags,.mobile-media{display:flex!important}.mobile-bottom-nav{display:flex!important;position:fixed;left:.75rem;right:.75rem;bottom:.75rem;z-index:1060;background:white;border-radius:1rem;box-shadow:0 .5rem 1.5rem rgba(15,23,42,.18);padding:.35rem}.mobile-bottom-nav .btn{font-size:.8rem;padding:.45rem .25rem}.mobile-tag-picker{display:block!important}.desktop-quick-create{display:none!important}.mobile-create-nav{display:none!important}.desktop-file{display:none!important}.mobile-create-buttons{position:sticky;top:57px;z-index:10;background:#f7f7f4;padding:.35rem 0}.toolbar-filter{display:block;background:#f7f7f4;border-top:4px solid #fff;padding:.35rem .75rem .5rem;box-shadow:0 .4rem .9rem rgba(15,23,42,.05)}.mobile-filter-sidebar{display:none}.filter-panel{background:#f7f7f4;padding:0}.filter-panel-title{display:block;margin:0 0 .3rem!important;font-size:.82rem;color:#64748b;line-height:1}.filter-row{display:grid;grid-template-columns:repeat(5,minmax(0,1fr)) 2.25rem;gap:.25rem;width:100%}.filter-row .btn,.filter-row summary{width:100%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-left:.1rem;padding-right:.1rem}.mobile-filter-toggle{position:absolute;opacity:0;pointer-events:none}.mobile-filter-more{grid-column:6;grid-row:1;position:relative;border:0;background:transparent;color:#475569;font-size:1.2rem;line-height:1;text-align:center;cursor:pointer}.mobile-filter-more .badge{position:absolute;top:-.15rem;right:.15rem;font-size:.55rem;line-height:1;padding:.12rem .25rem}.mobile-filter-details{display:none;grid-column:1/-1;grid-row:2;margin-top:.45rem;padding:.75rem;background:white;border-radius:1rem;box-shadow:0 .35rem 1rem rgba(15,23,42,.12)}.mobile-filter-toggle:checked ~ .mobile-filter-details{display:block}.quick-actions{flex-wrap:wrap}.quick-actions .form-control{min-width:100%}.slash-menu{width:calc(100vw - 1.5rem)}textarea.form-control-lg{font-size:1rem}.btn{touch-action:manipulation}.item-row{flex-direction:column}.item-actions{align-self:flex-end}.item-main{width:100%;min-width:0}.copy-item{opacity:.65}}
</style> </style>
</head> </head>
<body> <body>
@@ -28,17 +28,17 @@
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'}); document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
window.quickComposer = window.quickComposer || function(tags, itemId=null){ window.quickComposer = window.quickComposer || function(tags, itemId=null){
return { return {
open:false,showTags:false,text:'',active:0,query:'',currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[], open:false,showTags:false,text:'',active:0,query:'',queryStart:0,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||[]})})}, 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:'/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" %}'}], 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 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'}]}, 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:''}]},
isContentField(el){return el && (el.name==='content' || el.id==='id_content')}, isContentField(el){return el && (el.name==='content' || el.id==='id_content')},
onInput(e){this.update(e.target)}, 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)}, 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}]]`)}, 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}, 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;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)}, onKeydown(e){this.currentEl=e.target;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.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('![[');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||[]})}},
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)})}, 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';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)})}
} }
+29 -20
View File
@@ -120,7 +120,7 @@
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details> <details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ form.content }} {{ form.content }}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak> <div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span><span x-show="idx === 0 || filtered[idx-1].group !== cmd.group" class="float-end text-muted text-uppercase slash-group" x-text="cmd.group"></span></button></template>
</div> </div>
<div class="d-flex align-items-center gap-2 mt-2 quick-actions"> <div class="d-flex align-items-center gap-2 mt-2 quick-actions">
<input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"> <input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)">
@@ -142,6 +142,7 @@ function quickComposer(tags, itemId=null){
text:'', text:'',
active:0, active:0,
query:'', query:'',
queryStart:0,
currentEl:null, currentEl:null,
preview:null, preview:null,
linkSuggestions:[], linkSuggestions:[],
@@ -158,32 +159,39 @@ function quickComposer(tags, itemId=null){
}); });
}, },
commands:[ commands:[
{token:'/todo',label:'{% trans "Create task" %}'}, {token:'/todo',label:'{% trans "Create task" %}',group:'{% trans "Type" %}'},
{token:'/note',label:'{% trans "Create note" %}'}, {token:'/note',label:'{% trans "Create note" %}',group:'{% trans "Type" %}'},
{token:'/link',label:'{% trans "Save link" %}'}, {token:'/link',label:'{% trans "Save link" %}',group:'{% trans "Type" %}'},
{token:'/journal',label:'{% trans "Journal entry" %}'}, {token:'/journal',label:'{% trans "Journal entry" %}',group:'{% trans "Type" %}'},
{token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}'}, {token:'/public',label:'{% trans "public" %}',group:'{% trans "Visibility" %}'},
{token:'/morgen',label:'{% trans "Tomorrow" %}'}, {token:'/private',label:'{% trans "private" %}',group:'{% trans "Visibility" %}'},
{token:'/übermorgen',label:'{% trans "Day after tomorrow" %}'}, {token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}',group:'{% trans "Date" %}'},
{token:'/nächstewoche',label:'{% trans "Next week" %}'}, {token:'/morgen',label:'{% trans "Tomorrow" %}',group:'{% trans "Date" %}'},
{token:'/public',label:'{% trans "public" %}'}, {token:'/übermorgen',label:'{% trans "Day after tomorrow" %}',group:'{% trans "Date" %}'},
{token:'/private',label:'{% trans "private" %}'}, {token:'/nächstewoche',label:'{% trans "Next week" %}',group:'{% trans "Date" %}'},
{token:'/code',label:'{% trans "Insert Markdown code block" %}'}, {token:'/code',label:'{% trans "Insert Markdown code block" %}',group:'{% trans "Formatting" %}'},
{token:'[[',label:'{% trans "Search internal task/note" %}'}, {token:'[[',label:'{% trans "Search internal task/note" %}',group:'{% trans "References" %}'},
{token:'![[',label:'{% trans "Insert image from attachments" %}'} {token:'![[',label:'{% trans "Insert image from attachments" %}',group:'{% trans "References" %}'}
], ],
get filtered(){ get filtered(){
let q=this.query.toLowerCase(); let q=this.query.toLowerCase();
let hasType=/(^|\s)\/(todo|note|link|journal)(?=\s)/i.test(this.text); let selectedType=(this.text.match(/^\/(todo|note|link|journal)(?=\s)/i)||[])[1]?.toLowerCase();
let commands=this.commands.filter(c=>!hasType||!['/todo','/note','/link','/journal'].includes(c.token)); 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('#') 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('![[') : 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('[[') : 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)); : 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')}, isContentField(el){return el && (el.name==='content' || el.id==='id_content')},
onInput(e){this.update(e.target)}, onInput(e){this.update(e.target)},
@@ -244,6 +252,7 @@ function quickComposer(tags, itemId=null){
let before=el.value.slice(0,el.selectionStart); let before=el.value.slice(0,el.selectionStart);
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2]; let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
this.query=fragment; this.query=fragment;
this.queryStart=el.selectionStart-fragment.length;
this.active=0; this.active=0;
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||fragment.startsWith('![['); this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||fragment.startsWith('![[');
if(fragment.startsWith('![[')&&this.itemId){ if(fragment.startsWith('![[')&&this.itemId){
+1 -1
View File
@@ -18,7 +18,7 @@
<textarea class="form-control" name="content" rows="18" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea> <textarea class="form-control" name="content" rows="18" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
<textarea class="form-control" name="comment" rows="5" placeholder="{% trans 'Comment' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea> <textarea class="form-control" name="comment" rows="5" placeholder="{% trans 'Comment' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak> <div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span><span x-show="idx === 0 || filtered[idx-1].group !== cmd.group" class="float-end text-muted text-uppercase slash-group" x-text="cmd.group"></span></button></template>
</div> </div>
<div class="row g-2"> <div class="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option><option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option></select></div> <div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option><option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option></select></div>
+1 -1
View File
@@ -28,7 +28,7 @@
</div> </div>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details> <details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ form.content }} {{ form.content }}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak><template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template></div> <div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak><template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span><span x-show="idx === 0 || filtered[idx-1].group !== cmd.group" class="float-end text-muted text-uppercase slash-group" x-text="cmd.group"></span></button></template></div>
<div class="d-flex align-items-center gap-2 mt-2 quick-actions"><input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"><div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div><button class="btn btn-dark px-4">{% trans "Save" %}</button></div> <div class="d-flex align-items-center gap-2 mt-2 quick-actions"><input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"><div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div><button class="btn btn-dark px-4">{% trans "Save" %}</button></div>
</form> </form>
<div id="items" class="vstack gap-3"> <div id="items" class="vstack gap-3">
+1 -1
View File
@@ -17,7 +17,7 @@
</div> </div>
<textarea class="form-control" name="content" rows="14" placeholder="Text … #tag [[ ![[" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)"></textarea> <textarea class="form-control" name="content" rows="14" placeholder="Text … #tag [[ ![[" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)"></textarea>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak> <div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span><span x-show="idx === 0 || filtered[idx-1].group !== cmd.group" class="float-end text-muted text-uppercase slash-group" x-text="cmd.group"></span></button></template>
</div> </div>
<div> <div>
<label class="form-label">{% trans "Add image" %}</label> <label class="form-label">{% trans "Add image" %}</label>
+21 -1
View File
@@ -12,11 +12,31 @@ ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
} }
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']} ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]') 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 @register.filter
def markdown(text): 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)) return mark_safe(bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS))
+28 -1
View File
@@ -3,6 +3,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from .models import Item, Tag, UserPreference from .models import Item, Tag, UserPreference
from .templatetags.markdown_extras import markdown
from .views import parse_due_command, parse_quick_content from .views import parse_due_command, parse_quick_content
@@ -75,7 +76,33 @@ class DueDateCommandTests(TestCase):
response = self.client.get(reverse('index')) response = self.client.get(reverse('index'))
self.assertContains(response, "{token:'/note'") self.assertContains(response, "{token:'/note'")
self.assertContains(response, "{token:'/datum'") 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('<p>#project starts here</p>', rendered)
self.assertNotIn('<h1>', rendered)
def test_heading_with_space_remains_a_heading(self):
self.assertIn('<h1>Project</h1>', 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): class CopyButtonTests(TestCase):
+15 -10
View File
@@ -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 .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 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+') URL_RE = re.compile(r'https?://\S+')
ONLY_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) TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', re.I | re.S)
@@ -44,20 +45,24 @@ def fetch_page_title(url):
def parse_quick_content(raw, default_kind=Item.Kind.NOTE): def parse_quick_content(raw, default_kind=Item.Kind.NOTE):
content = raw.strip() content = raw.strip()
commands = {c.lower() for c in COMMAND_RE.findall(content)} type_match = TYPE_COMMAND_RE.match(content)
content = COMMAND_RE.sub('', content).strip() 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)) only_url = bool(ONLY_URL_RE.match(content))
if 'todo' in commands: if type_command == 'todo':
kind = Item.Kind.TODO kind = Item.Kind.TODO
elif 'journal' in commands: elif type_command == 'journal':
kind = Item.Kind.JOURNAL kind = Item.Kind.JOURNAL
elif 'link' in commands or only_url: elif type_command == 'link' or only_url:
kind = Item.Kind.LINK kind = Item.Kind.LINK
elif 'note' in commands: elif type_command == 'note':
kind = Item.Kind.NOTE kind = Item.Kind.NOTE
else: else:
kind = default_kind if default_kind in Item.Kind.values else Item.Kind.NOTE 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_match = URL_RE.search(content)
url = url_match.group(0) if kind == Item.Kind.LINK and url_match else '' url = url_match.group(0) if kind == Item.Kind.LINK and url_match else ''
if only_url: if only_url:
@@ -287,7 +292,7 @@ def new_item(request):
form = QuickItemForm(request.POST, request.FILES) form = QuickItemForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
raw = form.cleaned_data['content'] 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 = f'/{requested_kind} {raw}'
raw, due_at, _ = parse_due_command(raw) raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind) 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) form = QuickItemForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
raw = form.cleaned_data['content'] 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 = f'/journal {raw}'
raw, due_at, _ = parse_due_command(raw) raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw) kind, visibility, content, url = parse_quick_content(raw)
+20
View File
@@ -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." msgid "You are now a member of team %(team)s."
msgstr "Du bist jetzt Mitglied im 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 #: core/templates/core/base.html core/templates/core/index.html
msgid "Due date (DD.MM.YY)" msgid "Due date (DD.MM.YY)"
msgstr "Fälligkeitsdatum (TT.MM.JJ)" msgstr "Fälligkeitsdatum (TT.MM.JJ)"
+20
View File
@@ -638,6 +638,26 @@ msgstr ""
msgid "You are now a member of team %(team)s." msgid "You are now a member of team %(team)s."
msgstr "" 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 #: core/templates/core/base.html core/templates/core/index.html
msgid "Due date (DD.MM.YY)" msgid "Due date (DD.MM.YY)"
msgstr "Due date (DD.MM.YY)" msgstr "Due date (DD.MM.YY)"