feat: import people
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Contact Import Store
|
||||
*
|
||||
* Drives the VCF import flow: a queue of files, each targeting a collection, imported
|
||||
* sequentially while live counters stream in. Memory stays flat regardless of contact
|
||||
* count — per-session counters are plain integers and recent results are capped.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { entityService } from '../services/entityService'
|
||||
import type { CollectionIdentifier } from '../types/common'
|
||||
import type { EntityImportResponse } from '../types/entity'
|
||||
import type {
|
||||
ImportCounters,
|
||||
ImportFileAdd,
|
||||
ImportFileEntry,
|
||||
ImportFileOptions,
|
||||
ImportSession,
|
||||
ImportSessionStage,
|
||||
} from '../types/import'
|
||||
|
||||
/** Max object results retained per session for UI display. */
|
||||
const RECENT_RESULTS_CAP = 25
|
||||
|
||||
function createEmptyCounters(): ImportCounters {
|
||||
return { discovered: 0, processed: 0, created: 0, updated: 0, exists: 0, error: 0 }
|
||||
}
|
||||
|
||||
function defaultOptions(): ImportFileOptions {
|
||||
return { supersede: false }
|
||||
}
|
||||
|
||||
export const useImportStore = defineStore('peopleImportStore', () => {
|
||||
// State
|
||||
const lastFileInsertId = ref(-1)
|
||||
const files = ref<ImportFileEntry[]>([])
|
||||
const stage = ref<ImportSessionStage>('idle')
|
||||
const running = ref(false)
|
||||
const activeFileId = ref<number | null>(null)
|
||||
const lastError = ref<string | null>(null)
|
||||
const sessions = ref<Record<number, ImportSession>>({})
|
||||
const order = ref<number[]>([])
|
||||
|
||||
// Computed
|
||||
|
||||
/** Aggregate counters across every session. */
|
||||
const totals = computed<ImportCounters>(() => {
|
||||
const aggregate = createEmptyCounters()
|
||||
for (const id of order.value) {
|
||||
const session = sessions.value[id]
|
||||
if (!session) continue
|
||||
aggregate.discovered += session.counters.discovered
|
||||
aggregate.processed += session.counters.processed
|
||||
aggregate.created += session.counters.created
|
||||
aggregate.updated += session.counters.updated
|
||||
aggregate.exists += session.counters.exists
|
||||
aggregate.error += session.counters.error
|
||||
}
|
||||
return aggregate
|
||||
})
|
||||
|
||||
const activeSession = computed<ImportSession | null>(() =>
|
||||
activeFileId.value !== null ? sessions.value[activeFileId.value] ?? null : null,
|
||||
)
|
||||
|
||||
// Actions
|
||||
|
||||
/** Queue a file for import; returns its assigned id. */
|
||||
function addFile(file: ImportFileAdd): number {
|
||||
const id = ++lastFileInsertId.value
|
||||
files.value.push({
|
||||
file: { id, ...file },
|
||||
collectionId: null,
|
||||
options: defaultOptions(),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/** Clear the queue, retaining the insert counter. */
|
||||
function removeAllFiles(): void {
|
||||
files.value = []
|
||||
}
|
||||
|
||||
/** Reset run state, preserving queued files. */
|
||||
function reset(): void {
|
||||
stage.value = 'idle'
|
||||
running.value = false
|
||||
activeFileId.value = null
|
||||
lastError.value = null
|
||||
sessions.value = {}
|
||||
order.value = []
|
||||
}
|
||||
|
||||
function entryFor(fileId: number): ImportFileEntry | undefined {
|
||||
return files.value.find((entry) => entry.file.id === fileId)
|
||||
}
|
||||
|
||||
/** Set the destination collection for a queued file. */
|
||||
function setCollectionForFile(fileId: number, collectionId: CollectionIdentifier | null): void {
|
||||
const entry = entryFor(fileId)
|
||||
if (entry) entry.collectionId = collectionId
|
||||
}
|
||||
|
||||
/** Merge option changes for a queued file without dropping untouched keys. */
|
||||
function setOptionsForFile(fileId: number, options: Partial<ImportFileOptions>): void {
|
||||
const entry = entryFor(fileId)
|
||||
if (entry) entry.options = { ...entry.options, ...options }
|
||||
}
|
||||
|
||||
/** Record the discovered total (progress denominator) for a session. */
|
||||
function setDiscovered(fileId: number, expected: number): void {
|
||||
const session = sessions.value[fileId]
|
||||
if (session) session.counters.discovered = expected
|
||||
}
|
||||
|
||||
/** Fold one per-contact result into a session's counters and recent window. */
|
||||
function recordObject(fileId: number, object: EntityImportResponse): void {
|
||||
const session = sessions.value[fileId]
|
||||
if (!session) return
|
||||
|
||||
session.recentResults = [object, ...session.recentResults].slice(0, RECENT_RESULTS_CAP)
|
||||
session.counters.processed += 1
|
||||
session.counters[object.disposition] += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Import every queued file sequentially, streaming live counters into each session.
|
||||
*
|
||||
* @returns aggregated counters across all files
|
||||
* @throws if a queued file has no destination collection
|
||||
*/
|
||||
async function startImport(): Promise<ImportCounters> {
|
||||
const entries = files.value.slice()
|
||||
if (entries.length === 0) {
|
||||
stage.value = 'completed'
|
||||
return createEmptyCounters()
|
||||
}
|
||||
|
||||
// Initialise sessions up front so the UI can render the full set.
|
||||
sessions.value = {}
|
||||
order.value = []
|
||||
for (const entry of entries) {
|
||||
sessions.value[entry.file.id] = {
|
||||
fileId: entry.file.id,
|
||||
fileName: entry.file.name,
|
||||
targetDisplayName: entry.collectionId ?? '',
|
||||
targetIdentifier: entry.collectionId,
|
||||
status: 'pending',
|
||||
counters: createEmptyCounters(),
|
||||
recentResults: [],
|
||||
lastError: null,
|
||||
}
|
||||
order.value.push(entry.file.id)
|
||||
}
|
||||
|
||||
running.value = true
|
||||
stage.value = 'importing'
|
||||
lastError.value = null
|
||||
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
const session = sessions.value[entry.file.id]
|
||||
if (!entry.collectionId) {
|
||||
session.status = 'error'
|
||||
session.lastError = 'No destination collection selected'
|
||||
throw new Error(`Selected collection not found for "${entry.file.name}"`)
|
||||
}
|
||||
|
||||
activeFileId.value = entry.file.id
|
||||
session.status = 'importing'
|
||||
|
||||
try {
|
||||
await entityService.import(
|
||||
{ target: entry.collectionId, data: entry.file.contents, options: entry.options },
|
||||
(object) => recordObject(entry.file.id, object),
|
||||
(expected) => setDiscovered(entry.file.id, expected),
|
||||
)
|
||||
session.status = session.counters.error > 0 ? 'error' : 'completed'
|
||||
} catch (error) {
|
||||
session.status = 'error'
|
||||
session.lastError = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
stage.value = 'completed'
|
||||
return totals.value
|
||||
} catch (error) {
|
||||
stage.value = 'error'
|
||||
lastError.value = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
} finally {
|
||||
running.value = false
|
||||
activeFileId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
lastFileInsertId,
|
||||
files,
|
||||
stage,
|
||||
running,
|
||||
activeFileId,
|
||||
lastError,
|
||||
sessions,
|
||||
order,
|
||||
// computed
|
||||
totals,
|
||||
activeSession,
|
||||
// actions
|
||||
addFile,
|
||||
removeAllFiles,
|
||||
reset,
|
||||
setCollectionForFile,
|
||||
setOptionsForFile,
|
||||
startImport,
|
||||
}
|
||||
})
|
||||
|
||||
export default useImportStore
|
||||
@@ -2,3 +2,4 @@ export { useProvidersStore } from './providersStore';
|
||||
export { useServicesStore } from './servicesStore';
|
||||
export { useCollectionsStore } from './collectionsStore';
|
||||
export { useEntitiesStore } from './entitiesStore';
|
||||
export { useImportStore } from './importStore';
|
||||
|
||||
Reference in New Issue
Block a user