Initial my2dos Django app
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
from django.contrib import admin
|
||||
from .models import ApiKey, Attachment, Item, Tag
|
||||
|
||||
|
||||
class AttachmentInline(admin.TabularInline):
|
||||
model = Attachment
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Item)
|
||||
class ItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'kind', 'visibility', 'is_done', 'owner', 'created_at')
|
||||
list_filter = ('kind', 'visibility', 'is_done', 'tags')
|
||||
search_fields = ('content', 'url')
|
||||
inlines = [AttachmentInline]
|
||||
|
||||
|
||||
@admin.register(ApiKey)
|
||||
class ApiKeyAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'owner', 'is_active', 'created_at')
|
||||
filter_horizontal = ('tags',)
|
||||
|
||||
|
||||
admin.site.register(Tag)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
@@ -0,0 +1,60 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from .models import ApiKey, Attachment, Item
|
||||
|
||||
|
||||
class QuickItemForm(forms.ModelForm):
|
||||
files = forms.FileField(required=False, widget=forms.ClearableFileInput(attrs={'multiple': False}))
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ['content']
|
||||
widgets = {'content': forms.Textarea(attrs={
|
||||
'class': 'form-control form-control-lg',
|
||||
'rows': 3,
|
||||
'placeholder': 'Schreibe etwas … /todo /link /public #tag [[note:1]]',
|
||||
'x-model': 'text',
|
||||
'@keydown': 'onKeydown($event)',
|
||||
'@input': 'onInput($event)',
|
||||
'@paste': 'handlePaste($event)',
|
||||
})}
|
||||
|
||||
|
||||
class EditItemForm(forms.ModelForm):
|
||||
files = forms.FileField(required=False, widget=forms.ClearableFileInput())
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ['content', 'comment', 'visibility', 'due_at', 'url']
|
||||
widgets = {
|
||||
'comment': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'due_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
|
||||
'url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'URL'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['due_at'].input_formats = ['%Y-%m-%dT%H:%M']
|
||||
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['first_name', 'last_name', 'email']
|
||||
widgets = {field: forms.TextInput(attrs={'class': 'form-control'}) for field in ['first_name', 'last_name', 'email']}
|
||||
|
||||
|
||||
class ApiKeyForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ApiKey
|
||||
fields = ['name', 'tags']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Name des API Keys'}),
|
||||
'tags': forms.CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
|
||||
class AttachmentForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Attachment
|
||||
fields = ['file']
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated for my2dos MVP
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.SlugField(allow_unicode=True, max_length=80, unique=True)),
|
||||
],
|
||||
options={'ordering': ['name']},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Item',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('kind', models.CharField(choices=[('todo', 'Aufgabe'), ('note', 'Notiz'), ('link', 'Link')], default='note', max_length=10)),
|
||||
('content', models.TextField()),
|
||||
('visibility', models.CharField(choices=[('private', 'Privat'), ('public', 'Öffentlich')], default='private', max_length=10)),
|
||||
('is_done', models.BooleanField(default=False)),
|
||||
('url', models.URLField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('linked_items', models.ManyToManyField(blank=True, related_name='backlinks', to='core.item')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to=settings.AUTH_USER_MODEL)),
|
||||
('tags', models.ManyToManyField(blank=True, related_name='items', to='core.tag')),
|
||||
],
|
||||
options={'ordering': ['-created_at']},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Attachment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(upload_to='attachments/%Y/%m/')),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='core.item')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('core', '0001_initial')]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='due_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('core', '0002_item_due_at'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ApiKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='API Key', max_length=120)),
|
||||
('token', models.CharField(editable=False, max_length=80, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
|
||||
('tags', models.ManyToManyField(blank=True, related_name='api_keys', to='core.tag')),
|
||||
],
|
||||
options={'ordering': ['-created_at']},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('core', '0003_apikey')]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
import re
|
||||
import secrets
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
TAG_RE = re.compile(r'(?<!\w)#([\wäöüÄÖÜß-]+)')
|
||||
LINK_RE = re.compile(r'\[\[(todo|note|link)?:?(\d+)\]\]', re.I)
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.SlugField(max_length=80, unique=True, allow_unicode=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return f'#{self.name}'
|
||||
|
||||
|
||||
class Item(models.Model):
|
||||
class Kind(models.TextChoices):
|
||||
TODO = 'todo', 'Aufgabe'
|
||||
NOTE = 'note', 'Notiz'
|
||||
LINK = 'link', 'Link'
|
||||
|
||||
class Visibility(models.TextChoices):
|
||||
PRIVATE = 'private', 'Privat'
|
||||
PUBLIC = 'public', 'Öffentlich'
|
||||
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items')
|
||||
kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.NOTE)
|
||||
content = models.TextField()
|
||||
comment = models.TextField(blank=True)
|
||||
visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
|
||||
is_done = models.BooleanField(default=False)
|
||||
due_at = models.DateTimeField(blank=True, null=True)
|
||||
url = models.URLField(blank=True)
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name='items')
|
||||
linked_items = models.ManyToManyField('self', blank=True, symmetrical=False, related_name='backlinks')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.content[:80]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('item_detail', args=[self.pk])
|
||||
|
||||
def sync_metadata(self):
|
||||
tag_names = {m.group(1).lower() for m in TAG_RE.finditer(self.content)}
|
||||
tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names]
|
||||
self.tags.set(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))
|
||||
|
||||
|
||||
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')
|
||||
token = models.CharField(max_length=80, unique=True, editable=False)
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name='api_keys')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.token:
|
||||
self.token = secrets.token_urlsafe(32)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Attachment(models.Model):
|
||||
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='attachments')
|
||||
file = models.FileField(upload_to='attachments/%Y/%m/')
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.file.name
|
||||
@@ -0,0 +1,2 @@
|
||||
{% include 'core/_item.html' %}
|
||||
{% include 'core/_tags.html' with tags=tags oob=True %}
|
||||
@@ -0,0 +1,25 @@
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
<div class="card-body vstack gap-2">
|
||||
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
|
||||
<textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
|
||||
<textarea class="form-control" name="comment" rows="3" placeholder="Kommentar, z.B. gleiche Aufgabe bereits erfasst [[12]]" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
|
||||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||
<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' }}" 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>
|
||||
{% 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>
|
||||
<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>
|
||||
</form>
|
||||
@@ -0,0 +1,31 @@
|
||||
{% load markdown_extras %}
|
||||
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div class="flex-grow-1">
|
||||
<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 text-bg-light">{{ item.get_visibility_display }}</span>
|
||||
<a class="text-muted small" href="{{ item.get_absolute_url }}">#{{ 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>
|
||||
{% 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 %}
|
||||
{% if item.linked_items.all %}<div class="small mt-2">Links: {% for linked in item.linked_items.all %}<a href="{{ linked.get_absolute_url }}">#{{ linked.id }}</a> {% endfor %}</div>{% endif %}
|
||||
{% 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 %}
|
||||
</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 %}
|
||||
<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>
|
||||
<button class="btn btn-outline-danger" hx-post="{% url 'delete_item' item.id %}" hx-target="#item-{{ item.id }}" hx-swap="delete">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,10 @@
|
||||
{% load querystring %}
|
||||
<div id="tag-list" class="d-flex flex-wrap gap-2"{% if oob %} hx-swap-oob="true"{% endif %}>
|
||||
{% for tag in tags %}
|
||||
{% if active_tag == tag.name %}
|
||||
<a class="badge rounded-pill text-bg-dark text-decoration-none tag" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>
|
||||
{% else %}
|
||||
<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>
|
||||
{% endif %}
|
||||
{% empty %}<span class="text-muted small">Noch keine Tags</span>{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>my2dos</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<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}
|
||||
</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>
|
||||
<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){
|
||||
return {
|
||||
open:false,text:'',active:0,query:'',currentEl:null,preview:null,linkSuggestions:[],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'}]},
|
||||
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},
|
||||
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)})}
|
||||
}
|
||||
};
|
||||
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()}}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← 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 %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,170 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% load querystring %}
|
||||
{% block content %}
|
||||
<div class="row g-4">
|
||||
<aside class="col-lg-3 order-lg-2">
|
||||
<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_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>
|
||||
<form class="mb-3 position-relative" method="get" x-data="searchBox([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:keydown.window="if(($event.ctrlKey||$event.metaKey)&&$event.key==='f'){ $event.preventDefault(); $refs.search.focus(); $refs.search.select(); }">
|
||||
{% 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="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>
|
||||
<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' %}
|
||||
</div>
|
||||
</div></div>
|
||||
</aside>
|
||||
<section class="col-lg-9">
|
||||
<form class="position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
|
||||
{% csrf_token %}
|
||||
{{ form.content }}
|
||||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-2">
|
||||
<input class="form-control" type="file" name="files" x-ref="fileInput" @change="previewFile($event)">
|
||||
<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>
|
||||
</form>
|
||||
<div id="items" class="vstack gap-3">
|
||||
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">Noch nichts vorhanden.</div>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
function quickComposer(tags){
|
||||
return {
|
||||
open:false,
|
||||
text:'',
|
||||
active:0,
|
||||
query:'',
|
||||
currentEl:null,
|
||||
preview:null,
|
||||
linkSuggestions:[],
|
||||
tags:tags||[],
|
||||
init(){
|
||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||
if(event.detail.successful && event.target === this.$el){ this.preview=null; }
|
||||
});
|
||||
document.body.addEventListener('tagsChanged', () => {
|
||||
fetch('{% url "api_tags" %}').then(r=>r.json()).then(data=>{this.tags=data.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'}];
|
||||
},
|
||||
onInput(e){this.update(e.target)},
|
||||
handlePaste(e){
|
||||
let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));
|
||||
if(!item) 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;
|
||||
},
|
||||
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('[[')){
|
||||
let q=fragment.slice(2);
|
||||
fetch(`{% url "api_item_suggestions" %}?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(data=>{this.linkSuggestions=data.items||[]});
|
||||
}
|
||||
},
|
||||
insert(token){
|
||||
let el=this.currentEl || document.getElementById('id_content');
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center"><div class="col-md-5">
|
||||
<div class="card item-card shadow-sm"><div class="card-body">
|
||||
<h1 class="h4 mb-3">Anmelden</h1>
|
||||
{% if form.errors %}<div class="alert alert-danger">Benutzername oder Passwort ist falsch.</div>{% endif %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3"><label class="form-label">Benutzername</label><input class="form-control" name="username" autofocus></div>
|
||||
<div class="mb-3"><label class="form-label">Passwort</label><input class="form-control" type="password" name="password"></div>
|
||||
<button class="btn btn-dark w-100">Login</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</div></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
|
||||
<div class="row g-4">
|
||||
<section class="col-lg-6">
|
||||
<div class="card item-card shadow-sm"><div class="card-body">
|
||||
<h1 class="h4 mb-3">Benutzereinstellungen</h1>
|
||||
<form method="post" class="vstack gap-3">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="profile">
|
||||
<div><label class="form-label">Vorname</label>{{ profile_form.first_name }}</div>
|
||||
<div><label class="form-label">Name</label>{{ profile_form.last_name }}</div>
|
||||
<div><label class="form-label">E-Mail</label>{{ profile_form.email }}</div>
|
||||
<button class="btn btn-dark">Speichern</button>
|
||||
</form>
|
||||
</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">API Key erzeugen</h2>
|
||||
<form method="post" class="vstack gap-3">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="apikey">
|
||||
<div><label class="form-label">Name</label>{{ key_form.name }}</div>
|
||||
<div><label class="form-label">Auf Tags beschränken</label><div class="small text-muted mb-2">Keine Auswahl = Zugriff auf alle eigenen Einträge.</div>{{ key_form.tags }}</div>
|
||||
<button class="btn btn-dark">API Key generieren</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="card item-card shadow-sm"><div class="card-body">
|
||||
<h2 class="h5 mb-3">API Keys</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for key in api_keys %}
|
||||
<div class="border rounded p-2">
|
||||
<div class="d-flex justify-content-between gap-2"><strong>{{ key.name }}</strong><form method="post" action="{% url 'delete_api_key' key.id %}">{% csrf_token %}<button class="btn btn-sm btn-outline-danger">Löschen</button></form></div>
|
||||
<code class="small user-select-all">{{ key.token }}</code>
|
||||
<div class="small text-muted">Tags: {% for tag in key.tags.all %}#{{ tag.name }} {% empty %}alle{% endfor %}</div>
|
||||
</div>
|
||||
{% empty %}<span class="text-muted">Noch keine API Keys.</span>{% endfor %}
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
import bleach
|
||||
import markdown as md
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
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'
|
||||
}
|
||||
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class']}
|
||||
|
||||
|
||||
@register.filter
|
||||
def markdown(text):
|
||||
html = md.markdown(text or '', extensions=['extra', 'sane_lists', 'nl2br'])
|
||||
return mark_safe(bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS))
|
||||
|
||||
|
||||
@register.filter
|
||||
def is_image(file_name):
|
||||
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
|
||||
@@ -0,0 +1,15 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def query_replace(context, **kwargs):
|
||||
query = context['request'].GET.copy()
|
||||
for key, value in kwargs.items():
|
||||
if value is None or value == '':
|
||||
query.pop(key, None)
|
||||
else:
|
||||
query[key] = value
|
||||
encoded = query.urlencode()
|
||||
return f'?{encoded}' if encoded else '?'
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
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('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>/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/items/', views.api_items, name='api_items'),
|
||||
path('api/items/<int:pk>/', views.api_item_detail, name='api_item_detail'),
|
||||
]
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
import json
|
||||
import re
|
||||
from functools import wraps
|
||||
from html import unescape
|
||||
from urllib.request import Request, urlopen
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
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
|
||||
|
||||
COMMAND_RE = re.compile(r'/(todo|note|link|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)
|
||||
|
||||
|
||||
def fetch_page_title(url):
|
||||
try:
|
||||
request = Request(url, headers={'User-Agent': 'my2dos/1.0'})
|
||||
with urlopen(request, timeout=4) as response:
|
||||
html = response.read(200_000).decode(response.headers.get_content_charset() or 'utf-8', errors='ignore')
|
||||
match = TITLE_RE.search(html)
|
||||
if match:
|
||||
return re.sub(r'\s+', ' ', unescape(match.group(1))).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
def parse_quick_content(raw):
|
||||
content = raw.strip()
|
||||
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
|
||||
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 ''
|
||||
if only_url:
|
||||
content = fetch_page_title(url) or url
|
||||
return kind, visibility, content, url
|
||||
|
||||
|
||||
def visible_items(user, api_key=None):
|
||||
qs = Item.objects.select_related('owner').prefetch_related('tags', 'attachments', 'linked_items')
|
||||
if api_key:
|
||||
qs = qs.filter(owner=api_key.owner)
|
||||
allowed_tags = api_key.tags.all()
|
||||
if allowed_tags.exists():
|
||||
qs = qs.filter(tags__in=allowed_tags).distinct()
|
||||
return qs
|
||||
if user.is_authenticated:
|
||||
return qs.filter(Q(owner=user) | Q(visibility=Item.Visibility.PUBLIC))
|
||||
return qs.filter(visibility=Item.Visibility.PUBLIC)
|
||||
|
||||
|
||||
def get_api_key(request):
|
||||
token = request.headers.get('X-Api-Key') or request.headers.get('Authorization', '').removeprefix('Bearer ').strip()
|
||||
if not token:
|
||||
return None
|
||||
return ApiKey.objects.filter(token=token, is_active=True).select_related('owner').prefetch_related('tags').first()
|
||||
|
||||
|
||||
def api_auth_required(view):
|
||||
@wraps(view)
|
||||
def wrapper(request, *args, **kwargs):
|
||||
request.api_key = get_api_key(request)
|
||||
if request.user.is_authenticated or request.api_key:
|
||||
return view(request, *args, **kwargs)
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
return wrapper
|
||||
|
||||
|
||||
@login_required
|
||||
def index(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'])
|
||||
active_tag = request.GET.get('tag')
|
||||
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)
|
||||
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'
|
||||
return response
|
||||
return redirect('index')
|
||||
tag = request.GET.get('tag')
|
||||
kind = request.GET.get('kind')
|
||||
status = request.GET.get('status')
|
||||
q = request.GET.get('q', '').strip()
|
||||
if q.startswith('#') and len(q) > 1:
|
||||
query = request.GET.copy()
|
||||
query['tag'] = q[1:].strip().lower()
|
||||
query.pop('q', None)
|
||||
return redirect(f'{request.path}?{query.urlencode()}')
|
||||
items = visible_items(request.user)
|
||||
if tag:
|
||||
items = items.filter(tags__name=tag)
|
||||
if kind in dict(Item.Kind.choices):
|
||||
items = items.filter(kind=kind)
|
||||
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:
|
||||
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()
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_view(request):
|
||||
if request.method == 'POST':
|
||||
if request.POST.get('action') == 'profile':
|
||||
form = UserSettingsForm(request.POST, instance=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'Einstellungen gespeichert.')
|
||||
return redirect('settings')
|
||||
elif request.POST.get('action') == 'apikey':
|
||||
key_form = ApiKeyForm(request.POST)
|
||||
if key_form.is_valid():
|
||||
api_key = key_form.save(commit=False)
|
||||
api_key.owner = request.user
|
||||
api_key.save()
|
||||
key_form.save_m2m()
|
||||
messages.success(request, f'API Key erzeugt: {api_key.token}')
|
||||
return redirect('settings')
|
||||
return render(request, 'core/settings.html', {
|
||||
'profile_form': UserSettingsForm(instance=request.user),
|
||||
'key_form': ApiKeyForm(),
|
||||
'api_keys': request.user.api_keys.prefetch_related('tags'),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_api_key(request, pk):
|
||||
api_key = get_object_or_404(ApiKey, pk=pk, owner=request.user)
|
||||
api_key.delete()
|
||||
return redirect('settings')
|
||||
|
||||
|
||||
@login_required
|
||||
def item_detail(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
return render(request, 'core/detail.html', {'item': item})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_done(request, pk):
|
||||
item = get_object_or_404(Item, pk=pk, owner=request.user, kind=Item.Kind.TODO)
|
||||
item.is_done = not item.is_done
|
||||
item.save(update_fields=['is_done', 'updated_at'])
|
||||
if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive':
|
||||
return HttpResponse('')
|
||||
return render(request, 'core/_item.html', {'item': item})
|
||||
|
||||
|
||||
@login_required
|
||||
def item_card(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
return render(request, 'core/_item.html', {'item': item})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_item(request, pk):
|
||||
item = get_object_or_404(Item, pk=pk, owner=request.user)
|
||||
if request.method == 'POST':
|
||||
form = EditItemForm(request.POST, request.FILES, instance=item)
|
||||
if form.is_valid():
|
||||
item = form.save()
|
||||
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['HX-Trigger'] = 'tagsChanged'
|
||||
return response
|
||||
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_item(request, pk):
|
||||
item = get_object_or_404(Item, pk=pk, owner=request.user)
|
||||
item.delete()
|
||||
return JsonResponse({'ok': True})
|
||||
|
||||
|
||||
@login_required
|
||||
def tag_list(request):
|
||||
return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
|
||||
|
||||
|
||||
@login_required
|
||||
def api_tags(request):
|
||||
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
|
||||
|
||||
|
||||
@login_required
|
||||
def api_item_suggestions(request):
|
||||
q = request.GET.get('q', '').strip()
|
||||
items = visible_items(request.user)
|
||||
if q:
|
||||
items = items.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||
data = [{'id': i.id, 'kind': i.kind, 'label': i.content[:80]} for i in items[:10]]
|
||||
return JsonResponse({'items': data})
|
||||
|
||||
|
||||
def serialize_item(item):
|
||||
return {
|
||||
'id': item.id,
|
||||
'kind': item.kind,
|
||||
'content': item.content,
|
||||
'comment': item.comment,
|
||||
'visibility': item.visibility,
|
||||
'is_done': item.is_done,
|
||||
'due_at': item.due_at.isoformat() if item.due_at else None,
|
||||
'url': item.url,
|
||||
'tags': [t.name for t in item.tags.all()],
|
||||
'linked_items': [i.id for i in item.linked_items.all()],
|
||||
'attachments': [a.file.url for a in item.attachments.all()],
|
||||
'created_at': item.created_at.isoformat(),
|
||||
'updated_at': item.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@api_auth_required
|
||||
@require_http_methods(['GET', 'POST'])
|
||||
def api_items(request):
|
||||
user = request.api_key.owner if request.api_key else request.user
|
||||
if request.method == 'GET':
|
||||
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
|
||||
raw = data.get('content', '')
|
||||
kind, visibility, content, url = parse_quick_content(raw)
|
||||
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()
|
||||
return JsonResponse(serialize_item(item), status=201)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@api_auth_required
|
||||
@require_http_methods(['GET', 'PATCH', 'DELETE'])
|
||||
def api_item_detail(request, pk):
|
||||
user = request.api_key.owner if request.api_key else request.user
|
||||
item = get_object_or_404(visible_items(user, request.api_key), pk=pk)
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(serialize_item(item))
|
||||
if request.method == 'DELETE':
|
||||
if item.owner != user:
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
item.delete()
|
||||
return JsonResponse({'ok': True})
|
||||
if item.owner != user:
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
data = json.loads(request.body or '{}')
|
||||
for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']:
|
||||
if field in data:
|
||||
setattr(item, field, data[field])
|
||||
item.save()
|
||||
item.sync_metadata()
|
||||
return JsonResponse(serialize_item(item))
|
||||
Reference in New Issue
Block a user