From 2ce088e96b7eafa6b53dc7e74550251af653570a Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Mon, 13 Apr 2026 11:23:23 +0200 Subject: [PATCH] feat: Import/Export fuer Kontakte und Kalender + Bulk-Loeschen Kontakte Kontakte: - Mehrfachauswahl in der Liste (Checkbox-Spalte) mit Bulk-Loeschen - Export als Sammel-vCard (.vcf), als ZIP mit Einzel-vCards oder als CSV - Import aus vCard (mehrere im File moeglich) oder CSV; Match per UID, bestehende Kontakte werden aktualisiert Kalender: - Export als iCalendar (.ics) oder CSV - Import aus .ics oder CSV; bestehende Termine via UID aktualisiert Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/calendar.py | 155 +++++++++++++++++++++++++++ backend/app/api/contacts.py | 158 ++++++++++++++++++++++++++++ frontend/src/views/CalendarView.vue | 125 ++++++++++++++++++++++ frontend/src/views/ContactsView.vue | 117 ++++++++++++++++++++ 4 files changed, 555 insertions(+) diff --git a/backend/app/api/calendar.py b/backend/app/api/calendar.py index cb66043..a40cc57 100644 --- a/backend/app/api/calendar.py +++ b/backend/app/api/calendar.py @@ -1,3 +1,6 @@ +import csv +import io +import re import secrets import uuid from datetime import datetime, timezone @@ -236,6 +239,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//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//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//events', methods=['POST']) @token_required def create_event(cal_id): diff --git a/backend/app/api/contacts.py b/backend/app/api/contacts.py index a3f0e61..a47712e 100644 --- a/backend/app/api/contacts.py +++ b/backend/app/api/contacts.py @@ -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 @@ -404,6 +407,161 @@ def list_contacts(book_id): return jsonify([c.to_dict() for c in contacts]), 200 +@api_bp.route('/addressbooks//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//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//contacts', methods=['POST']) @token_required def create_contact(book_id): diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 62413b7..7319559 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -5,6 +5,10 @@
@@ -107,6 +111,37 @@ + + +
+ + +
+
+ + +
@@ -28,9 +32,18 @@ +
+ {{ selectedContacts.length }} ausgewaehlt +
+ +
+ +

Aus Adressbuch {{ currentBook?.name }}

+
+ +