Complete open my2dos tasks
This commit is contained in:
+3
-1
@@ -56,9 +56,10 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
class UserPreferenceForm(forms.ModelForm):
|
class UserPreferenceForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserPreference
|
model = UserPreference
|
||||||
fields = ['language', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines']
|
fields = ['language', 'default_item_kind', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines']
|
||||||
labels = {
|
labels = {
|
||||||
'language': _('Language'),
|
'language': _('Language'),
|
||||||
|
'default_item_kind': _('Default type for new entries'),
|
||||||
'show_notes': _('Show notes'),
|
'show_notes': _('Show notes'),
|
||||||
'show_open_todos': _('Show open tasks'),
|
'show_open_todos': _('Show open tasks'),
|
||||||
'show_done_todos': _('Show completed tasks'),
|
'show_done_todos': _('Show completed tasks'),
|
||||||
@@ -68,6 +69,7 @@ class UserPreferenceForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'language': forms.Select(attrs={'class': 'form-select'}),
|
'language': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'default_item_kind': forms.Select(attrs={'class': 'form-select'}),
|
||||||
'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50}),
|
'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def remove_unused_tags(apps, schema_editor):
|
||||||
|
Tag = apps.get_model('core', 'Tag')
|
||||||
|
Team = apps.get_model('core', 'Team')
|
||||||
|
Tag.objects.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True).exclude(
|
||||||
|
name__in=Team.objects.values_list('slug', flat=True),
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0015_userpreference_due_first'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='default_item_kind',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('todo', 'Task'), ('note', 'Note'), ('link', 'Link'), ('journal', 'Journal')],
|
||||||
|
default='note',
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(remove_unused_tags, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
+40
-1
@@ -2,12 +2,42 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
TAG_RE = re.compile(r'(?<!\w)#([\wäöüÄÖÜß-]+)')
|
TAG_RE = re.compile(r'(?<!\w)#([\wäöüÄÖÜß-]+)')
|
||||||
LINK_RE = re.compile(r'\[\[(todo|note|link|journal)?:?(\d+)\]\]', re.I)
|
LINK_RE = re.compile(r'\[\[(todo|note|link|journal)?:?(\d+)\]\]', re.I)
|
||||||
|
FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})')
|
||||||
|
INLINE_CODE_RE = re.compile(r'(`+).*?\1', re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def without_markdown_code(text):
|
||||||
|
"""Remove fenced and inline Markdown code before extracting metadata."""
|
||||||
|
visible_lines = []
|
||||||
|
fence = None
|
||||||
|
for line in (text or '').splitlines():
|
||||||
|
match = FENCE_RE.match(line)
|
||||||
|
if fence:
|
||||||
|
if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence):
|
||||||
|
fence = None
|
||||||
|
visible_lines.append('')
|
||||||
|
elif match:
|
||||||
|
fence = match.group(1)
|
||||||
|
visible_lines.append('')
|
||||||
|
else:
|
||||||
|
visible_lines.append(line)
|
||||||
|
return INLINE_CODE_RE.sub('', '\n'.join(visible_lines))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_unused_tags(tags=None):
|
||||||
|
"""Delete tags that are no longer used by items or tag-based features."""
|
||||||
|
candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags])
|
||||||
|
candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True).exclude(
|
||||||
|
name__in=Team.objects.values_list('slug', flat=True),
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
class Tag(models.Model):
|
class Tag(models.Model):
|
||||||
@@ -141,9 +171,12 @@ class Item(models.Model):
|
|||||||
return self.kind == self.Kind.TODO and not self.is_done and self.due_at and timezone.localdate(self.due_at) == timezone.localdate()
|
return self.kind == self.Kind.TODO and not self.is_done and self.due_at and timezone.localdate(self.due_at) == timezone.localdate()
|
||||||
|
|
||||||
def sync_metadata(self):
|
def sync_metadata(self):
|
||||||
tag_names = {m.group(1).lower() for m in TAG_RE.finditer(self.content)}
|
old_tags = list(self.tags.all())
|
||||||
|
metadata_text = without_markdown_code(self.content)
|
||||||
|
tag_names = {m.group(1).lower() for m in TAG_RE.finditer(metadata_text)}
|
||||||
tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names]
|
tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names]
|
||||||
self.tags.set(tags)
|
self.tags.set(tags)
|
||||||
|
delete_unused_tags(old_tags)
|
||||||
ids = [int(m.group(2)) for m in LINK_RE.finditer(f'{self.content}\n{self.comment}')]
|
ids = [int(m.group(2)) for m in LINK_RE.finditer(f'{self.content}\n{self.comment}')]
|
||||||
self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id))
|
self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id))
|
||||||
|
|
||||||
@@ -165,6 +198,7 @@ class Kanban(models.Model):
|
|||||||
class UserPreference(models.Model):
|
class UserPreference(models.Model):
|
||||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
|
||||||
language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')])
|
language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')])
|
||||||
|
default_item_kind = models.CharField(max_length=10, choices=Item.Kind.choices, default=Item.Kind.NOTE)
|
||||||
show_notes = models.BooleanField(default=True)
|
show_notes = models.BooleanField(default=True)
|
||||||
show_open_todos = models.BooleanField(default=True)
|
show_open_todos = models.BooleanField(default=True)
|
||||||
show_done_todos = models.BooleanField(default=False)
|
show_done_todos = models.BooleanField(default=False)
|
||||||
@@ -204,3 +238,8 @@ class Attachment(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.file.name
|
return self.file.name
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=Item)
|
||||||
|
def clean_tags_after_item_delete(sender, instance, **kwargs):
|
||||||
|
delete_unused_tags()
|
||||||
|
|||||||
@@ -35,5 +35,6 @@
|
|||||||
<button class="btn btn-outline-danger item-action-btn" title="{% trans 'Delete' %}" aria-label="{% trans 'Delete' %}" hx-post="{% url 'delete_item' item.id %}" hx-target="#item-{{ item.id }}" hx-swap="delete">× <span class="d-md-none">{% trans "Delete" %}</span></button>
|
<button class="btn btn-outline-danger item-action-btn" title="{% trans 'Delete' %}" aria-label="{% trans 'Delete' %}" hx-post="{% url 'delete_item' item.id %}" hx-target="#item-{{ item.id }}" hx-swap="delete">× <span class="d-md-none">{% trans "Delete" %}</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="copy-item btn btn-sm btn-light" title="{% trans 'Copy text' %}" aria-label="{% trans 'Copy text' %}" onclick="copyItemText(this, '{{ item.content|escapejs }}')">⧉</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -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}.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}}
|
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}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -44,7 +44,9 @@ window.quickComposer = window.quickComposer || function(tags, itemId=null){
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
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()}}}
|
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()}}}
|
||||||
function enhanceCodeBlocks(root=document){root.querySelectorAll('.content pre:not([data-copy])').forEach(pre=>{pre.dataset.copy='1';let btn=document.createElement('button');btn.type='button';btn.className='copy-code btn btn-sm btn-light';btn.title='Kopieren';btn.innerHTML='⧉';btn.addEventListener('click',()=>navigator.clipboard.writeText(pre.querySelector('code')?.innerText || pre.innerText).then(()=>{btn.innerHTML='✓';setTimeout(()=>btn.innerHTML='⧉',1200)}));pre.appendChild(btn)})}
|
async function writeClipboard(text){if(navigator.clipboard&&window.isSecureContext){return navigator.clipboard.writeText(text)}let area=document.createElement('textarea');area.value=text;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();document.execCommand('copy');area.remove()}
|
||||||
|
function copyItemText(button,text){writeClipboard(text).then(()=>{button.innerHTML='✓';setTimeout(()=>button.innerHTML='⧉',1200)})}
|
||||||
|
function enhanceCodeBlocks(root=document){root.querySelectorAll('.content pre:not([data-copy])').forEach(pre=>{pre.dataset.copy='1';let btn=document.createElement('button');btn.type='button';btn.className='copy-code btn btn-sm btn-light';btn.title='Kopieren';btn.innerHTML='⧉';btn.addEventListener('click',()=>writeClipboard(pre.querySelector('code')?.innerText || pre.innerText).then(()=>{btn.innerHTML='✓';setTimeout(()=>btn.innerHTML='⧉',1200)}));pre.appendChild(btn)})}
|
||||||
document.addEventListener('DOMContentLoaded',()=>enhanceCodeBlocks());document.body.addEventListener('htmx:afterSwap',e=>enhanceCodeBlocks(e.target));
|
document.addEventListener('DOMContentLoaded',()=>enhanceCodeBlocks());document.body.addEventListener('htmx:afterSwap',e=>enhanceCodeBlocks(e.target));
|
||||||
document.addEventListener('click', event => {let link=event.target.closest('a.tag-filter[data-tag]');if(!link||!(event.ctrlKey||event.metaKey))return;event.preventDefault();let url=new URL(window.location.href);let tag=link.dataset.tag;let tags=url.searchParams.getAll('tag');url.searchParams.delete('tag');if(tags.includes(tag)){tags=tags.filter(t=>t!==tag)}else{tags.push(tag)}tags.forEach(t=>url.searchParams.append('tag',t));window.location.href=url.pathname+(url.searchParams.toString()?'?'+url.searchParams.toString():'')});
|
document.addEventListener('click', event => {let link=event.target.closest('a.tag-filter[data-tag]');if(!link||!(event.ctrlKey||event.metaKey))return;event.preventDefault();let url=new URL(window.location.href);let tag=link.dataset.tag;let tags=url.searchParams.getAll('tag');url.searchParams.delete('tag');if(tags.includes(tag)){tags=tags.filter(t=>t!==tag)}else{tags.push(tag)}tags.forEach(t=>url.searchParams.append('tag',t));window.location.href=url.pathname+(url.searchParams.toString()?'?'+url.searchParams.toString():'')});
|
||||||
let deferredPwaPrompt=null;window.addEventListener('beforeinstallprompt',event=>{event.preventDefault();deferredPwaPrompt=event;document.getElementById('pwa-install')?.classList.remove('d-none')});document.addEventListener('click',async event=>{if(event.target?.id==='pwa-install'&&deferredPwaPrompt){deferredPwaPrompt.prompt();await deferredPwaPrompt.userChoice;deferredPwaPrompt=null;event.target.classList.add('d-none')}});
|
let deferredPwaPrompt=null;window.addEventListener('beforeinstallprompt',event=>{event.preventDefault();deferredPwaPrompt=event;document.getElementById('pwa-install')?.classList.remove('d-none')});document.addEventListener('click',async event=>{if(event.target?.id==='pwa-install'&&deferredPwaPrompt){deferredPwaPrompt.prompt();await deferredPwaPrompt.userChoice;deferredPwaPrompt=null;event.target.classList.add('d-none')}});
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
{% csrf_token %}<input type="hidden" name="action" value="preferences">
|
{% csrf_token %}<input type="hidden" name="action" value="preferences">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for field in preference_form %}
|
{% for field in preference_form %}
|
||||||
{% if field.name == 'language' or field.name == 'overview_lines' %}
|
{% if field.name == 'language' or field.name == 'default_item_kind' or field.name == 'overview_lines' %}
|
||||||
<div class="col-md-6"><label class="form-label">{{ field.label }}</label>{{ field }}</div>
|
<div class="col-md-4"><label class="form-label">{{ field.label }}</label>{{ field }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from .models import Item, Tag, UserPreference
|
||||||
|
from .views import parse_quick_content
|
||||||
|
|
||||||
|
|
||||||
|
class ItemMetadataTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(username='tester', password='secret')
|
||||||
|
|
||||||
|
def test_code_sections_are_ignored_when_extracting_tags(self):
|
||||||
|
item = Item.objects.create(
|
||||||
|
owner=self.user,
|
||||||
|
content='Outside #real\n```python\n# fenced\n```\nInline `#inline` text',
|
||||||
|
)
|
||||||
|
item.sync_metadata()
|
||||||
|
self.assertEqual(list(item.tags.values_list('name', flat=True)), ['real'])
|
||||||
|
self.assertFalse(Tag.objects.filter(name__in=['fenced', 'inline']).exists())
|
||||||
|
|
||||||
|
def test_unused_tags_are_removed_after_sync_and_delete(self):
|
||||||
|
item = Item.objects.create(owner=self.user, content='#old')
|
||||||
|
item.sync_metadata()
|
||||||
|
item.content = '#new'
|
||||||
|
item.save()
|
||||||
|
item.sync_metadata()
|
||||||
|
self.assertFalse(Tag.objects.filter(name='old').exists())
|
||||||
|
self.assertTrue(Tag.objects.filter(name='new').exists())
|
||||||
|
|
||||||
|
item.delete()
|
||||||
|
self.assertFalse(Tag.objects.filter(name='new').exists())
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultItemKindTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(username='tester', password='secret')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.preferences = UserPreference.objects.create(user=self.user, default_item_kind=Item.Kind.TODO)
|
||||||
|
|
||||||
|
def test_quick_create_uses_user_default(self):
|
||||||
|
response = self.client.post(reverse('index'), {'content': 'Default task'})
|
||||||
|
self.assertRedirects(response, reverse('index'))
|
||||||
|
self.assertEqual(Item.objects.get().kind, Item.Kind.TODO)
|
||||||
|
|
||||||
|
def test_explicit_command_overrides_default(self):
|
||||||
|
kind, _, content, _ = parse_quick_content('/note Explicit note', Item.Kind.TODO)
|
||||||
|
self.assertEqual(kind, Item.Kind.NOTE)
|
||||||
|
self.assertEqual(content, 'Explicit note')
|
||||||
|
|
||||||
|
def test_new_item_editor_uses_user_default(self):
|
||||||
|
response = self.client.get(reverse('new_item'))
|
||||||
|
self.assertEqual(response.context['requested_kind'], Item.Kind.TODO)
|
||||||
|
|
||||||
|
|
||||||
|
class CopyButtonTests(TestCase):
|
||||||
|
def test_item_has_copy_button_with_original_text(self):
|
||||||
|
user = get_user_model().objects.create_user(username='tester', password='secret')
|
||||||
|
item = Item.objects.create(owner=user, content='Text to copy')
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(reverse('item_detail', args=[item.pk]))
|
||||||
|
self.assertContains(response, 'class="copy-item')
|
||||||
|
self.assertContains(response, 'Text to copy')
|
||||||
+18
-7
@@ -40,12 +40,21 @@ def fetch_page_title(url):
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def parse_quick_content(raw):
|
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)}
|
commands = {c.lower() for c in COMMAND_RE.findall(content)}
|
||||||
content = COMMAND_RE.sub('', content).strip()
|
content = COMMAND_RE.sub('', content).strip()
|
||||||
only_url = bool(ONLY_URL_RE.match(content))
|
only_url = bool(ONLY_URL_RE.match(content))
|
||||||
kind = Item.Kind.TODO if 'todo' in commands else Item.Kind.JOURNAL if 'journal' in commands else Item.Kind.LINK if 'link' in commands or only_url else Item.Kind.NOTE
|
if 'todo' in commands:
|
||||||
|
kind = Item.Kind.TODO
|
||||||
|
elif 'journal' in commands:
|
||||||
|
kind = Item.Kind.JOURNAL
|
||||||
|
elif 'link' in commands or only_url:
|
||||||
|
kind = Item.Kind.LINK
|
||||||
|
elif 'note' in commands:
|
||||||
|
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 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 ''
|
||||||
@@ -153,11 +162,12 @@ def index(request):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
return redirect('login')
|
return redirect('login')
|
||||||
return render(request, 'core/landing.html')
|
return render(request, 'core/landing.html')
|
||||||
|
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
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']
|
||||||
kind, visibility, content, url = parse_quick_content(raw)
|
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
|
||||||
active_tag = request.GET.get('tag')
|
active_tag = request.GET.get('tag')
|
||||||
if active_tag and f'#{active_tag.lower()}' not in content.lower():
|
if active_tag and f'#{active_tag.lower()}' not in content.lower():
|
||||||
content = f'{content} #{active_tag}'
|
content = f'{content} #{active_tag}'
|
||||||
@@ -183,7 +193,6 @@ def index(request):
|
|||||||
query.pop('q', None)
|
query.pop('q', None)
|
||||||
return redirect(f'{request.path}?{query.urlencode()}')
|
return redirect(f'{request.path}?{query.urlencode()}')
|
||||||
items = visible_items(request.user)
|
items = visible_items(request.user)
|
||||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
|
||||||
applied_preferences = False
|
applied_preferences = False
|
||||||
if active_tags:
|
if active_tags:
|
||||||
if active_tag_mode == 'all':
|
if active_tag_mode == 'all':
|
||||||
@@ -234,7 +243,8 @@ def index(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def new_item(request):
|
def new_item(request):
|
||||||
requested_kind = request.GET.get('kind', '')
|
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||||
|
requested_kind = request.GET.get('kind') or prefs.default_item_kind
|
||||||
next_url = request.GET.get('next') or request.POST.get('next') or reverse('index')
|
next_url = request.GET.get('next') or request.POST.get('next') or reverse('index')
|
||||||
if next_url == 'index':
|
if next_url == 'index':
|
||||||
next_url = reverse('index')
|
next_url = reverse('index')
|
||||||
@@ -244,7 +254,7 @@ def new_item(request):
|
|||||||
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 re.search(r'/(todo|note|link|journal)\b', raw, re.I):
|
||||||
raw = f'/{requested_kind} {raw}'
|
raw = f'/{requested_kind} {raw}'
|
||||||
kind, visibility, content, url = parse_quick_content(raw)
|
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
|
||||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
|
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
|
||||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||||
item.sync_metadata()
|
item.sync_metadata()
|
||||||
@@ -646,7 +656,8 @@ def api_items(request):
|
|||||||
return JsonResponse({'items': [serialize_item(i) for i in visible_items(user, request.api_key)[:200]]})
|
return JsonResponse({'items': [serialize_item(i) for i in visible_items(user, request.api_key)[:200]]})
|
||||||
data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
|
data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
|
||||||
raw = data.get('content', '')
|
raw = data.get('content', '')
|
||||||
kind, visibility, content, url = parse_quick_content(raw)
|
prefs, created = UserPreference.objects.get_or_create(user=user)
|
||||||
|
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
|
||||||
item = Item.objects.create(owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url)
|
item = Item.objects.create(owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url)
|
||||||
item.sync_metadata()
|
item.sync_metadata()
|
||||||
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
|
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
|
||||||
|
|||||||
@@ -644,6 +644,14 @@ 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/forms.py
|
||||||
|
msgid "Default type for new entries"
|
||||||
|
msgstr "Standardtyp für neue Einträge"
|
||||||
|
|
||||||
|
#: core/templates/core/_item.html
|
||||||
|
msgid "Copy text"
|
||||||
|
msgstr "Text kopieren"
|
||||||
|
|
||||||
#~ msgid "Notes · Tasks · Links"
|
#~ msgid "Notes · Tasks · Links"
|
||||||
#~ msgstr "Notizen · Aufgaben · Links"
|
#~ msgstr "Notizen · Aufgaben · Links"
|
||||||
|
|
||||||
|
|||||||
@@ -637,3 +637,11 @@ msgstr ""
|
|||||||
#, python-format
|
#, python-format
|
||||||
msgid "You are now a member of team %(team)s."
|
msgid "You are now a member of team %(team)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/forms.py
|
||||||
|
msgid "Default type for new entries"
|
||||||
|
msgstr "Default type for new entries"
|
||||||
|
|
||||||
|
#: core/templates/core/_item.html
|
||||||
|
msgid "Copy text"
|
||||||
|
msgstr "Copy text"
|
||||||
|
|||||||
Reference in New Issue
Block a user