Define Revit CSV contracts

This commit is contained in:
Julian Appel 2026-08-02 15:51:52 +02:00
parent 927b868dcb
commit 43cee890e7
4 changed files with 409 additions and 4 deletions

View file

@ -0,0 +1,185 @@
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",
"quantity",
] 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 (!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);
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);
}