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) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<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
|
||||
@@ -404,6 +407,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):
|
||||
|
||||
Reference in New Issue
Block a user