forked from jappel/leistungsbilanz-ts
199 lines
6.7 KiB
TypeScript
199 lines
6.7 KiB
TypeScript
import {
|
|
externalCsvConfigurationSchemaVersion,
|
|
type ExternalCsvConfiguration,
|
|
type ExternalCsvDisplayNameSuggestion,
|
|
type ExternalCsvQuantityRule,
|
|
} from "./external-csv-contracts.js";
|
|
|
|
const supportedCategories = new Set([
|
|
"lighting",
|
|
"single_phase",
|
|
"three_phase",
|
|
]);
|
|
|
|
const requiredColumnKeys = [
|
|
"ifcGuid",
|
|
"roomNumber",
|
|
"roomName",
|
|
"familyAndType",
|
|
"selectionMarker",
|
|
"circuitIdentifier",
|
|
"power",
|
|
] as const;
|
|
|
|
export function assertExternalCsvConfiguration(
|
|
value: unknown
|
|
): asserts value is ExternalCsvConfiguration {
|
|
if (!isRecord(value)) {
|
|
throw new Error("External CSV configuration must be an object.");
|
|
}
|
|
if (value.schemaVersion !== externalCsvConfigurationSchemaVersion) {
|
|
throw new Error("Unsupported external CSV configuration schema version.");
|
|
}
|
|
if (value.encoding !== "utf-8") {
|
|
throw new Error("External CSV encoding must be utf-8.");
|
|
}
|
|
if (value.delimiter !== ";" && value.delimiter !== "," && value.delimiter !== "\t") {
|
|
throw new Error("External CSV delimiter is unsupported.");
|
|
}
|
|
if (value.decimalSeparator !== "," && value.decimalSeparator !== ".") {
|
|
throw new Error("External CSV decimal separator is unsupported.");
|
|
}
|
|
if (value.powerUnit !== "W" && value.powerUnit !== "kW") {
|
|
throw new Error("External CSV power unit is unsupported.");
|
|
}
|
|
assertPositiveFiniteNumber(value.wattsPerSourceUnit, "wattsPerSourceUnit");
|
|
|
|
if (!isRecord(value.columns)) {
|
|
throw new Error("External CSV columns must be an object.");
|
|
}
|
|
const mappedColumns = new Set<string>();
|
|
for (const key of requiredColumnKeys) {
|
|
const columnName = assertTrimmedString(value.columns[key], `columns.${key}`);
|
|
if (mappedColumns.has(columnName)) {
|
|
throw new Error(`External CSV column is mapped more than once: ${columnName}`);
|
|
}
|
|
mappedColumns.add(columnName);
|
|
}
|
|
if (value.columns.quantity !== null) {
|
|
const quantityColumn = assertTrimmedString(
|
|
value.columns.quantity,
|
|
"columns.quantity"
|
|
);
|
|
if (mappedColumns.has(quantityColumn)) {
|
|
throw new Error(`External CSV column is mapped more than once: ${quantityColumn}`);
|
|
}
|
|
mappedColumns.add(quantityColumn);
|
|
}
|
|
|
|
if (!Array.isArray(value.additionalSourceMappings)) {
|
|
throw new Error("External CSV additional source mappings must be an array.");
|
|
}
|
|
const targetFields = new Set<string>();
|
|
for (const [index, mapping] of value.additionalSourceMappings.entries()) {
|
|
if (!isRecord(mapping)) {
|
|
throw new Error(`additionalSourceMappings.${index} must be an object.`);
|
|
}
|
|
const sourceColumn = assertTrimmedString(
|
|
mapping.sourceColumn,
|
|
`additionalSourceMappings.${index}.sourceColumn`
|
|
);
|
|
const targetField = assertTrimmedString(
|
|
mapping.targetField,
|
|
`additionalSourceMappings.${index}.targetField`
|
|
);
|
|
if (mappedColumns.has(sourceColumn)) {
|
|
throw new Error(`External CSV column is mapped more than once: ${sourceColumn}`);
|
|
}
|
|
if (targetFields.has(targetField)) {
|
|
throw new Error(`External target field is mapped more than once: ${targetField}`);
|
|
}
|
|
mappedColumns.add(sourceColumn);
|
|
targetFields.add(targetField);
|
|
}
|
|
|
|
if (!Array.isArray(value.familyTypeRules)) {
|
|
throw new Error("External CSV family/type rules must be an array.");
|
|
}
|
|
const exactFamilyValues = new Set<string>();
|
|
for (const [index, rule] of value.familyTypeRules.entries()) {
|
|
if (!isRecord(rule)) {
|
|
throw new Error(`familyTypeRules.${index} must be an object.`);
|
|
}
|
|
const exactFamilyAndType = assertTrimmedString(
|
|
rule.exactFamilyAndType,
|
|
`familyTypeRules.${index}.exactFamilyAndType`
|
|
);
|
|
if (exactFamilyValues.has(exactFamilyAndType)) {
|
|
throw new Error(
|
|
`External CSV family/type rule is duplicated: ${exactFamilyAndType}`
|
|
);
|
|
}
|
|
exactFamilyValues.add(exactFamilyAndType);
|
|
assertTrimmedString(
|
|
rule.internalDeviceType,
|
|
`familyTypeRules.${index}.internalDeviceType`
|
|
);
|
|
if (rule.connectionKind !== null) {
|
|
assertTrimmedString(
|
|
rule.connectionKind,
|
|
`familyTypeRules.${index}.connectionKind`
|
|
);
|
|
}
|
|
if (typeof rule.category !== "string" || !supportedCategories.has(rule.category)) {
|
|
throw new Error(`familyTypeRules.${index}.category is unsupported.`);
|
|
}
|
|
assertQuantityRule(rule.quantityRule, index);
|
|
if (rule.quantityRule.kind === "mapped-column" && value.columns.quantity === null) {
|
|
throw new Error(
|
|
`familyTypeRules.${index}.quantityRule requires a configured quantity column.`
|
|
);
|
|
}
|
|
assertDisplayNameSuggestion(rule.displayNameSuggestion, index);
|
|
}
|
|
}
|
|
|
|
export function validateExternalCsvConfiguration(
|
|
value: unknown
|
|
): { success: true; data: ExternalCsvConfiguration } | { success: false; error: string } {
|
|
try {
|
|
assertExternalCsvConfiguration(value);
|
|
return { success: true, data: value };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Invalid external CSV configuration.",
|
|
};
|
|
}
|
|
}
|
|
|
|
function assertQuantityRule(value: unknown, index: number): asserts value is ExternalCsvQuantityRule {
|
|
if (!isRecord(value)) {
|
|
throw new Error(`familyTypeRules.${index}.quantityRule must be an object.`);
|
|
}
|
|
if (value.kind === "mapped-column") {
|
|
return;
|
|
}
|
|
if (value.kind === "fixed") {
|
|
assertPositiveFiniteNumber(value.quantity, `familyTypeRules.${index}.quantityRule.quantity`);
|
|
return;
|
|
}
|
|
throw new Error(`familyTypeRules.${index}.quantityRule is unsupported.`);
|
|
}
|
|
|
|
function assertDisplayNameSuggestion(
|
|
value: unknown,
|
|
index: number
|
|
): asserts value is ExternalCsvDisplayNameSuggestion | null {
|
|
if (value === null) {
|
|
return;
|
|
}
|
|
if (!isRecord(value)) {
|
|
throw new Error(`familyTypeRules.${index}.displayNameSuggestion must be an object or null.`);
|
|
}
|
|
if (value.kind === "selection-marker" || value.kind === "family-and-type") {
|
|
return;
|
|
}
|
|
if (value.kind === "fixed") {
|
|
assertTrimmedString(value.value, `familyTypeRules.${index}.displayNameSuggestion.value`);
|
|
return;
|
|
}
|
|
throw new Error(`familyTypeRules.${index}.displayNameSuggestion is unsupported.`);
|
|
}
|
|
|
|
function assertPositiveFiniteNumber(value: unknown, field: string) {
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
throw new Error(`${field} must be a positive finite number.`);
|
|
}
|
|
}
|
|
|
|
function assertTrimmedString(value: unknown, field: string) {
|
|
if (typeof value !== "string" || !value.trim() || value !== value.trim()) {
|
|
throw new Error(`${field} must be a non-empty trimmed string.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|