Add Linear Excel transport

This commit is contained in:
Julian Appel 2026-09-30 07:33:06 +02:00
parent d4803a18d2
commit 52d3b1a270
10 changed files with 2131 additions and 83 deletions

View file

@ -0,0 +1,153 @@
export const MAX_EXTERNAL_XLSX_BYTES = 10 * 1024 * 1024;
export const DEFAULT_EXTERNAL_XLSX_LIMITS = {
maxRows: 50_000,
maxColumns: 256,
maxCells: 2_000_000,
} as const;
export type ExternalXlsxScalar = string | number | boolean | null;
export interface ExternalXlsxCell {
columnNumber: number;
value: ExternalXlsxScalar;
valueType: "empty" | "string" | "number" | "boolean";
numberFormat: string | null;
formula: string | null;
usedCachedFormulaValue: boolean;
}
export type ExternalXlsxRowClassification =
| "object"
| "suspect-object"
| "passthrough";
export interface ExternalXlsxRow {
rowNumber: number;
classification: ExternalXlsxRowClassification;
cells: ExternalXlsxCell[];
}
export interface ExternalXlsxColumn {
columnNumber: number;
header: string;
comment: string | null;
width: number | null;
}
export type ExternalXlsxIssueCode =
| "cached-formula-value-used"
| "formula-without-cached-value"
| "suspect-object-skipped"
| "duplicate-ifc-guid"
| "optional-return-column-missing";
export interface ExternalXlsxIssue {
code: ExternalXlsxIssueCode;
message: string;
rowNumber?: number;
columnNumber?: number;
field?: ExternalXlsxColumnKey;
}
export interface ExternalXlsxDocument {
sheetName: string;
headerRowNumber: number;
firstDataRowNumber: number;
firstDataColumnNumber: number;
columns: ExternalXlsxColumn[];
rows: ExternalXlsxRow[];
issues: ExternalXlsxIssue[];
}
export interface ExternalXlsxLayoutConfiguration {
headerRow: number;
firstDataRow: number;
firstDataColumn: number;
}
export type ExternalXlsxCircuitMode = "combined" | "separate";
export interface ExternalXlsxColumnConfiguration {
worksharingId: string;
ifcGuid: string | null;
roomNumber: string | null;
roomName: string | null;
familyAndType: string;
selectionMarker: string | null;
socketCount: string | null;
sourceRemark: string | null;
circuitCombined: string | null;
boardName: string | null;
circuitIdentifier: string | null;
designation: string | null;
electricalPowerW: string | null;
voltageV: string | null;
currentA: string | null;
}
export type ExternalXlsxColumnKey = keyof ExternalXlsxColumnConfiguration;
export interface ExternalXlsxTransportConfiguration {
layout: ExternalXlsxLayoutConfiguration;
circuitMode: ExternalXlsxCircuitMode;
columns: ExternalXlsxColumnConfiguration;
}
export interface ExternalXlsxLimits {
maxRows: number;
maxColumns: number;
maxCells: number;
}
export type ExternalXlsxReturnValues = Partial<
Record<
| "circuitCombined"
| "boardName"
| "circuitIdentifier"
| "designation"
| "electricalPowerW"
| "voltageV"
| "currentA",
ExternalXlsxScalar
>
>;
export interface ExternalXlsxReturnUpdate {
worksharingId: string;
values: ExternalXlsxReturnValues;
}
export interface ExternalXlsxReturnResult {
bytes: Uint8Array;
exportedObjectCount: number;
issues: ExternalXlsxIssue[];
}
export function createDefaultExternalXlsxTransportConfiguration(): ExternalXlsxTransportConfiguration {
return {
layout: {
headerRow: 1,
firstDataRow: 3,
firstDataColumn: 1,
},
circuitMode: "combined",
columns: {
worksharingId: "Worksharing-ID",
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
socketCount: "CAX_AnzahlObjekte",
sourceRemark: "CAx_Anmerkung",
circuitCombined: "kbp_Stromkreisnummer",
boardName: "kbp-E-Verteiler",
circuitIdentifier: "kbp-E-Stromkreis",
designation: "kbp-E-Bezeichnung",
electricalPowerW: "kbp-E-Elektrische Leistung",
voltageV: "kbp-E-Spannung",
currentA: "kbp-E-Stromstärke",
},
};
}

View file

