e04957de5e
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
220 lines
6.3 KiB
TypeScript
220 lines
6.3 KiB
TypeScript
/**
|
|
* 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<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
|
|
|
|
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
|