import * as XLSX from 'xlsx';
import type { ImportedDataset } from '../domain/dataset';
import type { DiscardedImportContent, ImportDocument } from '../domain/import-intermediate';
import type { ImportSource } from '../domain/import-source';
import { rowsToCsv } from '../exports/csv-export';
import { clean } from '../shared/text';
import { escapeHtmlText, isSafeLinkTarget } from './html-extraction';
import { createImportDocument, createImportTable, importDocumentToDataset } from './intermediate';
/**
* Excel fuehrt Verweisziele nicht im Zelltext, sondern als eigene Eigenschaft
* der Zelle - `sheet_to_json` liefert sie deshalb nicht mit, und die
* Belegstellen der Werkzeuge gingen beim Import verloren.
*
* Zellen mit sicherem Ziel werden als Anker geschrieben, damit der Verweis im
* Feldwert ankommt und in der Darstellung anklickbar bleibt. Das Format kennt
* hoechstens ein Ziel je Zelle; mehrere Belegstellen in einer Zelle bleiben
* daher unverlinkter Text.
*/
function withCellLinks(sheet: XLSX.WorkSheet, rows: unknown[][], sourceRowIndexes: number[]): unknown[][] {
return rows.map((row, rowIndex) => row.map((value, columnIndex) => {
const reference = XLSX.utils.encode_cell({ r: sourceRowIndexes[rowIndex] ?? rowIndex, c: columnIndex });
const target = (sheet[reference] as XLSX.CellObject | undefined)?.l?.Target ?? '';
const text = clean(value);
if (!text || !isSafeLinkTarget(target)) return value;
return `${escapeHtmlText(text)}`;
}));
}
export async function parseExcelDocument(source: ImportSource): Promise {
const workbook = XLSX.read(source.bytes, {
type: 'array',
cellDates: true,
cellFormula: false,
bookVBA: false
});
const visibility = new Map((workbook.Workbook?.Sheets ?? []).map(sheet => [sheet.name, Number(sheet.Hidden ?? 0)]));
const discardedContent: DiscardedImportContent[] = [];
const tables = workbook.SheetNames.flatMap((name, workbookIndex) => {
const sheet = workbook.Sheets[name];
const decodedRows = XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: '',
raw: false,
blankrows: true,
dateNF: 'yyyy-mm-dd'
});
const rawRows = XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: '',
raw: true,
blankrows: true
});
const sourceRowIndexes = decodedRows
.map((row, rowIndex) => ({ row, rowIndex }))
.filter(item => item.row.some(cell => clean(cell)))
.map(item => item.rowIndex);
if (!sourceRowIndexes.length) {
discardedContent.push({
kind: 'empty-sheet',
reason: 'Leere Excel-Sheets enthalten keine importierbaren Tabellenwerte.',
source: { fileName: source.metadata.name, tableIndex: workbookIndex }
});
return [];
}
const compactDecodedRows = withCellLinks(
sheet,
sourceRowIndexes.map(rowIndex => decodedRows[rowIndex] ?? []),
sourceRowIndexes
);
// Die Rohwerte bleiben unveraendert die Quellwerte der Zellen.
const compactRawRows = sourceRowIndexes.map(rowIndex => rawRows[rowIndex] ?? []);
return [createImportTable({
sourceFile: source.metadata.name,
name,
index: workbookIndex,
decodedRows: compactDecodedRows,
rawRows: compactRawRows,
rawText: rowsToCsv(compactDecodedRows),
attributes: {
workbookIndex,
hidden: visibility.get(name) ?? 0
},
positionForCell: (rowIndex, columnIndex) => ({
cellReference: XLSX.utils.encode_cell({ r: sourceRowIndexes[rowIndex] ?? rowIndex, c: columnIndex })
})
})];
});
const rawText = tables.map(table => `# ${table.name}\n${table.rawText}`).join('\n\n');
return createImportDocument({
file: source.metadata,
format: 'Excel',
rawText,
tables,
discardedContent
});
}
export async function importExcel(source: ImportSource): Promise> {
return importDocumentToDataset(await parseExcelDocument(source));
}