Add Linear Excel transport
This commit is contained in:
parent
d4803a18d2
commit
52d3b1a270
10 changed files with 2131 additions and 83 deletions
153
src/external-model/xlsx/external-xlsx-contracts.ts
Normal file
153
src/external-model/xlsx/external-xlsx-contracts.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
582
src/external-model/xlsx/external-xlsx-transport.ts
Normal file
582
src/external-model/xlsx/external-xlsx-transport.ts
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue