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')
|
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))
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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.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',
|
||||||
|
|
|
||||||
|
|
@ -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'
|
'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,
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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