feat: people import
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
||||||
@@ -8,6 +8,7 @@ import CollectionList from '@/components/CollectionList.vue'
|
|||||||
import CollectionEditor from '@/components/CollectionEditor.vue'
|
import CollectionEditor from '@/components/CollectionEditor.vue'
|
||||||
import PersonList from '@/components/PersonList.vue'
|
import PersonList from '@/components/PersonList.vue'
|
||||||
import PersonEditor from '@/components/PersonEditor.vue'
|
import PersonEditor from '@/components/PersonEditor.vue'
|
||||||
|
import ImportDialog from '@/components/ImportDialog.vue'
|
||||||
|
|
||||||
// Vuetify display
|
// Vuetify display
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -30,6 +31,7 @@ const {
|
|||||||
showCollectionEditor,
|
showCollectionEditor,
|
||||||
editingCollection,
|
editingCollection,
|
||||||
collectionEditorMode,
|
collectionEditorMode,
|
||||||
|
showImportDialog,
|
||||||
collections,
|
collections,
|
||||||
} = storeToRefs(peopleStore)
|
} = storeToRefs(peopleStore)
|
||||||
|
|
||||||
@@ -47,8 +49,26 @@ const {
|
|||||||
deleteEntity,
|
deleteEntity,
|
||||||
saveCollection,
|
saveCollection,
|
||||||
deleteCollection,
|
deleteCollection,
|
||||||
|
openImport,
|
||||||
|
closeImport,
|
||||||
} = peopleStore
|
} = 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await initialize()
|
await initialize()
|
||||||
@@ -147,6 +167,31 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
Address Book
|
Address Book
|
||||||
</v-btn>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
@@ -209,6 +254,13 @@ onMounted(async () => {
|
|||||||
@save="saveCollection"
|
@save="saveCollection"
|
||||||
@delete="deleteCollection"
|
@delete="deleteCollection"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- VCF Import Dialog -->
|
||||||
|
<ImportDialog
|
||||||
|
v-model="showImportDialog"
|
||||||
|
:collections="collections"
|
||||||
|
@close="closeImport"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import type { ServiceObject } from '@PeopleManager/models/service'
|
|||||||
import { useCollectionsStore } from '@PeopleManager/stores/collectionsStore'
|
import { useCollectionsStore } from '@PeopleManager/stores/collectionsStore'
|
||||||
import { useEntitiesStore } from '@PeopleManager/stores/entitiesStore'
|
import { useEntitiesStore } from '@PeopleManager/stores/entitiesStore'
|
||||||
import { useServicesStore } from '@PeopleManager/stores/servicesStore'
|
import { useServicesStore } from '@PeopleManager/stores/servicesStore'
|
||||||
|
import { useImportStore } from '@PeopleManager/stores/importStore'
|
||||||
|
|
||||||
export const usePeopleStore = defineStore('peopleStore', () => {
|
export const usePeopleStore = defineStore('peopleStore', () => {
|
||||||
const servicesStore = useServicesStore()
|
const servicesStore = useServicesStore()
|
||||||
const collectionsStore = useCollectionsStore()
|
const collectionsStore = useCollectionsStore()
|
||||||
const entitiesStore = useEntitiesStore()
|
const entitiesStore = useEntitiesStore()
|
||||||
|
const importStore = useImportStore()
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const sidebarVisible = ref(true)
|
const sidebarVisible = ref(true)
|
||||||
@@ -31,6 +33,9 @@ export const usePeopleStore = defineStore('peopleStore', () => {
|
|||||||
const editingCollection = shallowRef<CollectionObject | null>(null)
|
const editingCollection = shallowRef<CollectionObject | null>(null)
|
||||||
const collectionEditorMode = ref<'create' | 'edit'>('create')
|
const collectionEditorMode = ref<'create' | 'edit'>('create')
|
||||||
|
|
||||||
|
// Import state
|
||||||
|
const showImportDialog = ref(false)
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
const collections = computed(() => collectionsStore.collections)
|
const collections = computed(() => collectionsStore.collections)
|
||||||
const entities = computed(() => entitiesStore.entities)
|
const entities = computed(() => entitiesStore.entities)
|
||||||
@@ -161,6 +166,55 @@ export const usePeopleStore = defineStore('peopleStore', () => {
|
|||||||
closeEntityEditor()
|
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 ---
|
// --- Lifecycle ---
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
@@ -190,6 +244,7 @@ export const usePeopleStore = defineStore('peopleStore', () => {
|
|||||||
showCollectionEditor,
|
showCollectionEditor,
|
||||||
editingCollection,
|
editingCollection,
|
||||||
collectionEditorMode,
|
collectionEditorMode,
|
||||||
|
showImportDialog,
|
||||||
|
|
||||||
// Derived
|
// Derived
|
||||||
collections,
|
collections,
|
||||||
@@ -211,6 +266,10 @@ export const usePeopleStore = defineStore('peopleStore', () => {
|
|||||||
saveEntity,
|
saveEntity,
|
||||||
deleteEntity,
|
deleteEntity,
|
||||||
|
|
||||||
|
// Import actions
|
||||||
|
openImport,
|
||||||
|
closeImport,
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
initialize,
|
initialize,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user