/** * Contact Import Store */ 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([]) const stage = ref('idle') const running = ref(false) const activeFileId = ref(null) const lastError = ref(null) const sessions = ref>({}) const order = ref([]) // Computed /** Aggregate counters across every session. */ const totals = computed(() => { 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(() => activeFileId.value !== null ? sessions.value[activeFileId.value] ?? null : null, ) // Actions 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): 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 { 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