feat: people import

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-22 22:56:31 -04:00
parent 583e6a3359
commit b6b3e53095
3 changed files with 324 additions and 1 deletions
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useImportStore } from '@PeopleManager/stores/importStore'
import { CollectionObject } from '@PeopleManager/models/collection'
// Props
const props = defineProps<{
modelValue: boolean
collections: CollectionObject[]
}>()
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
// refresh = true once contacts were created/updated, so the caller can reload lists
'close': [refresh: boolean]
}>()
// Store
const importStore = useImportStore()
const { files, sessions, order, stage, running, totals } = storeToRefs(importStore)
// Collection picker options
const collectionOptions = computed(() =>
props.collections.map((collection: CollectionObject) => ({
title: collection.properties.label || 'Unnamed',
value: collection.identifier,
})),
)
const isSelecting = computed(() => stage.value === 'selecting')
const isComplete = computed(() => stage.value === 'completed' || stage.value === 'error')
// Every queued file must have a destination before import can start.
const canImport = computed(() =>
files.value.length > 0 && files.value.every((entry) => entry.collectionId !== null),
)
const progress = computed(() => {
const { discovered, processed } = totals.value
return discovered > 0 ? Math.min(100, Math.round((processed / discovered) * 100)) : 0
})
const counters = computed(() => {
const t = totals.value
return [
{ key: 'discovered', label: 'Discovered', value: t.discovered, color: 'info' },
{ key: 'created', label: 'Created', value: t.created, color: 'success' },
{ key: 'updated', label: 'Updated', value: t.updated, color: 'info' },
{ key: 'exists', label: 'Skipped', value: t.exists, color: 'warning' },
{ key: 'error', label: 'Errors', value: t.error, color: 'error' },
]
})
function setCollection(fileId: number, collectionId: CollectionObject['identifier'] | null) {
importStore.setCollectionForFile(fileId, collectionId)
}
function setSupersede(fileId: number, value: boolean) {
importStore.setOptionsForFile(fileId, { supersede: value })
}
async function startImport() {
try {
await importStore.startImport()
} catch (error) {
console.error('[People] - Import failed:', error)
}
}
function cancel() {
emit('update:modelValue', false)
emit('close', false)
}
function done() {
const refresh = totals.value.created > 0 || totals.value.updated > 0
emit('update:modelValue', false)
emit('close', refresh)
}
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="640"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-database-import" class="mr-2" />
Import Contacts
</v-card-title>
<v-divider />
<!-- Selecting stage: choose a destination per file -->
<v-card-text v-if="isSelecting" class="pt-4">
<p class="text-body-2 text-medium-emphasis mb-4">
Choose a destination address book for each file. Existing contacts are kept
unless "Overwrite existing" is enabled.
</p>
<v-card
v-for="entry in files"
:key="entry.file.id"
variant="tonal"
class="mb-3 pa-3"
>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-file-account-outline" size="small" class="mr-2" />
<span class="text-subtitle-2 font-weight-medium text-truncate">{{ entry.file.name }}</span>
</div>
<v-select
:model-value="entry.collectionId"
:items="collectionOptions"
label="Address book"
density="compact"
variant="outlined"
hide-details
class="mb-2"
@update:model-value="setCollection(entry.file.id, $event)"
/>
<v-switch
:model-value="entry.options.supersede"
label="Overwrite existing contacts"
color="primary"
density="compact"
hide-details
@update:model-value="setSupersede(entry.file.id, $event ?? false)"
/>
</v-card>
</v-card-text>
<!-- Importing / completed stage: progress + counters -->
<v-card-text v-else class="pt-4">
<div class="d-flex align-center justify-space-between mb-1">
<span class="text-body-2">
{{ isComplete ? 'Import complete' : 'Importing…' }}
</span>
<span class="text-body-2 text-medium-emphasis">
{{ totals.processed }} / {{ totals.discovered }}
</span>
</div>
<v-progress-linear
:model-value="progress"
:indeterminate="running && totals.discovered === 0"
color="primary"
height="8"
rounded
class="mb-4"
/>
<div class="d-flex flex-wrap ga-2">
<v-chip
v-for="counter in counters"
:key="counter.key"
:color="counter.color"
size="small"
variant="tonal"
>
{{ counter.label }}: {{ counter.value }}
</v-chip>
</div>
<v-list v-if="order.length > 1" density="compact" class="mt-3 bg-transparent">
<v-list-item
v-for="id in order"
:key="id"
:title="sessions[id]?.fileName"
:subtitle="sessions[id]?.lastError ?? undefined"
>
<template #prepend>
<v-icon
:icon="sessions[id]?.status === 'error' ? 'mdi-alert-circle' : sessions[id]?.status === 'completed' ? 'mdi-check-circle' : 'mdi-progress-clock'"
:color="sessions[id]?.status === 'error' ? 'error' : sessions[id]?.status === 'completed' ? 'success' : 'info'"
size="small"
/>
</template>
<template #append>
<span class="text-caption text-medium-emphasis">
{{ sessions[id]?.counters.processed }} / {{ sessions[id]?.counters.discovered }}
</span>
</template>
</v-list-item>
</v-list>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<template v-if="isSelecting">
<v-btn variant="text" @click="cancel">Cancel</v-btn>
<v-btn color="primary" variant="flat" :disabled="!canImport" @click="startImport">
Import
</v-btn>
</template>
<template v-else>
<v-btn color="primary" variant="flat" :disabled="running" @click="done">
Close
</v-btn>
</template>
</v-card-actions>
</v-card>
</v-dialog>
</template>
+53 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC/stores/moduleStore'
@@ -8,6 +8,7 @@ import CollectionList from '@/components/CollectionList.vue'
import CollectionEditor from '@/components/CollectionEditor.vue'
import PersonList from '@/components/PersonList.vue'
import PersonEditor from '@/components/PersonEditor.vue'
import ImportDialog from '@/components/ImportDialog.vue'
// Vuetify display
const display = useDisplay()
@@ -30,6 +31,7 @@ const {
showCollectionEditor,
editingCollection,
collectionEditorMode,
showImportDialog,
collections,
} = storeToRefs(peopleStore)
@@ -47,8 +49,26 @@ const {
deleteEntity,
saveCollection,
deleteCollection,
openImport,
closeImport,
} = peopleStore
// VCF import file picker
const fileInputRef = ref<HTMLInputElement | null>(null)
function triggerFileInput() {
fileInputRef.value?.click()
}
async function onFilesSelected(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = '' // allow re-selecting the same file later
if (files.length > 0) {
await openImport(files)
}
}
onMounted(async () => {
try {
await initialize()
@@ -147,6 +167,31 @@ onMounted(async () => {
>
Address Book
</v-btn>
<v-divider class="my-2" />
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-database-import" size="small" class="mr-2" />
<span class="text-subtitle-2 font-weight-bold">Import</span>
</div>
<v-btn
variant="text"
prepend-icon="mdi-upload"
size="small"
block
class="justify-start"
@click="triggerFileInput"
>
Import VCF
</v-btn>
<input
ref="fileInputRef"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
multiple
class="d-none"
@change="onFilesSelected"
/>
</div>
</div>
</v-navigation-drawer>
@@ -209,6 +254,13 @@ onMounted(async () => {
@save="saveCollection"
@delete="deleteCollection"
/>
<!-- VCF Import Dialog -->
<ImportDialog
v-model="showImportDialog"
:collections="collections"
@close="closeImport"
/>
</div>
</template>
+59
View File
@@ -9,11 +9,13 @@ import type { ServiceObject } from '@PeopleManager/models/service'
import { useCollectionsStore } from '@PeopleManager/stores/collectionsStore'
import { useEntitiesStore } from '@PeopleManager/stores/entitiesStore'
import { useServicesStore } from '@PeopleManager/stores/servicesStore'
import { useImportStore } from '@PeopleManager/stores/importStore'
export const usePeopleStore = defineStore('peopleStore', () => {
const servicesStore = useServicesStore()
const collectionsStore = useCollectionsStore()
const entitiesStore = useEntitiesStore()
const importStore = useImportStore()
// UI state
const sidebarVisible = ref(true)
@@ -31,6 +33,9 @@ export const usePeopleStore = defineStore('peopleStore', () => {
const editingCollection = shallowRef<CollectionObject | null>(null)
const collectionEditorMode = ref<'create' | 'edit'>('create')
// Import state
const showImportDialog = ref(false)
// Derived state
const collections = computed(() => collectionsStore.collections)
const entities = computed(() => entitiesStore.entities)
@@ -161,6 +166,55 @@ export const usePeopleStore = defineStore('peopleStore', () => {
closeEntityEditor()
}
// --- Import actions ---
/**
* Queue selected VCF files and open the import dialog.
* Defaults each file's target to the currently selected collection.
*/
async function openImport(files: File[]) {
importStore.removeAllFiles()
importStore.reset()
for (const file of files) {
const contents = await file.text()
const id = importStore.addFile({
name: file.name,
contents,
size: file.size,
type: file.type,
})
if (selectedCollection.value) {
importStore.setCollectionForFile(id, selectedCollection.value.identifier)
}
}
importStore.stage = 'selecting'
showImportDialog.value = true
}
/**
* Close the import dialog. When refresh is requested, reload the entity lists for
* every collection that received contacts, then clear the import queue.
*/
async function closeImport(refresh: boolean = false) {
showImportDialog.value = false
if (refresh) {
const targets = new Set<CollectionObject['identifier']>()
for (const id of importStore.order) {
const target = importStore.sessions[id]?.targetIdentifier
if (target) targets.add(target)
}
for (const target of targets) {
await entitiesStore.list([target])
}
}
importStore.removeAllFiles()
importStore.reset()
}
// --- Lifecycle ---
async function initialize() {
@@ -190,6 +244,7 @@ export const usePeopleStore = defineStore('peopleStore', () => {
showCollectionEditor,
editingCollection,
collectionEditorMode,
showImportDialog,
// Derived
collections,
@@ -211,6 +266,10 @@ export const usePeopleStore = defineStore('peopleStore', () => {
saveEntity,
deleteEntity,
// Import actions
openImport,
closeImport,
// Lifecycle
initialize,
}