212 lines
9.7 KiB
TypeScript
212 lines
9.7 KiB
TypeScript
import type { ImportedDataset } from '../domain/dataset';
|
|
import type { ImportFormat, ImportFormatDetectionResult, ImportDiagnostic } from '../domain/import-intermediate';
|
|
import type { ImportProfile } from '../domain/import-profile';
|
|
import type { ImportSource } from '../domain/import-source';
|
|
import { importSourceExtension } from '../domain/import-source';
|
|
import { recognizeImportDocument, selectImportProfile } from '../recognition/recognition-engine';
|
|
import { ImportError } from '../shared/errors';
|
|
import { importCsv } from './csv-importer';
|
|
import { importExcel } from './excel-importer';
|
|
import { detectImportFormat, inspectImportBinary } from './format-detection';
|
|
import { importHtml } from './html-importer';
|
|
import { importJson } from './json-importer';
|
|
import { importXml } from './xml-importer';
|
|
import { mapFindingCandidates } from './candidate-mapping';
|
|
import { makeSheet } from './sheet-factory';
|
|
import type { ImportRuntimeAdapters } from './runtime-adapters';
|
|
import { extractSourceDocument } from './source-extractor-registry';
|
|
import { rowsToCsv } from '../exports/csv-export';
|
|
import { cellText } from '../shared/text';
|
|
import {
|
|
resolveImportResourcePolicy, validateDecodedTextResources, validateImportDocumentResources,
|
|
validateImportSourceResources, type ImportResourcePolicy
|
|
} from './resource-policy';
|
|
|
|
export interface ImportOptions {
|
|
delimiter?: string;
|
|
profiles: ImportProfile[];
|
|
resourcePolicy?: Partial<ImportResourcePolicy>;
|
|
}
|
|
|
|
function formatResourceKey(format: ImportFormat): string {
|
|
if (format === 'Excel') return 'xlsx';
|
|
return format.toLocaleLowerCase('en-US');
|
|
}
|
|
|
|
function detectionDiagnostics(detection: ImportFormatDetectionResult): ImportDiagnostic[] {
|
|
const diagnostics: ImportDiagnostic[] = [];
|
|
if (detection.conflicts.length) diagnostics.push({
|
|
severity: 'warning',
|
|
code: 'format-evidence-conflict',
|
|
stage: 'format-decoding',
|
|
message: `Inhalt oder Dateisignatur wurde als ${detection.selected} erkannt; abweichende Endungs- oder Medientyphinweise wurden nicht als Parserentscheidung übernommen.`,
|
|
details: {
|
|
selectedFormat: detection.selected,
|
|
conflicts: detection.conflicts.map(item => ({ signal: item.signal, format: item.format ?? null }))
|
|
}
|
|
});
|
|
if (detection.requiresParserValidation) diagnostics.push({
|
|
severity: 'info',
|
|
code: 'format-parser-validation',
|
|
stage: 'format-decoding',
|
|
message: `Die Formaterkennung für ${detection.selected} beruhte auf schwachen Hinweisen und wurde durch den erfolgreichen sicheren Parserlauf validiert.`
|
|
});
|
|
return diagnostics;
|
|
}
|
|
|
|
function attachFormatDetection(
|
|
imported: Omit<ImportedDataset, 'profileId' | 'scanTool'>,
|
|
detection: ImportFormatDetectionResult,
|
|
decodedEncoding?: string
|
|
): Omit<ImportedDataset, 'profileId' | 'scanTool'> {
|
|
if (!imported.intermediate) return imported;
|
|
const addedDiagnostics = detectionDiagnostics(detection);
|
|
const diagnostics = [...imported.intermediate.diagnostics, ...addedDiagnostics];
|
|
const status = imported.intermediate.status === 'failed'
|
|
? 'failed'
|
|
: imported.intermediate.status === 'partial' || addedDiagnostics.some(item => item.severity === 'warning')
|
|
? 'partial'
|
|
: 'complete';
|
|
const processing = imported.intermediate.processing.map(item => item.stage === 'format-decoding'
|
|
? { ...item, status: 'complete' as const, diagnosticCodes: addedDiagnostics.map(diagnostic => diagnostic.code) }
|
|
: item);
|
|
return {
|
|
...imported,
|
|
intermediate: {
|
|
...imported.intermediate,
|
|
status,
|
|
file: { ...imported.intermediate.file, encoding: decodedEncoding ?? imported.intermediate.file.encoding },
|
|
diagnostics,
|
|
processing,
|
|
formatDetection: detection
|
|
}
|
|
};
|
|
}
|
|
|
|
function formatError(code: string, message: string, details?: Record<string, string>): ImportError {
|
|
return new ImportError(message, [{ severity: 'error', code, message, stage: 'format-decoding', details }]);
|
|
}
|
|
|
|
export async function importSource(
|
|
source: ImportSource,
|
|
options: ImportOptions,
|
|
adapters: ImportRuntimeAdapters
|
|
): Promise<ImportedDataset> {
|
|
const extension = importSourceExtension(source.metadata);
|
|
const resourcePolicy = resolveImportResourcePolicy(options.resourcePolicy);
|
|
validateImportSourceResources(source, resourcePolicy);
|
|
let imported: Omit<ImportedDataset, 'profileId' | 'scanTool'>;
|
|
let detection: ImportFormatDetectionResult;
|
|
let decodedEncoding: string | undefined;
|
|
try {
|
|
const binaryInspection = inspectImportBinary(source);
|
|
const binaryDetection = detectImportFormat(source);
|
|
if (binaryInspection.containerKind === 'zip') {
|
|
throw formatError('unsupported-container', 'ZIP-Container ohne erkannte Excel-Workbookstruktur werden noch nicht importiert.', { containerKind: 'zip' });
|
|
}
|
|
if (binaryInspection.binary && binaryInspection.signedFormat !== 'Excel') {
|
|
throw formatError('unsupported-binary-format', 'Die Binärdatei besitzt keine unterstützte und validierte Dateisignatur.');
|
|
}
|
|
if (binaryDetection?.selected === 'Excel' && binaryDetection.structurallyValidated) {
|
|
detection = binaryDetection;
|
|
imported = await importExcel(source);
|
|
} else {
|
|
const decoded = adapters.textDecoder.decode(source.bytes);
|
|
decodedEncoding = decoded.encoding;
|
|
const textDetection = detectImportFormat(source, decoded.text);
|
|
if (!textDetection) throw formatError('unsupported-import-format', 'Das Dateiformat konnte nicht sicher einem unterstützten Format zugeordnet werden.');
|
|
if (textDetection.binary) throw formatError('unsupported-binary-format', 'Die Binärdatei besitzt keine unterstützte und validierte Dateisignatur.');
|
|
if (textDetection.selected === 'Excel') {
|
|
throw formatError('excel-signature-mismatch', 'Eine als Excel deklarierte Datei besitzt keine gültige Excel-Dateisignatur.');
|
|
}
|
|
detection = textDetection;
|
|
validateDecodedTextResources(decoded.text, formatResourceKey(detection.selected), resourcePolicy);
|
|
if (detection.selected === 'JSON') imported = importJson(source.metadata, decoded.text);
|
|
else if (detection.selected === 'XML') imported = importXml(source.metadata, decoded.text, adapters.markup);
|
|
else if (detection.selected === 'HTML') imported = importHtml(source.metadata, decoded.text, adapters.markup);
|
|
else imported = importCsv(source.metadata, decoded.text, options.delimiter);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ImportError) throw error;
|
|
const parserMessage = error instanceof Error ? error.message : 'Unbekannter Parserfehler';
|
|
throw new ImportError(`Die Datei „${source.metadata.name}“ konnte nicht verarbeitet werden.`, [{
|
|
severity: 'error',
|
|
code: 'file-parse-error',
|
|
message: parserMessage,
|
|
details: { extension }
|
|
}], { cause: error });
|
|
}
|
|
imported = attachFormatDetection(imported, detection, decodedEncoding);
|
|
if (imported.intermediate) validateImportDocumentResources(imported.intermediate, resourcePolicy);
|
|
if (!options.profiles.length) {
|
|
throw new ImportError('Für den Import ist mindestens ein Profil erforderlich.', [{
|
|
severity: 'error',
|
|
code: 'missing-import-profile',
|
|
message: 'Die Importengine wurde ohne verfügbares Importprofil aufgerufen.'
|
|
}]);
|
|
}
|
|
if (imported.intermediate) {
|
|
const extraction = extractSourceDocument(imported.intermediate);
|
|
const supportingTableIndexBase = Math.max(-1, ...imported.intermediate.tables.map(table => table.index)) + 1;
|
|
const supportingSheets = (extraction.supportingTables ?? []).map((table, index) => {
|
|
const rows = [table.headers, ...table.rows.map(row => row.map(cellText))];
|
|
return makeSheet(table.name, rows, rowsToCsv(rows), source.metadata.name, {
|
|
sourceFile: source.metadata.name,
|
|
sourceSheet: table.name,
|
|
sourceTableIndex: supportingTableIndexBase + index
|
|
});
|
|
});
|
|
imported = {
|
|
...imported,
|
|
sheets: [...imported.sheets, ...supportingSheets],
|
|
extraction,
|
|
intermediate: {
|
|
...imported.intermediate,
|
|
diagnostics: [...imported.intermediate.diagnostics, ...extraction.diagnostics],
|
|
processing: [...imported.intermediate.processing, extraction.processing]
|
|
}
|
|
};
|
|
}
|
|
const hasTableContent = imported.sheets.some(sheet => sheet.rows.some(row => row.some(cell => cell.trim())));
|
|
const hasExtractedContent = Boolean(imported.extraction && (
|
|
imported.extraction.candidates.length
|
|
|| imported.extraction.reportSections?.length
|
|
|| imported.extraction.environmentObservations?.length
|
|
));
|
|
if (!hasTableContent && !hasExtractedContent) {
|
|
throw new ImportError('Die Datei enthält keine auswertbaren Tabellen.', [{
|
|
severity: 'error',
|
|
code: 'empty-import',
|
|
message: 'Der Parser und die registrierten Quellenextraktoren haben keine auswertbaren Inhalte geliefert.'
|
|
}]);
|
|
}
|
|
const recognition = imported.intermediate ? recognizeImportDocument(imported.intermediate) : undefined;
|
|
const profile = recognition
|
|
? selectImportProfile(recognition, options.profiles)
|
|
: options.profiles.find(candidate => candidate.definition.fallback) ?? options.profiles[0];
|
|
const mapping = imported.extraction
|
|
? mapFindingCandidates(imported.extraction, profile, recognition)
|
|
: undefined;
|
|
if (mapping && imported.intermediate) {
|
|
imported = {
|
|
...imported,
|
|
mapping,
|
|
intermediate: {
|
|
...imported.intermediate,
|
|
diagnostics: [...imported.intermediate.diagnostics, ...mapping.diagnostics],
|
|
processing: [...imported.intermediate.processing, mapping.processing]
|
|
}
|
|
};
|
|
}
|
|
const sheets = profile.prioritizeSheets(imported.sheets);
|
|
return {
|
|
...imported,
|
|
sheets,
|
|
profileId: profile.id,
|
|
profileVersion: profile.version,
|
|
scanTool: profile.name,
|
|
recognition,
|
|
mapping
|
|
};
|
|
}
|