mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 23:01:37 +00:00
6f1b97e8d0
Mark locked-channel job maps mutable in the configurator schema and initialize new structured list rows from their schema template so required frequency ranges remain valid.
750 lines
19 KiB
JavaScript
750 lines
19 KiB
JavaScript
const { readFileSync } = require('node:fs')
|
|
const { resolve } = require('node:path')
|
|
|
|
class LuaTable {
|
|
constructor() {
|
|
this.entries = []
|
|
this.nextArrayIndex = 1
|
|
}
|
|
|
|
get(key) {
|
|
return this.entries.find((entry) => entry.key === key)?.value
|
|
}
|
|
|
|
set(key, value) {
|
|
const entry = this.entries.find((candidate) => candidate.key === key)
|
|
if (entry) entry.value = value
|
|
else this.entries.push({ key, value })
|
|
}
|
|
}
|
|
|
|
function tokenize(source) {
|
|
const tokens = []
|
|
let index = 0
|
|
|
|
while (index < source.length) {
|
|
const character = source[index]
|
|
if (/\s/.test(character)) {
|
|
index += 1
|
|
continue
|
|
}
|
|
if (source.startsWith('--[[', index)) {
|
|
const end = source.indexOf(']]', index + 4)
|
|
index = end < 0 ? source.length : end + 2
|
|
continue
|
|
}
|
|
if (source.startsWith('--', index)) {
|
|
const end = source.indexOf('\n', index + 2)
|
|
index = end < 0 ? source.length : end + 1
|
|
continue
|
|
}
|
|
if (character === '"' || character === "'") {
|
|
const quote = character
|
|
let value = ''
|
|
index += 1
|
|
while (index < source.length && source[index] !== quote) {
|
|
if (source[index] !== '\\') {
|
|
value += source[index]
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
index += 1
|
|
const escaped = source[index]
|
|
const escapeValues = {
|
|
a: '\u0007',
|
|
b: '\b',
|
|
f: '\f',
|
|
n: '\n',
|
|
r: '\r',
|
|
t: '\t',
|
|
v: '\u000b',
|
|
}
|
|
value += escapeValues[escaped] ?? escaped
|
|
index += 1
|
|
}
|
|
if (source[index] !== quote)
|
|
throw new Error('Unterminated Lua string in configurator fixture.')
|
|
index += 1
|
|
tokens.push({ type: 'string', value })
|
|
continue
|
|
}
|
|
if (/[A-Za-z_]/.test(character)) {
|
|
const match = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/)
|
|
tokens.push({ type: 'name', value: match[0] })
|
|
index += match[0].length
|
|
continue
|
|
}
|
|
if (
|
|
/\d/.test(character) ||
|
|
(character === '.' && /\d/.test(source[index + 1]))
|
|
) {
|
|
const match = source
|
|
.slice(index)
|
|
.match(/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/)
|
|
tokens.push({ type: 'number', value: Number(match[0]) })
|
|
index += match[0].length
|
|
continue
|
|
}
|
|
|
|
tokens.push({ type: 'symbol', value: character })
|
|
index += 1
|
|
}
|
|
|
|
return tokens
|
|
}
|
|
|
|
class LuaConfigParser {
|
|
constructor(source) {
|
|
this.tokens = tokenize(source)
|
|
this.index = 0
|
|
this.config = new LuaTable()
|
|
}
|
|
|
|
current(offset = 0) {
|
|
return this.tokens[this.index + offset]
|
|
}
|
|
|
|
matches(value, offset = 0) {
|
|
return this.current(offset)?.value === value
|
|
}
|
|
|
|
take(value) {
|
|
const token = this.current()
|
|
if (!token || (value !== undefined && token.value !== value)) {
|
|
throw new Error(
|
|
`Expected '${value}', received '${token?.value ?? 'end of file'}' in configurator fixture.`,
|
|
)
|
|
}
|
|
this.index += 1
|
|
return token
|
|
}
|
|
|
|
parse() {
|
|
while (this.current()) {
|
|
if (this.matches('Config')) {
|
|
const start = this.index
|
|
const path = this.parsePath()
|
|
if (path.length && this.matches('=')) {
|
|
this.take('=')
|
|
this.setPath(path, this.parseExpression())
|
|
continue
|
|
}
|
|
this.index = start + 1
|
|
continue
|
|
}
|
|
this.index += 1
|
|
}
|
|
return this.config
|
|
}
|
|
|
|
parsePath() {
|
|
this.take('Config')
|
|
const path = []
|
|
while (this.matches('.') && this.current(1)?.type === 'name') {
|
|
this.take('.')
|
|
path.push(this.take().value)
|
|
}
|
|
return path
|
|
}
|
|
|
|
getPath(path) {
|
|
let value = this.config
|
|
for (const key of path) {
|
|
if (!(value instanceof LuaTable)) return undefined
|
|
value = value.get(key)
|
|
}
|
|
return value
|
|
}
|
|
|
|
setPath(path, value) {
|
|
let parent = this.config
|
|
for (let index = 0; index < path.length - 1; index += 1) {
|
|
const key = path[index]
|
|
let child = parent.get(key)
|
|
if (!(child instanceof LuaTable)) {
|
|
child = new LuaTable()
|
|
parent.set(key, child)
|
|
}
|
|
parent = child
|
|
}
|
|
parent.set(path.at(-1), value)
|
|
}
|
|
|
|
parseExpression() {
|
|
return this.parseOr()
|
|
}
|
|
|
|
parseOr() {
|
|
let value = this.parseAdditive()
|
|
while (this.matches('or')) {
|
|
this.take('or')
|
|
const fallback = this.parseAdditive()
|
|
value =
|
|
value !== false && value !== null && value !== undefined
|
|
? value
|
|
: fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
parseAdditive() {
|
|
let value = this.parseMultiplicative()
|
|
while (this.matches('+') || this.matches('-')) {
|
|
const operator = this.take().value
|
|
const right = this.parseMultiplicative()
|
|
value =
|
|
operator === '+'
|
|
? Number(value) + Number(right)
|
|
: Number(value) - Number(right)
|
|
}
|
|
return value
|
|
}
|
|
|
|
parseMultiplicative() {
|
|
let value = this.parseUnary()
|
|
while (this.matches('*') || this.matches('/')) {
|
|
const operator = this.take().value
|
|
const right = this.parseUnary()
|
|
value =
|
|
operator === '*'
|
|
? Number(value) * Number(right)
|
|
: Number(value) / Number(right)
|
|
}
|
|
return value
|
|
}
|
|
|
|
parseUnary() {
|
|
if (this.matches('-')) {
|
|
this.take('-')
|
|
return -Number(this.parseUnary())
|
|
}
|
|
return this.parsePrimary()
|
|
}
|
|
|
|
parsePrimary() {
|
|
const token = this.current()
|
|
if (!token)
|
|
throw new Error(
|
|
'Unexpected end of Lua configuration in configurator fixture.',
|
|
)
|
|
if (token.type === 'number' || token.type === 'string') {
|
|
this.index += 1
|
|
return token.value
|
|
}
|
|
if (this.matches('true') || this.matches('false') || this.matches('nil')) {
|
|
this.index += 1
|
|
return token.value === 'true'
|
|
? true
|
|
: token.value === 'false'
|
|
? false
|
|
: null
|
|
}
|
|
if (this.matches('{')) return this.parseTable()
|
|
if (this.matches('(')) {
|
|
this.take('(')
|
|
const value = this.parseExpression()
|
|
this.take(')')
|
|
return value
|
|
}
|
|
if (this.matches('Config')) return this.getPath(this.parsePath())
|
|
if (token.type === 'name' && this.current(1)?.value === '(') {
|
|
const name = this.take().value
|
|
this.take('(')
|
|
const argumentsList = []
|
|
while (!this.matches(')')) {
|
|
argumentsList.push(this.parseExpression())
|
|
if (!this.matches(',')) break
|
|
this.take(',')
|
|
}
|
|
this.take(')')
|
|
if (/^vector[234]$/.test(name)) {
|
|
const axes = ['x', 'y', 'z', 'w']
|
|
return Object.fromEntries([
|
|
['__skyType', name],
|
|
...argumentsList.map((value, index) => [axes[index], value]),
|
|
])
|
|
}
|
|
throw new Error(`Unsupported Lua call '${name}' in configurator fixture.`)
|
|
}
|
|
|
|
throw new Error(
|
|
`Unsupported Lua token '${token.value}' in configurator fixture.`,
|
|
)
|
|
}
|
|
|
|
parseTable() {
|
|
const table = new LuaTable()
|
|
this.take('{')
|
|
while (!this.matches('}')) {
|
|
if (this.matches('[')) {
|
|
this.take('[')
|
|
const key = this.parseExpression()
|
|
this.take(']')
|
|
this.take('=')
|
|
table.set(key, this.parseExpression())
|
|
} else if (
|
|
this.current()?.type === 'name' &&
|
|
this.current(1)?.value === '='
|
|
) {
|
|
const key = this.take().value
|
|
this.take('=')
|
|
table.set(key, this.parseExpression())
|
|
} else {
|
|
table.set(table.nextArrayIndex, this.parseExpression())
|
|
table.nextArrayIndex += 1
|
|
}
|
|
|
|
if (this.matches(',') || this.matches(';')) this.index += 1
|
|
else if (!this.matches('}')) {
|
|
throw new Error(
|
|
`Expected a Lua table separator, received '${this.current()?.value ?? 'end of file'}'.`,
|
|
)
|
|
}
|
|
}
|
|
this.take('}')
|
|
return table
|
|
}
|
|
}
|
|
|
|
function serializeLuaValue(value) {
|
|
if (!(value instanceof LuaTable)) {
|
|
if (Array.isArray(value)) return value.map(serializeLuaValue)
|
|
if (value && typeof value === 'object') {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, child]) => [
|
|
key,
|
|
serializeLuaValue(child),
|
|
]),
|
|
)
|
|
}
|
|
return value
|
|
}
|
|
|
|
const numericKeys = value.entries
|
|
.filter((entry) => typeof entry.key === 'number')
|
|
.map((entry) => entry.key)
|
|
.sort((left, right) => left - right)
|
|
const isSequence =
|
|
numericKeys.length === value.entries.length &&
|
|
numericKeys.every((key, index) => key === index + 1)
|
|
if (isSequence) {
|
|
return [...value.entries]
|
|
.sort((left, right) => left.key - right.key)
|
|
.map((entry) => serializeLuaValue(entry.value))
|
|
}
|
|
if (value.entries.every((entry) => typeof entry.key === 'string')) {
|
|
return Object.fromEntries(
|
|
value.entries.map((entry) => [entry.key, serializeLuaValue(entry.value)]),
|
|
)
|
|
}
|
|
|
|
return {
|
|
__skyType: 'map',
|
|
entries: value.entries.map((entry) => ({
|
|
key: entry.key,
|
|
keyType: typeof entry.key,
|
|
value: serializeLuaValue(entry.value),
|
|
})),
|
|
}
|
|
}
|
|
|
|
function humanize(value) {
|
|
return String(value ?? '')
|
|
.replace(/[_-]+/g, ' ')
|
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
.replace(/([A-Za-z])(\d)/g, '$1 $2')
|
|
.replace(/^./, (character) => character.toUpperCase())
|
|
}
|
|
|
|
function sensitivePath(path) {
|
|
const leaf = path.split('.').at(-1) ?? path
|
|
const normalized = leaf.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
return (
|
|
normalized.includes('apikey') ||
|
|
normalized.includes('secret') ||
|
|
normalized.includes('pepper') ||
|
|
[
|
|
'password',
|
|
'token',
|
|
'authorization',
|
|
'credential',
|
|
'connectionstring',
|
|
].includes(normalized)
|
|
)
|
|
}
|
|
|
|
function maskValue(value, path) {
|
|
if (Array.isArray(value))
|
|
return value.map((child, index) => maskValue(child, `${path}.${index + 1}`))
|
|
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
|
return {
|
|
__skyType: 'map',
|
|
entries: value.entries.map((entry) => ({
|
|
key: entry.key,
|
|
keyType: entry.keyType,
|
|
value: maskValue(entry.value, `${path}.${entry.key}`),
|
|
})),
|
|
}
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, child]) => [
|
|
key,
|
|
maskValue(child, `${path}.${key}`),
|
|
]),
|
|
)
|
|
}
|
|
return typeof value === 'string' && sensitivePath(path)
|
|
? '***REDACTED***'
|
|
: value
|
|
}
|
|
|
|
function emptyStructure(scope, path) {
|
|
if (scope !== 'config') return undefined
|
|
if (path === 'Garage.VehicleImages.ModelNames') {
|
|
return {
|
|
entries: [],
|
|
keyType: 'number',
|
|
kind: 'map',
|
|
template: { kind: 'value', valueType: 'string' },
|
|
}
|
|
}
|
|
if (
|
|
path === 'CrewLink.ExternalPingResources' ||
|
|
path === 'CustomApps.TrustedAdapters'
|
|
) {
|
|
return {
|
|
fields: {},
|
|
kind: 'table',
|
|
mutableKeys: true,
|
|
template: { kind: 'value', valueType: 'boolean' },
|
|
}
|
|
}
|
|
if (path === 'FlipTok.MusicTracks') {
|
|
return {
|
|
items: [],
|
|
kind: 'list',
|
|
template: {
|
|
fields: {
|
|
Artist: { kind: 'value', valueType: 'string' },
|
|
Id: { kind: 'value', valueType: 'string' },
|
|
Title: { kind: 'value', valueType: 'string' },
|
|
Url: { kind: 'value', valueType: 'string' },
|
|
},
|
|
kind: 'table',
|
|
},
|
|
}
|
|
}
|
|
if (path === 'Music.Tracks') {
|
|
return {
|
|
items: [],
|
|
kind: 'list',
|
|
template: {
|
|
fields: {
|
|
Artist: { kind: 'value', valueType: 'string' },
|
|
Id: { kind: 'value', valueType: 'string' },
|
|
Title: { kind: 'value', valueType: 'string' },
|
|
},
|
|
kind: 'table',
|
|
},
|
|
}
|
|
}
|
|
if (path === 'Payphones.CustomLocations') {
|
|
return {
|
|
items: [],
|
|
kind: 'list',
|
|
template: { kind: 'vector', vectorType: 'vector4' },
|
|
}
|
|
}
|
|
if (/^Companies\.Definitions\.[^.]+\.Services$/.test(path)) {
|
|
return {
|
|
items: [],
|
|
kind: 'list',
|
|
template: {
|
|
fields: {
|
|
Description: { kind: 'value', valueType: 'string' },
|
|
Id: { kind: 'value', valueType: 'string' },
|
|
Price: { kind: 'value', valueType: 'string' },
|
|
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
|
Title: { kind: 'value', valueType: 'string' },
|
|
},
|
|
kind: 'table',
|
|
},
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function companyDefinitionEntryDefault(definitions) {
|
|
const firstDefinition = definitions[Object.keys(definitions).sort()[0]]
|
|
const usedNumbers = new Set(
|
|
Object.values(definitions).map((definition) =>
|
|
String(definition.ServiceLine.Number).replace(/\D/g, ''),
|
|
),
|
|
)
|
|
let serviceNumber = ''
|
|
for (let offset = 0; offset < 999; offset += 1) {
|
|
const candidate = String(((499 + offset) % 999) + 1).padStart(3, '0')
|
|
if (!usedNumbers.has(candidate)) {
|
|
serviceNumber = candidate
|
|
break
|
|
}
|
|
}
|
|
return {
|
|
AcceptsRequests: true,
|
|
Address: '',
|
|
Category: firstDefinition.Category,
|
|
DefaultAvailability: 'closed',
|
|
Description: '',
|
|
District: '',
|
|
Emergency: false,
|
|
Icon: 'building',
|
|
Job: '',
|
|
Location: { __skyType: 'vector3', x: 0, y: 0, z: 0 },
|
|
LocationLabel: '',
|
|
LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180',
|
|
Name: '',
|
|
Permissions: {
|
|
Announcement: 0,
|
|
Assign: 0,
|
|
Availability: 0,
|
|
Hours: 0,
|
|
Profile: 0,
|
|
Services: 0,
|
|
WorkQueue: 0,
|
|
},
|
|
Public: true,
|
|
ServiceLine: {
|
|
AutoContact: true,
|
|
CanCall: true,
|
|
CanMessage: false,
|
|
MinimumGrade: 0,
|
|
Number: serviceNumber,
|
|
Routing: 'round_robin',
|
|
},
|
|
Services: [
|
|
{
|
|
Description: '',
|
|
Id: '',
|
|
Price: '',
|
|
RequestsEnabled: true,
|
|
Title: '',
|
|
},
|
|
],
|
|
Verified: false,
|
|
}
|
|
}
|
|
|
|
function buildStructure(value, scope, path) {
|
|
if (scope === 'config' && path === 'Phone.Keybind') {
|
|
return { kind: 'optionalString' }
|
|
}
|
|
if (
|
|
scope === 'config' &&
|
|
/^Radio\.LockedChannels\.\d+\.jobs$/.test(path) &&
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value)
|
|
) {
|
|
return {
|
|
fields: Object.fromEntries(
|
|
Object.entries(value).map(([key, child]) => [
|
|
key,
|
|
buildStructure(child, scope, `${path}.${key}`),
|
|
]),
|
|
),
|
|
kind: 'table',
|
|
mutableKeys: true,
|
|
template: { kind: 'value', valueType: 'boolean' },
|
|
}
|
|
}
|
|
if (
|
|
scope === 'config' &&
|
|
path === 'Companies.Definitions' &&
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value)
|
|
) {
|
|
const keys = Object.keys(value).sort()
|
|
const fields = Object.fromEntries(
|
|
keys.map((key) => [
|
|
key,
|
|
buildStructure(value[key], scope, `${path}.${key}`),
|
|
]),
|
|
)
|
|
return {
|
|
entryDefault: companyDefinitionEntryDefault(value),
|
|
fields,
|
|
kind: 'table',
|
|
mutableKeys: true,
|
|
template: keys[0] ? fields[keys[0]] : undefined,
|
|
}
|
|
}
|
|
const configuredEmptyStructure =
|
|
Array.isArray(value) && value.length === 0
|
|
? emptyStructure(scope, path)
|
|
: undefined
|
|
if (configuredEmptyStructure) {
|
|
return configuredEmptyStructure
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const items = value.map((child, index) =>
|
|
buildStructure(child, scope, `${path}.${index + 1}`),
|
|
)
|
|
return {
|
|
kind: 'list',
|
|
items,
|
|
template: items[0],
|
|
}
|
|
}
|
|
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
|
const entries = value.entries.map((entry) => ({
|
|
key: entry.key,
|
|
keyType: entry.keyType,
|
|
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
|
|
}))
|
|
const keyTypes = new Set(entries.map((entry) => entry.keyType))
|
|
return {
|
|
kind: 'map',
|
|
entries,
|
|
keyType: keyTypes.size === 1 ? entries[0]?.keyType : undefined,
|
|
template: entries[0]?.structure,
|
|
}
|
|
}
|
|
if (/^vector[234]$/.test(value?.__skyType ?? '')) {
|
|
return { kind: 'vector', vectorType: value.__skyType }
|
|
}
|
|
if (value !== null && typeof value === 'object') {
|
|
return {
|
|
kind: 'table',
|
|
fields: Object.fromEntries(
|
|
Object.entries(value).map(([key, child]) => [
|
|
key,
|
|
buildStructure(child, scope, `${path}.${key}`),
|
|
]),
|
|
),
|
|
}
|
|
}
|
|
return { kind: 'value', valueType: typeof value }
|
|
}
|
|
|
|
function addField(fields, scope, path, value) {
|
|
const valueType = Array.isArray(value) ? 'json' : typeof value
|
|
const sensitive = typeof value === 'string' && sensitivePath(path)
|
|
fields.push({
|
|
configured: sensitive ? value !== '' : undefined,
|
|
label:
|
|
scope === 'config' && path === 'Companies.Definitions'
|
|
? 'Jobs'
|
|
: humanize(path.split('.').at(-1)),
|
|
path,
|
|
scope,
|
|
sensitive,
|
|
structure:
|
|
value !== null && typeof value === 'object'
|
|
? buildStructure(value, scope, path)
|
|
: undefined,
|
|
type:
|
|
scope === 'config' && path === 'Phone.Keybind'
|
|
? 'stringOrFalse'
|
|
: value !== null && typeof value === 'object'
|
|
? 'json'
|
|
: valueType,
|
|
value: sensitive ? '' : maskValue(value, path),
|
|
})
|
|
}
|
|
|
|
function flattenCompanyFields(fields, path, value) {
|
|
if (
|
|
path === 'Companies.Definitions' ||
|
|
value === null ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value) ||
|
|
value.__skyType ||
|
|
Object.keys(value).length === 0
|
|
) {
|
|
addField(fields, 'config', path, value)
|
|
return
|
|
}
|
|
|
|
for (const key of Object.keys(value).sort()) {
|
|
flattenCompanyFields(fields, `${path}.${key}`, value[key])
|
|
}
|
|
}
|
|
|
|
function buildSections(scope, payload) {
|
|
const sections = []
|
|
const generalFields = []
|
|
for (const key of Object.keys(payload).sort()) {
|
|
const value = payload[key]
|
|
if (scope === 'config' && key === 'Companies') {
|
|
const fields = []
|
|
flattenCompanyFields(fields, 'Companies', value)
|
|
fields.sort((left, right) => {
|
|
if (left.path === 'Companies.Definitions') return 1
|
|
if (right.path === 'Companies.Definitions') return -1
|
|
return left.path.localeCompare(right.path)
|
|
})
|
|
sections.push({
|
|
fields,
|
|
id: 'config:Companies',
|
|
label: humanize(key),
|
|
scope,
|
|
})
|
|
} else if (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
!value.__skyType &&
|
|
Object.keys(value).length > 0
|
|
) {
|
|
const fields = []
|
|
addField(fields, scope, key, value)
|
|
sections.push({
|
|
fields,
|
|
id: `${scope}:${key}`,
|
|
label: humanize(key),
|
|
scope,
|
|
})
|
|
} else {
|
|
addField(generalFields, scope, key, value)
|
|
}
|
|
}
|
|
if (generalFields.length) {
|
|
const general = {
|
|
fields: generalFields,
|
|
id: `${scope}:general`,
|
|
label: 'General',
|
|
scope,
|
|
}
|
|
if (scope === 'config') sections.unshift(general)
|
|
else sections.push(general)
|
|
}
|
|
return sections
|
|
}
|
|
|
|
function loadConfiguratorSections() {
|
|
const resourceRoot = resolve(__dirname, '../..')
|
|
const config = serializeLuaValue(
|
|
new LuaConfigParser(
|
|
readFileSync(
|
|
resolve(resourceRoot, 'sky_phone/config/config.lua'),
|
|
'utf8',
|
|
),
|
|
).parse(),
|
|
)
|
|
const mediaRoot = serializeLuaValue(
|
|
new LuaConfigParser(
|
|
readFileSync(resolve(resourceRoot, 'sky_phone/config/media.lua'), 'utf8'),
|
|
).parse(),
|
|
)
|
|
const media = mediaRoot.Media
|
|
delete config.PhoneConfigurator
|
|
delete config.CommandPermissions
|
|
delete config.Media
|
|
return [...buildSections('config', config), ...buildSections('media', media)]
|
|
}
|
|
|
|
module.exports = { loadConfiguratorSections }
|