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);
}

View file

@ -0,0 +1,106 @@
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
export const externalCsvConfigurationSchemaVersion = 1 as const;
export type ExternalCsvEncoding = "utf-8";
export type ExternalCsvDelimiter = ";" | "," | "\t";
export type ExternalCsvDecimalSeparator = "," | ".";
export type ExternalCsvPowerUnit = "W" | "kW";
export interface ExternalCsvColumnMapping {
ifcGuid: string;
roomNumber: string;
roomName: string;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string;
}
export interface ExternalCsvAdditionalSourceMapping {
sourceColumn: string;
targetField: string;
}
export type ExternalCsvQuantityRule =
| { kind: "fixed"; quantity: number }
| { kind: "mapped-column" };
export type ExternalCsvDisplayNameSuggestion =
| { kind: "fixed"; value: string }
| { kind: "selection-marker" }
| { kind: "family-and-type" };
export interface ExternalCsvFamilyTypeRule {
exactFamilyAndType: string;
internalDeviceType: string;
connectionKind: string | null;
category: CircuitGroupCategory;
quantityRule: ExternalCsvQuantityRule;
displayNameSuggestion: ExternalCsvDisplayNameSuggestion | null;
}
export interface ExternalCsvConfiguration {
schemaVersion: typeof externalCsvConfigurationSchemaVersion;
encoding: ExternalCsvEncoding;
delimiter: ExternalCsvDelimiter;
decimalSeparator: ExternalCsvDecimalSeparator;
powerUnit: ExternalCsvPowerUnit;
wattsPerSourceUnit: number;
columns: ExternalCsvColumnMapping;
additionalSourceMappings: ExternalCsvAdditionalSourceMapping[];
familyTypeRules: ExternalCsvFamilyTypeRule[];
}
export type ExternalCsvLineEnding = "\r\n" | "\n" | "\r";
export interface ExternalCsvDialect {
encoding: ExternalCsvEncoding;
hasBom: boolean;
delimiter: ExternalCsvDelimiter;
lineEnding: ExternalCsvLineEnding;
quoteCharacter: '"';
quoteAllFields: boolean;
hasTrailingLineEnding: boolean;
}
export interface ExternalCsvCell {
value: string;
wasQuoted: boolean;
}
export type ExternalCsvRowClassification =
| "metadata"
| "header"
| "passthrough"
| "object"
| "suspect-object";
export interface ExternalCsvRow {
index: number;
cells: ExternalCsvCell[];
classification: ExternalCsvRowClassification;
}
export interface ExternalCsvDocument {
dialect: ExternalCsvDialect;
headerRowIndex: number;
rows: ExternalCsvRow[];
}
export function createDefaultExternalCsvConfiguration(
columns: ExternalCsvColumnMapping
): ExternalCsvConfiguration {
return {
schemaVersion: externalCsvConfigurationSchemaVersion,
encoding: "utf-8",
delimiter: ";",
decimalSeparator: ",",
powerUnit: "W",
wattsPerSourceUnit: 1,
columns,
additionalSourceMappings: [],
familyTypeRules: [],
};
}