diff --git a/core/forms.py b/core/forms.py index 8d9db24..abb4b1e 100644 --- a/core/forms.py +++ b/core/forms.py @@ -56,9 +56,10 @@ class UserSettingsForm(forms.ModelForm): class UserPreferenceForm(forms.ModelForm): class Meta: 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 = { 'language': _('Language'), + 'default_item_kind': _('Default type for new entries'), 'show_notes': _('Show notes'), 'show_open_todos': _('Show open tasks'), 'show_done_todos': _('Show completed tasks'), @@ -68,6 +69,7 @@ class UserPreferenceForm(forms.ModelForm): } widgets = { '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}), } diff --git a/core/migrations/0016_userpreference_default_item_kind.py b/core/migrations/0016_userpreference_default_item_kind.py new file mode 100644 index 0000000..501e58b --- /dev/null +++ b/core/migrations/0016_userpreference_default_item_kind.py @@ -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), + ] diff --git a/core/models.py b/core/models.py index 628cf9f..b7c90ec 100644 --- a/core/models.py +++ b/core/models.py @@ -2,12 +2,42 @@ import re import secrets from django.conf import settings 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.utils import timezone from django.utils.translation import gettext_lazy as _ TAG_RE = re.compile(r'(?= 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): @@ -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() 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] 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}')] 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): 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')]) + default_item_kind = models.CharField(max_length=10, choices=Item.Kind.choices, default=Item.Kind.NOTE) show_notes = models.BooleanField(default=True) show_open_todos = models.BooleanField(default=True) show_done_todos = models.BooleanField(default=False) @@ -204,3 +238,8 @@ class Attachment(models.Model): def __str__(self): return self.file.name + + +@receiver(post_delete, sender=Item) +def clean_tags_after_item_delete(sender, instance, **kwargs): + delete_unused_tags() diff --git a/core/templates/core/_item.html b/core/templates/core/_item.html index 978f9fe..52f8b11 100644 --- a/core/templates/core/_item.html +++ b/core/templates/core/_item.html @@ -35,5 +35,6 @@ + diff --git a/core/templates/core/base.html b/core/templates/core/base.html index 75d0392..1f406a3 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -17,7 +17,7 @@
@@ -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()}}} -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('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')}}); diff --git a/core/templates/core/settings.html b/core/templates/core/settings.html index a7b5ef4..506bb57 100644 --- a/core/templates/core/settings.html +++ b/core/templates/core/settings.html @@ -35,8 +35,8 @@ {% csrf_token %}