feat: import people
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -22,8 +22,8 @@ import type {
|
||||
EntityDeltaResponse,
|
||||
EntityMoveRequest,
|
||||
EntityMoveResponse,
|
||||
EntityCopyRequest,
|
||||
EntityCopyResponse,
|
||||
EntityImportRequest,
|
||||
EntityImportResponse,
|
||||
EntityInterface,
|
||||
} from '../types/entity';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
@@ -184,14 +184,25 @@ export const entityService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy entities to a target collection
|
||||
* Import vCards into a collection, streaming progress as it arrives.
|
||||
*
|
||||
* @param request - copy request parameters
|
||||
* @param request - import request (target collection, raw data, options)
|
||||
* @param onObject - called synchronously for each per-contact result frame
|
||||
* @param onDiscovered - called once with the discovered total (progress denominator)
|
||||
*
|
||||
* @returns Promise with copy results keyed by source entity identifier
|
||||
* @returns Promise resolving to { total } (objects processed) when the stream completes
|
||||
*/
|
||||
async copy(request: EntityCopyRequest): Promise<EntityCopyResponse> {
|
||||
return await transceivePost<EntityCopyRequest, EntityCopyResponse>('entity.copy', request);
|
||||
async import(
|
||||
request: EntityImportRequest,
|
||||
onObject: (object: EntityImportResponse) => void,
|
||||
onDiscovered: (expected: number) => void,
|
||||
): Promise<{ total: number }> {
|
||||
return await transceiveStream<EntityImportRequest, EntityImportResponse>(
|
||||
'entity.import',
|
||||
request,
|
||||
onObject,
|
||||
{ onStart: (expected) => { if (expected !== undefined) onDiscovered(expected); } },
|
||||
);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
+31
-34
@@ -53,32 +53,55 @@ export async function transceivePost<TRequest, TResponse>(
|
||||
* Stream an NDJSON API response, unwrapping data frames for the caller.
|
||||
*
|
||||
* The server emits one JSON object per line with a transport-level `type`
|
||||
* discriminant. This helper consumes control and error frames, forwards only
|
||||
* unwrapped `data` payloads to the caller, and returns the final stream total.
|
||||
* discriminant. This consumes the chunked body, splits it into lines, forwards
|
||||
* only unwrapped `data` payloads to the caller, and returns the final total.
|
||||
*
|
||||
* @param operation - Operation name, e.g. 'entity.listStream'
|
||||
* @param operation - Operation name, e.g. 'entity.listStream', 'entity.import'
|
||||
* @param data - Operation-specific request data
|
||||
* @param onData - Synchronous callback invoked for every unwrapped data payload.
|
||||
* May throw to abort the stream.
|
||||
* @param user - Optional user identifier override
|
||||
* @param options - Optional `user` override and an `onStart` hook, invoked once
|
||||
* with the expected total (the progress denominator) when the
|
||||
* server declares one on the start frame.
|
||||
* @returns Promise resolving to the final stream total from the control/end frame
|
||||
*/
|
||||
export async function transceiveStream<TRequest, TData>(
|
||||
operation: string,
|
||||
data: TRequest,
|
||||
onData: (data: TData) => void,
|
||||
user?: string
|
||||
options?: { user?: string; onStart?: (expected?: number) => void }
|
||||
): Promise<{ total: number }> {
|
||||
const request: ApiRequest<TRequest> = {
|
||||
version: API_VERSION,
|
||||
transaction: generateTransactionId(),
|
||||
operation,
|
||||
data,
|
||||
user,
|
||||
user: options?.user,
|
||||
};
|
||||
|
||||
let total = 0;
|
||||
|
||||
// Interpret one NDJSON line: control frames carry start/end metadata, error
|
||||
// frames abort, data frames are unwrapped to the caller.
|
||||
const dispatch = (line: string): void => {
|
||||
const message = JSON.parse(line) as ApiStreamResponse<TData>;
|
||||
|
||||
if (message.type === 'control') {
|
||||
if (message.status === 'start') {
|
||||
options?.onStart?.(message.total);
|
||||
} else if (message.status === 'end') {
|
||||
total = message.total;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'error') {
|
||||
throw new Error(`[${operation}] ${message.message}`);
|
||||
}
|
||||
|
||||
onData(message.data);
|
||||
};
|
||||
|
||||
await fetchWrapper.post(API_URL, request, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
onStream: async (response: Response) => {
|
||||
@@ -100,38 +123,12 @@ export async function transceiveStream<TRequest, TData>(
|
||||
buffer = lines.pop()!; // retain any incomplete trailing chunk
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const message = JSON.parse(line) as ApiStreamResponse<TData>;
|
||||
|
||||
if (message.type === 'control') {
|
||||
if (message.status === 'end') {
|
||||
total = message.total;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'error') {
|
||||
throw new Error(`[${operation}] ${message.message}`);
|
||||
}
|
||||
|
||||
onData(message.data);
|
||||
if (line.trim()) dispatch(line);
|
||||
}
|
||||
}
|
||||
|
||||
// flush any remaining bytes still in the buffer
|
||||
if (buffer.trim()) {
|
||||
const message = JSON.parse(buffer) as ApiStreamResponse<TData>;
|
||||
|
||||
if (message.type === 'control') {
|
||||
if (message.status === 'end') {
|
||||
total = message.total;
|
||||
}
|
||||
} else if (message.type === 'error') {
|
||||
throw new Error(`[${operation}] ${message.message}`);
|
||||
} else {
|
||||
onData(message.data);
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) dispatch(buffer);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
+5
-1
@@ -44,13 +44,17 @@ export interface ApiErrorResponse {
|
||||
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
|
||||
/**
|
||||
* Stream control start line
|
||||
* Stream control start line.
|
||||
*
|
||||
* `total`, when present, is the expected number of data frames (the progress
|
||||
* denominator). Streams that cannot cheaply know their size up front omit it.
|
||||
*/
|
||||
export interface ApiStreamStartResponse {
|
||||
type: 'control';
|
||||
status: 'start';
|
||||
version: number;
|
||||
transaction: string;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import type { GroupInterface } from './group';
|
||||
import type { IndividualInterface } from './individual';
|
||||
import type { OrganizationInterface } from './organization';
|
||||
import type { ImportDisposition, ImportFileOptions } from './import';
|
||||
|
||||
export type EntityPropertiesInterface = IndividualInterface | OrganizationInterface | GroupInterface;
|
||||
|
||||
@@ -167,3 +168,18 @@ export interface EntityMoveResponse {
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity import
|
||||
*/
|
||||
export interface EntityImportRequest {
|
||||
target: CollectionIdentifier;
|
||||
data: string;
|
||||
options: ImportFileOptions;
|
||||
}
|
||||
|
||||
export interface EntityImportResponse {
|
||||
identifier: string | null;
|
||||
disposition: ImportDisposition;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Types for the VCF / vCard contact import UI flow (file queue, options, and
|
||||
* live session state).
|
||||
*
|
||||
* The wire request/response for the `entity.import` operation live alongside the
|
||||
* other entity operations in `./entity` ({@link EntityImportRequest},
|
||||
* {@link EntityImportResponse}).
|
||||
*/
|
||||
|
||||
import type { CollectionIdentifier } from './common';
|
||||
import type { EntityImportResponse } from './entity';
|
||||
|
||||
export type ImportDisposition = 'created' | 'updated' | 'exists' | 'error';
|
||||
|
||||
export type ImportSessionStage = 'idle' | 'preparing' | 'selecting' | 'importing' | 'completed' | 'error';
|
||||
|
||||
/**
|
||||
* Per-file import options sent to the backend.
|
||||
*/
|
||||
export interface ImportFileOptions {
|
||||
supersede: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file queued for import (raw contents read from disk).
|
||||
*/
|
||||
export interface ImportFileSource {
|
||||
id: number;
|
||||
name: string;
|
||||
contents: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for queueing a file (id is assigned by the store).
|
||||
*/
|
||||
export type ImportFileAdd = Omit<ImportFileSource, 'id'>;
|
||||
|
||||
/**
|
||||
* A queued file paired with its chosen target collection and options.
|
||||
*/
|
||||
export interface ImportFileEntry {
|
||||
file: ImportFileSource;
|
||||
collectionId: CollectionIdentifier | null;
|
||||
options: ImportFileOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate counters for an import run.
|
||||
*/
|
||||
export interface ImportCounters {
|
||||
discovered: number;
|
||||
processed: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
exists: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live state for one file's import session.
|
||||
*/
|
||||
export interface ImportSession {
|
||||
fileId: number;
|
||||
fileName: string;
|
||||
targetDisplayName: string;
|
||||
targetIdentifier: CollectionIdentifier | null;
|
||||
status: 'pending' | 'importing' | 'completed' | 'error';
|
||||
counters: ImportCounters;
|
||||
/** Bounded rolling window of recent object results, capped (see RECENT_RESULTS_CAP). */
|
||||
recentResults: EntityImportResponse[];
|
||||
lastError: string | null;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type * from './collection';
|
||||
export type * from './common';
|
||||
export type * from './entity';
|
||||
export type * from './group';
|
||||
export type * from './import';
|
||||
export type * from './individual';
|
||||
export type * from './organization';
|
||||
export type * from './provider';
|
||||
|
||||
Reference in New Issue
Block a user