feat: import

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-28 18:06:12 -04:00
parent 0880bba063
commit b7e12621cd
16 changed files with 968 additions and 11 deletions
+24
View File
@@ -22,6 +22,8 @@ import type {
EntityDeltaResponse,
EntityMoveRequest,
EntityMoveResponse,
EntityImportRequest,
EntityImportResponse,
EntityInterface,
} from '../types/entity';
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
@@ -139,6 +141,28 @@ export const entityService = {
return await transceivePost<EntityMoveRequest, EntityMoveResponse>('entity.move', request);
},
/**
* Import vCards into a collection, streaming progress as it arrives.
*
* @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 resolving to { total } (objects processed) when the stream completes
*/
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); } },
);
},
};
export default entityService;
+124
View File
@@ -0,0 +1,124 @@
/**
* Contact Import Store
*/
import { computed, ref } 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'
const RECENT_RESULTS_CAP = 25
const emptyCounters = (): ImportCounters => ({ discovered: 0, processed: 0, created: 0, updated: 0, exists: 0, error: 0 })
export const useImportStore = defineStore('chronoImportStore', () => {
// State
const nextId = ref(0)
const files = ref<ImportFileEntry[]>([])
const stage = ref<ImportSessionStage>('idle')
const running = ref(false)
const sessions = ref<Record<number, ImportSession>>({})
const order = ref<number[]>([])
// Computed
const totals = computed(() => order.value.reduce((total, id) => {
const counters = sessions.value[id]?.counters
if (counters) Object.keys(total).forEach(key => {
total[key as keyof ImportCounters] += counters[key as keyof ImportCounters]
})
return total
}, emptyCounters()))
// Actions
function addFile(file: ImportFileAdd): number {
const id = nextId.value++
files.value.push({
file: { id, ...file },
collectionId: null,
options: { supersede: false }
})
return id
}
function removeAllFiles() {
files.value = []
}
function reset() {
stage.value = 'idle';
running.value = false;
sessions.value = {};
order.value = [];
}
function entryFor(id: number) {
return files.value.find(entry => entry.file.id === id)
}
/** Set the destination collection for a queued file. */
function setCollectionForFile(id: number, collectionId: CollectionIdentifier | null) {
const entry = entryFor(id);
if (entry) entry.collectionId = collectionId;
}
/** Merge option changes for a queued file without dropping untouched keys. */
function setOptionsForFile(id: number, options: Partial<ImportFileOptions>) {
const entry = entryFor(id);
if (entry) entry.options = { ...entry.options, ...options };
}
async function startImport(): Promise<ImportCounters> {
const entries = [...files.value]
sessions.value = {}
order.value = entries.map(entry => entry.file.id)
for (const entry of entries) sessions.value[entry.file.id] = {
fileId: entry.file.id, fileName: entry.file.name, targetIdentifier: entry.collectionId,
status: 'pending', counters: emptyCounters(), recentResults: [], lastError: null,
}
running.value = true
stage.value = 'importing'
try {
for (const entry of entries) {
const session = sessions.value[entry.file.id]!
if (!entry.collectionId) throw new Error(`No calendar selected for "${entry.file.name}"`)
session.status = 'importing'
try {
await entityService.import(
{ target: entry.collectionId, data: entry.file.contents, options: entry.options },
(object: EntityImportResponse) => {
session.counters.processed++
session.counters[object.disposition]++
session.recentResults = [object, ...session.recentResults].slice(0, RECENT_RESULTS_CAP)
},
expected => { session.counters.discovered = expected },
)
session.status = session.counters.error ? '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'
throw error
} finally { running.value = false }
}
return { files, stage, running, sessions, order, totals, addFile, removeAllFiles, reset, setCollectionForFile, setOptionsForFile, startImport }
})
+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';
+13
View File
@@ -11,6 +11,7 @@ import type {
import type { EventInterface } from './event';
import type { TaskInterface } from './task';
import type { JournalInterface } from './journal';
import type { ImportDisposition, ImportFileOptions } from './import';
export type EntityPropertiesInterface = EventInterface | TaskInterface | JournalInterface;
@@ -167,3 +168,15 @@ export interface EntityMoveResponse {
error?: string;
};
}
export interface EntityImportRequest {
target: CollectionIdentifier;
data: string;
options: ImportFileOptions;
}
export interface EntityImportResponse {
identifier: string | null;
disposition: ImportDisposition;
errors: string[];
}
+19
View File
@@ -0,0 +1,19 @@
import type { CollectionIdentifier } from './common';
import type { EntityImportResponse } from './entity';
export type ImportDisposition = 'created' | 'updated' | 'exists' | 'error';
export type ImportSessionStage = 'idle' | 'selecting' | 'importing' | 'completed' | 'error';
export interface ImportFileOptions { supersede: boolean; }
export interface ImportFileSource { id: number; name: string; contents: string; size: number; type: string; }
export type ImportFileAdd = Omit<ImportFileSource, 'id'>;
export interface ImportFileEntry { file: ImportFileSource; collectionId: CollectionIdentifier | null; options: ImportFileOptions; }
export interface ImportCounters { discovered: number; processed: number; created: number; updated: number; exists: number; error: number; }
export interface ImportSession {
fileId: number;
fileName: string;
targetIdentifier: CollectionIdentifier | null;
status: 'pending' | 'importing' | 'completed' | 'error';
counters: ImportCounters;
recentResults: EntityImportResponse[];
lastError: string | null;
}
+2 -1
View File
@@ -3,6 +3,7 @@ export type * from './common';
export type * from './entity';
export type * from './event';
export type * from './journal';
export type * from './import';
export type * from './provider';
export type * from './service';
export type * from './task';
export type * from './task';