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:
parent
c6241519a6
commit
2ce088e96b
|
|
@ -1,3 +1,6 @@
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
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
|
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'])
|
@api_bp.route('/calendars/<int:cal_id>/events', methods=['POST'])
|
||||||
@token_required
|
@token_required
|
||||||
def create_event(cal_id):
|
def create_event(cal_id):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask import request, jsonify, Response
|
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
|
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'])
|
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['POST'])
|
||||||
@token_required
|
@token_required
|
||||||
def create_contact(book_id):
|
def create_contact(book_id):
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<SelectButton v-model="viewMode" :options="viewModeOptions" optionLabel="label" optionValue="value" size="small" />
|
<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 Kalender" size="small" outlined @click="showNewCalendar = true" />
|
||||||
|
<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" @click="openNewEvent()" />
|
<Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,6 +111,37 @@
|
||||||
</div>
|
</div>
|
||||||
</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="ownCalendars" optionLabel="name" 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 -->
|
<!-- New Calendar Dialog -->
|
||||||
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
|
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -576,6 +611,96 @@ const currentEditScope = ref(null)
|
||||||
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
|
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
|
||||||
|
|
||||||
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
|
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
|
||||||
|
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 (!ownCalendars.value.length) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Kein eigener 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 = ownCalendars.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(() =>
|
const fullIcalUrl = computed(() =>
|
||||||
selectedCal.value?.ical_token ? `${window.location.origin}/ical/${selectedCal.value.ical_token}` : ''
|
selectedCal.value?.ical_token ? `${window.location.origin}/ical/${selectedCal.value.ical_token}` : ''
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
<h2>Kontakte</h2>
|
<h2>Kontakte</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
|
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
|
||||||
|
<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" @click="openNewContact" />
|
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -28,9 +32,18 @@
|
||||||
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="onSearch" />
|
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="onSearch" />
|
||||||
</div>
|
</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
|
<DataTable :value="contacts" :loading="loading" striped-rows
|
||||||
|
v-model:selection="selectedContacts" dataKey="id"
|
||||||
@row-click="onRowClick" :rowClass="() => 'clickable'">
|
@row-click="onRowClick" :rowClass="() => 'clickable'">
|
||||||
<template #empty><p class="empty">Keine Kontakte</p></template>
|
<template #empty><p class="empty">Keine Kontakte</p></template>
|
||||||
|
<Column selectionMode="multiple" headerStyle="width:3rem" />
|
||||||
<Column header="Name" sortable sortField="display_name">
|
<Column header="Name" sortable sortField="display_name">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="contact-row">
|
<div class="contact-row">
|
||||||
|
|
@ -316,6 +329,21 @@
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</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' }">
|
<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>
|
<p>Möchtest du <strong>{{ deleteContactTarget?.display_name }}</strong> wirklich löschen?</p>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
@ -675,6 +703,93 @@ async function deleteContact() {
|
||||||
await loadContacts()
|
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
|
// Live-Refresh via SSE
|
||||||
let eventSource = null
|
let eventSource = null
|
||||||
let reloadTimer = null
|
let reloadTimer = null
|
||||||
|
|
@ -729,6 +844,8 @@ watch(selectedBookId, loadContacts)
|
||||||
.book-item:hover .book-menu { opacity: 1; }
|
.book-item:hover .book-menu { opacity: 1; }
|
||||||
.contacts-main { flex: 1; min-width: 0; }
|
.contacts-main { flex: 1; min-width: 0; }
|
||||||
.search-bar { margin-bottom: 0.75rem; }
|
.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; }
|
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
|
||||||
.contact-row { display: flex; align-items: center; gap: 0.75rem; }
|
.contact-row { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
.avatar { width: 36px; height: 36px; border-radius: 50%; background: #888; color: white;
|
.avatar { width: 36px; height: 36px; border-radius: 50%; background: #888; color: white;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue