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:
Stefan Hacker 2026-04-13 11:23:23 +02:00
parent c6241519a6
commit 2ce088e96b
4 changed files with 555 additions and 0 deletions

View File

@ -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):

View File

@ -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):

View File

@ -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}` : ''
) )

View File

@ -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;