feat: Aufgaben (Tasks) mit CalDAV VTODO-Sync

Neuer Menuepunkt "Aufgaben" unterhalb Kontakte.

Backend:
- TaskList + Task + TaskListShare Models
- REST-API: CRUD, Teilen, my-color, Import/Export (.ics mit VTODO, CSV)
- CalDAV: Task-Listen tauchen als Calendar-Collection mit
  supported-calendar-component-set=VTODO im autodiscovery auf
- PROPFIND/REPORT/GET/PUT/DELETE/PROPPATCH/MKCOL fuer /dav/<user>/tl-<id>/
- SSE-Notifications bei Aenderungen

Frontend:
- TasksView mit Listen-Sidebar, Suche, "Erledigte ausblenden"
- Mehrfachauswahl + Bulk-Loeschen, Status-Toggle per Checkbox
- Editor mit Titel/Beschreibung/Faellig/Prioritaet/Status/Fortschritt
- Teilen, Farbe persoenlich anpassen, Import/Export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-14 15:07:06 +02:00
parent 2ce088e96b
commit ba3e619963
10 changed files with 1727 additions and 5 deletions

View File

@ -2,4 +2,4 @@ from flask import Blueprint
api_bp = Blueprint('api', __name__, url_prefix='/api') api_bp = Blueprint('api', __name__, url_prefix='/api')
from app.api import auth, users, files, calendar, contacts, email, office, passwords, backup, client_downloads # noqa: E402, F401 from app.api import auth, users, files, calendar, contacts, tasks, email, office, passwords, backup, client_downloads # noqa: E402, F401

587
backend/app/api/tasks.py Normal file
View File