@ -0,0 +1,582 @@
import ExcelJS from "exceljs";
import type { CellValue, Worksheet } from "exceljs";
import {
DEFAULT_EXTERNAL_XLSX_LIMITS,
MAX_EXTERNAL_XLSX_BYTES,
type ExternalXlsxCell,
type ExternalXlsxColumnKey,
type ExternalXlsxDocument,
type ExternalXlsxIssue,
type ExternalXlsxLimits,
type ExternalXlsxReturnResult,
type ExternalXlsxReturnUpdate,
type ExternalXlsxScalar,
type ExternalXlsxTransportConfiguration,
} from "./external-xlsx-contracts.js";
export type ExternalXlsxErrorCode =
| "invalid-file-extension"
| "file-too-large"
| "invalid-workbook"
| "worksheet-count"
| "worksheet-too-large"
| "invalid-layout"
| "header-not-found"
| "ambiguous-header"
| "required-formula-result-missing"
| "invalid-worksharing-id"
| "duplicate-worksharing-id"
| "return-circuit-column-missing"
| "unknown-worksharing-id";
export class ExternalXlsxError extends Error {
constructor(
public readonly code: ExternalXlsxErrorCode,
message: string
) {
super(message);
this.name = "ExternalXlsxError";
}
}
export interface ParseExternalXlsxInput {
fileName: string;
bytes: Uint8Array;
configuration: ExternalXlsxTransportConfiguration;
limits?: ExternalXlsxLimits;
}
export interface CreateExternalXlsxReturnInput extends ParseExternalXlsxInput {
selectedWorksharingIds?: readonly string[];
updates?: readonly ExternalXlsxReturnUpdate[];
}
export async function parseExternalXlsx(
input: ParseExternalXlsxInput
): Promise<ExternalXlsxDocument> {
assertUpload(input.fileName, input.bytes);
assertConfiguration(input.configuration);
const workbook = await loadWorkbook(input.bytes);
const worksheet = getOnlyWorksheet(workbook);
assertWorksheetLimits(
worksheet,
input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS
);
return parseWorksheet(worksheet, input.configuration);
}
export async function createExternalXlsxReturn(
input: CreateExternalXlsxReturnInput
): Promise<ExternalXlsxReturnResult> {
assertUpload(input.fileName, input.bytes);
assertConfiguration(input.configuration);
const workbook = await loadWorkbook(input.bytes);
const worksheet = getOnlyWorksheet(workbook);
assertWorksheetLimits(
worksheet,
input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS
);
const document = parseWorksheet(worksheet, input.configuration);
const headerLookup = createHeaderLookup(worksheet, input.configuration);
const issues = [...document.issues];
const returnColumns = resolveReturnColumns(
headerLookup,
input.configuration,
issues
);
const worksharingIdColumn = headerLookup.get(
input.configuration.columns.worksharingId
)!;
const objectRows = document.rows.filter((row) => row.classification === "object");
const objectRowById = new Map(
objectRows.map((row) => [
scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null),
row.rowNumber,
])
);
const selectedIds = input.selectedWorksharingIds
? new Set(input.selectedWorksharingIds)
: new Set(objectRowById.keys());
for (const worksharingId of selectedIds) {
if (!objectRowById.has(worksharingId)) {
throw new ExternalXlsxError(
"unknown-worksharing-id",
`Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}`
);
}
}
const updates = new Map(
(input.updates ?? []).map((update) => [update.worksharingId, update.values])
);
for (const worksharingId of updates.keys()) {
if (!objectRowById.has(worksharingId)) {
throw new ExternalXlsxError(
"unknown-worksharing-id",
`Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}`
);
}
}
const selectedRows = objectRows.filter((row) => {
const id = scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null);
return selectedIds.has(id);
});
const lastColumn = worksheet.columnCount;
const snapshots = selectedRows.map((row) =>
captureRow(worksheet, row.rowNumber, lastColumn)
);
snapshots.forEach((snapshot, index) => {
restoreRow(
worksheet,
input.configuration.layout.firstDataRow + index,
snapshot,
lastColumn
);
const destinationRow = worksheet.getRow(
input.configuration.layout.firstDataRow + index
);
const id = scalarToIdentity(destinationRow.getCell(worksharingIdColumn).value);
const values = updates.get(id);
if (!values) return;
for (const [field, value] of Object.entries(values) as [
keyof typeof values,
ExternalXlsxScalar,
][]) {
const columnNumber = returnColumns.get(field);
if (columnNumber !== undefined) {
destinationRow.getCell(columnNumber).value = value;
}
}
});
const firstRemovedRow =
input.configuration.layout.firstDataRow + snapshots.length;
const removeCount = Math.max(0, worksheet.rowCount - firstRemovedRow + 1);
// ExcelJS 4.4 does not shrink its internal row array when a multi-row
// splice removes the complete tail. Removing the tail one row at a time
// produces an actual OOXML deletion instead of serialized empty rows.
for (let index = 0; index < removeCount; index += 1) {
worksheet.spliceRows(worksheet.rowCount, 1);
}
const buffer = await workbook.xlsx.writeBuffer();
return {
bytes: new Uint8Array(buffer),
exportedObjectCount: snapshots.length,
issues,
};
}
function parseWorksheet(
worksheet: Worksheet,
configuration: ExternalXlsxTransportConfiguration
): ExternalXlsxDocument {
const headerLookup = createHeaderLookup(worksheet, configuration);
const issues: ExternalXlsxIssue[] = [];
const worksharingIdColumn = headerLookup.get(configuration.columns.worksharingId)!;
const familyAndTypeColumn = headerLookup.get(configuration.columns.familyAndType)!;
const ifcGuidColumn = configuration.columns.ifcGuid
? headerLookup.get(configuration.columns.ifcGuid)
: undefined;
const seenWorksharingIds = new Set<string>();
const seenIfcGuids = new Set<string>();
const rows = [] as ExternalXlsxDocument["rows"];
for (
let rowNumber = configuration.layout.firstDataRow;
rowNumber <= worksheet.rowCount;
rowNumber += 1
) {
const row = worksheet.getRow(rowNumber);
const cells: ExternalXlsxCell[] = [];
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
cells.push(
toExternalCell(row.getCell(columnNumber), rowNumber, columnNumber, issues)
);
}
const worksharingIdCell = cells[worksharingIdColumn - 1];
const familyAndTypeCell = cells[familyAndTypeColumn - 1];
for (const [field, requiredCell] of [
["Worksharing-ID", worksharingIdCell],
["Familie und Typ", familyAndTypeCell],
] as const) {
if (requiredCell?.formula != null && !requiredCell.usedCachedFormulaValue) {
throw new ExternalXlsxError(
"required-formula-result-missing",
`Das Pflichtfeld ${field} hat in Zeile ${rowNumber} kein gespeichertes Formelergebnis.`
);
}
}
const worksharingId = scalarToIdentity(worksharingIdCell?.value ?? null);
const familyAndType = scalarToIdentity(familyAndTypeCell?.value ?? null);
if (!worksharingId) {
const classification = familyAndType ? "suspect-object" : "passthrough";
if (classification === "suspect-object") {
issues.push({
code: "suspect-object-skipped",
message: `Zeile ${rowNumber} enthält Familie und Typ, aber keine Worksharing-ID, und wird übersprungen.`,
rowNumber,
});
}
rows.push({ rowNumber, classification, cells });
continue;
}
if (!isWorksharingId(worksharingId)) {
throw new ExternalXlsxError(
"invalid-worksharing-id",
`Zeile ${rowNumber} enthält eine ungültige Worksharing-ID.`
);
}
if (seenWorksharingIds.has(worksharingId)) {
throw new ExternalXlsxError(
"duplicate-worksharing-id",
`Die Worksharing-ID kommt mehrfach vor: ${worksharingId}`
);
}
seenWorksharingIds.add(worksharingId);
if (ifcGuidColumn !== undefined) {
const ifcGuid = scalarToIdentity(cells[ifcGuidColumn - 1]?.value ?? null);
if (ifcGuid) {
if (seenIfcGuids.has(ifcGuid)) {
issues.push({
code: "duplicate-ifc-guid",
message: `IfcGUID kommt mehrfach vor: ${ifcGuid}`,
rowNumber,
columnNumber: ifcGuidColumn,
field: "ifcGuid",
});
}
seenIfcGuids.add(ifcGuid);
}
}
rows.push({ rowNumber, classification: "object", cells });
}
const headerRow = worksheet.getRow(configuration.layout.headerRow);
const columns = [] as ExternalXlsxDocument["columns"];
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
const cell = headerRow.getCell(columnNumber);
columns.push({
columnNumber,
header: scalarToIdentity(toScalar(cell.value).value),
comment: getCommentText(cell.note),
width: worksheet.getColumn(columnNumber).width ?? null,
});
}
return {
sheetName: worksheet.name,
headerRowNumber: configuration.layout.headerRow,
firstDataRowNumber: configuration.layout.firstDataRow,
firstDataColumnNumber: configuration.layout.firstDataColumn,
columns,
rows,
issues,
};
}
function createHeaderLookup(
worksheet: Worksheet,
configuration: ExternalXlsxTransportConfiguration
) {
const headers = new Map<string, number[]>();
const row = worksheet.getRow(configuration.layout.headerRow);
for (
let columnNumber = configuration.layout.firstDataColumn;
columnNumber <= worksheet.columnCount;
columnNumber += 1
) {
const header = scalarToIdentity(toScalar(row.getCell(columnNumber).value).value);
if (!header) continue;
headers.set(header, [...(headers.get(header) ?? []), columnNumber]);
}
const configured = Object.entries(configuration.columns) as [
ExternalXlsxColumnKey,
string | null,
][];
const required = new Set<ExternalXlsxColumnKey>(["worksharingId", "familyAndType"]);
const lookup = new Map<string, number>();
for (const [field, header] of configured) {
if (header === null) continue;
const matches = headers.get(header) ?? [];
if (matches.length > 1) {
throw new ExternalXlsxError(
"ambiguous-header",
`Die konfigurierte Kopfzelle ist nicht eindeutig: ${header}`
);
}
if (matches.length === 0 && required.has(field)) {
throw new ExternalXlsxError(
"header-not-found",
`Die konfigurierte Pflichtspalte fehlt: ${header}`
);
}
if (matches.length === 1) lookup.set(header, matches[0]);
}
return lookup;
}
function resolveReturnColumns(
headerLookup: Map<string, number>,
configuration: ExternalXlsxTransportConfiguration,
issues: ExternalXlsxIssue[]
) {
const result = new Map<keyof ExternalXlsxReturnUpdate["values"], number>();
const circuitFields: ("circuitCombined" | "boardName" | "circuitIdentifier")[] =
configuration.circuitMode === "combined"
? ["circuitCombined"]
: ["boardName", "circuitIdentifier"];
for (const field of circuitFields) {
const header = configuration.columns[field];
const columnNumber = header === null ? undefined : headerLookup.get(header);
if (columnNumber === undefined) {
throw new ExternalXlsxError(
"return-circuit-column-missing",
`Die Stromkreisspalte des gewählten Modus fehlt: ${field}`
);
}
result.set(field, columnNumber);
}
const optionalFields = [
"designation",
"electricalPowerW",
"voltageV",
"currentA",
] as const;
for (const field of optionalFields) {
const header = configuration.columns[field];
const columnNumber = header === null ? undefined : headerLookup.get(header);
if (columnNumber === undefined) {
issues.push({
code: "optional-return-column-missing",
message: `Die optionale Rückgabespalte fehlt und wird übersprungen: ${field}`,
field,
});
} else {
result.set(field, columnNumber);
}
}
return result;
}
function toExternalCell(
cell: ExcelJS.Cell,
rowNumber: number,
columnNumber: number,
issues: ExternalXlsxIssue[]
): ExternalXlsxCell {
const converted = toScalar(cell.value);
if (converted.formula !== null) {
issues.push({
code: converted.usedCachedFormulaValue
? "cached-formula-value-used"
: "formula-without-cached-value",
message: converted.usedCachedFormulaValue
? `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} wird mit ihrem gespeicherten Ergebnis gelesen.`
: `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} hat kein nutzbares gespeichertes Ergebnis und bleibt leer.`,
rowNumber,
columnNumber,
});
}
return {
columnNumber,
value: converted.value,
valueType: getScalarValueType(converted.value),
numberFormat: cell.numFmt || null,
formula: converted.formula,
usedCachedFormulaValue: converted.usedCachedFormulaValue,
};
}
function toScalar(value: CellValue): {
value: ExternalXlsxScalar;
formula: string | null;
usedCachedFormulaValue: boolean;
} {
if (value === null || value === undefined) {
return { value: null, formula: null, usedCachedFormulaValue: false };
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return { value, formula: null, usedCachedFormulaValue: false };
}
if (value instanceof Date) {
return { value: value.toISOString(), formula: null, usedCachedFormulaValue: false };
}
if ("formula" in value || "sharedFormula" in value) {
const formula =
("formula" in value ? value.formula : value.sharedFormula) ?? "";
const result = "result" in value ? value.result : undefined;
if (
typeof result === "string" ||
typeof result === "number" ||
typeof result === "boolean"
) {
return { value: result, formula, usedCachedFormulaValue: true };
}
return { value: null, formula, usedCachedFormulaValue: false };
}
if ("error" in value) {
return { value: null, formula: null, usedCachedFormulaValue: false };
}
if ("richText" in value) {
return {
value: value.richText.map((part) => part.text).join(""),
formula: null,
usedCachedFormulaValue: false,
};
}
if ("text" in value) {
return { value: value.text, formula: null, usedCachedFormulaValue: false };
}
return { value: null, formula: null, usedCachedFormulaValue: false };
}
function captureRow(worksheet: Worksheet, rowNumber: number, lastColumn: number) {
const row = worksheet.getRow(rowNumber);
return {
height: row.height,
cells: Array.from({ length: lastColumn }, (_, index) => {
const cell = row.getCell(index + 1);
const converted = toScalar(cell.value);
return {
value: converted.formula === null ? cloneValue(cell.value) : converted.value,
style: structuredClone(cell.style),
note: cell.note === undefined ? undefined : structuredClone(cell.note),
};
}),
};
}
function restoreRow(
worksheet: Worksheet,
rowNumber: number,
snapshot: ReturnType<typeof captureRow>,
lastColumn: number
) {
const row = worksheet.getRow(rowNumber);
row.height = snapshot.height;
for (let columnNumber = 1; columnNumber <= lastColumn; columnNumber += 1) {
const cell = row.getCell(columnNumber);
const source = snapshot.cells[columnNumber - 1];
cell.value = source.value;
cell.style = structuredClone(source.style);
if (source.note === undefined) {
// ExcelJS has no public comment-removal API. Its note setter creates a
// Note even for undefined, which would add an empty comment to the cell.
(cell as unknown as { _comment?: unknown })._comment = undefined;
} else {
cell.note = source.note;
}
}
}
function cloneValue(value: CellValue): CellValue {
if (value instanceof Date) return new Date(value);
if (value && typeof value === "object") return structuredClone(value);
return value;
}
function getCommentText(note: ExcelJS.Cell["note"]): string | null {
if (note === undefined) return null;
if (typeof note === "string") return note;
return note.texts?.map((part) => part.text).join("") ?? null;
}
function getScalarValueType(value: ExternalXlsxScalar): ExternalXlsxCell["valueType"] {
if (value === null) return "empty";
if (typeof value === "string") return "string";
if (typeof value === "number") return "number";
return "boolean";
}
function scalarToIdentity(value: ExternalXlsxScalar | CellValue): string {
const scalar =
typeof value === "object" && value !== null ? toScalar(value).value : value;
return typeof scalar === "string" ? scalar : scalar === null ? "" : String(scalar);
}
function isWorksharingId(value: string) {
return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/.test(
value
);
}
function assertUpload(fileName: string, bytes: Uint8Array) {
if (!fileName.toLocaleLowerCase("en-US").endsWith(".xlsx")) {
throw new ExternalXlsxError(
"invalid-file-extension",
"Es werden ausschließlich .xlsx-Dateien unterstützt."
);
}
if (bytes.byteLength > MAX_EXTERNAL_XLSX_BYTES) {
throw new ExternalXlsxError(
"file-too-large",
"Die XLSX-Datei darf höchstens 10 MiB groß sein."
);
}
}
async function loadWorkbook(bytes: Uint8Array) {
const workbook = new ExcelJS.Workbook();
try {
await workbook.xlsx.load(
Buffer.from(bytes) as unknown as Parameters<typeof workbook.xlsx.load>[0]
);
} catch (error) {
throw new ExternalXlsxError(
"invalid-workbook",
`Die XLSX-Datei konnte nicht gelesen werden: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`
);
}
return workbook;
}
function getOnlyWorksheet(workbook: ExcelJS.Workbook) {
if (workbook.worksheets.length !== 1) {
throw new ExternalXlsxError(
"worksheet-count",
"Die XLSX-Datei muss genau ein Arbeitsblatt enthalten."
);
}
return workbook.worksheets[0];
}
function assertWorksheetLimits(worksheet: Worksheet, limits: ExternalXlsxLimits) {
const rowCount = worksheet.rowCount;
const columnCount = worksheet.columnCount;
if (
rowCount > limits.maxRows ||
columnCount > limits.maxColumns ||
rowCount * columnCount > limits.maxCells
) {
throw new ExternalXlsxError(
"worksheet-too-large",
`Das Arbeitsblatt überschreitet die Grenzen (${rowCount} Zeilen, ${columnCount} Spalten).`
);
}
}
function assertConfiguration(configuration: ExternalXlsxTransportConfiguration) {
const { headerRow, firstDataRow, firstDataColumn } = configuration.layout;
if (
!Number.isInteger(headerRow) ||
headerRow < 1 ||
!Number.isInteger(firstDataRow) ||
firstDataRow <= headerRow ||
!Number.isInteger(firstDataColumn) ||
firstDataColumn < 1
) {
throw new ExternalXlsxError(
"invalid-layout",
"Kopfzeile, erste Datenzeile oder erste Datenspalte sind ungültig."
);
}
if (!configuration.columns.worksharingId || !configuration.columns.familyAndType) {
throw new ExternalXlsxError(
"invalid-layout",
"Worksharing-ID und Familie und Typ müssen konfiguriert sein."
);
}
}