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:
parent
2ce088e96b
commit
ba3e619963
|
|
@ -2,4 +2,4 @@ from flask import Blueprint
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -284,11 +284,11 @@ def propfind(subpath=''):
|
|||
multistatus.append(_principal_response(user))
|
||||
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 parts[0] != user.username:
|
||||
return Response('', 403)
|
||||
# A plain collection container
|
||||
container = ET.Element(_qn('d', 'response'))
|
||||
ET.SubElement(container, _qn('d', 'href')).text = f'/dav/{user.username}/calendars/'
|
||||
propstat = ET.SubElement(container, _qn('d', 'propstat'))
|
||||
|
|
@ -301,6 +301,9 @@ def propfind(subpath=''):
|
|||
if depth != '0':
|
||||
for cal in _user_calendars(user):
|
||||
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)
|
||||
|
||||
# /dav/<username>/addressbooks/ : only addressbook collections
|
||||
|
|
@ -322,10 +325,13 @@ def propfind(subpath=''):
|
|||
multistatus.append(_addressbook_response(user, ab))
|
||||
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 parts[0] != user.username:
|
||||
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])
|
||||
if cal_id is None:
|
||||
return Response('Not found', 404)
|
||||
|
|
@ -338,10 +344,13 @@ def propfind(subpath=''):
|
|||
multistatus.append(_event_response(user, cal, ev))
|
||||
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 parts[0] != user.username:
|
||||
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 = _calendar_for(user, cal_id) if cal_id else None
|
||||
if not cal:
|
||||
|
|
@ -367,6 +376,9 @@ def report(subpath):
|
|||
parts = [p for p in subpath.split('/') if p]
|
||||
if len(parts) < 2 or parts[0] != user.username:
|
||||
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 = _calendar_for(user, cal_id) if cal_id else None
|
||||
if not cal:
|
||||
|
|
@ -449,6 +461,9 @@ def get_event(username, cal_part, filename):
|
|||
if cal_part.startswith('ab-'):
|
||||
from .carddav import ab_get
|
||||
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
|
||||
if username != user.username:
|
||||
return Response('', 403)
|
||||
|
|
@ -477,6 +492,9 @@ def put_event(username, cal_part, filename):
|
|||
if cal_part.startswith('ab-'):
|
||||
from .carddav import ab_put
|
||||
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
|
||||
if username != user.username:
|
||||
return Response('', 403)
|
||||
|
|
@ -536,6 +554,9 @@ def delete_event(username, cal_part, filename):
|
|||
if cal_part.startswith('ab-'):
|
||||
from .carddav import ab_delete
|
||||
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
|
||||
if username != user.username:
|
||||
return Response('', 403)
|
||||
|
|
@ -561,6 +582,9 @@ def delete_calendar(username, cal_part):
|
|||
if cal_part.startswith('ab-'):
|
||||
from .carddav import ab_delete_collection
|
||||
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
|
||||
if username != user.username:
|
||||
return Response('', 403)
|
||||
|
|
@ -587,6 +611,9 @@ def delete_calendar(username, cal_part):
|
|||
@dav_bp.route('/<username>/<cal_part>', methods=['PROPPATCH'])
|
||||
@basic_auth
|
||||
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
|
||||
if username != user.username:
|
||||
return Response('', 403)
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
|
|
@ -2,6 +2,7 @@ from app.models.user import User
|
|||
from app.models.file import File, FilePermission, ShareLink
|
||||
from app.models.calendar import Calendar, CalendarEvent, CalendarShare
|
||||
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.password_vault import PasswordFolder, PasswordEntry, PasswordShare
|
||||
from app.models.settings import AppSettings
|
||||
|
|
@ -13,6 +14,7 @@ __all__ = [
|
|||
'File', 'FilePermission', 'ShareLink',
|
||||
'Calendar', 'CalendarEvent', 'CalendarShare',
|
||||
'AddressBook', 'Contact', 'AddressBookShare',
|
||||
'TaskList', 'Task', 'TaskListShare',
|
||||
'EmailAccount',
|
||||
'PasswordFolder', 'PasswordEntry', 'PasswordShare',
|
||||
'AppSettings',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
)
|
||||
|
|
@ -92,3 +92,13 @@ def notify_calendar_change(owner_id: int, calendar_id: int, change: str,
|
|||
'change': change, # 'event'|'share'|'deleted'
|
||||
'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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ const routes = [
|
|||
name: 'Contacts',
|
||||
component: () => import('../views/ContactsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'Tasks',
|
||||
component: () => import('../views/TasksView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'Email',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@
|
|||
<span>Kontakte</span>
|
||||
</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
|
||||
v-if="auth.hasEmailAccounts"
|
||||
to="/email"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue