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
+125
View File
@@ -5,6 +5,10 @@
<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-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()" />
</div>
</div>
@@ -107,6 +111,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="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 -->
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
<div class="field">
@@ -576,6 +611,96 @@ 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'))
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(() =>
selectedCal.value?.ical_token ? `${window.location.origin}/ical/${selectedCal.value.ical_token}` : ''
)
+117
View File
@@ -4,6 +4,10 @@
<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-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" />
</div>
</div>
@@ -28,9 +32,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">
@@ -316,6 +329,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>
@@ -675,6 +703,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
@@ -729,6 +844,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;