Add journal, editor, and overview improvements

This commit is contained in:
rucki
2026-08-22 15:19:39 +02:00
parent 4e542c2b8e
commit 28f100c6df
18 changed files with 405 additions and 56 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import ApiKey, Attachment, Item, Tag
from .models import ApiKey, Attachment, Item, Tag, UserPreference
class AttachmentInline(admin.TabularInline):
@@ -22,3 +22,4 @@ class ApiKeyAdmin(admin.ModelAdmin):
admin.site.register(Tag)
admin.site.register(UserPreference)
+30 -3
View File
@@ -1,10 +1,22 @@
from django import forms
from django.contrib.auth.models import User
from .models import ApiKey, Attachment, Item
from .models import ApiKey, Attachment, Item, UserPreference
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def clean(self, data, initial=None):
if not data:
return []
files = data if isinstance(data, (list, tuple)) else [data]
return [super(MultipleFileField, self).clean(file, initial) for file in files]
class QuickItemForm(forms.ModelForm):
files = forms.FileField(required=False, widget=forms.ClearableFileInput(attrs={'multiple': False}))
files = MultipleFileField(required=False, widget=MultipleFileInput(attrs={'multiple': True}))
class Meta:
model = Item
@@ -21,7 +33,7 @@ class QuickItemForm(forms.ModelForm):
class EditItemForm(forms.ModelForm):
files = forms.FileField(required=False, widget=forms.ClearableFileInput())
files = MultipleFileField(required=False, widget=MultipleFileInput(attrs={'multiple': True}))
class Meta:
model = Item
@@ -44,6 +56,21 @@ class UserSettingsForm(forms.ModelForm):
widgets = {field: forms.TextInput(attrs={'class': 'form-control'}) for field in ['first_name', 'last_name', 'email']}
class UserPreferenceForm(forms.ModelForm):
class Meta:
model = UserPreference
fields = ['show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'show_journal', 'overview_lines']
labels = {
'show_notes': 'Notizen anzeigen',
'show_open_todos': 'Offene Aufgaben anzeigen',
'show_done_todos': 'Erledigte Aufgaben anzeigen',
'show_links': 'Links anzeigen',
'show_journal': 'Journal anzeigen',
'overview_lines': 'Zeilen in der Übersicht',
}
widgets = {'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50})}
class ApiKeyForm(forms.ModelForm):
class Meta:
model = ApiKey
+12
View File
@@ -0,0 +1,12 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('core', '0004_item_comment')]
operations = [
migrations.AlterField(
model_name='item',
name='kind',
field=models.CharField(choices=[('todo', 'Aufgabe'), ('note', 'Notiz'), ('link', 'Link'), ('journal', 'Journal')], default='note', max_length=10),
),
]
+21
View File
@@ -0,0 +1,21 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [('core', '0005_journal_kind'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name='UserPreference',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('show_notes', models.BooleanField(default=True)),
('show_open_todos', models.BooleanField(default=True)),
('show_done_todos', models.BooleanField(default=False)),
('show_links', models.BooleanField(default=True)),
('show_journal', models.BooleanField(default=False)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='preferences', to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,12 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('core', '0006_userpreference')]
operations = [
migrations.AddField(
model_name='userpreference',
name='overview_lines',
field=models.PositiveSmallIntegerField(default=4),
),
]
+14
View File
@@ -23,6 +23,7 @@ class Item(models.Model):
TODO = 'todo', 'Aufgabe'
NOTE = 'note', 'Notiz'
LINK = 'link', 'Link'
JOURNAL = 'journal', 'Journal'
class Visibility(models.TextChoices):
PRIVATE = 'private', 'Privat'
@@ -58,6 +59,19 @@ class Item(models.Model):
self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id))
class UserPreference(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
show_notes = models.BooleanField(default=True)
show_open_todos = models.BooleanField(default=True)
show_done_todos = models.BooleanField(default=False)
show_links = models.BooleanField(default=True)
show_journal = models.BooleanField(default=False)
overview_lines = models.PositiveSmallIntegerField(default=4)
def __str__(self):
return f'Einstellungen {self.user}'
class ApiKey(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys')
name = models.CharField(max_length=120, default='API Key')
+3 -2
View File
@@ -1,4 +1,4 @@
<form id="item-{{ item.id }}" class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" hx-post="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-init="text=$el.querySelector('textarea').value">
<form id="item-{{ item.id }}" class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" hx-post="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea').value">
{% csrf_token %}
<div class="card-body vstack gap-2">
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
@@ -13,12 +13,13 @@
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>Öffentlich</option>
</select></div>
{% if item.kind == 'todo' %}<div class="col-md-4"><input class="form-control" type="datetime-local" name="due_at" value="{{ item.due_at|date:'Y-m-d\\TH:i' }}" title="Datum/Zeit für Todo"></div>{% endif %}
<div class="col-md-4"><input class="form-control" type="file" name="files" x-ref="fileInput" @change="previewFile($event)"></div>
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
</div>
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
<template x-if="preview"><div class="small"><span class="text-muted">Bild aus Zwischenablage:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
<div class="d-flex gap-2">
<button class="btn btn-dark btn-sm">Speichern</button>
<a class="btn btn-outline-primary btn-sm" href="{% url 'item_editor' item.id %}?next={{ request.get_full_path|urlencode }}">Im Editor öffnen</a>
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'item_card' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">Abbrechen</button>
</div>
</div>
+13 -6
View File
@@ -1,15 +1,18 @@
{% load markdown_extras %}
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}">
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}" ondblclick="if(!event.target.closest('a,button,input,textarea,select,label')) window.location.href='{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}'">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="flex-grow-1">
<div class="flex-grow-1 min-w-0" style="min-width:0">
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
<span class="badge {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% else %}text-bg-secondary{% endif %}">{{ item.get_kind_display }}</span>
<span class="badge {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% elif item.kind == 'journal' %}text-bg-info{% else %}text-bg-secondary{% endif %}">{{ item.get_kind_display }}</span>
<span class="badge text-bg-light">{{ item.get_visibility_display }}</span>
<a class="text-muted small" href="{{ item.get_absolute_url }}">#{{ item.id }}</a>
<a class="text-muted small" href="{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}">#{{ item.id }}</a>
{% for tag in item.tags.all %}<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="/?tag={{ tag.name }}">#{{ tag.name }}</a>{% endfor %}
</div>
<div class="content">{{ item.content|markdown }}</div>
<div x-data="{expanded:false,showMore:false,check(){this.$nextTick(()=>{let el=this.$refs.content;this.showMore=el && el.scrollHeight > el.clientHeight + 2})}}" x-init="check()" class="position-relative">
<div x-ref="content" class="content overview-content" :class="expanded ? 'expanded' : ''" style="--overview-lines: {{ overview_lines|default:999 }}">{{ item|item_markdown }}</div>
{% if overview_lines %}<button x-show="showMore" type="button" class="btn btn-link btn-sm p-0 mt-1" @click="expanded=!expanded" x-text="expanded ? 'weniger anzeigen' : 'mehr anzeigen'"></button>{% endif %}
</div>
{% if item.comment %}<div class="border-start ps-2 mt-2 small content text-muted">{{ item.comment|markdown }}</div>{% endif %}
{% if item.due_at %}<div class="small text-warning-emphasis mt-2">Fällig: {{ item.due_at|date:'d.m.Y H:i' }}</div>{% endif %}
{% if item.url %}<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.url }}</a>{% endif %}
@@ -17,13 +20,17 @@
{% if item.attachments.all %}
<div class="small mt-2">Anhänge: {% for a in item.attachments.all %}<a href="{{ a.file.url }}">{{ a.file.name }}</a> {% endfor %}</div>
<div class="d-flex flex-wrap gap-2 mt-2">
{% for a in item.attachments.all %}{% if a.file.name|is_image %}<a href="{{ a.file.url }}" target="_blank"><img src="{{ a.file.url }}" class="img-thumbnail" style="max-width:220px;max-height:180px" alt="{{ a.file.name }}"></a>{% endif %}{% endfor %}
{% for a in item.attachments.all %}{% if a.file.name|is_image and not a|is_embedded:item %}<a href="{{ a.file.url }}" target="_blank"><img src="{{ a.file.url }}" class="img-thumbnail" style="max-width:220px;max-height:180px" alt="{{ a.file.name }}"></a>{% endif %}{% endfor %}
</div>
{% endif %}
</div>
<div class="btn-group btn-group-sm">
{% if item.kind == 'todo' %}<button class="btn btn-outline-primary" hx-post="{% url 'toggle_done' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">✓</button>{% endif %}
{% if request.resolver_match.url_name == 'item_detail' %}
<a class="btn btn-outline-secondary" href="{% url 'item_editor' item.id %}?next={{ request.GET.next|default:'/'|urlencode }}">✎</a>
{% else %}
<button class="btn btn-outline-secondary" hx-get="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">✎</button>
{% endif %}
<button class="btn btn-outline-danger" hx-post="{% url 'delete_item' item.id %}" hx-target="#item-{{ item.id }}" hx-swap="delete">×</button>
</div>
</div>
+13 -10
View File
@@ -9,29 +9,32 @@
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
body{background:#f7f7f4}.shell{max-width:960px}.item-card{border:0;border-radius:1rem}.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}
body{background:#f7f7f4}.shell{max-width:960px}.item-card{border:0;border-radius:1rem}.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}
</style>
</head>
<body>
<nav class="navbar bg-white border-bottom sticky-top"><div class="container shell"><a class="navbar-brand fw-bold" href="/">my2dos</a><div class="d-flex align-items-center gap-3"><span class="text-muted small d-none d-sm-inline">Notizen · Aufgaben · Links</span>{% if user.is_authenticated %}<a class="small" href="{% url 'settings' %}">{{ user.get_full_name|default:user.username }}</a><form method="post" action="{% url 'logout' %}" class="m-0">{% csrf_token %}<button class="btn btn-sm btn-outline-secondary">Logout</button></form>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">Login</a>{% endif %}</div></div></nav>
<nav class="navbar bg-white border-bottom sticky-top"><div class="container shell"><a class="navbar-brand fw-bold" href="/">my2dos</a><div class="d-flex align-items-center gap-3"><span class="text-muted small d-none d-sm-inline">Notizen · Aufgaben · Links</span>{% if user.is_authenticated %}<a class="small" href="{% url 'journal' %}">Journal</a><a class="small" href="{% url 'settings' %}">{{ user.get_full_name|default:user.username }}</a><form method="post" action="{% url 'logout' %}" class="m-0">{% csrf_token %}<button class="btn btn-sm btn-outline-secondary">Logout</button></form>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">Login</a>{% endif %}</div></div></nav>
<main class="container shell py-4">{% for message in messages %}<div class="alert alert-{{ message.tags|default:'info' }}">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main>
<script>
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
window.quickComposer = window.quickComposer || function(tags){
window.quickComposer = window.quickComposer || function(tags, itemId=null){
return {
open:false,text:'',active:0,query:'',currentEl:null,preview:null,linkSuggestions:[],tags:tags||[],
open:false,text:'',active:0,query:'',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||[]})})},
commands:[{token:'/todo',label:'Aufgabe erstellen'},{token:'/link',label:'Link speichern'},{token:'/public',label:'öffentlich'},{token:'/private',label:'privat'},{token:'/code',label:'Markdown Codeblock einfügen'},{token:'[[',label:'interne Aufgabe/Notiz suchen'}],
get filtered(){let q=this.query.toLowerCase();let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'})):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`})):this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu'}]},
commands:[{token:'/todo',label:'Aufgabe erstellen'},{token:'/link',label:'Link speichern'},{token:'/journal',label:'Journal-Eintrag'},{token:'/public',label:'öffentlich'},{token:'/private',label:'privat'},{token:'/code',label:'Markdown Codeblock einfügen'},{token:'[[',label:'interne Aufgabe/Notiz suchen'},{token:'![[',label:'Bild aus Anhängen einfügen'}],
get filtered(){let q=this.query.toLowerCase();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:`Bild: ${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}`})):this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu'}]},
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();dt.items.add(named);this.$refs.fileInput.files=dt.files;this.preview=URL.createObjectURL(named)},
previewFile(e){let file=e.target.files[0];this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null},
handlePaste(e){let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));if(!item||!this.$refs.fileInput)return;let file=item.getAsFile();let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');let named=new File([file],`paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`,{type:file.type});let dt=new DataTransfer();[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));dt.items.add(named);this.$refs.fileInput.files=dt.files;this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));this.preview=URL.createObjectURL(named)},
insertImageToken(){if(this.pendingFiles.length)this.insert(`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`)},
previewFile(e){this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));let file=this.pendingFiles[0];this.preview=file?URL.createObjectURL(file):null},
onKeydown(e){this.currentEl=e.target;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;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('[[');if(fragment.startsWith('[[')){fetch(`/api/item-suggestions/?q=${encodeURIComponent(fragment.slice(2))}`).then(r=>r.json()).then(d=>{this.linkSuggestions=d.items||[]})}},
insert(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;if(token==='/code')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 prefix=start>0&&el.value[start-1]!==' '?' ':'';let value=el.value.slice(0,start)+prefix+token+' '+after;this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=start+prefix.length+token.length+1;el.focus();el.setSelectionRange(p,p)})}
update(el){this.currentEl=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||[]})}},
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;this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;el.focus();el.setSelectionRange(p,p)})}
}
};
window.searchBox = function(tags){return{open:false,active:0,q:'',tags:tags||[],get filtered(){let q=this.q.toLowerCase();return this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q))},onInput(e){this.q=e.target.value;this.open=this.q.startsWith('#')},onKeydown(e){if(!this.open)return;if(e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%Math.max(this.filtered.length,1)}else if(e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+Math.max(this.filtered.length,1))%Math.max(this.filtered.length,1)}else if(e.key==='Enter'||e.key==='Tab'){if(this.filtered.length){e.preventDefault();this.select(this.filtered[this.active])}}else if(e.key==='Escape'){this.open=false}},select(tag){let url=new URL(window.location.href);url.searchParams.set('tag',tag);url.searchParams.delete('q');window.location.href=url.pathname+'?'+url.searchParams.toString()}}}
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)})}
document.addEventListener('DOMContentLoaded',()=>enhanceCodeBlocks());document.body.addEventListener('htmx:afterSwap',e=>enhanceCodeBlocks(e.target));
</script>
</body>
</html>
+6 -1
View File
@@ -1,8 +1,13 @@
{% extends 'core/base.html' %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ request.GET.next|default:'/' }}">← zurück</a>
{% include 'core/_item.html' %}
{% if item.backlinks.all %}
<div class="mt-4"><h5>Backlinks</h5><div class="vstack gap-3">{% for item in item.backlinks.all %}{% include 'core/_item.html' %}{% endfor %}</div></div>
{% endif %}
<script>
document.addEventListener('keydown', function(event){
if(event.key === 'Escape') window.location.href = '{{ request.GET.next|default:"/"|escapejs }}';
});
</script>
{% endblock %}
+43 -16
View File
@@ -6,10 +6,10 @@
<div class="card item-card shadow-sm"><div class="card-body">
<div class="fw-semibold mb-2">Filter</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<a class="btn btn-sm {% if not active_kind and not active_status %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% query_replace kind=None status=None %}">Alle</a>
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% query_replace kind='todo' %}">Todos</a>
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% query_replace kind='note' %}">Notizen</a>
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% query_replace kind='link' %}">Links</a>
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">Notizen{% if active_kind == 'note' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% if active_kind == 'link' %}{% query_replace kind=None %}{% else %}{% query_replace kind='link' %}{% endif %}">Links{% if active_kind == 'link' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'journal' %}btn-info{% else %}btn-outline-info{% endif %}" href="{% if active_kind == 'journal' %}{% query_replace kind=None day=None %}{% else %}{% query_replace kind='journal' %}{% endif %}">Journal{% if active_kind == 'journal' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_status == 'open' %}btn-warning{% else %}btn-outline-warning{% endif %}" href="{% if active_status == 'open' %}{% query_replace status=None %}{% else %}{% query_replace status='open' %}{% endif %}">Unerledigt{% if active_status == 'open' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_status == 'archive' %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% if active_status == 'archive' %}{% query_replace status=None %}{% else %}{% query_replace status='archive' %}{% endif %}">Archiv{% if active_status == 'archive' %} ×{% endif %}</a>
</div>
@@ -17,11 +17,22 @@
{% if active_tag %}<input type="hidden" name="tag" value="{{ active_tag }}">{% endif %}
{% if active_kind %}<input type="hidden" name="kind" value="{{ active_kind }}">{% endif %}
{% if active_status %}<input type="hidden" name="status" value="{{ active_status }}">{% endif %}
<input x-ref="search" class="form-control form-control-sm" name="q" value="{{ q }}" placeholder="Suche (Ctrl+F, # für Tags)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" @input="onInput($event)" @keydown="onKeydown($event)">
<div class="input-group input-group-sm">
<input x-ref="search" class="form-control" name="q" value="{{ q }}" placeholder="Suche (Ctrl+F, # für Tags)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" @input="onInput($event)" @keydown="onKeydown($event)">
{% if q %}<a class="btn btn-outline-secondary" href="{% query_replace q=None %}" title="Suche löschen">×</a>{% endif %}
</div>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template>
</div>
</form>
{% if active_kind == 'journal' and journal_days %}
<div class="fw-semibold mb-2">Journal-Kalender</div>
<div class="d-flex flex-wrap gap-1 mb-3">
{% for d in journal_days %}
{% if d.has_entries %}<a class="btn btn-sm {% if active_day == d.date %}btn-info{% else %}btn-outline-info{% endif %}" href="{% query_replace day=d.date %}">{{ d.day }}</a>{% else %}<span class="btn btn-sm btn-light disabled">{{ d.day }}</span>{% endif %}
{% endfor %}
</div>
{% endif %}
<div class="fw-semibold mb-2">Tags</div>
<div hx-get="{% url 'tag_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-trigger="tagsChanged from:body" hx-target="#tag-list" hx-swap="outerHTML">
{% include 'core/_tags.html' %}
@@ -36,7 +47,8 @@
<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="d-flex align-items-center gap-2 mt-2">
<input class="form-control" type="file" name="files" x-ref="fileInput" @change="previewFile($event)">
<input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)">
<a class="btn btn-outline-secondary" href="{% url 'new_item' %}">Editor</a>
<button class="btn btn-dark px-4">Speichern</button>
</div>
<template x-if="preview"><div class="mt-2 small"><span class="text-muted">Bild aus Zwischenablage:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
@@ -47,7 +59,7 @@
</section>
</div>
<script>
function quickComposer(tags){
function quickComposer(tags, itemId=null){
return {
open:false,
text:'',
@@ -56,6 +68,9 @@ function quickComposer(tags){
currentEl:null,
preview:null,
linkSuggestions:[],
attachmentSuggestions:[],
pendingFiles:[],
itemId:itemId,
tags:tags||[],
init(){
document.body.addEventListener('htmx:afterRequest', (event) => {
@@ -68,15 +83,19 @@ function quickComposer(tags){
commands:[
{token:'/todo',label:'Aufgabe erstellen'},
{token:'/link',label:'Link speichern'},
{token:'/journal',label:'Journal-Eintrag'},
{token:'/public',label:'öffentlich'},
{token:'/private',label:'privat'},
{token:'/code',label:'Markdown Codeblock einfügen'},
{token:'[[',label:'interne Aufgabe/Notiz suchen'}
{token:'[[',label:'interne Aufgabe/Notiz suchen'},
{token:'![[',label:'Bild aus Anhängen einfügen'}
],
get filtered(){
let q=this.query.toLowerCase();
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:`Bild: ${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}`}))
: this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));
@@ -90,13 +109,16 @@ function quickComposer(tags){
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);
},
previewFile(e){
let file=e.target.files[0];
this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null;
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;
@@ -138,8 +160,10 @@ function quickComposer(tags){
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
this.query=fragment;
this.active=0;
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[');
if(fragment.startsWith('[[')){
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(data=>{this.attachmentSuggestions=data.attachments||[]});
}else if(fragment.startsWith('[[')){
let q=fragment.slice(2);
fetch(`{% url "api_item_suggestions" %}?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(data=>{this.linkSuggestions=data.items||[]});
}
@@ -147,19 +171,22 @@ function quickComposer(tags){
insert(token){
let el=this.currentEl || document.getElementById('id_content');
if(!el) return;
if(token==='/code') token='```\n\n```';
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 prefix=start>0&&el.value[start-1]!==' '?' ':'';
let value=el.value.slice(0,start)+prefix+token+' '+after;
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;
this.text=value;
el.value=value;
this.open=false;
this.$nextTick(()=>{
let p=start+prefix.length+token.length+1;
let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;
el.focus();
el.setSelectionRange(p,p);
});
+24
View File
@@ -0,0 +1,24 @@
{% extends 'core/base.html' %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ next_url }}">← zurück</a>
<form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea[name=content]').value">
{% csrf_token %}
<div class="card-body vstack gap-3">
<h1 class="h4">Eintrag #{{ item.id }} im Editor bearbeiten</h1>
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
<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="Kommentar" @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>
<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="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>Privat</option><option value="public" {% if item.visibility == 'public' %}selected{% endif %}>Öffentlich</option></select></div>
{% if item.kind == 'todo' %}<div class="col-md-4"><input class="form-control" type="datetime-local" name="due_at" value="{{ item.due_at|date:'Y-m-d\\TH:i' }}"></div>{% endif %}
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
</div>
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
<template x-if="preview"><div class="small"><span class="text-muted">Bildvorschau:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:180px"></div></template>
<div class="d-flex gap-2"><button class="btn btn-dark">Speichern</button><a class="btn btn-outline-secondary" href="{{ next_url }}">Abbrechen</a></div>
</div>
</form>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends 'core/base.html' %}
{% load markdown_extras %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<a class="btn btn-sm btn-outline-secondary" href="/">← zurück</a>
<h1 class="h4 m-0">Journal {{ month }}/{{ year }}</h1>
<div class="btn-group btn-group-sm">
<a class="btn btn-outline-dark" href="?year={{ prev_year }}&month={{ prev_month }}">←</a>
<a class="btn btn-outline-dark" href="?year={{ next_year }}&month={{ next_month }}">→</a>
</div>
</div>
<div class="card item-card shadow-sm"><div class="card-body table-responsive">
<table class="table table-bordered align-top mb-0">
<thead><tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr></thead>
<tbody>
{% for week in weeks %}<tr>{% for day, entries in week %}<td style="height:120px;width:14%" {% if not day %}class="bg-light"{% endif %}>
{% if day %}<div class="fw-semibold small mb-1">{{ day }}</div>{% endif %}
{% for item in entries %}<a class="d-block small text-decoration-none mb-1" href="{{ item.get_absolute_url }}">{{ item.content|truncatechars:55 }}</a>{% endfor %}
</td>{% endfor %}</tr>{% endfor %}
</tbody>
</table>
</div></div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends 'core/base.html' %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
<form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])">
{% csrf_token %}
<div class="card-body vstack gap-3">
<h1 class="h4">Erweiterter Editor</h1>
<textarea class="form-control" name="content" rows="16" placeholder="Längeres Dokument … /todo /link /journal /public #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>
<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>
<label class="form-label">Bilder/Dateien</label>
<input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)">
<div class="form-text">Mit <code>![[</code> können hochgeladene oder gepastete Bilder im Text eingefügt werden.</div>
</div>
<template x-if="preview"><div class="small"><span class="text-muted">Bildvorschau:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:180px"></div></template>
<button class="btn btn-dark align-self-start">Speichern</button>
</div>
</form>
{% endblock %}
+14
View File
@@ -15,6 +15,20 @@
</div></div>
</section>
<section class="col-lg-6">
<div class="card item-card shadow-sm mb-3"><div class="card-body">
<h2 class="h5 mb-3">Standardansicht</h2>
<form method="post" class="vstack gap-2">
{% csrf_token %}<input type="hidden" name="action" value="preferences">
{% for field in preference_form %}
{% if field.field.widget.input_type == 'checkbox' %}
<label class="form-check"><input class="form-check-input" type="checkbox" name="{{ field.name }}" {% if field.value %}checked{% endif %}> <span class="form-check-label">{{ field.label }}</span></label>
{% else %}
<div><label class="form-label">{{ field.label }}</label>{{ field }}</div>
{% endif %}
{% endfor %}
<button class="btn btn-dark mt-2">Standardansicht speichern</button>
</form>
</div></div>
<div class="card item-card shadow-sm mb-3"><div class="card-body">
<h2 class="h5 mb-3">API Key erzeugen</h2>
<form method="post" class="vstack gap-3">
+24 -2
View File
@@ -1,3 +1,4 @@
import re
import bleach
import markdown as md
from django import template
@@ -7,9 +8,10 @@ register = template.Library()
ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li',
'strong', 'em', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br'
'strong', 'em', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'img'
}
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class']}
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]')
@register.filter
@@ -18,6 +20,26 @@ def markdown(text):
return mark_safe(bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS))
@register.filter
def item_markdown(item):
text = item.content or ''
attachments = {str(a.id): a for a in item.attachments.all()}
def replace(match):
attachment = attachments.get(match.group(1))
if not attachment:
return match.group(0)
return f'![{attachment.file.name}]({attachment.file.url})'
return markdown(FILE_RE.sub(replace, text))
@register.filter
def is_image(file_name):
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
@register.filter
def is_embedded(attachment, item):
token = f'![[file:{attachment.id}]]'
return token in (item.content or '') or token in (item.comment or '')
+4
View File
@@ -6,16 +6,20 @@ urlpatterns = [
path('', views.index, name='index'),
path('login/', auth_views.LoginView.as_view(template_name='core/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('new/', views.new_item, name='new_item'),
path('journal/', views.journal, name='journal'),
path('settings/', views.settings_view, name='settings'),
path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
path('items/<int:pk>/', views.item_detail, name='item_detail'),
path('items/<int:pk>/card/', views.item_card, name='item_card'),
path('items/<int:pk>/edit/', views.edit_item, name='edit_item'),
path('items/<int:pk>/editor/', views.item_editor, name='item_editor'),
path('items/<int:pk>/toggle/', views.toggle_done, name='toggle_done'),
path('items/<int:pk>/delete/', views.delete_item, name='delete_item'),
path('tags/', views.tag_list, name='tag_list'),
path('api/tags/', views.api_tags, name='api_tags'),
path('api/item-suggestions/', views.api_item_suggestions, name='api_item_suggestions'),
path('api/attachment-suggestions/', views.api_attachment_suggestions, name='api_attachment_suggestions'),
path('api/items/', views.api_items, name='api_items'),
path('api/items/<int:pk>/', views.api_item_detail, name='api_item_detail'),
]
+122 -11
View File
@@ -1,6 +1,8 @@
import json
import re
from functools import wraps
from calendar import Calendar
from datetime import date
from html import unescape
from urllib.request import Request, urlopen
from django.contrib import messages
@@ -10,10 +12,10 @@ from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
from .forms import ApiKeyForm, EditItemForm, QuickItemForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Tag
from .forms import ApiKeyForm, EditItemForm, QuickItemForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Tag, UserPreference
COMMAND_RE = re.compile(r'/(todo|note|link|public|private)\b', re.I)
COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I)
URL_RE = re.compile(r'https?://\S+')
ONLY_URL_RE = re.compile(r'^https?://\S+$')
TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', re.I | re.S)
@@ -37,7 +39,7 @@ def parse_quick_content(raw):
commands = {c.lower() for c in COMMAND_RE.findall(content)}
content = COMMAND_RE.sub('', content).strip()
only_url = bool(ONLY_URL_RE.match(content))
kind = Item.Kind.TODO if 'todo' in commands else Item.Kind.LINK if 'link' in commands or only_url else Item.Kind.NOTE
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
visibility = Item.Visibility.PUBLIC if 'public' in commands else Item.Visibility.PRIVATE
url_match = URL_RE.search(content)
url = url_match.group(0) if kind == Item.Kind.LINK and url_match else ''
@@ -46,6 +48,23 @@ def parse_quick_content(raw):
return kind, visibility, content, url
def attach_files_and_replace_tokens(item, files):
replacements = {}
for file in files:
attachment = Attachment.objects.create(item=item, file=file)
replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]'
if replacements:
content = item.content
comment = item.comment
for old, new in replacements.items():
content = content.replace(old, new)
comment = comment.replace(old, new)
if content != item.content or comment != item.comment:
item.content = content
item.comment = comment
item.save(update_fields=['content', 'comment', 'updated_at'])
def visible_items(user, api_key=None):
qs = Item.objects.select_related('owner').prefetch_related('tags', 'attachments', 'linked_items')
if api_key:
@@ -86,9 +105,8 @@ def index(request):
if active_tag and f'#{active_tag.lower()}' not in content.lower():
content = f'{content} #{active_tag}'
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'))
item.sync_metadata()
if request.FILES.get('files'):
Attachment.objects.create(item=item, file=request.FILES['files'])
if request.headers.get('HX-Request'):
response = render(request, 'core/_create_response.html', {'item': item, 'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
response['HX-Trigger'] = 'tagsChanged'
@@ -97,6 +115,7 @@ def index(request):
tag = request.GET.get('tag')
kind = request.GET.get('kind')
status = request.GET.get('status')
day = request.GET.get('day')
q = request.GET.get('q', '').strip()
if q.startswith('#') and len(q) > 1:
query = request.GET.copy()
@@ -104,20 +123,83 @@ def index(request):
query.pop('q', None)
return redirect(f'{request.path}?{query.urlencode()}')
items = visible_items(request.user)
applied_preferences = False
if tag:
items = items.filter(tags__name=tag)
if kind in dict(Item.Kind.choices):
items = items.filter(kind=kind)
elif not any([tag, status, q, day]):
applied_preferences = True
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
type_filter = Q()
if prefs.show_notes:
type_filter |= Q(kind=Item.Kind.NOTE)
if prefs.show_links:
type_filter |= Q(kind=Item.Kind.LINK)
if prefs.show_journal:
type_filter |= Q(kind=Item.Kind.JOURNAL)
if prefs.show_open_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=False)
if prefs.show_done_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=True)
items = items.filter(type_filter) if type_filter else items.none()
if day:
items = items.filter(created_at__date=day)
if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'archive':
items = items.filter(kind=Item.Kind.TODO, is_done=True)
else:
elif not applied_preferences:
items = items.exclude(kind=Item.Kind.TODO, is_done=True)
if q:
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
journal_days = []
if kind == Item.Kind.JOURNAL:
today = date.today()
year = int(request.GET.get('year', today.year))
month = int(request.GET.get('month', today.month))
journal_qs = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month)
days_with_entries = set(journal_qs.dates('created_at', 'day'))
journal_days = [
{'day': d, 'date': date(year, month, d).isoformat(), 'has_entries': date(year, month, d) in days_with_entries}
for week in Calendar(firstweekday=0).monthdayscalendar(year, month) for d in week if d
]
return render(request, 'core/index.html', {
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'q': q,
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'journal_days': journal_days, 'overview_lines': prefs.overview_lines,
})
@login_required
def new_item(request):
if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
kind, visibility, content, url = parse_quick_content(form.cleaned_data['content'])
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'))
item.sync_metadata()
return redirect(item.get_absolute_url())
return render(request, 'core/new_item.html', {'form': QuickItemForm(), 'tags': Tag.objects.all()})
@login_required
def journal(request):
today = date.today()
year = int(request.GET.get('year', today.year))
month = int(request.GET.get('month', today.month))
entries = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month)
by_day = {}
for item in entries:
by_day.setdefault(item.created_at.day, []).append(item)
weeks = []
for week in Calendar(firstweekday=0).monthdayscalendar(year, month):
weeks.append([(day, by_day.get(day, [])) for day in week])
prev_month, prev_year = (12, year - 1) if month == 1 else (month - 1, year)
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
return render(request, 'core/journal.html', {
'weeks': weeks, 'year': year, 'month': month,
'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month,
})
@@ -130,6 +212,13 @@ def settings_view(request):
form.save()
messages.success(request, 'Einstellungen gespeichert.')
return redirect('settings')
elif request.POST.get('action') == 'preferences':
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
pref_form = UserPreferenceForm(request.POST, instance=prefs)
if pref_form.is_valid():
pref_form.save()
messages.success(request, 'Standardansicht gespeichert.')
return redirect('settings')
elif request.POST.get('action') == 'apikey':
key_form = ApiKeyForm(request.POST)
if key_form.is_valid():
@@ -139,8 +228,10 @@ def settings_view(request):
key_form.save_m2m()
messages.success(request, f'API Key erzeugt: {api_key.token}')
return redirect('settings')
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user),
'preference_form': UserPreferenceForm(instance=prefs),
'key_form': ApiKeyForm(),
'api_keys': request.user.api_keys.prefetch_related('tags'),
})
@@ -184,15 +275,28 @@ def edit_item(request, pk):
form = EditItemForm(request.POST, request.FILES, instance=item)
if form.is_valid():
item = form.save()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
if request.FILES.get('files'):
Attachment.objects.create(item=item, file=request.FILES['files'])
response = render(request, 'core/_item.html', {'item': item})
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
response['HX-Trigger'] = 'tagsChanged'
return response
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()})
@login_required
def item_editor(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user)
next_url = request.GET.get('next') or item.get_absolute_url()
if request.method == 'POST':
form = EditItemForm(request.POST, request.FILES, instance=item)
if form.is_valid():
item = form.save()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
return redirect(next_url)
return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url})
@login_required
@require_POST
def delete_item(request, pk):
@@ -211,6 +315,13 @@ def api_tags(request):
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
@login_required
def api_attachment_suggestions(request):
item = get_object_or_404(Item, pk=request.GET.get('item'), owner=request.user)
data = [{'id': a.id, 'name': a.file.name.split('/')[-1], 'url': a.file.url} for a in item.attachments.all()]
return JsonResponse({'attachments': data})
@login_required
def api_item_suggestions(request):
q = request.GET.get('q', '').strip()