feat: import people

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-22 21:59:27 -04:00
parent cee6c2924c
commit 5fbc217edb
19 changed files with 1321 additions and 73 deletions
+18 -7
View File
@@ -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
View File
@@ -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();
}
+222
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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;
}
/**
+16
View File
@@ -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[];
}
+74
View File
@@ -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;
}
+1
View File
@@ -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';