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:
@@ -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}` : ''
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user