Assessor_2.0/src/analysis/schema-detector.ts

69 lines
4.0 KiB
TypeScript

import type { SheetAnalysis } from '../domain/dataset';
import { clean } from '../shared/text';
import { profileColumn } from './column-profiler';
import { detectHeaderRow, isKeyLabel, isKeyValueTable, uniqueHeaders } from './header-detector';
import { buildRecord } from './record-builder';
export function analyzeRows(rows: string[][], sheetName: string, sourceFile = '', highlightedRows?: boolean[]): SheetAnalysis {
// Die Markierung eines Berichts (z. B. CIS-CAT: `<tr class="evaluated">")
// haengt an der Zeile, nicht an ihrem Index - Kopfzeilenerkennung und das
// Herausfiltern leerer Zeilen verschieben Indizes. Zeile und Markierung
// laufen deshalb ab hier als Paar durch dieselben Filter/Slice-Schritte.
const withFlags = rows.map((row, index) => ({ row, highlighted: highlightedRows?.[index] ?? false }));
const compactEntries = withFlags.filter(entry => entry.row.some(cell => clean(cell)));
const compactRows = compactEntries.map(entry => entry.row);
if (!compactRows.length) {
return {
headerIndex: 0,
headers: [],
records: [],
columns: [],
signals: ['Keine auswertbaren Inhalte gefunden.'],
title: ''
};
}
// Eine einzelne Zeile hat nichts, wovon sie eine Kopfzeile sein koennte -
// die Kopfzeilenerkennung waehlt trotzdem immer einen Index (und sei es der
// einzige), und die Zeile verschwindet spurlos als vermeintlicher Kopf ohne
// jede Datenzeile darunter. CIS-CAT gibt so z. B. den Hinweis
// "No matching system items were found." als einzeilige Belegtabelle aus -
// ohne diese Ausnahme stand dort ein leeres Tabellengeruest ohne Inhalt.
const singleRow = compactRows.length === 1;
// Eine Schluessel/Wert-Tabelle hat keine Kopfzeile: jede Zeile ist ein
// Wertepaar, und eine vorangestellte Zeile ohne Wert ist ihr Titel. Die
// Spaltennamen sind deshalb hier - und nur hier - vergeben statt gelesen.
const keyValue = !singleRow && isKeyValueTable(compactRows);
const headerIndex = singleRow
? -1
: keyValue
? Math.max(0, compactRows.findIndex(row => isKeyLabel(row[0])))
: detectHeaderRow(compactRows);
const title = headerIndex > 0 ? compactRows.slice(0, headerIndex).flat().map(clean).filter(Boolean).join(' · ') : '';
const headers = singleRow
? (compactRows[0].length === 1 ? ['Hinweis'] : uniqueHeaders(compactRows[0].map(() => '')))
: keyValue ? ['Feld', 'Wert'] : uniqueHeaders(compactRows[headerIndex] ?? []);
const dataEntries = singleRow
? compactEntries
: compactEntries
.slice(keyValue ? headerIndex : headerIndex + 1)
.filter(entry => entry.row.some(cell => clean(cell)));
const dataRows = dataEntries.map(entry => entry.row);
const occurrences = new Map<string, number>();
const records = dataEntries.map((entry, index) => {
const signature = JSON.stringify(entry.row.map(clean));
const occurrence = occurrences.get(signature) ?? 0;
occurrences.set(signature, occurrence + 1);
return buildRecord({ row: entry.row, headers, index, sourceFile, sheetName, occurrence, highlighted: entry.highlighted });
});
const columns = headers.map((header, columnIndex) => profileColumn(header, dataRows.map(row => row[columnIndex])));
const signals: string[] = [];
if (title) signals.push(`Titelbereich erkannt: ${title.slice(0, 120)}`);
if (keyValue) signals.push('Schlüssel/Wert-Tabelle ohne Kopfzeile erkannt: jede Zeile ist ein Wertepaar.');
else if (headerIndex > 0) signals.push(`Die eigentliche Kopfzeile wurde in Zeile ${headerIndex + 1} erkannt.`);
const sparse = columns.filter(column => column.total && column.fillRate < 20).map(column => column.header).slice(0, 4);
if (sparse.length) signals.push(`Sehr leere Felder: ${sparse.join(', ')}.`);
if (!records.length) signals.push('Nur Struktur erkannt, aber keine Datensätze unterhalb der Kopfzeile.');
if (!signals.length) signals.push('Struktur wirkt konsistent: Kopfzeile, Datensätze und Felder konnten abgeleitet werden.');
return { headerIndex, headers, records, columns, signals, title };
}