feat: import
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -2,3 +2,4 @@ export { useProvidersStore } from './providersStore';
|
||||
export { useServicesStore } from './servicesStore';
|
||||
export { useCollectionsStore } from './collectionsStore';
|
||||
export { useEntitiesStore } from './entitiesStore';
|
||||
export { useImportStore } from './importStore';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user