@ -0,0 +1,587 @@
"""REST API for task lists / tasks (VTODO).
Mirror der calendar.py-Architektur: TaskList = Calendar-aehnliche Sammlung,
Task = VTODO. CalDAV-Anbindung erfolgt in app/dav/caldav.py: TaskLists
erscheinen als Kalender-Collection mit supported-calendar-component-set
auf VTODO und unter URL /dav/<user>/tl-<id>/.
"""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from flask import request, jsonify, Response
from app.api import api_bp
from app.api.auth import token_required
from app.extensions import db
from app.models.task import TaskList, Task, TaskListShare
from app.models.user import User
from app.services.events import notify_tasklist_change
# ---------------------------------------------------------------------------
# Access helpers
# ---------------------------------------------------------------------------
def _list_recipients(tl: TaskList):
return [s.shared_with_id for s in
TaskListShare.query.filter_by(task_list_id=tl.id).all()]
def _get_list_or_err(list_id, user, need_write=False):
tl = db.session.get(TaskList, list_id)
if not tl:
return None, (jsonify({'error': 'Aufgabenliste nicht gefunden'}), 404)
if tl.owner_id == user.id:
return tl, None
share = TaskListShare.query.filter_by(
task_list_id=list_id, shared_with_id=user.id
).first()
if not share:
return None, (jsonify({'error': 'Zugriff verweigert'}), 403)
if need_write and share.permission != 'readwrite':
return None, (jsonify({'error': 'Schreibzugriff verweigert'}), 403)
return tl, None
# ---------------------------------------------------------------------------
# VTODO build / parse
# ---------------------------------------------------------------------------
def _fmt_dt(dt: datetime | None) -> str | None:
if not dt:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
def build_vtodo(task: Task) -> str:
lines = ['BEGIN:VTODO', f'UID:{task.uid}',
f'DTSTAMP:{_fmt_dt(datetime.now(timezone.utc))}',
f'SUMMARY:{(task.summary or "").replace(chr(10), " ")}']
if task.description:
lines.append(f'DESCRIPTION:{task.description.replace(chr(10), chr(92) + "n")}')
if task.status:
lines.append(f'STATUS:{task.status}')
if task.priority is not None:
lines.append(f'PRIORITY:{task.priority}')
if task.percent_complete is not None:
lines.append(f'PERCENT-COMPLETE:{task.percent_complete}')
if task.due:
lines.append(f'DUE:{_fmt_dt(task.due)}')
if task.dtstart:
lines.append(f'DTSTART:{_fmt_dt(task.dtstart)}')
if task.completed_at:
lines.append(f'COMPLETED:{_fmt_dt(task.completed_at)}')
if task.categories:
lines.append(f'CATEGORIES:{task.categories}')
lines.append('END:VTODO')
return '\r\n'.join(lines)
def _unfold(text: str):
out, current = [], ''
for line in text.replace('\r\n', '\n').split('\n'):
if line.startswith((' ', '\t')) and current:
current += line[1:]
else:
if current:
out.append(current)
current = line
if current:
out.append(current)
return out
def _parse_dt(value: str) -> datetime | None:
value = value.strip()
try:
if value.endswith('Z'):
return datetime.strptime(value, '%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
if 'T' in value:
return datetime.strptime(value, '%Y%m%dT%H%M%S')
return datetime.strptime(value, '%Y%m%d')
except ValueError:
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def parse_vtodo(raw: str) -> dict | None:
if 'BEGIN:VTODO' not in raw.upper():
return None
result: dict = {}
in_block = False
for line in _unfold(raw):
upper = line.upper()
if upper.startswith('BEGIN:VTODO'):
in_block = True
continue
if upper.startswith('END:VTODO'):
break
if not in_block or ':' not in line:
continue
key, _, value = line.partition(':')
name = key.split(';')[0].upper()
if name == 'UID':
result['uid'] = value.strip()
elif name == 'SUMMARY':
result['summary'] = value.strip()
elif name == 'DESCRIPTION':
result['description'] = value.replace('\\n', '\n').replace('\\,', ',').strip()
elif name == 'STATUS':
result['status'] = value.strip().upper()
elif name == 'PRIORITY':
try:
result['priority'] = int(value.strip())
except ValueError:
pass
elif name == 'PERCENT-COMPLETE':
try:
result['percent_complete'] = int(value.strip())
except ValueError:
pass
elif name == 'DUE':
result['due'] = _parse_dt(value)
elif name == 'DTSTART':
result['dtstart'] = _parse_dt(value)
elif name == 'COMPLETED':
result['completed_at'] = _parse_dt(value)
elif name == 'CATEGORIES':
result['categories'] = value.strip()
return result if result.get('summary') or result.get('uid') else None
def _apply(task: Task, data: dict):
if 'summary' in data:
task.summary = (data.get('summary') or '').strip() or None
if 'description' in data:
task.description = (data.get('description') or '').strip() or None
if 'status' in data:
s = (data.get('status') or '').upper().strip() or None
task.status = s
if s == 'COMPLETED' and not task.completed_at:
task.completed_at = datetime.now(timezone.utc)
task.percent_complete = 100
elif s != 'COMPLETED':
task.completed_at = None
if 'priority' in data:
task.priority = data['priority'] if data['priority'] is not None else None
if 'percent_complete' in data:
task.percent_complete = data['percent_complete']
if 'due' in data:
v = data['due']
task.due = datetime.fromisoformat(v) if v else None
if 'dtstart' in data:
v = data['dtstart']
task.dtstart = datetime.fromisoformat(v) if v else None
if 'completed_at' in data:
v = data['completed_at']
task.completed_at = datetime.fromisoformat(v) if v else None
if 'categories' in data:
cats = data['categories']
if isinstance(cats, list):
task.categories = ','.join(c.strip() for c in cats if c and c.strip()) or None
else:
task.categories = (cats or '').strip() or None
# ---------------------------------------------------------------------------
# REST endpoints - lists
# ---------------------------------------------------------------------------
@api_bp.route('/tasklists', methods=['GET'])
@token_required
def list_tasklists():
user = request.current_user
own = TaskList.query.filter_by(owner_id=user.id).all()
shared = TaskListShare.query.filter_by(shared_with_id=user.id).all()
out = []
for tl in own:
d = tl.to_dict()
d['permission'] = 'owner'
d['task_count'] = tl.tasks.count()
out.append(d)
for s in shared:
tl = s.task_list
if not tl:
continue
d = tl.to_dict()
d['permission'] = s.permission
d['owner_name'] = tl.owner.username if tl.owner else ''
d['task_count'] = tl.tasks.count()
if s.color:
d['color'] = s.color
out.append(d)
return jsonify(out), 200
@api_bp.route('/tasklists', methods=['POST'])
@token_required
def create_tasklist():
user = request.current_user
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Name erforderlich'}), 400
tl = TaskList(owner_id=user.id, name=name,
color=data.get('color') or '#10b981',
description=(data.get('description') or '').strip() or None)
db.session.add(tl)
db.session.commit()
notify_tasklist_change(user.id, tl.id, 'created')
return jsonify(tl.to_dict()), 201
@api_bp.route('/tasklists/<int:list_id>', methods=['PUT'])
@token_required
def update_tasklist(list_id):
user = request.current_user
tl, err = _get_list_or_err(list_id, user, need_write=True)
if err:
return err
if tl.owner_id != user.id:
return jsonify({'error': 'Nur Eigentuemer kann die Liste umbenennen'}), 403
data = request.get_json() or {}
if 'name' in data:
tl.name = data['name'].strip()
if 'color' in data:
tl.color = data['color']
if 'description' in data:
tl.description = (data['description'] or '').strip() or None
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'updated', shared_with=_list_recipients(tl))
return jsonify(tl.to_dict()), 200
@api_bp.route('/tasklists/<int:list_id>/my-color', methods=['PUT'])
@token_required
def set_my_tasklist_color(list_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl:
return jsonify({'error': 'Nicht gefunden'}), 404
color = (request.get_json() or {}).get('color')
if not color:
return jsonify({'error': 'color erforderlich'}), 400
if tl.owner_id == user.id:
tl.color = color
db.session.commit()
return jsonify({'color': tl.color}), 200
share = TaskListShare.query.filter_by(task_list_id=list_id, shared_with_id=user.id).first()
if not share:
return jsonify({'error': 'Zugriff verweigert'}), 403
share.color = color
db.session.commit()
return jsonify({'color': share.color}), 200
@api_bp.route('/tasklists/<int:list_id>', methods=['DELETE'])
@token_required
def delete_tasklist(list_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl or tl.owner_id != user.id:
return jsonify({'error': 'Nur Eigentuemer kann loeschen'}), 403
recipients = _list_recipients(tl)
db.session.delete(tl)
db.session.commit()
notify_tasklist_change(user.id, list_id, 'deleted', shared_with=recipients)
return jsonify({'message': 'Aufgabenliste geloescht'}), 200
# ---------------------------------------------------------------------------
# REST endpoints - tasks
# ---------------------------------------------------------------------------
@api_bp.route('/tasklists/<int:list_id>/tasks', methods=['GET'])
@token_required
def list_tasks(list_id):
user = request.current_user
tl, err = _get_list_or_err(list_id, user)
if err:
return err
show_done = (request.args.get('include_done') or 'true').lower() != 'false'
q = Task.query.filter_by(task_list_id=list_id)
if not show_done:
q = q.filter((Task.status.is_(None)) | (Task.status != 'COMPLETED'))
tasks = q.order_by(Task.due.asc().nullslast(), Task.priority.desc().nullslast(), Task.id).all()
return jsonify([t.to_dict() for t in tasks]), 200
@api_bp.route('/tasklists/<int:list_id>/tasks', methods=['POST'])
@token_required
def create_task(list_id):
user = request.current_user
tl, err = _get_list_or_err(list_id, user, need_write=True)
if err:
return err
data = request.get_json() or {}
if not (data.get('summary') or '').strip():
return jsonify({'error': 'Titel erforderlich'}), 400
task = Task(task_list_id=list_id, uid=str(uuid.uuid4()), ical_data='')
_apply(task, data)
if not task.status:
task.status = 'NEEDS-ACTION'
task.ical_data = build_vtodo(task)
db.session.add(task)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return jsonify(task.to_dict()), 201
@api_bp.route('/tasks/<int:task_id>', methods=['GET'])
@token_required
def get_task(task_id):
user = request.current_user
task = db.session.get(Task, task_id)
if not task:
return jsonify({'error': 'Aufgabe nicht gefunden'}), 404
tl, err = _get_list_or_err(task.task_list_id, user)
if err:
return err
return jsonify(task.to_dict()), 200
@api_bp.route('/tasks/<int:task_id>', methods=['PUT'])
@token_required
def update_task(task_id):
user = request.current_user
task = db.session.get(Task, task_id)
if not task:
return jsonify({'error': 'Aufgabe nicht gefunden'}), 404
tl, err = _get_list_or_err(task.task_list_id, user, need_write=True)
if err:
return err
data = request.get_json() or {}
if 'task_list_id' in data and data['task_list_id'] != task.task_list_id:
new_tl, e2 = _get_list_or_err(data['task_list_id'], user, need_write=True)
if e2:
return e2
task.task_list_id = data['task_list_id']
_apply(task, data)
task.ical_data = build_vtodo(task)
task.updated_at = datetime.now(timezone.utc)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return jsonify(task.to_dict()), 200
@api_bp.route('/tasks/<int:task_id>', methods=['DELETE'])
@token_required
def delete_task(task_id):
user = request.current_user
task = db.session.get(Task, task_id)
if not task:
return jsonify({'error': 'Aufgabe nicht gefunden'}), 404
tl, err = _get_list_or_err(task.task_list_id, user, need_write=True)
if err:
return err
db.session.delete(task)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return jsonify({'message': 'Aufgabe geloescht'}), 200
# ---------------------------------------------------------------------------
# Sharing
# ---------------------------------------------------------------------------
@api_bp.route('/tasklists/<int:list_id>/share', methods=['POST'])
@token_required
def share_tasklist(list_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl or tl.owner_id != user.id:
return jsonify({'error': 'Nur Eigentuemer kann teilen'}), 403
data = request.get_json() or {}
username = (data.get('username') or '').strip()
permission = data.get('permission', 'read')
if permission not in ('read', 'readwrite'):
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
target = User.query.filter_by(username=username).first()
if not target:
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
if target.id == user.id:
return jsonify({'error': 'Kann nicht mit sich selbst teilen'}), 400
existing = TaskListShare.query.filter_by(task_list_id=list_id, shared_with_id=target.id).first()
if existing:
existing.permission = permission
else:
db.session.add(TaskListShare(task_list_id=list_id, shared_with_id=target.id,
permission=permission))
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'share',
shared_with=[target.id, *_list_recipients(tl)])
return jsonify({'message': f'Geteilt mit {username}'}), 200
@api_bp.route('/tasklists/<int:list_id>/shares', methods=['GET'])
@token_required
def list_tasklist_shares(list_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl or tl.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404
shares = TaskListShare.query.filter_by(task_list_id=list_id).all()
return jsonify([{
'id': s.id, 'user_id': s.shared_with_id,
'username': s.shared_with.username, 'permission': s.permission,
} for s in shares]), 200
@api_bp.route('/tasklists/<int:list_id>/shares/<int:share_id>', methods=['DELETE'])
@token_required
def remove_tasklist_share(list_id, share_id):
user = request.current_user
tl = db.session.get(TaskList, list_id)
if not tl or tl.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404
share = db.session.get(TaskListShare, share_id)
if not share or share.task_list_id != list_id:
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
target_id = share.shared_with_id
db.session.delete(share)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'share',
shared_with=[target_id, *_list_recipients(tl)])
return jsonify({'message': 'Freigabe entfernt'}), 200
# ---------------------------------------------------------------------------
# Import / Export (.ics with VTODO; CSV)
# ---------------------------------------------------------------------------
@api_bp.route('/tasklists/<int:list_id>/export', methods=['GET'])
@token_required
def export_tasklist(list_id):
import csv
import io
user = request.current_user
tl, err = _get_list_or_err(list_id, user)
if err:
return err
fmt = (request.args.get('format') or 'ics').lower()
tasks = Task.query.filter_by(task_list_id=list_id).all()
safe = re.sub(r'[^A-Za-z0-9._-]+', '_', tl.name or 'aufgaben') or 'aufgaben'
if fmt == 'ics':
lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE', 'CALSCALE:GREGORIAN']
for t in tasks:
block = (t.ical_data or '').strip() or build_vtodo(t)
lines.append(block)
lines.append('END:VCALENDAR')
return Response(
'\r\n'.join(lines) + '\r\n',
mimetype='text/calendar; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{safe}.ics"'},
)
if fmt == 'csv':
out = io.StringIO()
w = csv.writer(out, delimiter=';', quoting=csv.QUOTE_ALL)
w.writerow(['summary', 'status', 'priority', 'percent_complete',
'due', 'dtstart', 'completed_at', 'categories', 'description', 'uid'])
for t in tasks:
w.writerow([
t.summary or '', t.status or '',
t.priority if t.priority is not None else '',
t.percent_complete if t.percent_complete is not None else '',
t.due.isoformat() if t.due else '',
t.dtstart.isoformat() if t.dtstart else '',
t.completed_at.isoformat() if t.completed_at else '',
t.categories or '',
(t.description or '').replace('\r\n', ' ').replace('\n', ' '),
t.uid or '',
])
return Response(
'\ufeff' + out.getvalue(), mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{safe}.csv"'},
)
return jsonify({'error': 'Unbekanntes Format'}), 400
@api_bp.route('/tasklists/<int:list_id>/import', methods=['POST'])
@token_required
def import_tasklist(list_id):
import csv
import io
user = request.current_user
tl, err = _get_list_or_err(list_id, user, need_write=True)
if err:
return err
file = request.files.get('file')
if not file:
return jsonify({'error': 'Keine Datei'}), 400
raw = file.read()
try:
text = raw.decode('utf-8-sig')
except UnicodeDecodeError:
text = raw.decode('latin-1', errors='replace')
name = (file.filename or '').lower()
imported, skipped = 0, 0
def _save(parsed: dict, ical_block: str | None = None):
nonlocal imported, skipped
if not parsed.get('summary'):
skipped += 1
return
uid = parsed.get('uid') or str(uuid.uuid4())
existing = Task.query.filter_by(task_list_id=list_id, uid=uid).first()
t = existing or Task(task_list_id=list_id, uid=uid, ical_data='')
t.summary = parsed.get('summary')
t.description = parsed.get('description')
t.status = parsed.get('status') or 'NEEDS-ACTION'
t.priority = parsed.get('priority')
t.percent_complete = parsed.get('percent_complete')
t.due = parsed.get('due')
t.dtstart = parsed.get('dtstart')
t.completed_at = parsed.get('completed_at')
cats = parsed.get('categories')
if isinstance(cats, list):
t.categories = ','.join(cats)
elif isinstance(cats, str):
t.categories = cats or None
t.ical_data = (ical_block or '').strip() or build_vtodo(t)
if not existing:
db.session.add(t)
imported += 1
if name.endswith('.csv') or (b';' in raw[:200] and b'BEGIN:VCALENDAR' not in raw[:200]):
reader = csv.DictReader(__import__('io').StringIO(text), delimiter=';')
if not reader.fieldnames or len(reader.fieldnames) < 2:
reader = csv.DictReader(__import__('io').StringIO(text), delimiter=',')
for row in reader:
row = {k.strip().lower(): (v or '').strip() for k, v in row.items() if k}
try:
due = datetime.fromisoformat(row['due']) if row.get('due') else None
except ValueError:
due = None
_save({
'uid': row.get('uid'),
'summary': row.get('summary') or row.get('titel'),
'description': row.get('description') or row.get('beschreibung'),
'status': (row.get('status') or '').upper() or None,
'priority': int(row['priority']) if row.get('priority', '').isdigit() else None,
'percent_complete': int(row['percent_complete']) if row.get('percent_complete', '').isdigit() else None,
'due': due,
'categories': row.get('categories') or row.get('kategorien'),
})
else:
blocks = re.findall(r'BEGIN:VTODO.*?END:VTODO', text, flags=re.DOTALL | re.IGNORECASE)
if not blocks:
return jsonify({'error': 'Keine VTODO-Daten gefunden'}), 400
for block in blocks:
parsed = parse_vtodo(block)
if not parsed:
skipped += 1
continue
_save(parsed, ical_block=block)
db.session.commit()
if imported:
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return jsonify({'imported': imported, 'skipped': skipped}), 200

View File

@ -284,11 +284,11 @@ def propfind(subpath=''):
multistatus.append(_principal_response(user)) multistatus.append(_principal_response(user))
return _xml_response(multistatus) return _xml_response(multistatus)
# /dav/<username>/calendars/ : only calendar collections # /dav/<username>/calendars/ : Kalender + Aufgabenlisten (DAVx5 erkennt
# VTODO-Listen automatisch an supported-calendar-component-set).
if len(parts) == 2 and parts[1] == 'calendars': if len(parts) == 2 and parts[1] == 'calendars':
if parts[0] != user.username: if parts[0] != user.username:
return Response('', 403) return Response('', 403)
# A plain collection container
container = ET.Element(_qn('d', 'response')) container = ET.Element(_qn('d', 'response'))
ET.SubElement(container, _qn('d', 'href')).text = f'/dav/{user.username}/calendars/' ET.SubElement(container, _qn('d', 'href')).text = f'/dav/{user.username}/calendars/'
propstat = ET.SubElement(container, _qn('d', 'propstat')) propstat = ET.SubElement(container, _qn('d', 'propstat'))
@ -301,6 +301,9 @@ def propfind(subpath=''):
if depth != '0': if depth != '0':
for cal in _user_calendars(user): for cal in _user_calendars(user):
multistatus.append(_calendar_response(user, cal)) multistatus.append(_calendar_response(user, cal))
from .taskdav import user_lists, list_response
for tl in user_lists(user):
multistatus.append(list_response(user, tl))
return _xml_response(multistatus) return _xml_response(multistatus)
# /dav/<username>/addressbooks/ : only addressbook collections # /dav/<username>/addressbooks/ : only addressbook collections
@ -322,10 +325,13 @@ def propfind(subpath=''):
multistatus.append(_addressbook_response(user, ab)) multistatus.append(_addressbook_response(user, ab))
return _xml_response(multistatus) return _xml_response(multistatus)
# /dav/<username>/cal-<id>/ : calendar + events # /dav/<username>/cal-<id>/ : calendar + events (auch tl-N delegieren)
if len(parts) == 2: if len(parts) == 2:
if parts[0] != user.username: if parts[0] != user.username:
return Response('', 403) return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_propfind
return tl_propfind(username=parts[0], tl_part=parts[1])
cal_id = _parse_calendar_path(parts[1]) cal_id = _parse_calendar_path(parts[1])
if cal_id is None: if cal_id is None:
return Response('Not found', 404) return Response('Not found', 404)
@ -338,10 +344,13 @@ def propfind(subpath=''):
multistatus.append(_event_response(user, cal, ev)) multistatus.append(_event_response(user, cal, ev))
return _xml_response(multistatus) return _xml_response(multistatus)
# /dav/<username>/cal-<id>/<uid>.ics : single event # /dav/<username>/cal-<id>/<uid>.ics : single event (tl-N delegieren)
if len(parts) == 3: if len(parts) == 3:
if parts[0] != user.username: if parts[0] != user.username:
return Response('', 403) return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_task_propfind
return tl_task_propfind(username=parts[0], tl_part=parts[1], filename=parts[2])
cal_id = _parse_calendar_path(parts[1]) cal_id = _parse_calendar_path(parts[1])
cal = _calendar_for(user, cal_id) if cal_id else None cal = _calendar_for(user, cal_id) if cal_id else None
if not cal: if not cal:
@ -367,6 +376,9 @@ def report(subpath):
parts = [p for p in subpath.split('/') if p] parts = [p for p in subpath.split('/') if p]
if len(parts) < 2 or parts[0] != user.username: if len(parts) < 2 or parts[0] != user.username:
return Response('', 403) return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_report
return tl_report(username=parts[0], tl_part=parts[1])
cal_id = _parse_calendar_path(parts[1]) cal_id = _parse_calendar_path(parts[1])
cal = _calendar_for(user, cal_id) if cal_id else None cal = _calendar_for(user, cal_id) if cal_id else None
if not cal: if not cal:
@ -449,6 +461,9 @@ def get_event(username, cal_part, filename):
if cal_part.startswith('ab-'): if cal_part.startswith('ab-'):
from .carddav import ab_get from .carddav import ab_get
return ab_get(username=username, ab_part=cal_part, filename=filename) return ab_get(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_get
return tl_get(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user user: User = request.dav_user
if username != user.username: if username != user.username:
return Response('', 403) return Response('', 403)
@ -477,6 +492,9 @@ def put_event(username, cal_part, filename):
if cal_part.startswith('ab-'): if cal_part.startswith('ab-'):
from .carddav import ab_put from .carddav import ab_put
return ab_put(username=username, ab_part=cal_part, filename=filename) return ab_put(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_put
return tl_put(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user user: User = request.dav_user
if username != user.username: if username != user.username:
return Response('', 403) return Response('', 403)
@ -536,6 +554,9 @@ def delete_event(username, cal_part, filename):
if cal_part.startswith('ab-'): if cal_part.startswith('ab-'):
from .carddav import ab_delete from .carddav import ab_delete
return ab_delete(username=username, ab_part=cal_part, filename=filename) return ab_delete(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_delete
return tl_delete(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user user: User = request.dav_user
if username != user.username: if username != user.username:
return Response('', 403) return Response('', 403)
@ -561,6 +582,9 @@ def delete_calendar(username, cal_part):
if cal_part.startswith('ab-'): if cal_part.startswith('ab-'):
from .carddav import ab_delete_collection from .carddav import ab_delete_collection
return ab_delete_collection(username=username, ab_part=cal_part) return ab_delete_collection(username=username, ab_part=cal_part)
if cal_part.startswith('tl-'):
from .taskdav import tl_delete_collection
return tl_delete_collection(username=username, tl_part=cal_part)
user: User = request.dav_user user: User = request.dav_user
if username != user.username: if username != user.username:
return Response('', 403) return Response('', 403)
@ -587,6 +611,9 @@ def delete_calendar(username, cal_part):
@dav_bp.route('/<username>/<cal_part>', methods=['PROPPATCH']) @dav_bp.route('/<username>/<cal_part>', methods=['PROPPATCH'])
@basic_auth @basic_auth
def proppatch_calendar(username, cal_part): def proppatch_calendar(username, cal_part):
if cal_part.startswith('tl-'):
from .taskdav import tl_proppatch
return tl_proppatch(username=username, tl_part=cal_part)
user: User = request.dav_user user: User = request.dav_user
if username != user.username: if username != user.username:
return Response('', 403) return Response('', 403)

368
backend/app/dav/taskdav.py Normal file
View File

@ -0,0 +1,368 @@
"""CalDAV Task-List Handler (VTODO).
TaskLists werden parallel zu Calendars als Calendar-Collection
ausgeliefert, jedoch mit `<supported-calendar-component-set>` = VTODO
(statt VEVENT). DAVx5/OpenTasks erkennen sie dadurch automatisch als
Aufgabenliste.
URL-Schema:
/dav/<user>/tl-<id>/ Collection
/dav/<user>/tl-<id>/<uid>.ics VTODO-Resource
Diese Funktionen werden aus caldav.py heraus aufgerufen, sobald der
URL-Bestandteil mit `tl-` beginnt - parallel zur ab-/CardDAV-Delegation.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from flask import Response, request
from app.extensions import db
from app.models.task import TaskList, Task
from app.models.user import User
from app.api.tasks import build_vtodo, parse_vtodo, _list_recipients
from app.services.events import notify_tasklist_change
# Re-use XML helpers from caldav.py
def _import_caldav_helpers():
from . import caldav
return caldav
def _qn(prefix, name):
return _import_caldav_helpers()._qn(prefix, name)
def _xml_response(elem):
return _import_caldav_helpers()._xml_response(elem)
def _make_response(href, populate):
return _import_caldav_helpers()._make_response(href, populate)
# ---------------------------------------------------------------------------
# Path / URL helpers
# ---------------------------------------------------------------------------
def parse_tl_path(part: str):
m = re.match(r'tl-(\d+)$', part)
return int(m.group(1)) if m else None
def href_list(username, lid):
return f'/dav/{username}/tl-{lid}/'
def href_task(username, lid, uid):
return f'/dav/{username}/tl-{lid}/{uid}.ics'
def user_lists(user: User):
return TaskList.query.filter_by(owner_id=user.id).all()
def list_for(user: User, lid: int):
tl = db.session.get(TaskList, lid)
if not tl or tl.owner_id != user.id:
return None
return tl
def _ctag(tl: TaskList) -> str:
last = db.session.query(db.func.max(Task.updated_at)).filter_by(task_list_id=tl.id).scalar()
ts = int((last or tl.updated_at or datetime.now(timezone.utc)).timestamp())
return f'"tl{tl.id}-{ts}"'
def _etag(t: Task) -> str:
ts = int((t.updated_at or t.created_at or datetime.now(timezone.utc)).timestamp() * 1000)
return f'"{t.id}-{ts}"'
def _wrap_vcalendar(t: Task) -> str:
block = (t.ical_data or '').strip() or build_vtodo(t)
return '\r\n'.join([
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE',
'CALSCALE:GREGORIAN', block, 'END:VCALENDAR',
])
# ---------------------------------------------------------------------------
# PROPFIND building blocks
# ---------------------------------------------------------------------------
def list_response(user: User, tl: TaskList) -> ET.Element:
href = href_list(user.username, tl.id)
def populate(prop):
rt = ET.SubElement(prop, _qn('d', 'resourcetype'))
ET.SubElement(rt, _qn('d', 'collection'))
ET.SubElement(rt, _qn('c', 'calendar'))
ET.SubElement(prop, _qn('d', 'displayname')).text = tl.name
ET.SubElement(prop, _qn('c', 'calendar-description')).text = tl.description or ''
supported = ET.SubElement(prop, _qn('c', 'supported-calendar-component-set'))
comp = ET.SubElement(supported, _qn('c', 'comp'))
comp.set('name', 'VTODO')
srs = ET.SubElement(prop, _qn('d', 'supported-report-set'))
for r in ('calendar-query', 'calendar-multiget'):
sup = ET.SubElement(srs, _qn('d', 'supported-report'))
rep = ET.SubElement(sup, _qn('d', 'report'))
ET.SubElement(rep, _qn('c', r))
ET.SubElement(prop, _qn('ic', 'calendar-color')).text = tl.color or '#10b981'
ET.SubElement(prop, _qn('cs', 'getctag')).text = _ctag(tl)
cups = ET.SubElement(prop, _qn('d', 'current-user-privilege-set'))
for priv in ('read', 'write', 'write-properties', 'write-content', 'bind', 'unbind'):
p = ET.SubElement(cups, _qn('d', 'privilege'))
ET.SubElement(p, _qn('d', priv))
return _make_response(href, populate)
def task_response(user: User, tl: TaskList, t: Task, include_data=False) -> ET.Element:
href = href_task(user.username, tl.id, t.uid)
def populate(prop):
ET.SubElement(prop, _qn('d', 'getetag')).text = _etag(t)
ET.SubElement(prop, _qn('d', 'getcontenttype')).text = \
'text/calendar; charset=utf-8; component=VTODO'
ET.SubElement(prop, _qn('d', 'resourcetype'))
if include_data:
ET.SubElement(prop, _qn('c', 'calendar-data')).text = _wrap_vcalendar(t)
return _make_response(href, populate)
# ---------------------------------------------------------------------------
# Handlers (entered from caldav.py when path starts with tl-)
# ---------------------------------------------------------------------------
def tl_propfind(username, tl_part):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
depth = request.headers.get('Depth', '0')
multi = ET.Element(_qn('d', 'multistatus'))
multi.append(list_response(user, tl))
if depth != '0':
for t in tl.tasks.all():
multi.append(task_response(user, tl, t))
return _xml_response(multi)
def tl_task_propfind(username, tl_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
uid = filename.removesuffix('.ics')
t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first()
if not t:
return Response('Not found', 404)
multi = ET.Element(_qn('d', 'multistatus'))
multi.append(task_response(user, tl, t, include_data=True))
return _xml_response(multi)
def tl_report(username, tl_part):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
try:
root = ET.fromstring(request.data or b'<x/>')
except ET.ParseError:
return Response('Malformed XML', 400)
wants_data = root.find(f".//{_qn('c', 'calendar-data')}") is not None
multi = ET.Element(_qn('d', 'multistatus'))
if root.tag == _qn('c', 'calendar-multiget'):
hrefs = [h.text for h in root.findall(_qn('d', 'href')) if h.text]
for href in hrefs:
uid = href.rsplit('/', 1)[-1].removesuffix('.ics')
t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first()
if t:
multi.append(task_response(user, tl, t, include_data=True))
return _xml_response(multi)
if root.tag == _qn('c', 'calendar-query'):
for t in tl.tasks.all():
multi.append(task_response(user, tl, t, include_data=wants_data))
return _xml_response(multi)
return _xml_response(multi)
def tl_get(username, tl_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
uid = filename.removesuffix('.ics')
t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first()
if not t:
return Response('Not found', 404)
return Response(_wrap_vcalendar(t),
mimetype='text/calendar; charset=utf-8',
headers={'ETag': _etag(t)})
def tl_put(username, tl_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
uid = filename.removesuffix('.ics')
raw = request.get_data(as_text=True) or ''
parsed = parse_vtodo(raw)
if not parsed:
return Response('Cannot parse VTODO', 400)
body_uid = parsed.get('uid') or uid
existing = Task.query.filter_by(task_list_id=tl.id, uid=body_uid).first()
if_match = request.headers.get('If-Match')
if_none_match = request.headers.get('If-None-Match')
if existing and if_none_match == '*':
return Response('', 412)
if if_match and existing and if_match.strip() != _etag(existing):
return Response('', 412)
is_new = existing is None
if is_new:
existing = Task(task_list_id=tl.id, uid=body_uid, ical_data=raw)
db.session.add(existing)
existing.summary = parsed.get('summary') or '(ohne Titel)'
existing.description = parsed.get('description')
existing.status = parsed.get('status') or 'NEEDS-ACTION'
existing.priority = parsed.get('priority')
existing.percent_complete = parsed.get('percent_complete')
existing.due = parsed.get('due')
existing.dtstart = parsed.get('dtstart')
existing.completed_at = parsed.get('completed_at')
cats = parsed.get('categories')
if isinstance(cats, str):
existing.categories = cats or None
elif isinstance(cats, list):
existing.categories = ','.join(cats) or None
# Roh-Block sichern fuer Round-Trip
block = re.search(r'BEGIN:VTODO.*?END:VTODO', raw, flags=re.DOTALL | re.IGNORECASE)
existing.ical_data = (block.group(0).strip() if block else raw.strip()) or build_vtodo(existing)
existing.updated_at = datetime.now(timezone.utc)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return Response('', 201 if is_new else 204, {'ETag': _etag(existing)})
def tl_delete(username, tl_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
uid = filename.removesuffix('.ics')
t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first()
if not t:
return Response('', 404)
db.session.delete(t)
db.session.commit()
notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl))
return Response('', 204)
def tl_delete_collection(username, tl_part):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('', 404)
recipients = _list_recipients(tl)
owner_id = tl.owner_id
list_id = tl.id
db.session.delete(tl)
db.session.commit()
notify_tasklist_change(owner_id, list_id, 'deleted', shared_with=recipients)
return Response('', 204)
def tl_options(username, tl_part):
return Response('', 200, {
'DAV': '1, 2, 3, calendar-access, addressbook',
'Allow': 'OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, MKCALENDAR, PROPPATCH',
})
def tl_proppatch(username, tl_part):
"""Bestaetige Property-Updates damit Clients zufrieden sind. Wir
persistieren Displayname + Color, alles andere wird stillschweigend
akzeptiert."""
user: User = request.dav_user
if username != user.username:
return Response('', 403)
lid = parse_tl_path(tl_part)
tl = list_for(user, lid) if lid else None
if not tl:
return Response('Not found', 404)
try:
root = ET.fromstring(request.data or b'<x/>')
except ET.ParseError:
return Response('Malformed XML', 400)
changed = False
for el in root.iter():
tag = (el.tag.split('}', 1)[1] if '}' in el.tag else el.tag).lower()
if tag == 'displayname' and el.text:
tl.name = el.text
changed = True
elif tag == 'calendar-color' and el.text:
tl.color = el.text[:7]
changed = True
if changed:
db.session.commit()
multi = ET.Element(_qn('d', 'multistatus'))
resp = ET.SubElement(multi, _qn('d', 'response'))
ET.SubElement(resp, _qn('d', 'href')).text = href_list(user.username, tl.id)
ps = ET.SubElement(resp, _qn('d', 'propstat'))
ET.SubElement(ps, _qn('d', 'status')).text = 'HTTP/1.1 200 OK'
return _xml_response(multi)
def tl_mkcol(username, tl_part):
"""Erstelle eine neue TaskList per MKCOL/MKCALENDAR. Der Pfadteil
`tl-N` ist bei MKCOL aber unbekannt - DAVx5 schickt einen frei
gewaehlten Namen wie `mein-task-uuid`. Daher: wir akzeptieren jeden
Pfadteil und legen eine TaskList an."""
user: User = request.dav_user
if username != user.username:
return Response('', 403)
name = 'Neue Aufgabenliste'
try:
body = request.get_data()
if body:
root = ET.fromstring(body)
for el in root.iter():
tag = (el.tag.split('}', 1)[1] if '}' in el.tag else el.tag).lower()
if tag == 'displayname' and el.text:
name = el.text
except ET.ParseError:
pass
tl = TaskList(owner_id=user.id, name=name)
db.session.add(tl)
db.session.commit()
notify_tasklist_change(user.id, tl.id, 'created')
return Response('', 201, {'Location': href_list(user.username, tl.id)})

View File

@ -2,6 +2,7 @@ from app.models.user import User
from app.models.file import File, FilePermission, ShareLink from app.models.file import File, FilePermission, ShareLink
from app.models.calendar import Calendar, CalendarEvent, CalendarShare from app.models.calendar import Calendar, CalendarEvent, CalendarShare
from app.models.contact import AddressBook, Contact, AddressBookShare from app.models.contact import AddressBook, Contact, AddressBookShare
from app.models.task import TaskList, Task, TaskListShare
from app.models.email_account import EmailAccount from app.models.email_account import EmailAccount
from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare
from app.models.settings import AppSettings from app.models.settings import AppSettings
@ -13,6 +14,7 @@ __all__ = [
'File', 'FilePermission', 'ShareLink', 'File', 'FilePermission', 'ShareLink',
'Calendar', 'CalendarEvent', 'CalendarShare', 'Calendar', 'CalendarEvent', 'CalendarShare',
'AddressBook', 'Contact', 'AddressBookShare', 'AddressBook', 'Contact', 'AddressBookShare',
'TaskList', 'Task', 'TaskListShare',
'EmailAccount', 'EmailAccount',
'PasswordFolder', 'PasswordEntry', 'PasswordShare', 'PasswordFolder', 'PasswordEntry', 'PasswordShare',
'AppSettings', 'AppSettings',

View File

@ -0,0 +1,86 @@
from datetime import datetime, timezone
from app.extensions import db
class TaskList(db.Model):
__tablename__ = 'task_lists'
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
color = db.Column(db.String(7), default='#10b981')
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
tasks = db.relationship('Task', backref='task_list', lazy='dynamic',
cascade='all, delete-orphan')
shares = db.relationship('TaskListShare', backref='task_list', lazy='dynamic',
cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'owner_id': self.owner_id,
'name': self.name,
'color': self.color,
'description': self.description,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class Task(db.Model):
__tablename__ = 'tasks'
id = db.Column(db.Integer, primary_key=True)
task_list_id = db.Column(db.Integer, db.ForeignKey('task_lists.id'), nullable=False, index=True)
uid = db.Column(db.String(255), unique=True, nullable=False)
ical_data = db.Column(db.Text, nullable=False, default='') # Full VTODO block
summary = db.Column(db.String(500), nullable=True)
description = db.Column(db.Text, nullable=True)
status = db.Column(db.String(32), nullable=True) # NEEDS-ACTION | IN-PROCESS | COMPLETED | CANCELLED
priority = db.Column(db.Integer, nullable=True) # 0 (keine) - 9
percent_complete = db.Column(db.Integer, nullable=True) # 0..100
due = db.Column(db.DateTime, nullable=True, index=True)
dtstart = db.Column(db.DateTime, nullable=True)
completed_at = db.Column(db.DateTime, nullable=True)
categories = db.Column(db.Text, nullable=True) # kommagetrennt
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self):
return {
'id': self.id,
'task_list_id': self.task_list_id,
'uid': self.uid,
'summary': self.summary,
'description': self.description,
'status': self.status or 'NEEDS-ACTION',
'priority': self.priority,
'percent_complete': self.percent_complete,
'due': self.due.isoformat() if self.due else None,
'dtstart': self.dtstart.isoformat() if self.dtstart else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'categories': self.categories.split(',') if self.categories else [],
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class TaskListShare(db.Model):
__tablename__ = 'task_list_shares'
id = db.Column(db.Integer, primary_key=True)
task_list_id = db.Column(db.Integer, db.ForeignKey('task_lists.id'), nullable=False, index=True)
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
permission = db.Column(db.String(20), nullable=False, default='read')
color = db.Column(db.String(7), nullable=True)
shared_with = db.relationship('User', backref='shared_task_lists')
__table_args__ = (
db.UniqueConstraint('task_list_id', 'shared_with_id', name='uq_task_list_share'),
)

View File

@ -92,3 +92,13 @@ def notify_calendar_change(owner_id: int, calendar_id: int, change: str,
'change': change, # 'event'|'share'|'deleted' 'change': change, # 'event'|'share'|'deleted'
'calendar_id': calendar_id, 'calendar_id': calendar_id,
}) })
def notify_tasklist_change(owner_id: int, list_id: int, change: str,
shared_with: Iterable[int] = ()) -> None:
recipients = [owner_id, *shared_with]
broadcaster.publish(recipients, {
'type': 'tasklist',
'change': change, # 'task'|'share'|'deleted'|'created'
'task_list_id': list_id,
})

View File

@ -48,6 +48,11 @@ const routes = [
name: 'Contacts', name: 'Contacts',
component: () => import('../views/ContactsView.vue'), component: () => import('../views/ContactsView.vue'),
}, },
{
path: 'tasks',
name: 'Tasks',
component: () => import('../views/TasksView.vue'),
},
{ {
path: 'email', path: 'email',
name: 'Email', name: 'Email',

View File

@ -22,6 +22,11 @@
<span>Kontakte</span> <span>Kontakte</span>
</router-link> </router-link>
<router-link to="/tasks" class="nav-item" active-class="active">
<i class="pi pi-check-square"></i>
<span>Aufgaben</span>
</router-link>
<router-link <router-link
v-if="auth.hasEmailAccounts" v-if="auth.hasEmailAccounts"
to="/email" to="/email"

View File

@ -0,0 +1,632 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Aufgaben</h2>
<div class="header-actions">
<Button icon="pi pi-list" label="Neue Liste" size="small" outlined @click="showNewList = true" />
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="triggerImport" />
<input ref="importInput" type="file" accept=".ics,.ical,.csv" hidden @change="onImportFile" />
<Button icon="pi pi-download" label="Export" size="small" outlined
:disabled="!selectedListId" @click="showExportDialog = true" />
<Button icon="pi pi-plus" label="Neue Aufgabe" size="small"
:disabled="!selectedListId" @click="openNewTask" />
</div>
</div>
<div class="tasks-layout">
<aside class="lists-sidebar">
<h4>Listen</h4>
<div v-for="tl in lists" :key="tl.id"
class="list-item" :class="{ active: selectedListId === tl.id }"
@click="selectedListId = tl.id">
<span class="list-color" :style="{ background: tl.color }"></span>
<span class="list-name">{{ tl.name }}</span>
<span v-if="tl.permission !== 'owner'" class="shared-label">(geteilt)</span>
<span class="count">{{ tl.task_count }}</span>
<Button icon="pi pi-ellipsis-v" text size="small" class="list-menu"
@click.stop="openListMenu(tl)" />
</div>
</aside>
<div class="tasks-main">
<div class="toolbar">
<InputText v-model="search" placeholder="Aufgaben suchen..." fluid />
<label class="toggle"><Checkbox v-model="hideDone" :binary="true" /> Erledigte ausblenden</label>
</div>
<div v-if="selectedTaskIds.length" class="bulk-bar">
<span>{{ selectedTaskIds.length }} ausgewaehlt</span>
<Button icon="pi pi-trash" :label="`${selectedTaskIds.length} loeschen`"
severity="danger" size="small" @click="bulkDelete" />
<Button label="Auswahl aufheben" size="small" text @click="selectedTaskIds = []" />
</div>
<table class="task-table">
<thead>
<tr>
<th class="col-check">
<Checkbox v-model="allSelected" :binary="true" @change="toggleAll" />
</th>
<th class="col-done"></th>
<th>Titel</th>
<th>Faellig</th>
<th>Prio</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in filteredTasks" :key="t.id" class="task-row"
:class="{ done: t.status === 'COMPLETED', selected: selectedTaskIds.includes(t.id) }"
@click="openEditTask(t)">
<td class="col-check" @click.stop>
<Checkbox :modelValue="selectedTaskIds.includes(t.id)" :binary="true"
@update:modelValue="toggleSelect(t.id, $event)" />
</td>
<td class="col-done" @click.stop>
<Checkbox :modelValue="t.status === 'COMPLETED'" :binary="true"
@update:modelValue="toggleDone(t, $event)" title="Erledigt" />
</td>
<td class="col-title">
<span>{{ t.summary || '(ohne Titel)' }}</span>
<small v-if="t.description" class="meta">{{ shortDesc(t.description) }}</small>
</td>
<td class="col-date">{{ formatDue(t.due) }}</td>
<td>{{ formatPrio(t.priority) }}</td>
<td><span class="status-badge" :class="statusClass(t.status)">{{ statusLabel(t.status) }}</span></td>
<td class="col-actions" @click.stop>
<Button icon="pi pi-trash" text size="small" severity="danger" @click="confirmDelete(t)" />
</td>
</tr>
<tr v-if="!filteredTasks.length">
<td colspan="7" class="empty-row">Keine Aufgaben.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- New List Dialog -->
<Dialog v-model:visible="showNewList" header="Neue Aufgabenliste" modal :style="{ width: '400px' }">
<div class="field">
<label>Name</label>
<InputText v-model="newListName" fluid autofocus @keyup.enter="createList" />
</div>
<div class="field">
<label>Farbe</label>
<InputText v-model="newListColor" type="color" style="width: 60px; height: 36px" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewList = false" />
<Button label="Erstellen" @click="createList" />
</template>
</Dialog>
<!-- List Menu -->
<Dialog v-model:visible="showListMenu" header="Listen-Optionen" modal :style="{ width: '480px' }">
<div v-if="menuList">
<p><strong>{{ menuList.name }}</strong></p>
<div class="field">
<label>Farbe</label>
<InputText :modelValue="menuList.color" @change="onListColor($event)" type="color" style="width:60px; height:36px" />
</div>
<div v-if="menuList.permission === 'owner'" class="field">
<label>Mit Benutzer teilen</label>
<div class="share-row">
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="doShare" />
</div>
<div v-if="listShares.length" class="existing-shares">
<div v-for="s in listShares" :key="s.id" class="share-perm-item">
<i class="pi pi-user"></i> <span>{{ s.username }}</span>
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(s.id)" />
</div>
</div>
</div>
<div v-if="menuList.permission === 'owner'" class="field" style="border-top:1px solid var(--p-surface-200); padding-top:1rem">
<Button label="Liste loeschen" severity="danger" outlined size="small" @click="confirmDeleteList = true" />
</div>
<div class="field" style="border-top:1px solid var(--p-surface-200); padding-top:1rem">
<label><i class="pi pi-info-circle"></i> CalDAV-Zugang (Handy / DAVx5)</label>
<div class="caldav-hint">In DAVx5 unter demselben Konto sichtbar wie Kalender. Aufgabenlisten sind mit "OpenTasks" synchronisierbar.</div>
<div class="url-row">
<strong>Listen-URL:</strong>
<code>{{ origin }}/dav/{{ username }}/tl-{{ menuList.id }}/</code>
<Button icon="pi pi-copy" text size="small" @click="copy(`${origin}/dav/${username}/tl-${menuList.id}/`)" />
</div>
</div>
</div>
</Dialog>
<!-- Task Dialog -->
<Dialog v-model:visible="showTaskDialog" :header="editingTaskId ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'"
modal :style="{ width: '560px' }">
<div class="field">
<label>Titel</label>
<InputText v-model="taskForm.summary" fluid autofocus />
</div>
<div class="field">
<label>Beschreibung</label>
<Textarea v-model="taskForm.description" rows="3" fluid />
</div>
<div class="field-row">
<div class="field">
<label>Faellig</label>
<InputText v-model="taskForm.due" type="datetime-local" fluid />
</div>
<div class="field">
<label>Status</label>
<Select v-model="taskForm.status" :options="statusOptions" optionLabel="label" optionValue="value" fluid />
</div>
</div>
<div class="field-row">
<div class="field">
<label>Prioritaet</label>
<Select v-model="taskForm.priority" :options="prioOptions" optionLabel="label" optionValue="value" fluid />
</div>
<div class="field">
<label>Fortschritt %</label>
<InputText v-model.number="taskForm.percent_complete" type="number" min="0" max="100" fluid />
</div>
</div>
<div class="field">
<label>Kategorien (kommagetrennt)</label>
<InputText v-model="taskForm.categories" fluid />
</div>
<template #footer>
<Button v-if="editingTaskId" label="Loeschen" text severity="danger" @click="deleteCurrent" />
<Button label="Abbrechen" text @click="showTaskDialog = false" />
<Button :label="editingTaskId ? 'Speichern' : 'Erstellen'" @click="saveTask" />
</template>
</Dialog>
<Dialog v-model:visible="confirmDeleteList" header="Liste loeschen" modal :style="{ width: '400px' }">
<p>Liste <strong>{{ menuList?.name }}</strong> mit allen Aufgaben loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteList = false" />
<Button label="Loeschen" severity="danger" @click="deleteList" />
</template>
</Dialog>
<!-- Export Dialog -->
<Dialog v-model:visible="showExportDialog" header="Aufgaben exportieren" modal :style="{ width: '420px' }">
<p>Aus Liste <strong>{{ currentList?.name }}</strong></p>
<div class="field">
<label>Format</label>
<Select v-model="exportFormat" :options="exportFormats" optionLabel="label" optionValue="value" fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showExportDialog = false" />
<Button label="Herunterladen" icon="pi pi-download" @click="doExport" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
import Checkbox from 'primevue/checkbox'
const toast = useToast()
const auth = useAuthStore()
const origin = computed(() => window.location.origin)
const username = computed(() => auth.user?.username || '')
const lists = ref([])
const selectedListId = ref(null)
const tasks = ref([])
const search = ref('')
const hideDone = ref(false)
const selectedTaskIds = ref([])
const showNewList = ref(false)
const newListName = ref('')
const newListColor = ref('#10b981')
const showListMenu = ref(false)
const menuList = ref(null)
const shareUsername = ref('')
const sharePermission = ref('read')
const listShares = ref([])
const permOptions = [
{ label: 'Lesen', value: 'read' },
{ label: 'Lesen+Schreiben', value: 'readwrite' },
]
const confirmDeleteList = ref(false)
const showTaskDialog = ref(false)
const editingTaskId = ref(null)
const taskForm = reactive({
summary: '', description: '',
due: '', status: 'NEEDS-ACTION', priority: null, percent_complete: null,
categories: '',
})
const statusOptions = [
{ label: 'Offen', value: 'NEEDS-ACTION' },
{ label: 'In Arbeit', value: 'IN-PROCESS' },
{ label: 'Erledigt', value: 'COMPLETED' },
{ label: 'Abgebrochen', value: 'CANCELLED' },
]
const prioOptions = [
{ label: '—', value: null },
{ label: 'Hoch (1)', value: 1 },
{ label: 'Mittel (5)', value: 5 },
{ label: 'Niedrig (9)', value: 9 },
]
const showExportDialog = ref(false)
const exportFormat = ref('ics')
const exportFormats = [
{ label: 'iCalendar (.ics)', value: 'ics' },
{ label: 'CSV (.csv)', value: 'csv' },
]
const importInput = ref(null)
const currentList = computed(() => lists.value.find(l => l.id === selectedListId.value))
const filteredTasks = computed(() => {
const q = search.value.trim().toLowerCase()
return tasks.value.filter(t => {
if (hideDone.value && t.status === 'COMPLETED') return false
if (q && !(t.summary || '').toLowerCase().includes(q)
&& !(t.description || '').toLowerCase().includes(q)) return false
return true
})
})
const allSelected = computed({
get: () => filteredTasks.value.length > 0 && filteredTasks.value.every(t => selectedTaskIds.value.includes(t.id)),
set: () => {},
})
function toggleAll() {
const ids = filteredTasks.value.map(t => t.id)
const allSel = ids.every(id => selectedTaskIds.value.includes(id))
if (allSel) selectedTaskIds.value = selectedTaskIds.value.filter(id => !ids.includes(id))
else {
const set = new Set(selectedTaskIds.value); ids.forEach(id => set.add(id))
selectedTaskIds.value = [...set]
}
}
function toggleSelect(id, checked) {
if (checked && !selectedTaskIds.value.includes(id)) selectedTaskIds.value = [...selectedTaskIds.value, id]
else if (!checked) selectedTaskIds.value = selectedTaskIds.value.filter(x => x !== id)
}
function shortDesc(s) { return s.length > 80 ? s.slice(0, 80) + '…' : s }
function formatDue(d) {
if (!d) return ''
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function formatPrio(p) {
if (p === null || p === undefined) return ''
if (p <= 3) return 'Hoch'
if (p >= 7) return 'Niedrig'
return 'Mittel'
}
function statusLabel(s) {
return ({ 'NEEDS-ACTION': 'Offen', 'IN-PROCESS': 'In Arbeit', 'COMPLETED': 'Erledigt', 'CANCELLED': 'Abgebrochen' })[s] || 'Offen'
}
function statusClass(s) {
return { 'NEEDS-ACTION': 'todo', 'IN-PROCESS': 'progress', 'COMPLETED': 'done', 'CANCELLED': 'cancelled' }[s] || 'todo'
}
async function loadLists() {
const res = await apiClient.get('/tasklists')
lists.value = res.data
if (!selectedListId.value && lists.value.length) selectedListId.value = lists.value[0].id
if (!lists.value.length) {
await apiClient.post('/tasklists', { name: 'Meine Aufgaben', color: '#10b981' })
await loadLists()
}
}
async function loadTasks() {
if (!selectedListId.value) { tasks.value = []; return }
try {
const res = await apiClient.get(`/tasklists/${selectedListId.value}/tasks`)
tasks.value = res.data
} catch { tasks.value = [] }
}
async function createList() {
if (!newListName.value.trim()) return
await apiClient.post('/tasklists', { name: newListName.value.trim(), color: newListColor.value })
showNewList.value = false
newListName.value = ''
await loadLists()
}
function openListMenu(tl) {
menuList.value = tl
shareUsername.value = ''
showListMenu.value = true
loadShares()
}
async function loadShares() {
if (!menuList.value || menuList.value.permission !== 'owner') { listShares.value = []; return }
try {
const res = await apiClient.get(`/tasklists/${menuList.value.id}/shares`)
listShares.value = res.data
} catch { listShares.value = [] }
}
async function doShare() {
if (!menuList.value || !shareUsername.value.trim()) return
try {
await apiClient.post(`/tasklists/${menuList.value.id}/share`, {
username: shareUsername.value.trim(), permission: sharePermission.value,
})
toast.add({ severity: 'success', summary: 'Geteilt', life: 2500 })
shareUsername.value = ''
await loadShares()
} catch (err) {
toast.add({ severity: 'error', summary: err.response?.data?.error || 'Fehler', life: 4000 })
}
}
async function removeShare(id) {
await apiClient.delete(`/tasklists/${menuList.value.id}/shares/${id}`)
await loadShares()
}
async function onListColor(ev) {
const color = ev.target.value
await apiClient.put(`/tasklists/${menuList.value.id}/my-color`, { color })
menuList.value.color = color
await loadLists()
}
async function deleteList() {
if (!menuList.value) return
await apiClient.delete(`/tasklists/${menuList.value.id}`)
confirmDeleteList.value = false
showListMenu.value = false
if (selectedListId.value === menuList.value.id) selectedListId.value = null
await loadLists()
await loadTasks()
}
function openNewTask() {
editingTaskId.value = null
Object.assign(taskForm, {
summary: '', description: '', due: '',
status: 'NEEDS-ACTION', priority: null, percent_complete: null,
categories: '',
})
showTaskDialog.value = true
}
function openEditTask(t) {
editingTaskId.value = t.id
Object.assign(taskForm, {
summary: t.summary || '',
description: t.description || '',
due: t.due ? t.due.slice(0, 16) : '',
status: t.status || 'NEEDS-ACTION',
priority: t.priority,
percent_complete: t.percent_complete,
categories: (t.categories || []).join(', '),
})
showTaskDialog.value = true
}
async function saveTask() {
if (!taskForm.summary.trim()) return
const payload = {
summary: taskForm.summary.trim(),
description: taskForm.description,
due: taskForm.due ? new Date(taskForm.due).toISOString() : null,
status: taskForm.status,
priority: taskForm.priority,
percent_complete: taskForm.percent_complete,
categories: taskForm.categories.split(',').map(s => s.trim()).filter(Boolean),
}
try {
if (editingTaskId.value) {
await apiClient.put(`/tasks/${editingTaskId.value}`, payload)
} else {
await apiClient.post(`/tasklists/${selectedListId.value}/tasks`, payload)
}
showTaskDialog.value = false
await loadLists()
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
}
}
async function toggleDone(t, checked) {
try {
await apiClient.put(`/tasks/${t.id}`, { status: checked ? 'COMPLETED' : 'NEEDS-ACTION' })
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', life: 3000 })
}
}
async function deleteCurrent() {
if (!editingTaskId.value) return
if (!confirm('Aufgabe wirklich loeschen?')) return
await apiClient.delete(`/tasks/${editingTaskId.value}`)
showTaskDialog.value = false
await loadLists()
await loadTasks()
}
async function confirmDelete(t) {
if (!confirm(`"${t.summary || '(ohne Titel)'}" loeschen?`)) return
await apiClient.delete(`/tasks/${t.id}`)
await loadLists()
await loadTasks()
}
async function bulkDelete() {
const ids = [...selectedTaskIds.value]
if (!ids.length || !confirm(`${ids.length} Aufgabe(n) loeschen?`)) return
let ok = 0, fail = 0
for (const id of ids) {
try { await apiClient.delete(`/tasks/${id}`); ok++ } catch { fail++ }
}
selectedTaskIds.value = []
toast.add({
severity: fail ? 'warn' : 'success',
summary: `${ok} geloescht${fail ? `, ${fail} fehlgeschlagen` : ''}`, life: 3000,
})
await loadLists()
await loadTasks()
}
function triggerImport() {
if (!selectedListId.value) {
toast.add({ severity: 'warn', summary: 'Keine Liste ausgewaehlt', life: 3000 })
return
}
importInput.value?.click()
}
async function onImportFile(ev) {
const file = ev.target.files?.[0]
ev.target.value = ''
if (!file) return
const fd = new FormData()
fd.append('file', file)
try {
const res = await apiClient.post(`/tasklists/${selectedListId.value}/import`, fd,
{ headers: { 'Content-Type': 'multipart/form-data' } })
toast.add({
severity: 'success',
summary: `${res.data.imported} importiert`,
detail: res.data.skipped ? `${res.data.skipped} uebersprungen` : undefined,
life: 4000,
})
await loadLists()
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Import fehlgeschlagen', detail: err.response?.data?.error, life: 5000 })
}
}
async function doExport() {
if (!selectedListId.value) return
try {
const res = await apiClient.get(`/tasklists/${selectedListId.value}/export`,
{ params: { format: exportFormat.value }, responseType: 'blob' })
const ext = exportFormat.value === 'csv' ? 'csv' : 'ics'
const url = URL.createObjectURL(new Blob([res.data]))
const a = document.createElement('a')
a.href = url
a.download = `${currentList.value?.name || 'aufgaben'}.${ext}`
a.click()
URL.revokeObjectURL(url)
showExportDialog.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Export fehlgeschlagen', life: 4000 })
}
}
function copy(text) {
navigator.clipboard.writeText(text)
toast.add({ severity: 'info', summary: 'Kopiert', life: 1500 })
}
// --- Live refresh via SSE ---
let eventSource = null
let reloadTimer = null
function scheduleReload() {
if (reloadTimer) return
reloadTimer = setTimeout(async () => {
reloadTimer = null
await loadLists()
await loadTasks()
}, 300)
}
onMounted(async () => {
await loadLists()
await loadTasks()
if (auth.accessToken) {
try {
eventSource = new EventSource(`/api/sync/events?token=${encodeURIComponent(auth.accessToken)}`)
eventSource.addEventListener('tasklist', scheduleReload)
eventSource.addEventListener('message', scheduleReload)
eventSource.onerror = () => {}
} catch {}
}
})
onUnmounted(() => {
if (reloadTimer) clearTimeout(reloadTimer)
if (eventSource) eventSource.close()
})
watch(selectedListId, loadTasks)
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; }
.tasks-layout { display: flex; gap: 1rem; align-items: flex-start; }
.lists-sidebar { width: 260px; flex-shrink: 0; }
.lists-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
.list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: 4px;
cursor: pointer; font-size: 0.875rem; }
.list-item:hover { background: var(--p-surface-50); }
.list-item.active { background: var(--p-primary-50); }
.list-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.list-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shared-label { color: var(--p-text-muted-color); font-size: 0.7rem; }
.count { color: var(--p-text-muted-color); font-size: 0.8rem; }
.list-menu { opacity: 0; transition: opacity .15s; }
.list-item:hover .list-menu { opacity: 1; }
.tasks-main { flex: 1; min-width: 0; }
.toolbar { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; }
.toggle { display: flex; align-items: center; gap: 0.35rem; font-size: 0.875rem; white-space: nowrap; }
.bulk-bar { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem 0.75rem;
background: var(--p-primary-50); border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.875rem; }
.task-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.task-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--p-surface-200); font-weight: 600; }
.task-table td { padding: 0.5rem; border-bottom: 1px solid var(--p-surface-100); vertical-align: top; }
.task-row { cursor: pointer; }
.task-row:hover { background: var(--p-surface-50); }
.task-row.done .col-title span { text-decoration: line-through; color: var(--p-text-muted-color); }
.task-row.selected { background: var(--p-primary-50); }
.col-check, .col-done { width: 36px; }
.col-actions { width: 60px; text-align: right; }
.col-date { white-space: nowrap; }
.col-title { }
.meta { display: block; color: var(--p-text-muted-color); font-size: 0.75rem; margin-top: 0.1rem; }
.empty-row { text-align: center; color: var(--p-text-muted-color); padding: 2rem !important; }
.status-badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.72rem; }
.status-badge.todo { background: var(--p-surface-100); }
.status-badge.progress { background: var(--p-blue-100); color: var(--p-blue-700); }
.status-badge.done { background: var(--p-green-100); color: var(--p-green-700); }
.status-badge.cancelled { background: var(--p-red-100); color: var(--p-red-700); }
.field { margin-bottom: 0.75rem; }
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
.field-row { display: flex; gap: 0.75rem; }
.field-row .field { flex: 1; }
.share-row { display: flex; gap: 0.5rem; align-items: center; }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.url-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.url-row strong { min-width: 110px; font-size: 0.8rem; }
.url-row code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; flex: 1; word-break: break-all; }
.caldav-hint { font-size: 0.8rem; color: var(--p-text-muted-color); margin: 0 0 0.5rem; }
</style>