Compare commits
35 Commits
848e4b9b0f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd40c55f7d | |||
| 78615d8897 | |||
| 3c340f9653 | |||
| 85dae4377f | |||
| 88c9617ae7 | |||
| 78cfbf1ad3 | |||
| 4026defe79 | |||
| 2937082ba2 | |||
| e55ce106d4 | |||
| 601e0741b1 | |||
| be121190b3 | |||
| 6274567219 | |||
| 204dbb6ab5 | |||
| d9a4ee6a0b | |||
| 8f70b047d8 | |||
| f9bf53803f | |||
| de1039fc7d | |||
| 2610e3b183 | |||
| 9f6132a400 | |||
| ed944339c4 | |||
| 2ef186e262 | |||
| 4d67819cac | |||
| e4dd555bd1 | |||
| a21bf6de1b | |||
| 3eb038abd8 | |||
| 9bb22eb17b | |||
| dca064427e | |||
| ba3e619963 | |||
| 2ce088e96b | |||
| c6241519a6 | |||
| f6626da114 | |||
| e96c84b5f7 | |||
| 1eba5d0adc | |||
| 655b789e06 | |||
| 50df055794 |
@@ -31,6 +31,24 @@ FRONTEND_URL=https://cloud.example.com
|
||||
# Max Upload-Groesse in MB
|
||||
MAX_UPLOAD_SIZE_MB=500
|
||||
|
||||
# Zeitzone (prozessweit) - IANA-Format "Region/Stadt".
|
||||
# Wirkt auf datetime.now(), strftime %Z und Kalender/Task-Zeitstempel.
|
||||
# Haeufige Werte:
|
||||
# Europe/Berlin, Europe/Vienna, Europe/Zurich, Europe/Amsterdam,
|
||||
# Europe/Paris, Europe/London, Europe/Madrid, Europe/Rome,
|
||||
# Europe/Warsaw, Europe/Prague, Europe/Copenhagen, Europe/Stockholm,
|
||||
# UTC, America/New_York, America/Los_Angeles, Asia/Tokyo, Australia/Sydney
|
||||
# Vollstaendige Liste: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# NTP-Server zum Pruefen der Uhrzeit beim Start (nicht-invasiver Offset-Check
|
||||
# - im Container kann die Systemuhr nicht gesetzt werden; bei Abweichung >5s
|
||||
# erscheint eine Warnung im Log, dann bitte die Host-Uhr synchronisieren).
|
||||
# Leerlassen um den Check zu deaktivieren.
|
||||
# Default: Physikalisch-Technische Bundesanstalt (offizielle deutsche Zeit).
|
||||
# Alternativen: ptbtime2.ptb.de, ptbtime3.ptb.de, de.pool.ntp.org, time.cloudflare.com
|
||||
NTP_SERVER=ptbtime1.ptb.de
|
||||
|
||||
# OnlyOffice Document Server (optional)
|
||||
# Eigene Subdomain mit HTTPS, z.B. https://office.example.com
|
||||
# JWT wird automatisch vom JWT_SECRET_KEY oben verwendet
|
||||
|
||||
@@ -11,6 +11,7 @@ FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# tzdata ist im python:3.11-slim bereits enthalten - nur gcc nachinstallieren.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -30,6 +31,7 @@ RUN mkdir -p /app/data/files
|
||||
|
||||
# Environment
|
||||
ENV FLASK_ENV=production
|
||||
ENV TZ=Europe/Berlin
|
||||
ENV DATABASE_PATH=/app/data/minicloud.db
|
||||
ENV UPLOAD_PATH=/app/data/files
|
||||
|
||||
|
||||
@@ -191,6 +191,36 @@ docker-compose up --build -d
|
||||
|
||||
**Ohne OnlyOffice** (`ONLYOFFICE_URL` leer) werden Office-Dateien in einer einfachen Vorschau angezeigt. **Mit OnlyOffice** erhaelt man einen vollwertigen Editor (wie Google Docs).
|
||||
|
||||
### Zeitzone & NTP
|
||||
|
||||
In der `.env` stehen zwei Variablen die die Systemzeit betreffen:
|
||||
|
||||
```env
|
||||
TZ=Europe/Berlin
|
||||
NTP_SERVER=ptbtime1.ptb.de
|
||||
```
|
||||
|
||||
**`TZ`** setzt die prozessweite Zeitzone (wirkt auf Log-Zeitstempel, Kalender/Task-Zeiten, `datetime.now()`). IANA-Format `Region/Stadt`.
|
||||
|
||||
Haeufige Werte:
|
||||
|
||||
| Region | Beispielwerte |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Deutschland | `Europe/Berlin` |
|
||||
| DACH/EU | `Europe/Vienna`, `Europe/Zurich`, `Europe/Amsterdam`, `Europe/Paris`, `Europe/London`, `Europe/Madrid`, `Europe/Rome`, `Europe/Warsaw` |
|
||||
| Nord-EU | `Europe/Copenhagen`, `Europe/Stockholm`, `Europe/Helsinki`, `Europe/Oslo` |
|
||||
| Sonstige | `UTC`, `America/New_York`, `America/Los_Angeles`, `Asia/Tokyo`, `Australia/Sydney` |
|
||||
|
||||
Vollstaendige Liste: <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
|
||||
|
||||
**`NTP_SERVER`** wird beim Start abgefragt, um die Abweichung der Systemuhr zu pruefen. Bei Drift > 5 s erscheint eine Warnung im Log. **Hinweis:** Im Container wird die Uhr dadurch nicht gesetzt (benoetigt `CAP_SYS_TIME`) - auf dem Host sollte ein NTP-Daemon laufen. Der Check dient nur zur Sichtbarkeit.
|
||||
|
||||
Default: `ptbtime1.ptb.de` (offizielle deutsche Zeitreferenz der Physikalisch-Technischen Bundesanstalt, Stratum 1, sehr hohe Verfuegbarkeit).
|
||||
|
||||
Alternativen: `ptbtime2.ptb.de`, `ptbtime3.ptb.de`, `de.pool.ntp.org`, `time.cloudflare.com`. Leerlassen um den Check zu deaktivieren.
|
||||
|
||||
Aktuelle Werte sind im Admin-Bereich unter **Einstellungen > System** einsehbar.
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Dateien
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Response, redirect, send_from_directory
|
||||
@@ -8,6 +9,20 @@ from app.config import Config
|
||||
from app.extensions import db, bcrypt, migrate
|
||||
|
||||
|
||||
def _configure_timezone(tz_name: str) -> None:
|
||||
"""Prozess-Zeitzone setzen, sodass datetime.now(), strftime %Z etc.
|
||||
die konfigurierte TZ verwenden. Sichere no-op wenn tzdata fehlt."""
|
||||
if not tz_name:
|
||||
return
|
||||
os.environ['TZ'] = tz_name
|
||||
tzset = getattr(time, 'tzset', None)
|
||||
if tzset:
|
||||
try:
|
||||
tzset()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _auto_migrate(db):
|
||||
"""Add missing columns to existing tables by comparing model definitions
|
||||
with actual database schema. This handles the case where new columns are
|
||||
@@ -61,6 +76,9 @@ def _auto_migrate(db):
|
||||
|
||||
|
||||
def create_app(config_class=Config):
|
||||
# Zeitzone moeglichst frueh setzen - vor allen datetime.now()-Aufrufen
|
||||
_configure_timezone(getattr(config_class, 'TIMEZONE', None) or os.environ.get('TZ'))
|
||||
|
||||
# Check if static frontend build exists (Docker production mode)
|
||||
static_dir = Path(__file__).resolve().parent.parent / 'static'
|
||||
if static_dir.exists():
|
||||
@@ -171,4 +189,15 @@ def create_app(config_class=Config):
|
||||
from app.services.backup_scheduler import start_backup_scheduler
|
||||
start_backup_scheduler(app)
|
||||
|
||||
# NTP-Offset gegen den konfigurierten Zeitserver pruefen (nicht fatal).
|
||||
ntp_server = app.config.get('NTP_SERVER') or ''
|
||||
if ntp_server.strip():
|
||||
import threading
|
||||
from app.services.ntp_check import check_and_log
|
||||
threading.Thread(
|
||||
target=check_and_log,
|
||||
args=(ntp_server.strip(), app.logger),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return app
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -106,6 +109,8 @@ def list_calendars():
|
||||
if share and share.color:
|
||||
d['color'] = share.color
|
||||
d['owner_name'] = c.owner.username
|
||||
d['owner_full_name'] = c.owner.full_name
|
||||
d['owner_display_name'] = c.owner.display_name
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
@@ -236,6 +241,158 @@ def list_events(cal_id):
|
||||
return jsonify([_redact_if_private(e.to_dict(), is_owner) for e in events]), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/export', methods=['GET'])
|
||||
@token_required
|
||||
def export_calendar(cal_id):
|
||||
"""Export VEVENTs als .ics oder .csv."""
|
||||
user = request.current_user
|
||||
cal, err = _get_calendar_or_err(cal_id, user)
|
||||
if err:
|
||||
return err
|
||||
fmt = (request.args.get('format') or 'ics').lower()
|
||||
events = CalendarEvent.query.filter_by(calendar_id=cal_id).order_by(CalendarEvent.dtstart).all()
|
||||
safe_name = re.sub(r'[^A-Za-z0-9._-]+', '_', cal.name or 'kalender') or 'kalender'
|
||||
|
||||
if fmt == 'ics':
|
||||
lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE', 'CALSCALE:GREGORIAN']
|
||||
for e in events:
|
||||
block = (e.ical_data or '').strip()
|
||||
if not block:
|
||||
block = _build_vevent(e.uid, e.summary or '', e.dtstart, e.dtend,
|
||||
e.all_day, e.description or '', e.location or '',
|
||||
e.recurrence_rule or '',
|
||||
(e.exdates or '').split(',') if e.exdates else None)
|
||||
# Make sure block contains BEGIN/END VEVENT
|
||||
if 'BEGIN:VEVENT' not in block.upper():
|
||||
continue
|
||||
lines.append(block.strip())
|
||||
lines.append('END:VCALENDAR')
|
||||
body = '\r\n'.join(lines) + '\r\n'
|
||||
return Response(
|
||||
body, mimetype='text/calendar; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename="{safe_name}.ics"'},
|
||||
)
|
||||
if fmt == 'csv':
|
||||
out = io.StringIO()
|
||||
cols = ['summary', 'dtstart', 'dtend', 'all_day', 'location',
|
||||
'description', 'recurrence_rule', 'uid']
|
||||
w = csv.writer(out, delimiter=';', quoting=csv.QUOTE_ALL)
|
||||
w.writerow(cols)
|
||||
for e in events:
|
||||
w.writerow([
|
||||
e.summary or '',
|
||||
e.dtstart.isoformat() if e.dtstart else '',
|
||||
e.dtend.isoformat() if e.dtend else '',
|
||||
'1' if e.all_day else '0',
|
||||
e.location or '',
|
||||
(e.description or '').replace('\r\n', ' ').replace('\n', ' '),
|
||||
e.recurrence_rule or '',
|
||||
e.uid or '',
|
||||
])
|
||||
return Response(
|
||||
'\ufeff' + out.getvalue(), mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename="{safe_name}.csv"'},
|
||||
)
|
||||
return jsonify({'error': 'Unbekanntes Format'}), 400
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/import', methods=['POST'])
|
||||
@token_required
|
||||
def import_calendar(cal_id):
|
||||
"""Import .ics oder .csv -> Termine ins Kalender."""
|
||||
from app.dav.caldav import _parse_vevent, _extract_vevent_block
|
||||
user = request.current_user
|
||||
cal, err = _get_calendar_or_err(cal_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()
|
||||
name = (file.filename or '').lower()
|
||||
try:
|
||||
text = raw.decode('utf-8-sig')
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode('latin-1', errors='replace')
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
|
||||
def _save(parsed: dict, ical_block: str | None = None):
|
||||
nonlocal imported, skipped
|
||||
if not parsed.get('summary') or not parsed.get('dtstart'):
|
||||
skipped += 1
|
||||
return
|
||||
uid = parsed.get('uid') or str(uuid.uuid4())
|
||||
existing = CalendarEvent.query.filter_by(calendar_id=cal_id, uid=uid).first()
|
||||
ev = existing or CalendarEvent(calendar_id=cal_id, uid=uid, ical_data='')
|
||||
ev.summary = parsed.get('summary') or '(ohne Titel)'
|
||||
ev.description = parsed.get('description')
|
||||
ev.location = parsed.get('location')
|
||||
ev.dtstart = parsed.get('dtstart')
|
||||
ev.dtend = parsed.get('dtend')
|
||||
ev.all_day = parsed.get('all_day', False)
|
||||
ev.recurrence_rule = parsed.get('rrule')
|
||||
ev.exdates = ','.join(parsed.get('exdates', [])) or None
|
||||
ev.ical_data = (ical_block or '').strip() or _build_vevent(
|
||||
uid, ev.summary, ev.dtstart, ev.dtend, ev.all_day,
|
||||
ev.description or '', ev.location or '', ev.recurrence_rule or '',
|
||||
(ev.exdates or '').split(',') if ev.exdates else None,
|
||||
)
|
||||
ev.updated_at = datetime.now(timezone.utc)
|
||||
if not existing:
|
||||
db.session.add(ev)
|
||||
imported += 1
|
||||
|
||||
if name.endswith('.csv') or (b';' in raw[:200] and b'BEGIN:VCALENDAR' not in raw[:200]):
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=';')
|
||||
if not reader.fieldnames or len(reader.fieldnames) < 2:
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=',')
|
||||
for row in reader:
|
||||
row = {k.strip().lower(): (v or '').strip() for k, v in row.items() if k}
|
||||
try:
|
||||
dtstart = datetime.fromisoformat(row.get('dtstart') or row.get('start') or '')
|
||||
except (ValueError, TypeError):
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
dtend = datetime.fromisoformat(row.get('dtend') or row.get('end') or '') if (row.get('dtend') or row.get('end')) else None
|
||||
except ValueError:
|
||||
dtend = None
|
||||
parsed = {
|
||||
'uid': row.get('uid'),
|
||||
'summary': row.get('summary') or row.get('titel') or row.get('title'),
|
||||
'description': row.get('description') or row.get('beschreibung'),
|
||||
'location': row.get('location') or row.get('ort'),
|
||||
'dtstart': dtstart,
|
||||
'dtend': dtend,
|
||||
'all_day': (row.get('all_day') or '').lower() in ('1', 'true', 'ja', 'yes'),
|
||||
'rrule': row.get('recurrence_rule') or row.get('rrule'),
|
||||
'exdates': [],
|
||||
}
|
||||
_save(parsed)
|
||||
else:
|
||||
# iCal: Kalender-Datei mit beliebig vielen VEVENTs
|
||||
blocks = re.findall(r'BEGIN:VEVENT.*?END:VEVENT', text, flags=re.DOTALL | re.IGNORECASE)
|
||||
if not blocks:
|
||||
return jsonify({'error': 'Keine VEVENT-Daten gefunden'}), 400
|
||||
for block in blocks:
|
||||
try:
|
||||
parsed = _parse_vevent(block)
|
||||
except Exception:
|
||||
parsed = None
|
||||
if not parsed:
|
||||
skipped += 1
|
||||
continue
|
||||
_save(parsed, ical_block=block)
|
||||
|
||||
db.session.commit()
|
||||
if imported:
|
||||
notify_calendar_change(cal.owner_id, cal.id, 'event',
|
||||
shared_with=_calendar_recipients(cal))
|
||||
return jsonify({'imported': imported, 'skipped': skipped}), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/events', methods=['POST'])
|
||||
@token_required
|
||||
def create_event(cal_id):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request, jsonify, Response
|
||||
@@ -289,6 +292,8 @@ def list_addressbooks():
|
||||
if share and share.color:
|
||||
d['color'] = share.color
|
||||
d['owner_name'] = b.owner.username
|
||||
d['owner_full_name'] = b.owner.full_name
|
||||
d['owner_display_name'] = b.owner.display_name
|
||||
d['contact_count'] = b.contacts.count()
|
||||
result.append(d)
|
||||
|
||||
@@ -404,6 +409,161 @@ def list_contacts(book_id):
|
||||
return jsonify([c.to_dict() for c in contacts]), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/export', methods=['GET'])
|
||||
@token_required
|
||||
def export_addressbook(book_id):
|
||||
"""Export contacts as a single .vcf, a .zip with one .vcf per contact, or .csv."""
|
||||
user = request.current_user
|
||||
book, err = _get_addressbook_or_err(book_id, user)
|
||||
if err:
|
||||
return err
|
||||
fmt = (request.args.get('format') or 'vcf').lower()
|
||||
contacts = Contact.query.filter_by(address_book_id=book_id).order_by(Contact.display_name).all()
|
||||
safe_name = re.sub(r'[^A-Za-z0-9._-]+', '_', book.name or 'kontakte') or 'kontakte'
|
||||
|
||||
if fmt == 'vcf':
|
||||
body = '\r\n'.join((c.vcard_data or _build_vcard(c)).strip() for c in contacts) + '\r\n'
|
||||
return Response(
|
||||
body, mimetype='text/vcard; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename="{safe_name}.vcf"'},
|
||||
)
|
||||
if fmt == 'vcf-zip':
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
seen = {}
|
||||
for c in contacts:
|
||||
base = re.sub(r'[^A-Za-z0-9._-]+', '_', c.display_name or c.uid) or c.uid
|
||||
seen[base] = seen.get(base, 0) + 1
|
||||
fname = f"{base}.vcf" if seen[base] == 1 else f"{base}_{seen[base]}.vcf"
|
||||
zf.writestr(fname, (c.vcard_data or _build_vcard(c)).strip() + '\r\n')
|
||||
buf.seek(0)
|
||||
return Response(
|
||||
buf.read(), mimetype='application/zip',
|
||||
headers={'Content-Disposition': f'attachment; filename="{safe_name}.zip"'},
|
||||
)
|
||||
if fmt == 'csv':
|
||||
out = io.StringIO()
|
||||
cols = ['display_name', 'prefix', 'first_name', 'middle_name', 'last_name', 'suffix',
|
||||
'nickname', 'organization', 'department', 'job_title',
|
||||
'primary_email', 'primary_phone', 'birthday', 'anniversary',
|
||||
'emails', 'phones', 'addresses', 'websites', 'categories', 'notes']
|
||||
w = csv.writer(out, delimiter=';', quoting=csv.QUOTE_ALL)
|
||||
w.writerow(cols)
|
||||
for c in contacts:
|
||||
d = c.to_dict()
|
||||
row = []
|
||||
for col in cols:
|
||||
v = d.get(col, '')
|
||||
if isinstance(v, list):
|
||||
if v and isinstance(v[0], dict):
|
||||
v = '; '.join(
|
||||
(x.get('value') or x.get('street') or '') +
|
||||
(f" ({x.get('type')})" if x.get('type') else '')
|
||||
for x in v if isinstance(x, dict)
|
||||
)
|
||||
else:
|
||||
v = ', '.join(str(x) for x in v)
|
||||
row.append('' if v is None else str(v))
|
||||
w.writerow(row)
|
||||
return Response(
|
||||
'\ufeff' + out.getvalue(), mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename="{safe_name}.csv"'},
|
||||
)
|
||||
return jsonify({'error': 'Unbekanntes Format'}), 400
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/import', methods=['POST'])
|
||||
@token_required
|
||||
def import_addressbook(book_id):
|
||||
"""Import vCard (.vcf, single oder mehrere im File) oder CSV."""
|
||||
user = request.current_user
|
||||
book, err = _get_addressbook_or_err(book_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()
|
||||
name = (file.filename or '').lower()
|
||||
try:
|
||||
text = raw.decode('utf-8-sig')
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode('latin-1', errors='replace')
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
|
||||
def _add_from_parsed(parsed: dict, raw_text: str | None = None) -> bool:
|
||||
nonlocal imported, skipped
|
||||
if not parsed.get('display_name') and not parsed.get('first_name') \
|
||||
and not parsed.get('last_name') and not parsed.get('organization'):
|
||||
skipped += 1
|
||||
return False
|
||||
uid = parsed.get('uid') or str(uuid.uuid4())
|
||||
existing = Contact.query.filter_by(address_book_id=book_id, uid=uid).first()
|
||||
contact = existing or Contact(address_book_id=book_id, uid=uid, vcard_data='')
|
||||
_apply_fields_to_contact(contact, parsed)
|
||||
contact.vcard_data = (raw_text or '').strip() or _build_vcard(contact)
|
||||
contact.updated_at = datetime.now(timezone.utc)
|
||||
if not existing:
|
||||
db.session.add(contact)
|
||||
imported += 1
|
||||
return True
|
||||
|
||||
if name.endswith('.csv') or (b',' in raw[:200] and b'BEGIN:VCARD' not in raw[:200]):
|
||||
# CSV import
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=';')
|
||||
if not reader.fieldnames or len(reader.fieldnames) < 2:
|
||||
# try comma
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=',')
|
||||
for row in reader:
|
||||
row = {k.strip().lower(): (v or '').strip() for k, v in row.items() if k}
|
||||
parsed = {
|
||||
'display_name': row.get('display_name') or row.get('name')
|
||||
or row.get('vollname') or row.get('full name'),
|
||||
'first_name': row.get('first_name') or row.get('vorname'),
|
||||
'last_name': row.get('last_name') or row.get('nachname'),
|
||||
'middle_name': row.get('middle_name'),
|
||||
'prefix': row.get('prefix') or row.get('anrede'),
|
||||
'suffix': row.get('suffix'),
|
||||
'nickname': row.get('nickname') or row.get('spitzname'),
|
||||
'organization': row.get('organization') or row.get('firma') or row.get('company'),
|
||||
'department': row.get('department') or row.get('abteilung'),
|
||||
'job_title': row.get('job_title') or row.get('position') or row.get('title'),
|
||||
'birthday': row.get('birthday') or row.get('geburtstag'),
|
||||
'notes': row.get('notes') or row.get('notizen'),
|
||||
'emails': [], 'phones': [], 'addresses': [], 'websites': [], 'categories': [],
|
||||
}
|
||||
email = row.get('primary_email') or row.get('email') or row.get('e-mail')
|
||||
if email:
|
||||
parsed['emails'].append({'type': 'home', 'value': email})
|
||||
phone = row.get('primary_phone') or row.get('phone') or row.get('telefon') or row.get('mobil')
|
||||
if phone:
|
||||
parsed['phones'].append({'type': 'cell', 'value': phone})
|
||||
cats = row.get('categories') or row.get('kategorien')
|
||||
if cats:
|
||||
parsed['categories'] = [c.strip() for c in cats.split(',') if c.strip()]
|
||||
_add_from_parsed(parsed)
|
||||
else:
|
||||
# vCard - eine oder mehrere im File
|
||||
parts = re.findall(r'BEGIN:VCARD.*?END:VCARD', text, flags=re.DOTALL | re.IGNORECASE)
|
||||
if not parts:
|
||||
return jsonify({'error': 'Keine VCARD-Daten gefunden'}), 400
|
||||
for vcf in parts:
|
||||
try:
|
||||
parsed = parse_vcard(vcf)
|
||||
except Exception:
|
||||
skipped += 1
|
||||
continue
|
||||
_add_from_parsed(parsed, raw_text=vcf)
|
||||
|
||||
db.session.commit()
|
||||
if imported:
|
||||
_notify_addressbook(book.owner_id, book.id, 'contact',
|
||||
shared_with=_book_recipients(book))
|
||||
return jsonify({'imported': imported, 'skipped': skipped}), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['POST'])
|
||||
@token_required
|
||||
def create_contact(book_id):
|
||||
|
||||
+63
-16
@@ -1254,32 +1254,79 @@ def list_locks():
|
||||
@api_bp.route('/sync/tree', methods=['GET'])
|
||||
@token_required
|
||||
def sync_tree():
|
||||
"""Returns complete file tree with checksums for sync clients."""
|
||||
"""Returns complete file tree with checksums for sync clients.
|
||||
|
||||
Includes both files owned by the user (under 'tree') and files
|
||||
shared WITH the user (as a virtual 'Geteilt mit mir' folder under
|
||||
'shared'). The client merges both.
|
||||
"""
|
||||
user = request.current_user
|
||||
|
||||
def _entry(f):
|
||||
entry = {
|
||||
'id': f.id,
|
||||
'name': f.name,
|
||||
'is_folder': f.is_folder,
|
||||
'size': f.size,
|
||||
'checksum': f.checksum,
|
||||
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
|
||||
'modified_at': f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
lock = FileLock.get_lock(f.id)
|
||||
if lock:
|
||||
entry['locked'] = True
|
||||
entry['locked_by'] = lock.user.username
|
||||
return entry
|
||||
|
||||
def _build_tree(parent_id):
|
||||
files = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)\
|
||||
.order_by(File.is_folder.desc(), File.name).all()
|
||||
result = []
|
||||
for f in files:
|
||||
entry = {
|
||||
'id': f.id,
|
||||
'name': f.name,
|
||||
'is_folder': f.is_folder,
|
||||
'size': f.size,
|
||||
'checksum': f.checksum,
|
||||
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
lock = FileLock.get_lock(f.id)
|
||||
if lock:
|
||||
entry['locked'] = True
|
||||
entry['locked_by'] = lock.user.username
|
||||
e = _entry(f)
|
||||
if f.is_folder:
|
||||
entry['children'] = _build_tree(f.id)
|
||||
result.append(entry)
|
||||
e['children'] = _build_tree(f.id)
|
||||
result.append(e)
|
||||
return result
|
||||
|
||||
return jsonify({'tree': _build_tree(None)}), 200
|
||||
def _build_shared_children(parent_id):
|
||||
files = File.query.filter_by(parent_id=parent_id, is_trashed=False)\
|
||||
.order_by(File.is_folder.desc(), File.name).all()
|
||||
out = []
|
||||
for f in files:
|
||||
e = _entry(f)
|
||||
if f.is_folder:
|
||||
e['children'] = _build_shared_children(f.id)
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
shared_perms = FilePermission.query.filter_by(user_id=user.id).all()
|
||||
shared_roots = []
|
||||
seen = set()
|
||||
for perm in shared_perms:
|
||||
f = perm.file
|
||||
if not f or f.is_trashed or f.id in seen:
|
||||
continue
|
||||
seen.add(f.id)
|
||||
# Nur "Top-Level"-Shares: wenn der Eltern-Ordner NICHT auch geteilt
|
||||
# ist, ist dieses Item die Wurzel des Shares beim Empfaenger.
|
||||
parent_shared = any(
|
||||
p.file_id == f.parent_id for p in shared_perms
|
||||
) if f.parent_id else False
|
||||
if parent_shared:
|
||||
continue
|
||||
e = _entry(f)
|
||||
owner = f.owner.display_name if hasattr(f, 'owner') and f.owner else None
|
||||
if owner:
|
||||
e['name'] = f'{f.name} (von {owner})'
|
||||
if f.is_folder:
|
||||
e['children'] = _build_shared_children(f.id)
|
||||
shared_roots.append(e)
|
||||
|
||||
return jsonify({
|
||||
'tree': _build_tree(None),
|
||||
'shared': shared_roots,
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/sync/events', methods=['GET'])
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
"""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
|
||||
owner = tl.owner
|
||||
d['owner_name'] = owner.username if owner else ''
|
||||
d['owner_full_name'] = owner.full_name if owner else ''
|
||||
d['owner_display_name'] = owner.display_name if 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
|
||||
@@ -145,6 +145,12 @@ def delete_user(user_id):
|
||||
@api_bp.route('/settings', methods=['GET'])
|
||||
@admin_required
|
||||
def get_settings():
|
||||
import time as _time
|
||||
from datetime import datetime as _dt
|
||||
try:
|
||||
tzname = _time.strftime('%Z')
|
||||
except Exception:
|
||||
tzname = ''
|
||||
return jsonify({
|
||||
'public_registration': AppSettings.get_bool('public_registration', default=True),
|
||||
'system_smtp_host': AppSettings.get('system_smtp_host', ''),
|
||||
@@ -155,6 +161,11 @@ def get_settings():
|
||||
'system_email_from': AppSettings.get('system_email_from', ''),
|
||||
'onlyoffice_url': os.environ.get('ONLYOFFICE_URL', ''),
|
||||
'onlyoffice_configured': bool(os.environ.get('ONLYOFFICE_URL', '')),
|
||||
# Read-only system info aus der .env
|
||||
'timezone': os.environ.get('TZ', 'Europe/Berlin'),
|
||||
'timezone_abbr': tzname,
|
||||
'server_time': _dt.now().isoformat(timespec='seconds'),
|
||||
'ntp_server': os.environ.get('NTP_SERVER', ''),
|
||||
}), 200
|
||||
|
||||
|
||||
@@ -270,6 +281,31 @@ def create_invite_link():
|
||||
|
||||
# --- User search (for sharing dialogs) ---
|
||||
|
||||
@api_bp.route('/auth/me', methods=['GET'])
|
||||
@token_required
|
||||
def get_me():
|
||||
return jsonify(request.current_user.to_dict(include_email=True)), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/me', methods=['PUT'])
|
||||
@token_required
|
||||
def update_me():
|
||||
user = request.current_user
|
||||
data = request.get_json() or {}
|
||||
if 'first_name' in data:
|
||||
user.first_name = (data.get('first_name') or '').strip() or None
|
||||
if 'last_name' in data:
|
||||
user.last_name = (data.get('last_name') or '').strip() or None
|
||||
if 'email' in data:
|
||||
email = (data.get('email') or '').strip() or None
|
||||
if email and email != user.email:
|
||||
if User.query.filter(User.email == email, User.id != user.id).first():
|
||||
return jsonify({'error': 'E-Mail ist bereits vergeben'}), 409
|
||||
user.email = email
|
||||
db.session.commit()
|
||||
return jsonify(user.to_dict(include_email=True)), 200
|
||||
|
||||
|
||||
@api_bp.route('/users/search', methods=['GET'])
|
||||
@token_required
|
||||
def search_users():
|
||||
@@ -278,13 +314,19 @@ def search_users():
|
||||
if len(query) < 2:
|
||||
return jsonify([]), 200
|
||||
|
||||
like = f'%{query}%'
|
||||
users = User.query.filter(
|
||||
User.username.ilike(f'%{query}%'),
|
||||
(User.username.ilike(like)) | (User.first_name.ilike(like)) | (User.last_name.ilike(like)),
|
||||
User.id != request.current_user.id,
|
||||
User.is_active == True,
|
||||
).limit(10).all()
|
||||
|
||||
return jsonify([{'id': u.id, 'username': u.username} for u in users]), 200
|
||||
return jsonify([{
|
||||
'id': u.id,
|
||||
'username': u.username,
|
||||
'full_name': u.full_name,
|
||||
'display_name': u.display_name,
|
||||
} for u in users]), 200
|
||||
|
||||
|
||||
# --- Change password (non-admin, own account) ---
|
||||
|
||||
@@ -40,3 +40,8 @@ class Config:
|
||||
|
||||
# CORS
|
||||
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
# Zeitzone (prozessweit, wirkt nach time.tzset())
|
||||
TIMEZONE = os.environ.get('TZ', 'Europe/Berlin')
|
||||
# NTP-Server fuer Offset-Check beim Start. Leerstring deaktiviert den Check.
|
||||
NTP_SERVER = os.environ.get('NTP_SERVER', 'ptbtime1.ptb.de')
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -9,6 +9,8 @@ class User(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=True)
|
||||
first_name = db.Column(db.String(100), nullable=True)
|
||||
last_name = db.Column(db.String(100), nullable=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.String(20), default='user', nullable=False) # 'admin' or 'user'
|
||||
master_key_salt = db.Column(db.LargeBinary, nullable=True) # For password manager
|
||||
@@ -23,6 +25,7 @@ class User(db.Model):
|
||||
foreign_keys='File.owner_id')
|
||||
calendars = db.relationship('Calendar', backref='owner', lazy='dynamic')
|
||||
address_books = db.relationship('AddressBook', backref='owner', lazy='dynamic')
|
||||
task_lists = db.relationship('TaskList', backref='owner', lazy='dynamic')
|
||||
email_accounts = db.relationship('EmailAccount', backref='user', lazy='dynamic',
|
||||
order_by='EmailAccount.sort_order')
|
||||
password_folders = db.relationship('PasswordFolder', backref='owner', lazy='dynamic')
|
||||
@@ -33,10 +36,25 @@ class User(db.Model):
|
||||
def check_password(self, password):
|
||||
return bcrypt.check_password_hash(self.password_hash, password)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
"""Vor- + Nachname zusammengesetzt, sonst Leerstring."""
|
||||
parts = [self.first_name or '', self.last_name or '']
|
||||
return ' '.join(p.strip() for p in parts if p and p.strip())
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Voller Name falls vorhanden, sonst Username."""
|
||||
return self.full_name or self.username
|
||||
|
||||
def to_dict(self, include_email=False):
|
||||
data = {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'first_name': self.first_name or '',
|
||||
'last_name': self.last_name or '',
|
||||
'full_name': self.full_name,
|
||||
'display_name': self.display_name,
|
||||
'role': self.role,
|
||||
'is_active': self.is_active,
|
||||
'storage_quota_mb': self.storage_quota_mb,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Leichtgewichtiger SNTP-Client zum Pruefen des Zeit-Offsets.
|
||||
|
||||
Im Container koennen wir die Systemzeit nicht wirklich setzen (braucht
|
||||
CAP_SYS_TIME). Aber wir koennen den Offset ermitteln und loggen, damit
|
||||
der Admin weiss, ob der Host driftet. Fuer einen harten Sync muss auf
|
||||
dem Host selbst ein NTP-Daemon laufen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
_NTP_EPOCH_OFFSET = 2208988800 # Sekunden zwischen 1900 und 1970
|
||||
|
||||
|
||||
def query_ntp(server: str, timeout: float = 3.0, port: int = 123) -> float | None:
|
||||
"""Fragt einen NTP-Server und gibt das Offset (Server - Local) in
|
||||
Sekunden zurueck, oder None bei Fehler."""
|
||||
packet = b'\x1b' + 47 * b'\0' # LI=0, VN=3, Mode=3 (client)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
t0 = time.time()
|
||||
sock.sendto(packet, (server, port))
|
||||
data, _ = sock.recvfrom(1024)
|
||||
t3 = time.time()
|
||||
except (socket.gaierror, socket.timeout, OSError):
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if len(data) < 48:
|
||||
return None
|
||||
# Transmit timestamp: Offset 40, 8 bytes, fixed point 32.32
|
||||
secs, frac = struct.unpack('!II', data[40:48])
|
||||
if secs == 0:
|
||||
return None
|
||||
t2 = secs - _NTP_EPOCH_OFFSET + frac / 2**32
|
||||
# Einfacher Offset (sans roundtrip): (t2 - ((t0 + t3) / 2))
|
||||
return t2 - (t0 + t3) / 2
|
||||
|
||||
|
||||
def check_and_log(server: str, logger=None) -> float | None:
|
||||
import logging
|
||||
log = logger or logging.getLogger('ntp')
|
||||
offset = query_ntp(server)
|
||||
if offset is None:
|
||||
log.warning('NTP-Check: Server %s nicht erreichbar', server)
|
||||
return None
|
||||
if abs(offset) > 5.0:
|
||||
log.warning('NTP-Check: Systemzeit weicht um %.2fs von %s ab -> Host-Uhr synchronisieren!',
|
||||
offset, server)
|
||||
else:
|
||||
log.info('NTP-Check: Offset %.3fs gegen %s (ok)', offset, server)
|
||||
return offset
|
||||
@@ -0,0 +1,70 @@
|
||||
# Native File-Provider-Integration (Platzhalter-Modus)
|
||||
|
||||
Zusaetzlich zum klassischen "alles-kopieren"-Sync bietet der Desktop-Client
|
||||
einen **OneDrive-aehnlichen Platzhalter-Modus**: Dateien erscheinen im
|
||||
Dateimanager als kleine Metadata-Dateien (Platzhalter) und werden erst
|
||||
bei Doppelklick vom Server geladen.
|
||||
|
||||
## Status
|
||||
|
||||
| Plattform | Status | Technologie |
|
||||
| --------- | --------- | ------------------------------------ |
|
||||
| Windows | **MVP** | Cloud Files API (`cfapi.dll`) |
|
||||
| Linux | Skelett | FUSE (libfuse3) - feature `linux_fuse` |
|
||||
| macOS | Geplant | `NSFileProviderExtension` + Signatur |
|
||||
|
||||
## Windows
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Windows 10 1709 (Build 16299) oder neuer
|
||||
- Der Client laeuft als regulaerer Benutzerprozess (keine Admin-Rechte noetig)
|
||||
|
||||
### Was funktioniert
|
||||
|
||||
- `CfRegisterSyncRoot` registriert einen Ordner als Sync-Root, der Explorer
|
||||
zeigt Wolken-Overlay-Icons an.
|
||||
- `CfCreatePlaceholders` legt fuer jede Mini-Cloud-Datei einen Platzhalter
|
||||
mit korrekter Groesse und Aenderungszeit an.
|
||||
- `FETCH_DATA`-Callback laedt per Range-Request vom Server, sobald der
|
||||
Explorer Dateidaten anfordert (z.B. beim Oeffnen).
|
||||
- `CfSetPinState` erlaubt manuelles "Immer offline halten" / "Nur in Cloud".
|
||||
|
||||
### Was noch fehlt
|
||||
|
||||
- Upload-Callback (`NOTIFY_FILE_CLOSE_COMPLETION`) fuer lokal geaenderte Dateien
|
||||
- Context-Menue "Ein-/Auschecken" via Shell-Extension
|
||||
- Delta-Updates (neue/geloeschte Dateien auf dem Server -> lokale Placeholder)
|
||||
- Konflikt-Aufloesung
|
||||
|
||||
### Einschalten
|
||||
|
||||
Im Client-UI den Schalter **"Cloud-Files-Modus"** aktivieren (ruft intern
|
||||
`cloud_files_enable`-Command auf). Alternativ per Kommandozeile beim Build:
|
||||
|
||||
```powershell
|
||||
# Aus clients/desktop/src-tauri:
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Windows-Targets brauchen das Windows-SDK (uebersetzt aber sauber mit
|
||||
cross-compile via `cargo xwin` aus Linux, wenn `build.sh windows` laeuft).
|
||||
|
||||
## Linux
|
||||
|
||||
FUSE-Provider ist optional und mit einem Feature-Flag versehen, damit
|
||||
normale Linux-Builds nicht `libfuse3-dev` voraussetzen:
|
||||
|
||||
```bash
|
||||
cargo build --features linux_fuse
|
||||
```
|
||||
|
||||
Overlay-Icons im Dateimanager (Nautilus / Dolphin / Caja) brauchen
|
||||
zusaetzlich eine native Extension pro DE - folgt in einem spaeteren
|
||||
Commit.
|
||||
|
||||
## macOS
|
||||
|
||||
Braucht eine Apple Developer ID + Notarization, da `NSFileProviderExtension`
|
||||
sonst vom Finder nicht geladen wird. Wird angegangen, sobald ein
|
||||
Apple-Dev-Zugang verfuegbar ist.
|
||||
@@ -19,7 +19,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls", "blocking"], default-features = false }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
notify = "7"
|
||||
sha2 = "0.10"
|
||||
@@ -28,3 +28,30 @@ rusqlite = { version = "0.34", features = ["bundled"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
open = "5"
|
||||
once_cell = "1"
|
||||
|
||||
# Plattform-spezifische File-Provider-Integration (OneDrive-artig).
|
||||
# Nur auf Windows gegen die Cloud Files API (cfapi.dll) linken.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_Storage_CloudFilters",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_CorrelationVector", # gate fuer CF_CALLBACK_INFO / CfExecute / CfConnectSyncRoot
|
||||
"Win32_UI_Shell",
|
||||
"Win32_Security",
|
||||
"Win32_System_Registry",
|
||||
] }
|
||||
widestring = "1"
|
||||
winreg = "0.52"
|
||||
|
||||
# Linux: FUSE-basiertes Virtual-Filesystem (optional, cargo build --features linux_fuse)
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
fuser = { version = "0.15", optional = true }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
linux_fuse = ["fuser"]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Linux FUSE-basierte File-Provider-Integration (Platzhalter-Modus).
|
||||
//!
|
||||
//! Status: Skelett. Funktioniert nur wenn mit `--features linux_fuse`
|
||||
//! gebaut wird und `libfuse3-dev` installiert ist. Overlay-Icons im
|
||||
//! Dateimanager (Nautilus/Dolphin) werden spaeter als separate Extension
|
||||
//! nachgereicht - das FUSE-Filesystem selbst kann die nicht setzen.
|
||||
|
||||
#![cfg(all(target_os = "linux", feature = "linux_fuse"))]
|
||||
|
||||
use super::RemoteEntry;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn mount(mount_point: &PathBuf) -> Result<(), String> {
|
||||
std::fs::create_dir_all(mount_point).map_err(|e| e.to_string())?;
|
||||
// TODO: fuser::Filesystem-Impl mit auf-Abruf-Download
|
||||
Err("Linux FUSE-Provider: noch nicht implementiert (MVP folgt)".into())
|
||||
}
|
||||
|
||||
pub fn unmount(_mount_point: &PathBuf) -> Result<(), String> {
|
||||
Err("Linux FUSE-Provider: noch nicht implementiert".into())
|
||||
}
|
||||
|
||||
pub fn populate(_mount_point: &PathBuf, _entries: &[RemoteEntry]) -> Result<(), String> {
|
||||
Err("Linux FUSE-Provider: noch nicht implementiert".into())
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Native File-Provider-Integration (Platzhalter-Dateien wie bei OneDrive).
|
||||
//!
|
||||
//! Auf Windows realisiert ueber die Cloud Files API (cfapi.dll), auf Linux
|
||||
//! ueber FUSE (optional, hinter `linux_fuse`-Feature). macOS folgt spaeter
|
||||
//! ueber NSFileProviderExtension (braucht Apple-Signatur).
|
||||
//!
|
||||
//! Der bestehende `sync::engine` bleibt unberuehrt und bietet weiterhin
|
||||
//! den klassischen "kopiere-alles-lokal"-Modus. Der Cloud-Files-Modus
|
||||
//! ist sozusagen "files-on-demand": Datei wird erst bei Zugriff geladen.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Ein Eintrag aus dem Mini-Cloud-Syncbaum, so wie er vom Server kommt.
|
||||
/// Wird von beiden Plattformen genutzt, um Platzhalter / FUSE-Inodes zu
|
||||
/// erzeugen.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteEntry {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub parent_id: Option<i64>,
|
||||
pub is_folder: bool,
|
||||
pub size: i64,
|
||||
/// UTC-ISO8601
|
||||
pub modified_at: String,
|
||||
/// SHA-256 falls vom Server ausgeliefert, sonst None.
|
||||
pub checksum: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncState {
|
||||
/// Datei existiert nur als Platzhalter (online-only).
|
||||
Cloud,
|
||||
/// Datei ist vollstaendig lokal vorhanden und aktuell.
|
||||
InSync,
|
||||
/// Lokal geaendert, Upload ausstehend.
|
||||
PendingUpload,
|
||||
/// Auf dem Server gesperrt (durch anderen Nutzer).
|
||||
LockedByOther,
|
||||
/// Durch diesen Client gesperrt.
|
||||
LockedLocal,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
#[cfg(windows)]
|
||||
pub mod shell_integration;
|
||||
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
|
||||
pub mod linux;
|
||||
pub mod sync_loop;
|
||||
pub mod watcher;
|
||||
|
||||
/// Registriere den Sync-Root beim Betriebssystem. Ruft je nach Plattform
|
||||
/// cfapi/CfRegisterSyncRoot bzw. mountet ein FUSE-Dateisystem.
|
||||
#[allow(unused_variables)]
|
||||
pub fn register_sync_root(
|
||||
mount_point: &PathBuf,
|
||||
provider_name: &str,
|
||||
account_id: &str,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
return windows::register_sync_root(mount_point, provider_name, account_id);
|
||||
|
||||
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
|
||||
return linux::mount(mount_point);
|
||||
|
||||
#[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))]
|
||||
Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub fn unregister_sync_root(mount_point: &PathBuf) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
return windows::unregister_sync_root(mount_point);
|
||||
|
||||
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
|
||||
return linux::unmount(mount_point);
|
||||
|
||||
#[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))]
|
||||
Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into())
|
||||
}
|
||||
|
||||
/// Erzeuge fuer alle Remote-Eintraege Platzhalter (cloud-only Dateien).
|
||||
/// Ordner werden als echte Verzeichnisse angelegt, Dateien als
|
||||
/// Platzhalter mit gespeicherten Metadaten (Groesse, Mtime, ID).
|
||||
#[allow(unused_variables)]
|
||||
pub fn populate_placeholders(
|
||||
mount_point: &PathBuf,
|
||||
entries: &[RemoteEntry],
|
||||
) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
return windows::populate_placeholders(mount_point, entries);
|
||||
|
||||
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
|
||||
return linux::populate(mount_point, entries);
|
||||
|
||||
#[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))]
|
||||
Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into())
|
||||
}
|
||||
|
||||
/// Ist File-Provider-Integration auf dieser Plattform grundsaetzlich verfuegbar?
|
||||
pub fn is_supported() -> bool {
|
||||
cfg!(windows) || cfg!(all(target_os = "linux", feature = "linux_fuse"))
|
||||
}
|
||||
|
||||
/// Markiere eine lokal bereits vorhandene Datei als "immer offline halten".
|
||||
#[allow(unused_variables)]
|
||||
pub fn pin_file(path: &PathBuf) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
return windows::set_pin_state(path, true);
|
||||
#[cfg(not(windows))]
|
||||
Err("Nur auf Windows unterstuetzt".into())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub fn unpin_file(path: &PathBuf) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
return windows::set_pin_state(path, false);
|
||||
#[cfg(not(windows))]
|
||||
Err("Nur auf Windows unterstuetzt".into())
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Explorer-Sidebar-Integration fuer Windows (ohne Admin-Rechte).
|
||||
//!
|
||||
//! Registriert den Sync-Ordner als Shell-Namespace-Extension unter
|
||||
//! HKEY_CURRENT_USER, sodass er mit eigenem Icon in der Navigation
|
||||
//! des Datei-Explorers erscheint (wie OneDrive/Dropbox).
|
||||
//!
|
||||
//! Anders als die eigentliche Cloud Files API ist das reine Registry-
|
||||
//! Kosmetik - der Ordner funktioniert auch ohne Sidebar-Eintrag,
|
||||
//! nur sieht man ihn dann nicht in der linken Leiste.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::path::Path;
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
// Stabile GUID fuer Mini-Cloud - gleiche wie in windows.rs als ProviderId.
|
||||
const CLSID_GUID: &str = "{4D696E69-436C-6F75-6444-7566667944AB}";
|
||||
|
||||
// Standard-CLSID fuer "Generic Shell Folder Implementation".
|
||||
const SHELL_FOLDER_CLSID: &str = "{0E5AAE11-A475-4c5b-AB00-C66DE400274E}";
|
||||
|
||||
/// Registriere den Mount-Ordner in der Explorer-Navigation.
|
||||
/// `icon_source`: Pfad zu ICO oder EXE mit Icon-Index (z.B. "C:\\app.exe,0")
|
||||
pub fn install(
|
||||
display_name: &str,
|
||||
mount_point: &Path,
|
||||
icon_source: &str,
|
||||
) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// 1) CLSID-Eintrag unter Software\Classes\CLSID\{GUID}
|
||||
let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID);
|
||||
let (clsid, _) = hkcu
|
||||
.create_subkey(&clsid_path)
|
||||
.map_err(|e| format!("create CLSID: {e}"))?;
|
||||
clsid
|
||||
.set_value("", &display_name.to_string())
|
||||
.map_err(|e| format!("set displayname: {e}"))?;
|
||||
clsid
|
||||
.set_value("System.IsPinnedToNameSpaceTree", &1u32)
|
||||
.map_err(|e| format!("set pinned: {e}"))?;
|
||||
clsid
|
||||
.set_value("SortOrderIndex", &0x42u32)
|
||||
.map_err(|e| format!("set sortorder: {e}"))?;
|
||||
|
||||
// 2) DefaultIcon
|
||||
let (icon_key, _) = clsid
|
||||
.create_subkey("DefaultIcon")
|
||||
.map_err(|e| format!("create DefaultIcon: {e}"))?;
|
||||
icon_key
|
||||
.set_value("", &icon_source.to_string())
|
||||
.map_err(|e| format!("set icon: {e}"))?;
|
||||
|
||||
// 3) InProcServer32 -> shell32.dll (Standard Shell-Folder-Host)
|
||||
let (inproc, _) = clsid
|
||||
.create_subkey("InProcServer32")
|
||||
.map_err(|e| format!("create InProcServer32: {e}"))?;
|
||||
inproc
|
||||
.set_value("", &"%SystemRoot%\\system32\\shell32.dll".to_string())
|
||||
.map_err(|e| format!("set shell32: {e}"))?;
|
||||
inproc
|
||||
.set_value("ThreadingModel", &"Both".to_string())
|
||||
.map_err(|e| format!("set threading: {e}"))?;
|
||||
|
||||
// 4) Instance -> zeigt auf generischen Shell-Folder
|
||||
let (instance, _) = clsid
|
||||
.create_subkey("Instance")
|
||||
.map_err(|e| format!("create Instance: {e}"))?;
|
||||
instance
|
||||
.set_value("CLSID", &SHELL_FOLDER_CLSID.to_string())
|
||||
.map_err(|e| format!("set inst clsid: {e}"))?;
|
||||
|
||||
let (pb, _) = instance
|
||||
.create_subkey("InitPropertyBag")
|
||||
.map_err(|e| format!("create InitPropertyBag: {e}"))?;
|
||||
pb.set_value("Attributes", &0x11u32)
|
||||
.map_err(|e| format!("set attrs pb: {e}"))?;
|
||||
pb.set_value(
|
||||
"TargetFolderPath",
|
||||
&mount_point.to_string_lossy().into_owned(),
|
||||
)
|
||||
.map_err(|e| format!("set target: {e}"))?;
|
||||
|
||||
// 5) ShellFolder-Flags
|
||||
let (sf, _) = clsid
|
||||
.create_subkey("ShellFolder")
|
||||
.map_err(|e| format!("create ShellFolder: {e}"))?;
|
||||
sf.set_value("FolderValueFlags", &0x28u32)
|
||||
.map_err(|e| format!("set folderflags: {e}"))?;
|
||||
sf.set_value("Attributes", &0xF080004Du32)
|
||||
.map_err(|e| format!("set attrs sf: {e}"))?;
|
||||
|
||||
// 6) In die Navigation einhaengen
|
||||
let ns_path = format!(
|
||||
"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Desktop\\NameSpace\\{}",
|
||||
CLSID_GUID
|
||||
);
|
||||
let (ns, _) = hkcu
|
||||
.create_subkey(&ns_path)
|
||||
.map_err(|e| format!("create NameSpace: {e}"))?;
|
||||
ns.set_value("", &display_name.to_string())
|
||||
.map_err(|e| format!("set ns name: {e}"))?;
|
||||
|
||||
// 7) Kontext-Menue-Verben (Rechtsklick) fuer Dateien unter dem Mount
|
||||
install_context_menu(mount_point)?;
|
||||
|
||||
// 8) Explorer informieren (SHChangeNotify)
|
||||
notify_shell();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Registriert "Immer offline halten" / "Speicher freigeben" als
|
||||
/// Rechtsklick-Menuepunkte, die nur fuer Dateien unterhalb des Mounts
|
||||
/// angezeigt werden (AppliesTo-Filter).
|
||||
fn install_context_menu(mount_point: &Path) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| format!("current_exe: {e}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
// Trailing Backslash wegstrippen, dann eine saubere AQS-Query bauen.
|
||||
// Registry-Werte sind normale Strings; Backslashes bleiben einfach.
|
||||
let mount_clean = mount_point
|
||||
.to_string_lossy()
|
||||
.trim_end_matches('\\')
|
||||
.to_string();
|
||||
// AppliesTo: nur Dateien, deren Pfad mit dem Mount-Ordner beginnt.
|
||||
let applies_to = format!("System.ItemPathDisplay:~< \"{}\"", mount_clean);
|
||||
|
||||
for (verb, label, flag) in [
|
||||
("MiniCloudPin", "Immer offline verfuegbar", "--pin"),
|
||||
("MiniCloudUnpin", "Speicher freigeben", "--unpin"),
|
||||
] {
|
||||
// Unter AllFilesystemObjects statt * - das greift auch fuer
|
||||
// Ordner und vermeidet Konflikte mit Dateityp-spezifischen Verben.
|
||||
let key_path = format!("Software\\Classes\\AllFilesystemObjects\\shell\\{}", verb);
|
||||
let (k, _) = hkcu
|
||||
.create_subkey(&key_path)
|
||||
.map_err(|e| format!("verb {verb}: {e}"))?;
|
||||
k.set_value("", &label.to_string())
|
||||
.map_err(|e| format!("default: {e}"))?;
|
||||
k.set_value("MUIVerb", &label.to_string())
|
||||
.map_err(|e| format!("MUIVerb: {e}"))?;
|
||||
k.set_value("AppliesTo", &applies_to)
|
||||
.map_err(|e| format!("AppliesTo: {e}"))?;
|
||||
k.set_value("Icon", &exe)
|
||||
.map_err(|e| format!("Icon: {e}"))?;
|
||||
|
||||
let (cmd, _) = k
|
||||
.create_subkey("command")
|
||||
.map_err(|e| format!("cmd: {e}"))?;
|
||||
cmd.set_value("", &format!("\"{}\" {} \"%1\"", exe, flag))
|
||||
.map_err(|e| format!("cmdline: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall_context_menu() {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
for verb in ["MiniCloudPin", "MiniCloudUnpin"] {
|
||||
// alte (falsche) Stelle ebenfalls aufraeumen
|
||||
let _ = hkcu.delete_subkey_all(format!("Software\\Classes\\*\\shell\\{}", verb));
|
||||
let _ = hkcu.delete_subkey_all(format!(
|
||||
"Software\\Classes\\AllFilesystemObjects\\shell\\{}",
|
||||
verb
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Entferne die Shell-Integration wieder.
|
||||
pub fn uninstall() -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
let ns_path = format!(
|
||||
"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Desktop\\NameSpace\\{}",
|
||||
CLSID_GUID
|
||||
);
|
||||
let _ = hkcu.delete_subkey_all(&ns_path);
|
||||
|
||||
let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID);
|
||||
let _ = hkcu.delete_subkey_all(&clsid_path);
|
||||
|
||||
uninstall_context_menu();
|
||||
|
||||
notify_shell();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Teilt Explorer mit, dass sich die Shell-Namespace-Liste geaendert hat.
|
||||
/// Ohne das sieht man den neuen Eintrag erst nach Explorer-Neustart.
|
||||
fn notify_shell() {
|
||||
use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST};
|
||||
unsafe {
|
||||
SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard-Icon-Quelle: die laufende .exe mit Index 0.
|
||||
pub fn default_icon_source() -> String {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(|s| format!("{},0", s)))
|
||||
.unwrap_or_else(|| "%SystemRoot%\\system32\\imageres.dll,2".to_string())
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Hintergrund-Synchronisation fuer den Cloud-Files-Modus.
|
||||
//!
|
||||
//! Zwei Aufgaben:
|
||||
//! 1. Lokale Aenderungen im Mount-Point beobachten (notify-Watcher) und
|
||||
//! geaenderte Dateien hochladen. Neu angelegte Dateien werden als
|
||||
//! neue Datei beim Server registriert und als Platzhalter markiert.
|
||||
//! 2. Serverseitige Aenderungen pollen (/api/sync/changes?since=...) und
|
||||
//! fehlende Platzhalter erzeugen bzw. entfernte loeschen.
|
||||
//!
|
||||
//! Der Loop laeuft in einem dedizierten Tokio-Task; ein gespeicherter
|
||||
//! `Stop`-Channel beendet ihn sauber beim Deaktivieren.
|
||||
|
||||
use super::RemoteEntry;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncLoopConfig {
|
||||
pub server_url: String,
|
||||
pub access_token: String,
|
||||
pub mount_point: PathBuf,
|
||||
pub poll_interval_secs: u64,
|
||||
}
|
||||
|
||||
pub struct SyncLoopHandle {
|
||||
pub stop_flag: Arc<AtomicBool>,
|
||||
pub tx: mpsc::UnboundedSender<LoopMessage>,
|
||||
}
|
||||
|
||||
pub enum LoopMessage {
|
||||
LocalChange(PathBuf),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Starte den Sync-Loop. Gibt einen Handle zurueck, mit dem man ihn
|
||||
/// stoppen oder externe Events (z.B. vom Watcher) einspeisen kann.
|
||||
pub fn start(cfg: SyncLoopConfig) -> SyncLoopHandle {
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<LoopMessage>();
|
||||
let stop = stop_flag.clone();
|
||||
let cfg_task = cfg.clone();
|
||||
tokio::spawn(async move {
|
||||
let client = reqwest::Client::new();
|
||||
let mut since: Option<String> = None;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(cfg_task.poll_interval_secs));
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
if let Err(e) = poll_server_changes(&client, &cfg_task, &mut since).await {
|
||||
eprintln!("[cloud_files] poll error: {e}");
|
||||
}
|
||||
}
|
||||
Some(msg) = rx.recv() => {
|
||||
match msg {
|
||||
LoopMessage::Shutdown => break,
|
||||
LoopMessage::LocalChange(path) => {
|
||||
if let Err(e) = upload_local_change(&client, &cfg_task, &path).await {
|
||||
eprintln!("[cloud_files] upload error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
SyncLoopHandle { stop_flag, tx }
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChangesResponse {
|
||||
#[serde(default)]
|
||||
created: Vec<RemoteEntry>,
|
||||
#[serde(default)]
|
||||
updated: Vec<RemoteEntry>,
|
||||
#[serde(default)]
|
||||
deleted: Vec<i64>,
|
||||
timestamp: Option<String>,
|
||||
}
|
||||
|
||||
async fn poll_server_changes(
|
||||
client: &reqwest::Client,
|
||||
cfg: &SyncLoopConfig,
|
||||
since: &mut Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let base = cfg.server_url.trim_end_matches('/');
|
||||
let mut url = format!("{}/api/sync/changes", base);
|
||||
if let Some(s) = since.as_deref() {
|
||||
url.push_str(&format!("?since={}", urlencode(s)));
|
||||
}
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&cfg.access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let body: ChangesResponse = resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Created + Updated: jeweils passendes Verzeichnis sichern, dann
|
||||
// Platzhalter (neu) anlegen. Bei Updates muss der alte Platzhalter
|
||||
// erst geloescht werden - Windows erlaubt kein "replace in place".
|
||||
for e in body.created.iter().chain(body.updated.iter()) {
|
||||
let rel = build_relative_path(e);
|
||||
let full = cfg.mount_point.join(&rel);
|
||||
if e.is_folder {
|
||||
let _ = std::fs::create_dir_all(&full);
|
||||
continue;
|
||||
}
|
||||
let parent = full.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| cfg.mount_point.clone());
|
||||
let _ = std::fs::create_dir_all(&parent);
|
||||
let _ = std::fs::remove_file(&full); // ignoriert falls nicht da
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let identity = e.id.to_string();
|
||||
if let Err(err) = super::windows::create_placeholder_at(
|
||||
&parent,
|
||||
&e.name,
|
||||
e.size,
|
||||
&e.modified_at,
|
||||
identity.as_bytes(),
|
||||
) {
|
||||
eprintln!("[cloud_files] placeholder {}: {}", e.name, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deleted: nur per ID vom Server - wir kennen den Pfad nicht mehr.
|
||||
// MVP: ignorieren. In Version 2 fuehren wir ein lokales Mapping.
|
||||
let _ = body.deleted;
|
||||
|
||||
if let Some(ts) = body.timestamp {
|
||||
*since = Some(ts);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_local_change(
|
||||
client: &reqwest::Client,
|
||||
cfg: &SyncLoopConfig,
|
||||
path: &PathBuf,
|
||||
) -> Result<(), String> {
|
||||
if !path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
// cfapi-Platzhalter oder gerade hydrierende Dateien NICHT hochladen -
|
||||
// sonst wird jede Wolken-Datei sofort komplett gesynct und wir haben
|
||||
// keinen On-Demand-Modus mehr.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if super::windows::is_cfapi_placeholder(path) {
|
||||
super::windows::log_msg(
|
||||
&cfg.mount_point,
|
||||
&format!("skip upload (placeholder): {}", path.display()),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Eigene Log-Datei nicht mit hochladen.
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with(".minicloud-"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Relativer Pfad im Mount = Ziel-Pfad auf Server
|
||||
let rel = path
|
||||
.strip_prefix(&cfg.mount_point)
|
||||
.map_err(|_| "path outside mount".to_string())?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let base = cfg.server_url.trim_end_matches('/');
|
||||
let url = format!("{}/api/files/upload", base);
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unnamed")
|
||||
.to_string();
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("path", rel.clone())
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(bytes).file_name(file_name),
|
||||
);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(&cfg.access_token)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_relative_path(e: &RemoteEntry) -> PathBuf {
|
||||
// Achtung: RemoteEntry hat nur parent_id, nicht den kompletten Pfad.
|
||||
// Fuer diesen einfachen Fall nehmen wir nur den Namen. Bei geschachtelten
|
||||
// Ordnern muesste man die Hierarchie ueber /api/sync/tree vor-laden -
|
||||
// das passiert einmal beim Aktivieren; Delta-Updates kommen meistens
|
||||
// flach (oder in einer gemeinsamen Wurzel).
|
||||
PathBuf::from(&e.name)
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
// Sehr minimalistisch: wir ersetzen nur problematische Zeichen.
|
||||
s.replace(' ', "%20").replace(':', "%3A").replace('+', "%2B")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Leichtgewichtiger Callback-basierter FS-Watcher fuer den Cloud-Files-Modus.
|
||||
//!
|
||||
//! Anders als `sync::watcher::FileWatcher` gibt dieser hier einen Closure
|
||||
//! direkt an notify weiter, sodass wir kein Channel-Pumpen brauchen.
|
||||
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, Config};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct CallbackWatcher {
|
||||
_watcher: RecommendedWatcher,
|
||||
}
|
||||
|
||||
impl CallbackWatcher {
|
||||
pub fn new<F>(watch_dir: &Path, mut on_change: F) -> Result<Self, String>
|
||||
where
|
||||
F: FnMut(PathBuf, EventKind) + Send + 'static,
|
||||
{
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(ev) = res {
|
||||
for path in ev.paths {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if name.starts_with('.')
|
||||
|| name.starts_with('~')
|
||||
|| name.ends_with(".tmp")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
on_change(path, ev.kind.clone());
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default(),
|
||||
)
|
||||
.map_err(|e| format!("Watcher-Fehler: {e}"))?;
|
||||
|
||||
watcher
|
||||
.watch(watch_dir, RecursiveMode::Recursive)
|
||||
.map_err(|e| format!("Watch-Fehler: {e}"))?;
|
||||
|
||||
Ok(Self { _watcher: watcher })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
//! Windows Cloud Files API Integration.
|
||||
//!
|
||||
//! Registriert den Sync-Ordner als Sync-Root, legt Platzhalter-Dateien an
|
||||
//! und reicht Zugriffe auf Dateidaten als HTTPS-Download durch. Der
|
||||
//! Explorer zeigt Wolken-/Haken-Overlays automatisch an, solange die
|
||||
//! Pin-Stati korrekt gesetzt sind.
|
||||
//!
|
||||
//! Voraussetzung: Windows 10 1709+ (cfapi.dll). Der Account-Identifier
|
||||
//! sollte stabil sein (z.B. Hash(Server-URL + Username)).
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use super::RemoteEntry;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::ptr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use widestring::U16CString;
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Storage::CloudFilters as CF;
|
||||
use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
|
||||
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct CloudContext {
|
||||
pub server_url: String,
|
||||
pub access_token: String,
|
||||
pub mount_point: PathBuf,
|
||||
}
|
||||
|
||||
static CONTEXT: Lazy<Arc<Mutex<CloudContext>>> =
|
||||
Lazy::new(|| Arc::new(Mutex::new(CloudContext::default())));
|
||||
|
||||
static CONNECTION_KEY: Lazy<Mutex<Option<CF::CF_CONNECTION_KEY>>> =
|
||||
Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub fn set_context(server_url: String, access_token: String, mount_point: PathBuf) {
|
||||
let mut ctx = CONTEXT.lock().unwrap();
|
||||
ctx.server_url = server_url;
|
||||
ctx.access_token = access_token;
|
||||
ctx.mount_point = mount_point;
|
||||
}
|
||||
|
||||
fn ctx_snapshot() -> CloudContext {
|
||||
CONTEXT.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
const PROVIDER_VERSION: &str = "1.0";
|
||||
|
||||
// Windows-FILETIME: 100ns-Ticks seit 1601-01-01. Unix-Epoch liegt
|
||||
// 11_644_473_600 Sekunden danach.
|
||||
fn unix_to_ft_ticks(unix_secs: i64) -> i64 {
|
||||
(unix_secs + 11_644_473_600) * 10_000_000
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync-Root-Registrierung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn register_sync_root(
|
||||
mount_point: &PathBuf,
|
||||
provider_name: &str,
|
||||
account_id: &str,
|
||||
) -> Result<(), String> {
|
||||
// COM initialisieren (cfapi benoetigt MTA-Apartment)
|
||||
unsafe {
|
||||
let _ = CoInitializeEx(Some(ptr::null()), COINIT_MULTITHREADED);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(mount_point).map_err(|e| format!("mkdir: {e}"))?;
|
||||
|
||||
let display = format!("Mini-Cloud - {}", account_id);
|
||||
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
|
||||
.map_err(|e| format!("path encode: {e}"))?;
|
||||
let display_wide = U16CString::from_str(&display).map_err(|e| e.to_string())?;
|
||||
let provider_wide = U16CString::from_str(provider_name).map_err(|e| e.to_string())?;
|
||||
let version_wide = U16CString::from_str(PROVIDER_VERSION).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut info = CF::CF_SYNC_REGISTRATION::default();
|
||||
info.StructSize = std::mem::size_of::<CF::CF_SYNC_REGISTRATION>() as u32;
|
||||
info.ProviderName = PCWSTR(provider_wide.as_ptr());
|
||||
info.ProviderVersion = PCWSTR(version_wide.as_ptr());
|
||||
// Stabile GUID fuer "Mini-Cloud" (random einmalig generiert).
|
||||
info.ProviderId = windows::core::GUID::from_u128(0x4D696E69_436C_6F75_6444_7566667944ab);
|
||||
|
||||
let mut policies = CF::CF_SYNC_POLICIES::default();
|
||||
policies.StructSize = std::mem::size_of::<CF::CF_SYNC_POLICIES>() as u32;
|
||||
policies.HardLink = CF::CF_HARDLINK_POLICY::default();
|
||||
policies.Hydration = CF::CF_HYDRATION_POLICY::default();
|
||||
policies.Population = CF::CF_POPULATION_POLICY::default();
|
||||
policies.InSync = CF::CF_INSYNC_POLICY::default();
|
||||
|
||||
// Hydration PARTIAL = Datei-Inhalt kommt bei Zugriff per FETCH_DATA.
|
||||
// Population FULL = Ordnerinhalt ist komplett vorgefuellt durch uns
|
||||
// (populate_placeholders). So muss Windows NICHT FETCH_PLACEHOLDERS
|
||||
// callen, den wir nicht implementieren - sonst timeout beim Oeffnen.
|
||||
policies.Hydration.Primary = CF::CF_HYDRATION_POLICY_PARTIAL;
|
||||
policies.Population.Primary = CF::CF_POPULATION_POLICY_FULL;
|
||||
|
||||
// Holder fuer displayname, damit wir ihn spaeter ggf. in ein eigenes
|
||||
// struct einbauen koennen. windows-rs verlangt hier nichts weiter.
|
||||
let _ = display_wide;
|
||||
|
||||
// Erst eine eventuell vorhandene Registrierung wegraeumen. Sonst
|
||||
// uebernimmt UPDATE nur einen Teil der Policies und alte PARTIAL-
|
||||
// Population-Einstellungen bleiben aktiv -> Explorer-Timeout.
|
||||
unsafe {
|
||||
let _ = CF::CfUnregisterSyncRoot(PCWSTR(path_wide.as_ptr()));
|
||||
}
|
||||
|
||||
log_msg(mount_point, &format!(
|
||||
"register_sync_root path={} provider={} account={}",
|
||||
mount_point.display(), provider_name, account_id
|
||||
));
|
||||
|
||||
unsafe {
|
||||
if let Err(e) = CF::CfRegisterSyncRoot(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
&info,
|
||||
&policies,
|
||||
CF::CF_REGISTER_FLAG_NONE,
|
||||
) {
|
||||
log_err(mount_point, &format!("CfRegisterSyncRoot FAILED: {e:?}"));
|
||||
// Als Fallback mit UPDATE-Flag
|
||||
CF::CfRegisterSyncRoot(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
&info,
|
||||
&policies,
|
||||
CF::CF_REGISTER_FLAG_UPDATE,
|
||||
)
|
||||
.map_err(|e| format!("CfRegisterSyncRoot(UPDATE): {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
log_msg(mount_point, "CfRegisterSyncRoot OK");
|
||||
connect_callbacks(mount_point)?;
|
||||
log_msg(mount_point, "callbacks connected");
|
||||
|
||||
// Explorer-Sidebar-Eintrag mit Wolken-Icon
|
||||
let icon = super::shell_integration::default_icon_source();
|
||||
match super::shell_integration::install(provider_name, mount_point, &icon) {
|
||||
Ok(()) => log_msg(mount_point, "shell integration installed"),
|
||||
Err(e) => log_err(mount_point, &format!("shell integration FAILED: {e}")),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unregister_sync_root(mount_point: &PathBuf) -> Result<(), String> {
|
||||
// Shell-Eintrag zuerst entfernen (schlaegt nie fehl).
|
||||
let _ = super::shell_integration::uninstall();
|
||||
|
||||
let _ = disconnect_callbacks();
|
||||
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
unsafe {
|
||||
CF::CfUnregisterSyncRoot(PCWSTR(path_wide.as_ptr()))
|
||||
.map_err(|e| format!("CfUnregisterSyncRoot: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Callback-Tabelle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
unsafe extern "system" fn on_fetch_data(
|
||||
info: *const CF::CF_CALLBACK_INFO,
|
||||
params: *const CF::CF_CALLBACK_PARAMETERS,
|
||||
) {
|
||||
let info = &*info;
|
||||
let params = &*params;
|
||||
let fetch = ¶ms.Anonymous.FetchData;
|
||||
|
||||
// FileIdentity enthaelt unsere Mini-Cloud-File-ID als UTF-8-Bytes.
|
||||
let identity = std::slice::from_raw_parts(
|
||||
info.FileIdentity as *const u8,
|
||||
info.FileIdentityLength as usize,
|
||||
);
|
||||
let file_id: i64 = std::str::from_utf8(identity)
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let offset: i64 = fetch.RequiredFileOffset;
|
||||
let length: u64 = fetch.RequiredLength as u64;
|
||||
let connection_key = info.ConnectionKey;
|
||||
let transfer_key = info.TransferKey;
|
||||
|
||||
// HTTPS-Download im separaten Thread (Callback darf nicht blockieren).
|
||||
let ctx = ctx_snapshot();
|
||||
std::thread::spawn(move || {
|
||||
log_msg(&ctx.mount_point, &format!(
|
||||
"FETCH_DATA file_id={file_id} offset={offset} len={length}"
|
||||
));
|
||||
match transfer_range(connection_key, transfer_key, file_id, offset, length, &ctx) {
|
||||
Ok(()) => log_msg(&ctx.mount_point, &format!(
|
||||
"fetch file_id={file_id} OK"
|
||||
)),
|
||||
Err(e) => {
|
||||
log_err(&ctx.mount_point, &format!(
|
||||
"fetch file_id={file_id} offset={offset} len={length} FAILED: {e}"
|
||||
));
|
||||
// Garantiert Fehler-Completion, damit Windows nicht in Timeout laeuft.
|
||||
let _ = complete_transfer(connection_key, transfer_key, None, offset, length);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn log_msg(mount: &Path, msg: &str) {
|
||||
use std::io::Write;
|
||||
// Log-Datei NEBEN den Mount, damit sie nicht selbst als Platzhalter
|
||||
// behandelt wird.
|
||||
let log = mount
|
||||
.parent()
|
||||
.map(|p| p.join(".minicloud-cloudfiles.log"))
|
||||
.unwrap_or_else(|| PathBuf::from(".minicloud-cloudfiles.log"));
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
|
||||
let _ = writeln!(f, "[{}] {}", chrono::Utc::now().to_rfc3339(), msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_err(mount: &Path, msg: &str) {
|
||||
log_msg(mount, msg);
|
||||
}
|
||||
|
||||
/// True wenn die Datei ein cfapi-Platzhalter ist (noch nicht hydriert)
|
||||
/// oder gerade vom Cloud-Filter verwaltet wird. Fuer solche Dateien
|
||||
/// duerfen wir KEINEN Upload ausloesen, sonst verwandelt der Sync-Loop
|
||||
/// jeden Platzhalter sofort in eine vollstaendig lokale Datei.
|
||||
pub fn is_cfapi_placeholder(path: &Path) -> bool {
|
||||
use windows::Win32::Storage::FileSystem::GetFileAttributesW;
|
||||
let Ok(w) = U16CString::from_str(path.to_string_lossy().as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
let attrs = unsafe { GetFileAttributesW(PCWSTR(w.as_ptr())) };
|
||||
if attrs == u32::MAX {
|
||||
return false;
|
||||
}
|
||||
// FILE_ATTRIBUTE_OFFLINE (0x1000) oder
|
||||
// FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS (0x400000) oder
|
||||
// FILE_ATTRIBUTE_RECALL_ON_OPEN (0x40000)
|
||||
(attrs & 0x0040_1000) != 0 || (attrs & 0x0004_0000) != 0
|
||||
}
|
||||
|
||||
fn transfer_range(
|
||||
connection_key: CF::CF_CONNECTION_KEY,
|
||||
transfer_key: i64,
|
||||
file_id: i64,
|
||||
offset: i64,
|
||||
length: u64,
|
||||
ctx: &CloudContext,
|
||||
) -> Result<(), String> {
|
||||
if ctx.server_url.is_empty() || ctx.access_token.is_empty() {
|
||||
return Err("CloudContext nicht gesetzt (Server/Token leer)".into());
|
||||
}
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| format!("client: {e}"))?;
|
||||
let url = format!(
|
||||
"{}/api/files/{}/download",
|
||||
ctx.server_url.trim_end_matches('/'),
|
||||
file_id
|
||||
);
|
||||
let range = format!("bytes={}-{}", offset, offset as u64 + length - 1);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&ctx.access_token)
|
||||
.header("Range", &range)
|
||||
.send()
|
||||
.map_err(|e| format!("send: {e}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() && status.as_u16() != 206 {
|
||||
return Err(format!("HTTP {}", status));
|
||||
}
|
||||
let bytes = resp.bytes().map_err(|e: reqwest::Error| e.to_string())?;
|
||||
// Wenn Server kein Range unterstuetzt und volle Datei liefert,
|
||||
// aus dem Body den angeforderten Bereich ausschneiden.
|
||||
let slice: &[u8] = if status.as_u16() == 206 {
|
||||
&bytes[..]
|
||||
} else {
|
||||
let start = offset as usize;
|
||||
let end = (start + length as usize).min(bytes.len());
|
||||
if start >= bytes.len() {
|
||||
&[]
|
||||
} else {
|
||||
&bytes[start..end]
|
||||
}
|
||||
};
|
||||
complete_transfer(connection_key, transfer_key, Some(slice), offset, slice.len() as u64)
|
||||
}
|
||||
|
||||
fn complete_transfer(
|
||||
connection_key: CF::CF_CONNECTION_KEY,
|
||||
transfer_key: i64,
|
||||
data: Option<&[u8]>,
|
||||
offset: i64,
|
||||
length: u64,
|
||||
) -> Result<(), String> {
|
||||
let mut op_info = CF::CF_OPERATION_INFO::default();
|
||||
op_info.StructSize = std::mem::size_of::<CF::CF_OPERATION_INFO>() as u32;
|
||||
op_info.Type = CF::CF_OPERATION_TYPE_TRANSFER_DATA;
|
||||
op_info.ConnectionKey = connection_key;
|
||||
op_info.TransferKey = transfer_key;
|
||||
|
||||
let mut params = CF::CF_OPERATION_PARAMETERS::default();
|
||||
params.ParamSize = std::mem::size_of::<CF::CF_OPERATION_PARAMETERS>() as u32;
|
||||
|
||||
unsafe {
|
||||
let transfer = &mut params.Anonymous.TransferData;
|
||||
if let Some(data) = data {
|
||||
transfer.CompletionStatus = windows::Win32::Foundation::NTSTATUS(0); // STATUS_SUCCESS
|
||||
transfer.Buffer = data.as_ptr() as _;
|
||||
transfer.Offset = offset;
|
||||
transfer.Length = length as i64;
|
||||
} else {
|
||||
transfer.CompletionStatus =
|
||||
windows::Win32::Foundation::NTSTATUS(0xC0000001u32 as i32); // STATUS_UNSUCCESSFUL
|
||||
}
|
||||
|
||||
CF::CfExecute(&op_info, &mut params).map_err(|e| format!("CfExecute: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe extern "system" fn on_fetch_placeholders(
|
||||
info: *const CF::CF_CALLBACK_INFO,
|
||||
_params: *const CF::CF_CALLBACK_PARAMETERS,
|
||||
) {
|
||||
// Safety-Net: wir populieren schon ueber populate_placeholders,
|
||||
// aber falls Windows trotzdem ruft, geben wir leere Antwort.
|
||||
let info = &*info;
|
||||
let mut op_info = CF::CF_OPERATION_INFO::default();
|
||||
op_info.StructSize = std::mem::size_of::<CF::CF_OPERATION_INFO>() as u32;
|
||||
op_info.Type = CF::CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS;
|
||||
op_info.ConnectionKey = info.ConnectionKey;
|
||||
op_info.TransferKey = info.TransferKey;
|
||||
let mut params = CF::CF_OPERATION_PARAMETERS::default();
|
||||
params.ParamSize = std::mem::size_of::<CF::CF_OPERATION_PARAMETERS>() as u32;
|
||||
let transfer = &mut params.Anonymous.TransferPlaceholders;
|
||||
transfer.CompletionStatus = windows::Win32::Foundation::NTSTATUS(0);
|
||||
transfer.PlaceholderTotalCount = 0;
|
||||
transfer.PlaceholderArray = std::ptr::null_mut();
|
||||
transfer.PlaceholderCount = 0;
|
||||
transfer.EntriesProcessed = 0;
|
||||
transfer.Flags = CF::CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION;
|
||||
let _ = CF::CfExecute(&op_info, &mut params);
|
||||
}
|
||||
|
||||
fn connect_callbacks(mount_point: &Path) -> Result<(), String> {
|
||||
let callbacks = [
|
||||
CF::CF_CALLBACK_REGISTRATION {
|
||||
Type: CF::CF_CALLBACK_TYPE_FETCH_DATA,
|
||||
Callback: Some(on_fetch_data),
|
||||
},
|
||||
CF::CF_CALLBACK_REGISTRATION {
|
||||
Type: CF::CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS,
|
||||
Callback: Some(on_fetch_placeholders),
|
||||
},
|
||||
// Sentinel: Type = INVALID beendet die Tabelle.
|
||||
CF::CF_CALLBACK_REGISTRATION {
|
||||
Type: CF::CF_CALLBACK_TYPE_NONE,
|
||||
Callback: None,
|
||||
},
|
||||
];
|
||||
|
||||
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let key = unsafe {
|
||||
CF::CfConnectSyncRoot(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
callbacks.as_ptr(),
|
||||
None,
|
||||
CF::CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO
|
||||
| CF::CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH,
|
||||
)
|
||||
.map_err(|e| format!("CfConnectSyncRoot: {e}"))?
|
||||
};
|
||||
*CONNECTION_KEY.lock().unwrap() = Some(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disconnect_callbacks() -> Result<(), String> {
|
||||
if let Some(key) = CONNECTION_KEY.lock().unwrap().take() {
|
||||
unsafe {
|
||||
CF::CfDisconnectSyncRoot(key)
|
||||
.map_err(|e| format!("CfDisconnectSyncRoot: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placeholder-Erzeugung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn populate_placeholders(
|
||||
mount_point: &PathBuf,
|
||||
entries: &[RemoteEntry],
|
||||
) -> Result<(), String> {
|
||||
use std::collections::HashMap;
|
||||
log_msg(mount_point, &format!(
|
||||
"populate_placeholders: {} Eintraege", entries.len()
|
||||
));
|
||||
let by_id: HashMap<i64, &RemoteEntry> = entries.iter().map(|e| (e.id, e)).collect();
|
||||
|
||||
fn rel_path<'a>(
|
||||
entry: &'a RemoteEntry,
|
||||
by_id: &HashMap<i64, &'a RemoteEntry>,
|
||||
) -> PathBuf {
|
||||
let mut parts = vec![entry.name.as_str()];
|
||||
let mut cur = entry.parent_id;
|
||||
while let Some(id) = cur {
|
||||
if let Some(p) = by_id.get(&id) {
|
||||
parts.push(p.name.as_str());
|
||||
cur = p.parent_id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
parts.reverse();
|
||||
parts.iter().collect()
|
||||
}
|
||||
|
||||
// Erst Ordner anlegen
|
||||
for e in entries.iter().filter(|e| e.is_folder) {
|
||||
let p = mount_point.join(rel_path(e, &by_id));
|
||||
std::fs::create_dir_all(&p).ok();
|
||||
}
|
||||
|
||||
// Dann Dateien als Platzhalter. Existierende "normale" Dateien
|
||||
// (z.B. nach vorherigem CfUnregisterSyncRoot) vorher loeschen,
|
||||
// weil CfCreatePlaceholders sonst mit ERROR_FILE_EXISTS scheitert
|
||||
// und die Datei nie zum Platzhalter wird -> spaeter koennte man
|
||||
// sie nicht mehr dehydrieren (0x80070178 "keine Clouddatei").
|
||||
for e in entries.iter().filter(|e| !e.is_folder) {
|
||||
let rel = rel_path(e, &by_id);
|
||||
let full = mount_point.join(&rel);
|
||||
let parent = rel
|
||||
.parent()
|
||||
.map(|p| mount_point.join(p))
|
||||
.unwrap_or_else(|| mount_point.clone());
|
||||
let identity = e.id.to_string();
|
||||
|
||||
if full.exists() && !is_cfapi_placeholder(&full) {
|
||||
log_msg(mount_point, &format!(
|
||||
"deleting non-placeholder {} to recreate",
|
||||
full.display()
|
||||
));
|
||||
if let Err(err) = std::fs::remove_file(&full) {
|
||||
log_err(mount_point, &format!(
|
||||
"remove {} failed: {err}", full.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match create_placeholder(&parent, &e.name, e.size, &e.modified_at, identity.as_bytes()) {
|
||||
Ok(()) => log_msg(mount_point, &format!("placeholder created: {}", full.display())),
|
||||
Err(err) => log_err(mount_point, &format!(
|
||||
"placeholder {} FAILED: {err}", full.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_placeholder_at(
|
||||
parent_dir: &Path,
|
||||
name: &str,
|
||||
size: i64,
|
||||
modified_iso: &str,
|
||||
file_identity: &[u8],
|
||||
) -> Result<(), String> {
|
||||
create_placeholder(parent_dir, name, size, modified_iso, file_identity)
|
||||
}
|
||||
|
||||
fn create_placeholder(
|
||||
parent_dir: &Path,
|
||||
name: &str,
|
||||
size: i64,
|
||||
modified_iso: &str,
|
||||
file_identity: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let parent_wide = U16CString::from_str(parent_dir.to_string_lossy().as_ref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let name_wide = U16CString::from_str(name).map_err(|e| e.to_string())?;
|
||||
|
||||
let mtime_unix = chrono::DateTime::parse_from_rfc3339(modified_iso)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let ft_ticks = unix_to_ft_ticks(mtime_unix);
|
||||
|
||||
let mut ph = CF::CF_PLACEHOLDER_CREATE_INFO::default();
|
||||
ph.RelativeFileName = PCWSTR(name_wide.as_ptr());
|
||||
ph.FsMetadata.FileSize = size;
|
||||
ph.FsMetadata.BasicInfo.FileAttributes = FILE_ATTRIBUTE_NORMAL.0;
|
||||
ph.FsMetadata.BasicInfo.LastWriteTime = ft_ticks;
|
||||
ph.FsMetadata.BasicInfo.CreationTime = ft_ticks;
|
||||
ph.FsMetadata.BasicInfo.ChangeTime = ft_ticks;
|
||||
ph.FsMetadata.BasicInfo.LastAccessTime = ft_ticks;
|
||||
ph.Flags = CF::CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC;
|
||||
ph.FileIdentity = file_identity.as_ptr() as _;
|
||||
ph.FileIdentityLength = file_identity.len() as u32;
|
||||
|
||||
// CfCreatePlaceholders nimmt in windows-rs 0.58 einen Slice und einen
|
||||
// Option<*mut u32> fuer "wie viele wurden angelegt".
|
||||
let mut phs = [ph];
|
||||
let mut count: u32 = 0;
|
||||
unsafe {
|
||||
CF::CfCreatePlaceholders(
|
||||
PCWSTR(parent_wide.as_ptr()),
|
||||
&mut phs,
|
||||
CF::CF_CREATE_FLAG_NONE,
|
||||
Some(&mut count as *mut u32),
|
||||
)
|
||||
.map_err(|e| format!("CfCreatePlaceholders: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pin / Unpin (offline halten)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn set_pin_state(file: &Path, pinned: bool) -> Result<(), String> {
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
FILE_WRITE_ATTRIBUTES, FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE, OPEN_EXISTING,
|
||||
};
|
||||
|
||||
let path_wide = U16CString::from_str(file.to_string_lossy().as_ref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// CfSetPinState / CfDehydratePlaceholder brauchen WRITE_ATTRIBUTES.
|
||||
// OPEN_REPARSE_POINT verhindert, dass das Oeffnen selbst eine
|
||||
// Hydration ausloest (sonst waere Unpin bedeutungslos).
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
(FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES).0,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
None,
|
||||
)
|
||||
}
|
||||
.map_err(|e| format!("open: {e}"))?;
|
||||
|
||||
let state = if pinned {
|
||||
CF::CF_PIN_STATE_PINNED
|
||||
} else {
|
||||
CF::CF_PIN_STATE_UNPINNED
|
||||
};
|
||||
let set_res = unsafe {
|
||||
CF::CfSetPinState(handle, state, CF::CF_SET_PIN_FLAG_NONE, None)
|
||||
};
|
||||
|
||||
// Hydrate bei Pin / Dehydrate bei Unpin. CfSetPinState aendert nur
|
||||
// das Flag - ohne explizite Hydrate-/Dehydrate-Calls passiert am
|
||||
// Disk-Inhalt und am Icon nichts Sichtbares.
|
||||
let (hydrate_err, dehydrate_err) = if set_res.is_ok() {
|
||||
if pinned {
|
||||
let r = unsafe {
|
||||
CF::CfHydratePlaceholder(
|
||||
handle,
|
||||
0,
|
||||
-1,
|
||||
CF::CF_HYDRATE_FLAG_NONE,
|
||||
None,
|
||||
)
|
||||
};
|
||||
(r.err().map(|e| format!("{:?}", e)), None)
|
||||
} else {
|
||||
let r = unsafe {
|
||||
CF::CfDehydratePlaceholder(
|
||||
handle,
|
||||
0,
|
||||
-1,
|
||||
CF::CF_DEHYDRATE_FLAG_NONE,
|
||||
None,
|
||||
)
|
||||
};
|
||||
(None, r.err().map(|e| format!("{:?}", e)))
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let _ = windows::Win32::Foundation::CloseHandle(handle);
|
||||
}
|
||||
|
||||
// Explorer Icon-Overlay aktualisieren
|
||||
notify_file_update(file);
|
||||
|
||||
// Log-Verzeichnis ist der Mount-Ordner oder dessen Parent
|
||||
let log_dir = file
|
||||
.ancestors()
|
||||
.find(|p| p.parent().is_some())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| file.to_path_buf());
|
||||
log_msg(
|
||||
&log_dir,
|
||||
&format!(
|
||||
"set_pin_state file={} pinned={} result={:?} hydrate_err={:?} dehydrate_err={:?}",
|
||||
file.display(),
|
||||
pinned,
|
||||
set_res,
|
||||
hydrate_err,
|
||||
dehydrate_err
|
||||
),
|
||||
);
|
||||
|
||||
set_res.map_err(|e| format!("CfSetPinState: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sagt dem Shell "diese Datei hat sich geaendert" damit das Overlay-
|
||||
/// Icon (Wolke/Haken) aktualisiert wird, ohne dass der User F5 druecken
|
||||
/// muss.
|
||||
fn notify_file_update(file: &Path) {
|
||||
use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_UPDATEITEM, SHCNF_PATHW};
|
||||
let Ok(w) = U16CString::from_str(file.to_string_lossy().as_ref()) else {
|
||||
return;
|
||||
};
|
||||
unsafe {
|
||||
SHChangeNotify(
|
||||
SHCNE_UPDATEITEM,
|
||||
SHCNF_PATHW,
|
||||
Some(w.as_ptr() as _),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod sync;
|
||||
mod cloud_files;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -26,6 +27,8 @@ struct AppState {
|
||||
sync_paths: Mutex<Vec<SyncPath>>,
|
||||
journal: Arc<Journal>,
|
||||
background_started: AtomicBool,
|
||||
cloud_files_loop: Mutex<Option<cloud_files::sync_loop::SyncLoopHandle>>,
|
||||
cloud_files_watcher: Mutex<Option<cloud_files::watcher::CallbackWatcher>>,
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
@@ -884,8 +887,272 @@ fn handle_single_instance() {
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native File-Provider-Integration (OneDrive-artige Platzhalter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_files_supported() -> bool {
|
||||
cloud_files::is_supported()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cloud_files_enable(
|
||||
state: State<'_, AppState>,
|
||||
mount_point: String,
|
||||
) -> Result<(), String> {
|
||||
let mp = PathBuf::from(&mount_point);
|
||||
// MutexGuards nur kurz halten, damit der Future Send bleibt.
|
||||
let (server, token, username) = {
|
||||
let api_guard = state.api.lock().unwrap();
|
||||
let api = api_guard.as_ref().ok_or("Nicht eingeloggt")?;
|
||||
let username = state
|
||||
.username
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| "user".into());
|
||||
(api.server_url.clone(), api.access_token.clone(), username)
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cloud_files::windows::set_context(server.clone(), token.clone(), mp.clone());
|
||||
}
|
||||
|
||||
cloud_files::register_sync_root(&mp, "Mini-Cloud", &username)?;
|
||||
|
||||
// Baum vom Server holen und Platzhalter anlegen
|
||||
let entries = fetch_remote_entries(&server, &token).await?;
|
||||
cloud_files::populate_placeholders(&mp, &entries)?;
|
||||
|
||||
// Hintergrund-Loop starten: poll Changes + upload lokaler Aenderungen
|
||||
let cfg = cloud_files::sync_loop::SyncLoopConfig {
|
||||
server_url: server.clone(),
|
||||
access_token: token.clone(),
|
||||
mount_point: mp.clone(),
|
||||
poll_interval_secs: 30,
|
||||
};
|
||||
let handle = cloud_files::sync_loop::start(cfg);
|
||||
|
||||
// Filesystem-Watcher mit Callback; leitet geaenderte Dateien
|
||||
// direkt an den Sync-Loop weiter.
|
||||
let tx = handle.tx.clone();
|
||||
let watcher = cloud_files::watcher::CallbackWatcher::new(&mp, move |path, kind| {
|
||||
use notify::EventKind;
|
||||
let relevant = matches!(kind, EventKind::Create(_) | EventKind::Modify(_));
|
||||
if relevant {
|
||||
let _ = tx.send(cloud_files::sync_loop::LoopMessage::LocalChange(path));
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("watcher: {e}"))?;
|
||||
|
||||
*state.cloud_files_loop.lock().unwrap() = Some(handle);
|
||||
*state.cloud_files_watcher.lock().unwrap() = Some(watcher);
|
||||
|
||||
// Mount-Pfad persistieren, damit er beim Neustart wiederkommt.
|
||||
let mut cfg = AppConfig::load();
|
||||
cfg.cloud_files_mount = mount_point.clone();
|
||||
let _ = cfg.save();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cloud_files_disable(
|
||||
state: State<'_, AppState>,
|
||||
mount_point: String,
|
||||
) -> Result<(), String> {
|
||||
// Loop und Watcher stoppen
|
||||
if let Some(handle) = state.cloud_files_loop.lock().unwrap().take() {
|
||||
handle.stop_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = handle.tx.send(cloud_files::sync_loop::LoopMessage::Shutdown);
|
||||
}
|
||||
state.cloud_files_watcher.lock().unwrap().take();
|
||||
let result = cloud_files::unregister_sync_root(&PathBuf::from(&mount_point));
|
||||
|
||||
// Auch bei Fehler Mount aus Config loeschen, damit der Client nicht
|
||||
// endlos versucht, einen toten Pfad wiederherzustellen.
|
||||
let mut cfg = AppConfig::load();
|
||||
cfg.cloud_files_mount.clear();
|
||||
let _ = cfg.save();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_files_get_mount() -> String {
|
||||
AppConfig::load().cloud_files_mount
|
||||
}
|
||||
|
||||
/// Notfall-Aufraeumen: Ordner als Sync-Root deregistrieren, auch wenn
|
||||
/// kein Callback-Handle existiert. Nuetzlich wenn der Client hart beendet
|
||||
/// wurde und ein "toter" Ordner in Windows haengt.
|
||||
#[tauri::command]
|
||||
async fn cloud_files_force_cleanup(mount_point: String) -> Result<(), String> {
|
||||
let mp = PathBuf::from(&mount_point);
|
||||
let _ = cloud_files::unregister_sync_root(&mp);
|
||||
let mut cfg = AppConfig::load();
|
||||
cfg.cloud_files_mount.clear();
|
||||
let _ = cfg.save();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cloud_files_pin(path: String) -> Result<(), String> {
|
||||
cloud_files::pin_file(&PathBuf::from(path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cloud_files_unpin(path: String) -> Result<(), String> {
|
||||
cloud_files::unpin_file(&PathBuf::from(path))
|
||||
}
|
||||
|
||||
async fn fetch_remote_entries(
|
||||
server: &str,
|
||||
token: &str,
|
||||
) -> Result<Vec<cloud_files::RemoteEntry>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/sync/tree", server.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("tree: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
let tree = json
|
||||
.get("tree")
|
||||
.ok_or("Antwort ohne 'tree'")?
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let shared = json
|
||||
.get("shared")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Rekursiv flach machen (Struktur parent_id beibehalten).
|
||||
// modified_at akzeptiert beides: das neue "modified_at" oder das
|
||||
// alte "updated_at" als Fallback.
|
||||
fn walk(
|
||||
nodes: &[serde_json::Value],
|
||||
parent: Option<i64>,
|
||||
out: &mut Vec<cloud_files::RemoteEntry>,
|
||||
) {
|
||||
for n in nodes {
|
||||
let id = n.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
|
||||
let name = n
|
||||
.get("name")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let is_folder = n.get("is_folder").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
let size = n.get("size").and_then(|x| x.as_i64()).unwrap_or(0);
|
||||
let modified_at = n
|
||||
.get("modified_at")
|
||||
.and_then(|x| x.as_str())
|
||||
.or_else(|| n.get("updated_at").and_then(|x| x.as_str()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let checksum = n
|
||||
.get("checksum")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.to_string());
|
||||
out.push(cloud_files::RemoteEntry {
|
||||
id,
|
||||
name,
|
||||
parent_id: parent,
|
||||
is_folder,
|
||||
size,
|
||||
modified_at,
|
||||
checksum,
|
||||
});
|
||||
if let Some(children) = n.get("children").and_then(|x| x.as_array()) {
|
||||
walk(children, Some(id), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut flat = Vec::new();
|
||||
walk(&tree, None, &mut flat);
|
||||
|
||||
// Virtueller Ordner "Geteilt mit mir" nur dann, wenn es geteilte
|
||||
// Dateien gibt. ID -1 ist reserviert dafuer (keine Kollision
|
||||
// mit echten DB-IDs).
|
||||
if !shared.is_empty() {
|
||||
flat.push(cloud_files::RemoteEntry {
|
||||
id: -1,
|
||||
name: "Geteilt mit mir".to_string(),
|
||||
parent_id: None,
|
||||
is_folder: true,
|
||||
size: 0,
|
||||
modified_at: String::new(),
|
||||
checksum: None,
|
||||
});
|
||||
walk(&shared, Some(-1), &mut flat);
|
||||
}
|
||||
|
||||
Ok(flat)
|
||||
}
|
||||
|
||||
/// Short-circuit fuer Shell-Kontextmenue-Aufrufe:
|
||||
/// `minicloud-sync --pin <file>` oder `--unpin <file>` fuehrt die
|
||||
/// Aktion direkt aus und beendet. Kein UI, kein Tray.
|
||||
/// Logs landen in %LOCALAPPDATA%\MiniCloud Sync\cli.log - sonst
|
||||
/// wuerden wir vom Explorer gestartete Prozesse nie debuggen koennen.
|
||||
#[cfg(windows)]
|
||||
fn handle_cli_shortcuts() {
|
||||
use std::io::Write;
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let cmd = args[1].as_str();
|
||||
if cmd != "--pin" && cmd != "--unpin" {
|
||||
return;
|
||||
}
|
||||
let path = std::path::PathBuf::from(&args[2]);
|
||||
|
||||
let log_path = dirs::data_local_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("MiniCloud Sync")
|
||||
.join("cli.log");
|
||||
if let Some(p) = log_path.parent() {
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
let log = |msg: &str| {
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
{
|
||||
let _ = writeln!(f, "[{}] {}", chrono::Utc::now().to_rfc3339(), msg);
|
||||
}
|
||||
};
|
||||
|
||||
log(&format!("CLI invoked: {} {}", cmd, path.display()));
|
||||
let result = match cmd {
|
||||
"--pin" => cloud_files::pin_file(&path),
|
||||
"--unpin" => cloud_files::unpin_file(&path),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
match &result {
|
||||
Ok(()) => log(&format!("{cmd} OK: {}", path.display())),
|
||||
Err(e) => log(&format!("{cmd} FAILED: {e}")),
|
||||
}
|
||||
std::process::exit(if result.is_ok() { 0 } else { 1 });
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn handle_cli_shortcuts() {}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
handle_cli_shortcuts();
|
||||
handle_single_instance();
|
||||
|
||||
tauri::Builder::default()
|
||||
@@ -902,6 +1169,8 @@ pub fn run() {
|
||||
sync_paths: Mutex::new(Vec::new()),
|
||||
journal: Arc::new(Journal::open().expect("Journal konnte nicht geoeffnet werden")),
|
||||
background_started: AtomicBool::new(false),
|
||||
cloud_files_loop: Mutex::new(None),
|
||||
cloud_files_watcher: Mutex::new(None),
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// Close button = minimize to tray instead of quit
|
||||
@@ -1016,6 +1285,13 @@ pub fn run() {
|
||||
browse_sync_folder,
|
||||
mark_offline,
|
||||
unmark_offline,
|
||||
cloud_files_supported,
|
||||
cloud_files_enable,
|
||||
cloud_files_disable,
|
||||
cloud_files_get_mount,
|
||||
cloud_files_force_cleanup,
|
||||
cloud_files_pin,
|
||||
cloud_files_unpin,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -13,6 +13,10 @@ pub struct AppConfig {
|
||||
pub auto_start: bool,
|
||||
#[serde(default)]
|
||||
pub start_minimized: bool,
|
||||
/// Persistierter Mount-Punkt der Cloud-Files-Integration.
|
||||
/// Leer = nicht aktiv. Wird beim App-Start wieder aktiviert.
|
||||
#[serde(default)]
|
||||
pub cloud_files_mount: String,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
|
||||
+123
-3
@@ -31,6 +31,75 @@ const newPathLocal = ref("");
|
||||
const newPathServerFolder = ref("");
|
||||
const newPathServerId = ref(null);
|
||||
const newPathMode = ref("virtual");
|
||||
|
||||
// Cloud-Files (Windows cfapi / Linux FUSE)
|
||||
const cloudFilesSupported = ref(false);
|
||||
const cloudFilesActive = ref(false);
|
||||
const cloudFilesBusy = ref(false);
|
||||
const cloudFilesMountPoint = ref("");
|
||||
const cloudFilesError = ref("");
|
||||
|
||||
async function checkCloudFilesSupport() {
|
||||
try { cloudFilesSupported.value = await invoke("cloud_files_supported"); }
|
||||
catch { cloudFilesSupported.value = false; }
|
||||
try {
|
||||
const saved = await invoke("cloud_files_get_mount");
|
||||
if (saved) cloudFilesMountPoint.value = saved;
|
||||
} catch { /* no saved mount */ }
|
||||
}
|
||||
|
||||
async function forceCleanupCloudFiles() {
|
||||
if (!cloudFilesMountPoint.value) return;
|
||||
if (!confirm(`Sync-Root unter ${cloudFilesMountPoint.value} zwangsweise aufraeumen?\n\nDanach kann der Ordner ggf. geloescht werden.`)) return;
|
||||
cloudFilesError.value = "";
|
||||
cloudFilesBusy.value = true;
|
||||
try {
|
||||
await invoke("cloud_files_force_cleanup", { mountPoint: cloudFilesMountPoint.value });
|
||||
cloudFilesActive.value = false;
|
||||
cloudFilesMountPoint.value = "";
|
||||
syncLog.value = [`[${ts()}] Cloud-Files Zwangsbereinigung durchgefuehrt`, ...syncLog.value].slice(0, 200);
|
||||
} catch (err) {
|
||||
cloudFilesError.value = String(err);
|
||||
} finally {
|
||||
cloudFilesBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function browseCfMount() {
|
||||
try {
|
||||
const selected = await dialogOpen({ directory: true, multiple: false,
|
||||
title: "Cloud-Files-Ordner waehlen" });
|
||||
if (selected) cloudFilesMountPoint.value = selected;
|
||||
} catch { /* cancelled */ }
|
||||
}
|
||||
|
||||
async function enableCloudFiles() {
|
||||
cloudFilesError.value = "";
|
||||
cloudFilesBusy.value = true;
|
||||
try {
|
||||
await invoke("cloud_files_enable", { mountPoint: cloudFilesMountPoint.value });
|
||||
cloudFilesActive.value = true;
|
||||
syncLog.value = [`[${ts()}] Cloud-Files aktiviert: ${cloudFilesMountPoint.value}`, ...syncLog.value].slice(0, 200);
|
||||
} catch (err) {
|
||||
cloudFilesError.value = String(err);
|
||||
} finally {
|
||||
cloudFilesBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disableCloudFiles() {
|
||||
cloudFilesError.value = "";
|
||||
cloudFilesBusy.value = true;
|
||||
try {
|
||||
await invoke("cloud_files_disable", { mountPoint: cloudFilesMountPoint.value });
|
||||
cloudFilesActive.value = false;
|
||||
syncLog.value = [`[${ts()}] Cloud-Files deaktiviert`, ...syncLog.value].slice(0, 200);
|
||||
} catch (err) {
|
||||
cloudFilesError.value = String(err);
|
||||
} finally {
|
||||
cloudFilesBusy.value = false;
|
||||
}
|
||||
}
|
||||
const serverFolders = ref([]);
|
||||
|
||||
// Local file browser
|
||||
@@ -289,6 +358,7 @@ function formatSize(b) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await checkCloudFilesSupport();
|
||||
// Try auto-login with saved credentials
|
||||
try {
|
||||
const saved = await invoke("load_saved_config");
|
||||
@@ -308,6 +378,15 @@ onMounted(async () => {
|
||||
if (syncPaths.value.length > 0) {
|
||||
await startSync();
|
||||
}
|
||||
// Cloud-Files automatisch reaktivieren, wenn Mount gespeichert.
|
||||
if (cloudFilesSupported.value && cloudFilesMountPoint.value) {
|
||||
try {
|
||||
await invoke("cloud_files_enable", { mountPoint: cloudFilesMountPoint.value });
|
||||
cloudFilesActive.value = true;
|
||||
} catch (e) {
|
||||
cloudFilesError.value = `Auto-Reaktivierung fehlgeschlagen: ${e}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
syncStatus.value = "Auto-Login fehlgeschlagen";
|
||||
// Show login screen with pre-filled fields
|
||||
@@ -387,8 +466,47 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Sync Paths -->
|
||||
<!-- Cloud-Files (Windows Cloud Files API, OneDrive-artig) -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3>Cloud-Files (OneDrive-Style)</h3>
|
||||
<span v-if="cloudFilesActive" class="status-badge syncing">☁ aktiv</span>
|
||||
<span v-else-if="!cloudFilesSupported" class="status-badge error">nicht verfuegbar</span>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Dateien erscheinen als Platzhalter im Explorer mit Wolken-Icon und
|
||||
werden erst bei Zugriff geladen. Rechtsklick im Explorer →
|
||||
"Immer offline halten" oder "Speicher freigeben".
|
||||
</p>
|
||||
<p v-if="!cloudFilesSupported" class="hint" style="color:#c62828">
|
||||
Auf dieser Plattform noch nicht verfuegbar. Aktuell: Windows 10/11.
|
||||
Linux-FUSE ist in Vorbereitung, macOS folgt mit Apple-Signatur.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="cf-row">
|
||||
<input v-model="cloudFilesMountPoint" placeholder="Ordner waehlen..." />
|
||||
<button class="btn-secondary" @click="browseCfMount">Durchsuchen</button>
|
||||
<button v-if="!cloudFilesActive" class="btn-primary"
|
||||
:disabled="!cloudFilesMountPoint || cloudFilesBusy"
|
||||
@click="enableCloudFiles">
|
||||
{{ cloudFilesBusy ? "Aktiviere..." : "Aktivieren" }}
|
||||
</button>
|
||||
<button v-else class="btn-secondary" :disabled="cloudFilesBusy"
|
||||
@click="disableCloudFiles">Deaktivieren</button>
|
||||
<button v-if="cloudFilesMountPoint && !cloudFilesActive"
|
||||
class="btn-secondary" :disabled="cloudFilesBusy"
|
||||
@click="forceCleanupCloudFiles"
|
||||
title="Toten Sync-Root nach hartem Beenden des Clients aufraeumen">
|
||||
Aufraeumen
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="cloudFilesError" class="error" style="margin-top:0.5rem">{{ cloudFilesError }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Sync Paths (Legacy) - auf Windows ausgeblendet sobald Cloud-Files
|
||||
aktiv ist; Cloud-Files ersetzt diese Ansicht vollstaendig. -->
|
||||
<div v-if="!cloudFilesActive" class="section">
|
||||
<div class="section-header">
|
||||
<h3>Sync-Pfade</h3>
|
||||
<div class="header-btns">
|
||||
@@ -454,8 +572,8 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local File Browser -->
|
||||
<div v-if="autoSyncActive" class="section" @click="hideContextMenu">
|
||||
<!-- Local File Browser (Legacy, nur fuer Full-Sync-Modus) -->
|
||||
<div v-if="autoSyncActive && !cloudFilesActive" class="section" @click="hideContextMenu">
|
||||
<div class="section-header">
|
||||
<h3>Lokale Dateien</h3>
|
||||
<button @click="loadLocalFiles(null)" class="btn-small">↻</button>
|
||||
@@ -604,6 +722,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;f
|
||||
.sp-actions{display:flex;align-items:center;gap:.375rem;flex-shrink:0}
|
||||
.sp-mode{font-size:.75rem;padding:.2rem .4rem;border-radius:4px;cursor:pointer;background:#f0f0f0}
|
||||
.sp-mode.Full{background:#e3f2fd;color:#1565c0}.sp-mode.Virtual{background:#f3e5f5;color:#7b1fa2}
|
||||
.cf-row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}
|
||||
.cf-row input{flex:1;min-width:300px}
|
||||
.file-tree{max-height:250px;overflow-y:auto}
|
||||
.tree-item{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;border-bottom:1px solid #f5f5f5;font-size:.85rem}
|
||||
.tree-item.indent{padding-left:1.5rem}.tree-icon{flex-shrink:0}.tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>Mini-Cloud</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 337 B |
@@ -1,3 +1,16 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watchEffect } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
watchEffect(() => {
|
||||
document.title = auth.user?.username
|
||||
? `Mini-Cloud - ${auth.user.username}`
|
||||
: 'Mini-Cloud'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -37,6 +37,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System-Info: Zeitzone & NTP (read-only) -->
|
||||
<div class="admin-section">
|
||||
<h3>System-Zeit</h3>
|
||||
<p class="hint">Wird in der <code>.env</code> festgelegt (Keys <code>TZ</code> und <code>NTP_SERVER</code>).
|
||||
Aenderungen erfordern einen Neustart des Backends.</p>
|
||||
<div class="sysinfo">
|
||||
<div class="sysinfo-row">
|
||||
<span class="sysinfo-label">Zeitzone:</span>
|
||||
<code>{{ settings.timezone || '—' }}</code>
|
||||
<span v-if="settings.timezone_abbr" class="sysinfo-extra">({{ settings.timezone_abbr }})</span>
|
||||
</div>
|
||||
<div class="sysinfo-row">
|
||||
<span class="sysinfo-label">Aktuelle Server-Zeit:</span>
|
||||
<code>{{ formatServerTime(settings.server_time) }}</code>
|
||||
</div>
|
||||
<div class="sysinfo-row">
|
||||
<span class="sysinfo-label">NTP-Server:</span>
|
||||
<code>{{ settings.ntp_server || '(deaktiviert)' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Email -->
|
||||
<div class="admin-section">
|
||||
<h3>System-E-Mail (SMTP)</h3>
|
||||
@@ -551,6 +573,17 @@ const smtpForm = ref({
|
||||
const smtpPasswordSet = ref(false)
|
||||
const onlyofficeConfigured = ref(false)
|
||||
const onlyofficeUrl = ref('')
|
||||
const settings = ref({ timezone: '', timezone_abbr: '', server_time: '', ntp_server: '' })
|
||||
|
||||
function formatServerTime(iso) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
})
|
||||
} catch { return iso }
|
||||
}
|
||||
const smtpTesting = ref(false)
|
||||
|
||||
// Backup & Restore
|
||||
@@ -660,6 +693,12 @@ async function loadSettings() {
|
||||
smtpPasswordSet.value = res.data.system_smtp_password_set
|
||||
onlyofficeConfigured.value = res.data.onlyoffice_configured
|
||||
onlyofficeUrl.value = res.data.onlyoffice_url || ''
|
||||
settings.value = {
|
||||
timezone: res.data.timezone || '',
|
||||
timezone_abbr: res.data.timezone_abbr || '',
|
||||
server_time: res.data.server_time || '',
|
||||
ntp_server: res.data.ntp_server || '',
|
||||
}
|
||||
} catch { /* first load, defaults */ }
|
||||
}
|
||||
|
||||
@@ -1216,6 +1255,12 @@ onMounted(() => {
|
||||
.field-row { display: flex; gap: 0.75rem; align-items: flex-end; }
|
||||
.flex-grow { flex: 1; }
|
||||
.hint { font-size: 0.85rem; color: var(--p-text-muted-color); margin: 0 0 0.75rem; }
|
||||
.hint code { background: var(--p-surface-100); padding: 0.05rem 0.35rem; border-radius: 3px; font-size: 0.8rem; }
|
||||
.sysinfo { display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.875rem; }
|
||||
.sysinfo-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.sysinfo-label { min-width: 180px; color: var(--p-text-muted-color); }
|
||||
.sysinfo code { background: var(--p-surface-100); padding: 0.15rem 0.5rem; border-radius: 4px; }
|
||||
.sysinfo-extra { color: var(--p-text-muted-color); font-size: 0.8rem; }
|
||||
.invite-section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--p-surface-200); }
|
||||
.invite-section h4 { margin: 0 0 0.25rem; font-size: 0.95rem; }
|
||||
.invite-row { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
<div class="header-actions">
|
||||
<SelectButton v-model="viewMode" :options="viewModeOptions" optionLabel="label" optionValue="value" size="small" />
|
||||
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
|
||||
<Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
|
||||
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="triggerCalImport" />
|
||||
<input ref="calImportInput" type="file" accept=".ics,.ical,.csv" hidden @change="onCalImportFile" />
|
||||
<Button icon="pi pi-download" label="Export" size="small" outlined
|
||||
:disabled="!exportableCalendars.length" @click="showCalExportDialog = true" />
|
||||
<Button icon="pi pi-plus" label="Neuer Termin" size="small"
|
||||
:disabled="!writableCalendars.length" @click="openNewEvent()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +21,10 @@
|
||||
<input type="checkbox" v-model="visibleCalendars[cal.id]" @change="refreshEvents" />
|
||||
<div class="calendar-color" :style="{ background: cal.color }"></div>
|
||||
<span class="cal-name">{{ cal.name }}</span>
|
||||
<span v-if="cal.permission !== 'owner'" class="shared-label">(geteilt)</span>
|
||||
<span v-if="cal.permission !== 'owner'" class="shared-label"
|
||||
:title="`Geteilt von ${cal.owner_display_name || cal.owner_name}`">
|
||||
(geteilt von {{ cal.owner_display_name || cal.owner_name }})
|
||||
</span>
|
||||
<Button icon="pi pi-ellipsis-v" text size="small" @click.stop="openCalendarMenu(cal)" />
|
||||
</div>
|
||||
</aside>
|
||||
@@ -45,10 +53,22 @@
|
||||
optionLabel="label" optionValue="value" placeholder="Alle Kalender"
|
||||
showClear style="min-width: 180px" />
|
||||
</div>
|
||||
<div class="list-meta">{{ filteredListEvents.length }} Termin(e)</div>
|
||||
<div class="list-meta-row">
|
||||
<div class="list-meta">{{ filteredListEvents.length }} Termin(e)</div>
|
||||
<div v-if="selectedListIds.length" class="list-bulk">
|
||||
<span>{{ selectedListIds.length }} ausgewaehlt</span>
|
||||
<Button icon="pi pi-trash" :label="`${selectedListIds.length} loeschen`"
|
||||
severity="danger" size="small" @click="deleteSelectedListEvents" />
|
||||
<Button label="Auswahl aufheben" size="small" text @click="selectedListIds = []" />
|
||||
</div>
|
||||
</div>
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check">
|
||||
<Checkbox v-model="allListSelected" :binary="true"
|
||||
@change="toggleAllListSelected" title="Alle auswaehlen" />
|
||||
</th>
|
||||
<th @click="toggleListSort('dtstart')" class="sortable">
|
||||
Datum <i v-if="listSort === 'dtstart'" :class="listSortDir === 'asc' ? 'pi pi-arrow-up' : 'pi pi-arrow-down'"></i>
|
||||
</th>
|
||||
@@ -61,7 +81,14 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ev in filteredListEvents" :key="ev.id" class="list-row" @click="openEditEvent(ev)">
|
||||
<tr v-for="ev in filteredListEvents" :key="ev.id" class="list-row"
|
||||
:class="{ selected: selectedListIds.includes(ev.id) }"
|
||||
@click="openEditEvent(ev)">
|
||||
<td class="col-check" @click.stop>
|
||||
<Checkbox :modelValue="selectedListIds.includes(ev.id)" :binary="true"
|
||||
:disabled="ev._cal?.permission === 'read'"
|
||||
@update:modelValue="toggleListSelect(ev.id, $event)" />
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<div>{{ formatListDate(ev) }}</div>
|
||||
<div v-if="!ev.all_day" class="meta-time">{{ formatListTime(ev) }}</div>
|
||||
@@ -80,7 +107,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredListEvents.length">
|
||||
<td colspan="5" class="empty-row">Keine Termine gefunden.</td>
|
||||
<td colspan="6" class="empty-row">Keine Termine gefunden.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -88,6 +115,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import-Auswahl Dialog -->
|
||||
<Dialog v-model:visible="showCalImportDialog" header="In welchen Kalender importieren?" modal :style="{ width: '420px' }">
|
||||
<div class="field">
|
||||
<label>Kalender</label>
|
||||
<Select v-model="importTargetCalId" :options="writableCalendarOptions" optionLabel="label" optionValue="id" fluid />
|
||||
</div>
|
||||
<p class="hint" style="font-size:0.85rem;color:var(--p-text-muted-color)">Datei: {{ pendingImportFile?.name }}</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="cancelCalImport" />
|
||||
<Button label="Importieren" icon="pi pi-upload" :disabled="!importTargetCalId" @click="doCalImport" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Export Dialog -->
|
||||
<Dialog v-model:visible="showCalExportDialog" header="Kalender exportieren" modal :style="{ width: '420px' }">
|
||||
<div class="field">
|
||||
<label>Kalender</label>
|
||||
<Select v-model="exportCalId" :options="exportableCalendars" optionLabel="name" optionValue="id" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Format</label>
|
||||
<Select v-model="calExportFormat" :options="calExportFormats" optionLabel="label" optionValue="value" fluid />
|
||||
</div>
|
||||
<p class="hint" v-if="calExportFormat === 'ics'" style="font-size:0.85rem;color:var(--p-text-muted-color)">Standard iCalendar-Datei (kompatibel mit jedem Kalender-Programm).</p>
|
||||
<p class="hint" v-if="calExportFormat === 'csv'" style="font-size:0.85rem;color:var(--p-text-muted-color)">CSV mit Titel, Start, Ende, Ort, Beschreibung, RRULE.</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showCalExportDialog = false" />
|
||||
<Button label="Herunterladen" icon="pi pi-download" :disabled="!exportCalId" @click="doCalExport" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- New Calendar Dialog -->
|
||||
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
@@ -111,9 +169,9 @@
|
||||
<label>Titel</label>
|
||||
<InputText v-model="eventForm.summary" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div v-if="writableCalendars.length > 1" class="field">
|
||||
<label>Kalender</label>
|
||||
<Select v-model="eventForm.calendar_id" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
|
||||
<Select v-model="eventForm.calendar_id" :options="writableCalendarOptions" optionLabel="label" optionValue="id" fluid />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
@@ -198,7 +256,22 @@
|
||||
<!-- Calendar Menu -->
|
||||
<Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '480px' }">
|
||||
<div v-if="selectedCal" class="cal-menu-content">
|
||||
<p><strong>{{ selectedCal.name }}</strong></p>
|
||||
<div class="rename-row">
|
||||
<template v-if="!isRenamingCal">
|
||||
<strong>{{ selectedCal.name }}</strong>
|
||||
<Button v-if="selectedCal.permission === 'owner'"
|
||||
icon="pi pi-pencil" text size="small" title="Umbenennen"
|
||||
@click="startRenameCal" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<InputText v-model="renameCalValue" fluid autofocus
|
||||
@keyup.enter="saveRenameCal" @keyup.escape="isRenamingCal = false" />
|
||||
<Button icon="pi pi-check" text size="small" severity="success"
|
||||
title="Speichern" @click="saveRenameCal" />
|
||||
<Button icon="pi pi-times" text size="small"
|
||||
title="Abbrechen" @click="isRenamingCal = false" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>
|
||||
@@ -222,7 +295,9 @@
|
||||
<div v-if="shareSearchResults.length" class="user-search-popup">
|
||||
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
|
||||
@click="shareUsername = u.username; shareSearchResults = []">
|
||||
<i class="pi pi-user"></i> {{ u.username }}
|
||||
<i class="pi pi-user"></i>
|
||||
<span>{{ u.username }}</span>
|
||||
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,6 +333,10 @@
|
||||
<div v-else class="ical-url">
|
||||
<code>{{ fullIcalUrl }}</code>
|
||||
<Button icon="pi pi-copy" text size="small" @click="copyIcal" title="Kopieren" />
|
||||
<div v-if="selectedCal.ical_has_password" class="ical-pw-hint">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Bei der Passwort-Abfrage <strong>Benutzername leer lassen</strong> und nur das Passwort eingeben.
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
|
||||
<span v-if="selectedCal.ical_has_password" class="hint-badge">
|
||||
<i class="pi pi-lock"></i> Passwortgeschuetzt
|
||||
@@ -337,6 +416,7 @@ import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Select from 'primevue/select'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
@@ -368,6 +448,57 @@ const listTo = ref('')
|
||||
const listCalFilter = ref(null)
|
||||
const listSort = ref('dtstart')
|
||||
const listSortDir = ref('asc')
|
||||
const selectedListIds = ref([])
|
||||
|
||||
const allListSelected = computed({
|
||||
get: () => {
|
||||
const writable = filteredListEvents.value.filter(e => e._cal?.permission !== 'read')
|
||||
return writable.length > 0 && writable.every(e => selectedListIds.value.includes(e.id))
|
||||
},
|
||||
set: () => {},
|
||||
})
|
||||
|
||||
function toggleAllListSelected() {
|
||||
const writableIds = filteredListEvents.value
|
||||
.filter(e => e._cal?.permission !== 'read').map(e => e.id)
|
||||
const allSel = writableIds.length > 0 && writableIds.every(id => selectedListIds.value.includes(id))
|
||||
if (allSel) {
|
||||
selectedListIds.value = selectedListIds.value.filter(id => !writableIds.includes(id))
|
||||
} else {
|
||||
const set = new Set(selectedListIds.value)
|
||||
writableIds.forEach(id => set.add(id))
|
||||
selectedListIds.value = [...set]
|
||||
}
|
||||
}
|
||||
|
||||
function toggleListSelect(id, checked) {
|
||||
if (checked) {
|
||||
if (!selectedListIds.value.includes(id)) selectedListIds.value = [...selectedListIds.value, id]
|
||||
} else {
|
||||
selectedListIds.value = selectedListIds.value.filter(x => x !== id)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedListEvents() {
|
||||
const ids = [...selectedListIds.value]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`${ids.length} Termin(e) wirklich loeschen?`)) return
|
||||
let ok = 0, fail = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await apiClient.delete(`/events/${id}`)
|
||||
ok++
|
||||
} catch { fail++ }
|
||||
}
|
||||
selectedListIds.value = []
|
||||
toast.add({
|
||||
severity: fail ? 'warn' : 'success',
|
||||
summary: `${ok} geloescht${fail ? `, ${fail} fehlgeschlagen` : ''}`,
|
||||
life: 3000,
|
||||
})
|
||||
await loadListEvents()
|
||||
refreshEvents()
|
||||
}
|
||||
|
||||
const listCalOptions = computed(() => calendars.value.map(c => ({ label: c.name, value: c.id })))
|
||||
|
||||
@@ -501,6 +632,107 @@ const currentEditScope = ref(null)
|
||||
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
|
||||
|
||||
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
|
||||
// Beschreibbar = eigener Kalender ODER Freigabe mit Schreibrecht.
|
||||
const writableCalendars = computed(() =>
|
||||
calendars.value.filter(c => c.permission === 'owner' || c.permission === 'readwrite')
|
||||
)
|
||||
// Mit "(geteilt von <Name>)"-Suffix fuer eindeutige Anzeige in Selects.
|
||||
const writableCalendarOptions = computed(() => writableCalendars.value.map(c => ({
|
||||
...c,
|
||||
label: c.permission === 'owner'
|
||||
? c.name
|
||||
: `${c.name} (geteilt von ${c.owner_display_name || c.owner_name})`,
|
||||
})))
|
||||
const exportableCalendars = computed(() => calendars.value)
|
||||
|
||||
// --- Calendar Import / Export ---
|
||||
const calImportInput = ref(null)
|
||||
const showCalImportDialog = ref(false)
|
||||
const pendingImportFile = ref(null)
|
||||
const importTargetCalId = ref(null)
|
||||
const showCalExportDialog = ref(false)
|
||||
const exportCalId = ref(null)
|
||||
const calExportFormat = ref('ics')
|
||||
const calExportFormats = [
|
||||
{ label: 'iCalendar (.ics)', value: 'ics' },
|
||||
{ label: 'CSV (.csv)', value: 'csv' },
|
||||
]
|
||||
|
||||
watch(showCalExportDialog, (v) => {
|
||||
if (v && !exportCalId.value && exportableCalendars.value.length) {
|
||||
exportCalId.value = exportableCalendars.value[0].id
|
||||
}
|
||||
})
|
||||
|
||||
function triggerCalImport() {
|
||||
if (!writableCalendars.value.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Kein beschreibbarer Kalender', life: 3000 })
|
||||
return
|
||||
}
|
||||
calImportInput.value?.click()
|
||||
}
|
||||
|
||||
function onCalImportFile(ev) {
|
||||
const file = ev.target.files?.[0]
|
||||
ev.target.value = ''
|
||||
if (!file) return
|
||||
pendingImportFile.value = file
|
||||
importTargetCalId.value = writableCalendars.value[0]?.id
|
||||
showCalImportDialog.value = true
|
||||
}
|
||||
|
||||
function cancelCalImport() {
|
||||
showCalImportDialog.value = false
|
||||
pendingImportFile.value = null
|
||||
}
|
||||
|
||||
async function doCalImport() {
|
||||
if (!pendingImportFile.value || !importTargetCalId.value) return
|
||||
const fd = new FormData()
|
||||
fd.append('file', pendingImportFile.value)
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/calendars/${importTargetCalId.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,
|
||||
})
|
||||
showCalImportDialog.value = false
|
||||
pendingImportFile.value = null
|
||||
refreshEvents()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Import fehlgeschlagen',
|
||||
detail: err.response?.data?.error || err.message, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function doCalExport() {
|
||||
if (!exportCalId.value) return
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/calendars/${exportCalId.value}/export`,
|
||||
{ params: { format: calExportFormat.value }, responseType: 'blob' }
|
||||
)
|
||||
const cal = calendars.value.find(c => c.id === exportCalId.value)
|
||||
const ext = calExportFormat.value === 'csv' ? 'csv' : 'ics'
|
||||
const blob = new Blob([res.data])
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${cal?.name || 'kalender'}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showCalExportDialog.value = false
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Export fehlgeschlagen',
|
||||
detail: err.response?.data?.error || err.message, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
const fullIcalUrl = computed(() =>
|
||||
selectedCal.value?.ical_token ? `${window.location.origin}/ical/${selectedCal.value.ical_token}` : ''
|
||||
)
|
||||
@@ -764,6 +996,10 @@ async function createCalendar() {
|
||||
}
|
||||
|
||||
function openNewEvent(start, end, allDay = false) {
|
||||
if (!writableCalendars.value.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Kein beschreibbarer Kalender vorhanden', life: 3000 })
|
||||
return
|
||||
}
|
||||
editingEvent.value = null
|
||||
const now = start || new Date()
|
||||
const later = end || new Date(now.getTime() + 3600000)
|
||||
@@ -771,7 +1007,7 @@ function openNewEvent(start, end, allDay = false) {
|
||||
summary: '',
|
||||
description: '',
|
||||
location: '',
|
||||
calendar_id: ownCalendars.value[0]?.id,
|
||||
calendar_id: writableCalendars.value[0].id,
|
||||
dtstart: toLocalISO(now, allDay),
|
||||
dtend: toLocalISO(later, allDay),
|
||||
all_day: allDay,
|
||||
@@ -856,10 +1092,38 @@ function openCalendarMenu(cal) {
|
||||
icalPassword.value = ''
|
||||
shareUsername.value = ''
|
||||
shareSearchResults.value = []
|
||||
isRenamingCal.value = false
|
||||
showCalMenu.value = true
|
||||
loadShares()
|
||||
}
|
||||
|
||||
const isRenamingCal = ref(false)
|
||||
const renameCalValue = ref('')
|
||||
|
||||
function startRenameCal() {
|
||||
renameCalValue.value = selectedCal.value?.name || ''
|
||||
isRenamingCal.value = true
|
||||
}
|
||||
|
||||
async function saveRenameCal() {
|
||||
const newName = renameCalValue.value.trim()
|
||||
if (!newName || !selectedCal.value || newName === selectedCal.value.name) {
|
||||
isRenamingCal.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await apiClient.put(`/calendars/${selectedCal.value.id}`, { name: newName })
|
||||
selectedCal.value.name = newName
|
||||
isRenamingCal.value = false
|
||||
await loadCalendars()
|
||||
refreshEvents()
|
||||
toast.add({ severity: 'success', summary: 'Umbenannt', life: 2000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler',
|
||||
detail: err.response?.data?.error || err.message, life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function onShareSearch() {
|
||||
clearTimeout(shareSearchTimer)
|
||||
const q = shareUsername.value.trim()
|
||||
@@ -1092,6 +1356,9 @@ onUnmounted(() => {
|
||||
max-height: 160px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; display: flex; gap: 0.5rem; align-items: center; }
|
||||
.user-result:hover { background: var(--p-primary-50); }
|
||||
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
|
||||
.rename-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.rename-row strong { font-size: 1rem; }
|
||||
.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; flex-wrap: wrap; }
|
||||
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
|
||||
@@ -1117,6 +1384,14 @@ onUnmounted(() => {
|
||||
.list-view { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.list-filters { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.list-meta { font-size: 0.8rem; color: var(--p-text-muted-color); }
|
||||
.list-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.list-bulk { display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; }
|
||||
.col-check { width: 36px; }
|
||||
.list-row.selected { background: var(--p-primary-50); }
|
||||
.ical-pw-hint { margin-top: 0.5rem; font-size: 0.8rem; color: var(--p-text-muted-color);
|
||||
background: var(--p-yellow-50, #fffbeb); border-left: 3px solid var(--p-yellow-400, #facc15);
|
||||
padding: 0.5rem 0.75rem; border-radius: 4px; }
|
||||
.ical-pw-hint i { margin-right: 0.4rem; }
|
||||
.list-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.list-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--p-surface-200); font-weight: 600; user-select: none; }
|
||||
.list-table th.sortable { cursor: pointer; }
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<h2>Kontakte</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
|
||||
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
|
||||
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="triggerImport" />
|
||||
<input ref="importInput" type="file" accept=".vcf,.vcard,.csv" hidden @change="onImportFile" />
|
||||
<Button icon="pi pi-download" label="Export" size="small" outlined
|
||||
:disabled="!selectedBookId" @click="showExportDialog = true" />
|
||||
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small"
|
||||
:disabled="!writableBooks.length" @click="openNewContact" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +21,10 @@
|
||||
@click="selectBook(book.id)">
|
||||
<span class="book-color" :style="{ background: book.color }"></span>
|
||||
<span class="book-name">{{ book.name }}</span>
|
||||
<span v-if="book.permission !== 'owner'" class="shared-label">(geteilt)</span>
|
||||
<span v-if="book.permission !== 'owner'" class="shared-label"
|
||||
:title="`Geteilt von ${book.owner_display_name || book.owner_name}`">
|
||||
(geteilt von {{ book.owner_display_name || book.owner_name }})
|
||||
</span>
|
||||
<span class="count">{{ book.contact_count }}</span>
|
||||
<Button icon="pi pi-ellipsis-v" text size="small" class="book-menu"
|
||||
@click.stop="openBookMenu(book)" />
|
||||
@@ -28,9 +36,18 @@
|
||||
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="onSearch" />
|
||||
</div>
|
||||
|
||||
<div v-if="selectedContacts.length" class="bulk-bar">
|
||||
<span>{{ selectedContacts.length }} ausgewaehlt</span>
|
||||
<Button icon="pi pi-trash" :label="`${selectedContacts.length} loeschen`"
|
||||
severity="danger" size="small" @click="bulkDeleteContacts" />
|
||||
<Button label="Auswahl aufheben" size="small" text @click="selectedContacts = []" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="contacts" :loading="loading" striped-rows
|
||||
v-model:selection="selectedContacts" dataKey="id"
|
||||
@row-click="onRowClick" :rowClass="() => 'clickable'">
|
||||
<template #empty><p class="empty">Keine Kontakte</p></template>
|
||||
<Column selectionMode="multiple" headerStyle="width:3rem" />
|
||||
<Column header="Name" sortable sortField="display_name">
|
||||
<template #body="{ data }">
|
||||
<div class="contact-row">
|
||||
@@ -75,7 +92,22 @@
|
||||
<!-- Book Menu (3-dot) -->
|
||||
<Dialog v-model:visible="showBookMenu" header="Adressbuch-Optionen" modal :style="{ width: '560px' }">
|
||||
<div v-if="menuBook" class="book-menu-content">
|
||||
<p><strong>{{ menuBook.name }}</strong></p>
|
||||
<div class="rename-row">
|
||||
<template v-if="!isRenamingBook">
|
||||
<strong>{{ menuBook.name }}</strong>
|
||||
<Button v-if="menuBook.permission === 'owner'"
|
||||
icon="pi pi-pencil" text size="small" title="Umbenennen"
|
||||
@click="startRenameBook" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<InputText v-model="renameBookValue" fluid autofocus
|
||||
@keyup.enter="saveRenameBook" @keyup.escape="isRenamingBook = false" />
|
||||
<Button icon="pi pi-check" text size="small" severity="success"
|
||||
title="Speichern" @click="saveRenameBook" />
|
||||
<Button icon="pi pi-times" text size="small"
|
||||
title="Abbrechen" @click="isRenamingBook = false" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>
|
||||
@@ -99,7 +131,9 @@
|
||||
<div v-if="shareSearchResults.length" class="user-search-popup">
|
||||
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
|
||||
@click="shareUsername = u.username; shareSearchResults = []">
|
||||
<i class="pi pi-user"></i> {{ u.username }}
|
||||
<i class="pi pi-user"></i>
|
||||
<span>{{ u.username }}</span>
|
||||
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,6 +195,11 @@
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="general">
|
||||
<div v-if="writableBooks.length > 1" class="field" style="max-width:400px">
|
||||
<label>Adressbuch</label>
|
||||
<Select v-model="contactTargetBookId" :options="writableBookOptions"
|
||||
optionLabel="label" optionValue="id" fluid />
|
||||
</div>
|
||||
<div class="photo-row">
|
||||
<div class="avatar large" :style="{ background: avatarColor(contactForm) }">
|
||||
<img v-if="contactForm.photo" :src="contactForm.photo" />
|
||||
@@ -175,9 +214,10 @@
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field" style="max-width:120px">
|
||||
<div class="field" style="max-width:130px">
|
||||
<label>Anrede</label>
|
||||
<InputText v-model="contactForm.prefix" />
|
||||
<Select v-model="contactForm.prefix" :options="salutationOptions"
|
||||
showClear placeholder="–" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Vorname</label>
|
||||
@@ -315,6 +355,21 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showExportDialog" header="Kontakte exportieren" modal :style="{ width: '420px' }">
|
||||
<p>Aus Adressbuch <strong>{{ currentBook?.name }}</strong></p>
|
||||
<div class="field">
|
||||
<label>Format</label>
|
||||
<Select v-model="exportFormat" :options="exportFormats" optionLabel="label" optionValue="value" fluid />
|
||||
</div>
|
||||
<p class="hint" v-if="exportFormat === 'vcf'">Eine Sammel-Datei im vCard-3.0-Format (alle Kontakte hintereinander).</p>
|
||||
<p class="hint" v-if="exportFormat === 'vcf-zip'">ZIP mit einer einzelnen .vcf-Datei je Kontakt.</p>
|
||||
<p class="hint" v-if="exportFormat === 'csv'">CSV mit den wichtigsten Feldern (Name, E-Mail, Telefon, Adresse, ...).</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showExportDialog = false" />
|
||||
<Button label="Herunterladen" icon="pi pi-download" @click="doExport" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="confirmDeleteContactDialog" header="Kontakt löschen" modal :style="{ width: '400px' }">
|
||||
<p>Möchtest du <strong>{{ deleteContactTarget?.display_name }}</strong> wirklich löschen?</p>
|
||||
<template #footer>
|
||||
@@ -357,6 +412,16 @@ const auth = useAuthStore()
|
||||
const addressBooks = ref([])
|
||||
const contacts = ref([])
|
||||
const selectedBookId = ref(null)
|
||||
const contactTargetBookId = ref(null)
|
||||
const writableBooks = computed(() =>
|
||||
addressBooks.value.filter(b => b.permission === 'owner' || b.permission === 'readwrite')
|
||||
)
|
||||
const writableBookOptions = computed(() => writableBooks.value.map(b => ({
|
||||
...b,
|
||||
label: b.permission === 'owner'
|
||||
? b.name
|
||||
: `${b.name} (geteilt von ${b.owner_display_name || b.owner_name})`,
|
||||
})))
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
let searchTimer = null
|
||||
@@ -409,10 +474,12 @@ const urlTypes = [
|
||||
{ label: 'Privat', value: 'home' }, { label: 'Geschäftlich', value: 'work' },
|
||||
{ label: 'Sonstige', value: 'other' },
|
||||
]
|
||||
const salutationOptions = ['Herr', 'Frau', 'Divers']
|
||||
|
||||
function emptyContact() {
|
||||
return {
|
||||
prefix: '', first_name: '', middle_name: '', last_name: '', suffix: '',
|
||||
prefix: '',
|
||||
first_name: '', middle_name: '', last_name: '', suffix: '',
|
||||
display_name: '', nickname: '',
|
||||
organization: '', department: '', job_title: '',
|
||||
emails: [], phones: [], addresses: [], websites: [], impp: [], categories: [],
|
||||
@@ -483,10 +550,37 @@ function openBookMenu(book) {
|
||||
shareUsername.value = ''
|
||||
shareSearchResults.value = []
|
||||
editingShareId.value = null
|
||||
isRenamingBook.value = false
|
||||
showBookMenu.value = true
|
||||
loadShares()
|
||||
}
|
||||
|
||||
const isRenamingBook = ref(false)
|
||||
const renameBookValue = ref('')
|
||||
|
||||
function startRenameBook() {
|
||||
renameBookValue.value = menuBook.value?.name || ''
|
||||
isRenamingBook.value = true
|
||||
}
|
||||
|
||||
async function saveRenameBook() {
|
||||
const newName = renameBookValue.value.trim()
|
||||
if (!newName || !menuBook.value || newName === menuBook.value.name) {
|
||||
isRenamingBook.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await apiClient.put(`/addressbooks/${menuBook.value.id}`, { name: newName })
|
||||
menuBook.value.name = newName
|
||||
isRenamingBook.value = false
|
||||
await loadBooks()
|
||||
toast.add({ severity: 'success', summary: 'Umbenannt', life: 2000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler',
|
||||
detail: err.response?.data?.error || err.message, life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
if (!menuBook.value || menuBook.value.permission !== 'owner') {
|
||||
bookShares.value = []
|
||||
@@ -579,10 +673,17 @@ function copyText(t) {
|
||||
}
|
||||
|
||||
function openNewContact() {
|
||||
if (!writableBooks.value.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Kein beschreibbares Adressbuch vorhanden', life: 3000 })
|
||||
return
|
||||
}
|
||||
editingContactId.value = null
|
||||
Object.assign(contactForm, emptyContact())
|
||||
categoriesString.value = ''
|
||||
activeTab.value = 'general'
|
||||
// Default: aktuell markiertes Buch, falls beschreibbar, sonst erstes beschreibbares
|
||||
const selectedBook = writableBooks.value.find(b => b.id === selectedBookId.value)
|
||||
contactTargetBookId.value = selectedBook ? selectedBook.id : writableBooks.value[0].id
|
||||
showContactDialog.value = true
|
||||
}
|
||||
|
||||
@@ -649,7 +750,12 @@ async function saveContact() {
|
||||
if (editingContactId.value) {
|
||||
await apiClient.put(`/contacts/${editingContactId.value}`, payload)
|
||||
} else {
|
||||
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, payload)
|
||||
const target = contactTargetBookId.value || selectedBookId.value
|
||||
if (!target) {
|
||||
toast.add({ severity: 'error', summary: 'Bitte Adressbuch waehlen', life: 3000 })
|
||||
return
|
||||
}
|
||||
await apiClient.post(`/addressbooks/${target}/contacts`, payload)
|
||||
}
|
||||
showContactDialog.value = false
|
||||
await loadBooks()
|
||||
@@ -672,6 +778,93 @@ async function deleteContact() {
|
||||
await loadContacts()
|
||||
}
|
||||
|
||||
// --- Multi-Select / Bulk-Loeschen ---
|
||||
const selectedContacts = ref([])
|
||||
|
||||
async function bulkDeleteContacts() {
|
||||
const ids = selectedContacts.value.map(c => c.id)
|
||||
if (!ids.length) return
|
||||
if (!confirm(`${ids.length} Kontakt(e) wirklich loeschen?`)) return
|
||||
let ok = 0, fail = 0
|
||||
for (const id of ids) {
|
||||
try { await apiClient.delete(`/contacts/${id}`); ok++ } catch { fail++ }
|
||||
}
|
||||
selectedContacts.value = []
|
||||
toast.add({
|
||||
severity: fail ? 'warn' : 'success',
|
||||
summary: `${ok} geloescht${fail ? `, ${fail} fehlgeschlagen` : ''}`,
|
||||
life: 3000,
|
||||
})
|
||||
await loadBooks()
|
||||
await loadContacts()
|
||||
}
|
||||
|
||||
// --- Import / Export ---
|
||||
const importInput = ref(null)
|
||||
const showExportDialog = ref(false)
|
||||
const exportFormat = ref('vcf')
|
||||
const exportFormats = [
|
||||
{ label: 'vCard (Sammeldatei .vcf)', value: 'vcf' },
|
||||
{ label: 'vCards einzeln (.zip)', value: 'vcf-zip' },
|
||||
{ label: 'CSV (.csv)', value: 'csv' },
|
||||
]
|
||||
const currentBook = computed(() => addressBooks.value.find(b => b.id === selectedBookId.value))
|
||||
|
||||
function triggerImport() {
|
||||
if (!selectedBookId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Kein Adressbuch ausgewaehlt', life: 3000 })
|
||||
return
|
||||
}
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
async function onImportFile(ev) {
|
||||
const file = ev.target.files?.[0]
|
||||
ev.target.value = ''
|
||||
if (!file || !selectedBookId.value) return
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/addressbooks/${selectedBookId.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 loadBooks()
|
||||
await loadContacts()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Import fehlgeschlagen',
|
||||
detail: err.response?.data?.error || err.message, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
if (!selectedBookId.value) return
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/addressbooks/${selectedBookId.value}/export`,
|
||||
{ params: { format: exportFormat.value }, responseType: 'blob' }
|
||||
)
|
||||
const ext = exportFormat.value === 'csv' ? 'csv' : (exportFormat.value === 'vcf-zip' ? 'zip' : 'vcf')
|
||||
const blob = new Blob([res.data])
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${currentBook.value?.name || 'kontakte'}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showExportDialog.value = false
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Export fehlgeschlagen',
|
||||
detail: err.response?.data?.error || err.message, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
// Live-Refresh via SSE
|
||||
let eventSource = null
|
||||
let reloadTimer = null
|
||||
@@ -726,6 +919,8 @@ watch(selectedBookId, loadContacts)
|
||||
.book-item:hover .book-menu { opacity: 1; }
|
||||
.contacts-main { flex: 1; min-width: 0; }
|
||||
.search-bar { margin-bottom: 0.75rem; }
|
||||
.bulk-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem;
|
||||
background: var(--p-primary-50); border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.875rem; }
|
||||
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
|
||||
.contact-row { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.avatar { width: 36px; height: 36px; border-radius: 50%; background: #888; color: white;
|
||||
@@ -749,12 +944,15 @@ watch(selectedBookId, loadContacts)
|
||||
.multi-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; }
|
||||
.address-card { border: 1px solid var(--p-surface-200); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.75rem; }
|
||||
.share-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.rename-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.rename-row strong { font-size: 1rem; }
|
||||
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
|
||||
background: white; border: 1px solid var(--p-surface-200);
|
||||
border-radius: 4px; max-height: 160px; overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; }
|
||||
.user-result:hover { background: var(--p-primary-50); }
|
||||
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
|
||||
.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; flex-wrap: wrap; }
|
||||
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
|
||||
<Button icon="pi pi-upload" label="Dateien" size="small" @click="triggerUpload" />
|
||||
<Button icon="pi pi-folder" label="Ordner" size="small" outlined @click="triggerFolderUpload" />
|
||||
<Button size="small" outlined @click="triggerFolderUpload">
|
||||
<i class="pi pi-upload" style="margin-right:0.35rem"></i>
|
||||
<i class="pi pi-folder" style="margin-right:0.5rem"></i>
|
||||
Ordner
|
||||
</Button>
|
||||
<input ref="fileInput" type="file" multiple hidden @change="handleUpload" />
|
||||
<input ref="folderInput" type="file" hidden webkitdirectory @change="handleFolderUpload" />
|
||||
</div>
|
||||
|
||||
@@ -12,15 +12,31 @@
|
||||
<span class="label">Benutzername:</span>
|
||||
<span>{{ auth.user?.username }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">E-Mail:</span>
|
||||
<span>{{ auth.user?.email || 'Nicht angegeben' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Rolle:</span>
|
||||
<Tag :value="auth.user?.role" :severity="auth.user?.role === 'admin' ? 'danger' : 'info'" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" style="margin:0.75rem 0 0.5rem;font-size:0.8rem;color:var(--p-text-muted-color)">
|
||||
Vor- und Nachname werden anderen Benutzern angezeigt, wenn du etwas mit ihnen teilst.
|
||||
</p>
|
||||
<form @submit.prevent="saveProfile" class="profile-form">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Vorname</label>
|
||||
<InputText v-model="profile.first_name" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nachname</label>
|
||||
<InputText v-model="profile.last_name" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
<InputText v-model="profile.email" type="email" fluid />
|
||||
</div>
|
||||
<Button type="submit" label="Profil speichern" :loading="profileLoading" size="small" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
@@ -192,6 +208,36 @@ function downloadClient(client) {
|
||||
window.location.href = `/api/clients/${client.platform}/download`
|
||||
}
|
||||
|
||||
// --- Profile (Vorname/Nachname/E-Mail) ---
|
||||
const profile = ref({ first_name: '', last_name: '', email: '' })
|
||||
const profileLoading = ref(false)
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const res = await apiClient.get('/auth/me')
|
||||
profile.value = {
|
||||
first_name: res.data.first_name || '',
|
||||
last_name: res.data.last_name || '',
|
||||
email: res.data.email || '',
|
||||
}
|
||||
auth.user = { ...auth.user, ...res.data }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
profileLoading.value = true
|
||||
try {
|
||||
const res = await apiClient.put('/auth/me', profile.value)
|
||||
auth.user = { ...auth.user, ...res.data }
|
||||
toast.add({ severity: 'success', summary: 'Profil gespeichert', life: 2500 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler',
|
||||
detail: err.response?.data?.error || err.message, life: 4000 })
|
||||
} finally {
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Password change ---
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
@@ -334,6 +380,7 @@ async function doDeleteAccount() {
|
||||
|
||||
onMounted(async () => {
|
||||
loadAccounts()
|
||||
loadProfile()
|
||||
try {
|
||||
const res = await apiClient.get('/clients')
|
||||
availableClients.value = res.data.clients
|
||||
@@ -352,6 +399,10 @@ onMounted(async () => {
|
||||
.section-header h3 { margin: 0; }
|
||||
.settings-info { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.info-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.profile-form { display: flex; flex-direction: column; gap: 0.5rem; max-width: 540px; }
|
||||
.profile-form .field-row { display: flex; gap: 0.75rem; }
|
||||
.profile-form .field-row .field { flex: 1; }
|
||||
.profile-form .field label { display: block; font-size: 0.8rem; margin-bottom: 0.25rem; }
|
||||
.info-row .label { font-weight: 500; min-width: 120px; }
|
||||
.password-form { max-width: 400px; }
|
||||
.password-form .field { margin-bottom: 1rem; }
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
<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="!writableLists.length" @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"
|
||||
:title="`Geteilt von ${tl.owner_display_name || tl.owner_name}`">
|
||||
(geteilt von {{ tl.owner_display_name || tl.owner_name }})
|
||||
</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">
|
||||
<div class="rename-row">
|
||||
<template v-if="!isRenaming">
|
||||
<strong>{{ menuList.name }}</strong>
|
||||
<Button v-if="menuList.permission === 'owner'"
|
||||
icon="pi pi-pencil" text size="small" title="Umbenennen"
|
||||
@click="startRename" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<InputText v-model="renameValue" fluid autofocus
|
||||
@keyup.enter="saveRename" @keyup.escape="isRenaming = false" />
|
||||
<Button icon="pi pi-check" text size="small" severity="success"
|
||||
title="Speichern" @click="saveRename" />
|
||||
<Button icon="pi pi-times" text size="small"
|
||||
title="Abbrechen" @click="isRenaming = false" />
|
||||
</template>
|
||||
</div>
|
||||
<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">
|
||||
<div style="position: relative; flex: 1;">
|
||||
<InputText v-model="shareUsername" placeholder="Benutzername suchen..."
|
||||
fluid @input="onShareSearch" />
|
||||
<div v-if="shareSearchResults.length" class="user-search-popup">
|
||||
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
|
||||
@click="shareUsername = u.username; shareSearchResults = []">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>{{ u.username }}</span>
|
||||
<small v-if="u.full_name" class="user-fullname">{{ u.full_name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<template v-for="s in listShares" :key="s.id">
|
||||
<div v-if="editingShareId !== 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-pencil" text size="small" title="Bearbeiten" @click="startEditShare(s)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" title="Entfernen" @click="removeShare(s.id)" />
|
||||
</div>
|
||||
<div v-else class="share-perm-item editing">
|
||||
<i class="pi pi-user"></i> <span>{{ s.username }}</span>
|
||||
<Select v-model="editSharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
|
||||
<Button icon="pi pi-check" text size="small" severity="success" title="Speichern" @click="saveEditShare(s)" />
|
||||
<Button icon="pi pi-times" text size="small" title="Abbrechen" @click="editingShareId = null" />
|
||||
</div>
|
||||
</template>
|
||||
</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 v-if="writableLists.length > 1" class="field">
|
||||
<label>Liste</label>
|
||||
<Select v-model="taskTargetListId" :options="writableListOptions"
|
||||
optionLabel="label" optionValue="id" fluid />
|
||||
</div>
|
||||
<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 taskTargetListId = ref(null)
|
||||
const writableLists = computed(() =>
|
||||
lists.value.filter(l => l.permission === 'owner' || l.permission === 'readwrite')
|
||||
)
|
||||
const writableListOptions = computed(() => writableLists.value.map(l => ({
|
||||
...l,
|
||||
label: l.permission === 'owner'
|
||||
? l.name
|
||||
: `${l.name} (geteilt von ${l.owner_display_name || l.owner_name})`,
|
||||
})))
|
||||
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 shareSearchResults = ref([])
|
||||
const editingShareId = ref(null)
|
||||
const editSharePermission = ref('read')
|
||||
const isRenaming = ref(false)
|
||||
const renameValue = ref('')
|
||||
|
||||
function startRename() {
|
||||
renameValue.value = menuList.value?.name || ''
|
||||
isRenaming.value = true
|
||||
}
|
||||
|
||||
async function saveRename() {
|
||||
const newName = renameValue.value.trim()
|
||||
if (!newName || !menuList.value || newName === menuList.value.name) {
|
||||
isRenaming.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await apiClient.put(`/tasklists/${menuList.value.id}`, { name: newName })
|
||||
menuList.value.name = newName
|
||||
isRenaming.value = false
|
||||
await loadLists()
|
||||
toast.add({ severity: 'success', summary: 'Umbenannt', life: 2000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler',
|
||||
detail: err.response?.data?.error || err.message, life: 4000 })
|
||||
}
|
||||
}
|
||||
let shareSearchTimer = null
|
||||
|
||||
function startEditShare(s) {
|
||||
editingShareId.value = s.id
|
||||
editSharePermission.value = s.permission
|
||||
}
|
||||
|
||||
async function saveEditShare(s) {
|
||||
if (!menuList.value) return
|
||||
try {
|
||||
await apiClient.post(`/tasklists/${menuList.value.id}/share`, {
|
||||
username: s.username,
|
||||
permission: editSharePermission.value,
|
||||
})
|
||||
editingShareId.value = null
|
||||
await loadShares()
|
||||
toast.add({ severity: 'success', summary: 'Berechtigung aktualisiert', life: 2500 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler',
|
||||
detail: err.response?.data?.error || err.message, life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function onShareSearch() {
|
||||
clearTimeout(shareSearchTimer)
|
||||
const q = shareUsername.value.trim()
|
||||
if (q.length < 2) { shareSearchResults.value = []; return }
|
||||
shareSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/users/search', { params: { q } })
|
||||
shareSearchResults.value = res.data
|
||||
} catch { shareSearchResults.value = [] }
|
||||
}, 250)
|
||||
}
|
||||
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 = ''
|
||||
shareSearchResults.value = []
|
||||
isRenaming.value = false
|
||||
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 = ''
|
||||
shareSearchResults.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() {
|
||||
if (!writableLists.value.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Keine beschreibbare Liste', life: 3000 })
|
||||
return
|
||||
}
|
||||
editingTaskId.value = null
|
||||
Object.assign(taskForm, {
|
||||
summary: '', description: '', due: '',
|
||||
status: 'NEEDS-ACTION', priority: null, percent_complete: null,
|
||||
categories: '',
|
||||
})
|
||||
// Default-Liste: aktuell markierte falls beschreibbar, sonst erste beschreibbare
|
||||
const sel = writableLists.value.find(l => l.id === selectedListId.value)
|
||||
taskTargetListId.value = sel ? sel.id : writableLists.value[0].id
|
||||
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 {
|
||||
const target = taskTargetListId.value || selectedListId.value
|
||||
if (!target) {
|
||||
toast.add({ severity: 'error', summary: 'Bitte Liste waehlen', life: 3000 })
|
||||
return
|
||||
}
|
||||
await apiClient.post(`/tasklists/${target}/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; flex-wrap: wrap; }
|
||||
.rename-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.rename-row strong { font-size: 1rem; }
|
||||
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
|
||||
background: white; border: 1px solid var(--p-surface-200);
|
||||
border-radius: 4px; max-height: 160px; overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem;
|
||||
display: flex; gap: 0.5rem; align-items: center; }
|
||||
.user-result:hover { background: var(--p-primary-50); }
|
||||
.user-fullname { color: var(--p-text-muted-color); font-size: 0.75rem; margin-left: auto; }
|
||||
.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; flex-wrap: wrap; }
|
||||
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
|
||||
.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>
|
||||
Reference in New Issue
Block a user