import {
CallExpressionShape,
isSNScope,
type Diagnostics,
type Factory,
ObjectShape,
Plugin,
type Record,
Shape,
type ShapeTransform,
} from '@servicenow/sdk-build-core'
import { BooleanColumn, choiceDropdown, type choiceDropdownType, GenericColumn } from '@servicenow/sdk-core/runtime/db'
import { getChoiceRecords, getLabelForDefaultLanguage } from './column/column-to-record'
import { COLUMN_API_TO_TYPE, COLUMN_TYPE_TO_API, DEFAULT_COLUMN_CALCULATION } from './column/column-helper'
import { ModuleFunctionShape } from './server-module-plugin'
import { generateDeprecatedDiagnostics } from './utils'
import { choiceAliases, generateChoices } from './choice-set-utils'
import * as _ from 'lodash'
// BCP 47 language tag pattern: 2-3 letter base language with optional subtags (e.g., 'en', 'zh-Hans', 'en-US')
export const BCP47_LANGUAGE_TAG_PATTERN = /^[a-z]{2,3}(-[a-zA-Z0-9]{2,8})*$/
const documentationAliases = {
urlTarget: ['url_target'],
}
const ACRONYMS = new Set([
'css',
'id',
'pid',
'url',
'uri',
'xml',
'html',
'ip',
'ci',
'hr',
'cmdb',
'cpu',
'ecc',
'ldap',
'ola',
'olap',
'rfc',
'sla',
'sql',
'ui',
'uid',
'unix',
'ups',
'vpn',
])
const PLURAL_EQUALS_LABEL = [
's',
'ed',
'by',
'Active',
')',
' to',
' with',
' for',
' in',
' out',
' use',
'Data',
' data',
]
const PLURAL_SPECIAL_CASE_Y_TO_IES = ['ry', 'ty', 'dy', 'cy', 'fy', 'ly', 'ny']
const PLURAL_SPECIAL_CASE_ADD_ES = ['x', 'ch', 'sh', 'z']
const columnAliases = {
readOnly: ['read_only'],
functionDefinition: ['function_definition'],
tableReference: ['table_reference'],
elementReference: ['element_reference'],
spellCheck: ['spell_check'],
xmlView: ['xml_view'],
textIndex: ['text_index'],
dynamicValueDefinitions: ['dynamic_value_definitions'],
referenceFloats: ['reference_floats'],
dynamicCreationScript: ['dynamic_creation_script'],
dynamicCreation: ['dyamic_creation'], // maintaining backwards compatibility with typo
referenceKey: ['reference_key'],
referenceQual: ['reference_qual'],
columnType: ['column_type'],
virtualType: ['virtual_type'],
useReferenceQualifier: ['use_reference_qualifier'],
dynamicRefQual: ['dynamic_ref_qual'],
}
const calculationAliases = {
calculatedValue: ['calculated_value'],
columnName: ['column_name'],
}
// Column types whose `dependent` value names a field in the table rather than a choice-dependency.
// For these, the `dependent_on_field` mirrors `dependent` and `use_dependent_field` is enabled automatically.
const DEPENDENT_FIELD_COLUMN_TYPES = new Set(['document_id', 'field_list', 'template_value'])
export const ColumnPlugin = Plugin.create({
name: 'ColumnPlugin',
shapes: [
{
shape: CallExpressionShape,
fileTypes: ['fluent'],
async toRecord(callExpression, { factory, diagnostics, config }) {
const callee = callExpression.getCallee()
if (!(callee in COLUMN_API_TO_TYPE) && !(callee === GenericColumn.name)) {
return { success: false }
}
const column = callExpression.getArgument(0).asObject().withAliasedKeys(columnAliases)
generateDeprecatedDiagnostics(column, diagnostics)
const tableName = column.get('table').ifString()?.getValue()
const columnName = column.get('name').ifString()?.getValue()
if (!tableName || !columnName) {
return { success: false }
}
const documentationRecords = await labelShapeToDocumentation(
column.get('label'),
tableName,
columnName,
config.defaultLanguage,
factory,
diagnostics
)
const choiceRecords = await getChoiceRecords(
callExpression,
choiceAliases,
factory,
diagnostics,
config.defaultLanguage
)
const choiceSetRecords: Record[] = []
if (choiceRecords.length > 0) {
choiceSetRecords.push(
await factory.createRecord({
source: callExpression,
table: 'sys_choice_set',
properties: {
name: tableName,
element: columnName,
},
})
)
}
const columnType = COLUMN_API_TO_TYPE[callee] ?? column.get('columnType').asString().getValue()
const isColumnDependentFieldType = DEPENDENT_FIELD_COLUMN_TYPES.has(columnType)
const dynamicRefQualShape = column.get('dynamicRefQual')
const dynamicRefQualRef =
dynamicRefQualShape.isString() && dynamicRefQualShape.getValue() !== ''
? await factory.createReference({
source: dynamicRefQualShape,
table: 'sys_filter_option_dynamic',
guid: dynamicRefQualShape,
})
: dynamicRefQualShape.ifDefined()
const mtomValue = column.get('mtom').ifString()?.ifNotEmpty()
if (mtomValue && !isSNScope(config.scope)) {
diagnostics.warn(
mtomValue,
`'mtom' is reserved for ServiceNow internal use and will be ignored. Use the sys_m2m table to establish many-to-many relationships.`
)
}
return {
success: true,
value: (
await factory.createRecord({
source: callExpression,
table: 'sys_dictionary',
properties: column.transform(({ $ }) => ({
name: $.val(tableName),
element: $.val(columnName),
internal_type: $.from('isFullUTF8')
.map((isFullUTF8) =>
isFullUTF8?.isBoolean() &&
isFullUTF8.asBoolean().getValue() &&
columnType === 'string'
? 'string_full_utf8'
: columnType
)
.def(columnType),
active: $.toBoolean().def(true),
array: $.toBoolean().def(false),
attributes: $.from('attributes', 'scale', 'script')
.map((attributes, scale, script) => {
const attrs = attributes?.isObject() ? attributes.asObject().getValue() : {}
// Add special parameters to attributes
if (scale?.isNumber()) {
attrs['scale'] = scale.asNumber().getValue()
}
if (script?.isString()) {
attrs['script'] = script.asString().getValue()
}
return Object.entries(attrs)
.map(([key, value]) => `${key}=${value}`)
.join(',')
})
.def(''),
audit: $.toBoolean().def(false),
reference_cascade_rule: $.from('cascadeRule'),
default_value: $.from('default'),
display: $.toBoolean().def(false),
dynamic_creation: $.from('dynamicCreation').toBoolean(),
dynamic_creation_script: $.from('dynamicCreationScript'),
reference_floats: $.from('referenceFloats'),
reference_key: $.from('referenceKey'),
mtom: $.from('mtom'),
reference_qual: $.from('referenceQual'),
calculation: $.from('dynamicValueDefinitions')
.map((dynamicValueDefinitions) => {
if (
dynamicValueDefinitions.ifObject()?.get('type').ifString()?.getValue() !==
'calculated_value'
) {
return undefined
}
const dynamicValueObject = dynamicValueDefinitions
.asObject()
.withAliasedKeys(calculationAliases)
generateDeprecatedDiagnostics(dynamicValueObject, diagnostics)
const calculatedValue = dynamicValueObject.get('calculatedValue')
if (calculatedValue.isUnresolved()) {
diagnostics.error(
calculatedValue.getOriginalNode(),
`Unable to resolve the script reference, ensure the imported module is within the ${config.serverModulesDir} directory.`
)
}
return (
calculatedValue
.if(ModuleFunctionShape)
?.toString((n) => `${n}({{PARAMS}})`, ['current']) ?? calculatedValue
)
})
.toCdata(),
choice: $.from('dropdown').map((d) => {
if (!d.isString()) {
return undefined
}
const idx = choiceDropdown.indexOf(d.getValue() as choiceDropdownType)
// Index 0 ('none') is the platform default — leave undefined so
// the install XML emits empty , matching stock records.
return idx > 0 ? idx : undefined
}),
choice_table: $.from('dynamicValueDefinitions').map((dynamicValueDefinitions) =>
dynamicValueDefinitions.ifObject()?.get('type').ifString()?.getValue() ===
'choices_from_other_table'
? dynamicValueDefinitions.asObject().get('table').ifString()
: undefined
),
virtual: $.from('dynamicValueDefinitions', 'formula')
.map((dynamicValueDefinitions, formula) => {
const hasFormula = formula.isString() && formula.getValue() !== ''
if (hasFormula) {
return true
}
const calculationType = dynamicValueDefinitions
.ifObject()
?.get('type')
.ifString()
?.getValue()
if (!calculationType) {
return undefined
}
return calculationType === 'calculated_value'
})
.toBoolean(),
choice_field: $.from('dynamicValueDefinitions').map((dynamicValueDefinitions) =>
dynamicValueDefinitions.ifObject()?.get('type').ifString()?.getValue() ===
'choices_from_other_table'
? dynamicValueDefinitions.asObject().get('field').ifString()
: undefined
),
dependent: $.from('dependent', 'dynamicValueDefinitions').map(
(dependent, dynamicValueDefinitions) => {
// New direct approach (DocumentIdColumn, TemplateValueColumn, FieldListColumn)
if (dependent?.isString()) {
return dependent.asString().getValue()
}
if (dependent?.isObject()) {
diagnostics.warn(
dependent.getOriginalNode(),
`Passing a TableNameColumn object to 'dependent' is deprecated and will not work as expected. Pass the field name as a string instead.`
)
}
// Legacy approach via dynamicValueDefinitions
if (
dynamicValueDefinitions?.ifObject()?.get('type').ifString()?.getValue() ===
'dependent_field'
) {
const dynamicValueObject = dynamicValueDefinitions
.asObject()
.withAliasedKeys(calculationAliases)
generateDeprecatedDiagnostics(dynamicValueObject, diagnostics)
diagnostics.warn(
dynamicValueDefinitions.getOriginalNode(),
`Using 'dynamicValueDefinitions: { type: "dependent_field" }' is deprecated. ` +
`Use 'dependent: "field_name"' directly on the column instead.`
)
return dynamicValueObject.get('columnName').ifString()?.getValue()
}
return undefined
}
),
dependent_on_field: $.from('dependent', 'dynamicValueDefinitions').map(
(dependent, dynamicValueDefinitions) => {
if (isColumnDependentFieldType) {
const direct = dependent.ifString()?.ifNotEmpty()
if (direct) {
return direct
}
}
if (
dynamicValueDefinitions.ifObject()?.get('type').ifString()?.getValue() !==
'dependent_field'
) {
return undefined
}
const dynamicValueObject = dynamicValueDefinitions
.asObject()
.withAliasedKeys(calculationAliases)
generateDeprecatedDiagnostics(dynamicValueObject, diagnostics)
return dynamicValueObject.get('columnName').ifString()
}
),
use_dependent_field: $.from('dependent', 'dynamicValueDefinitions')
.map((dependent, dynamicValueDefinitions) => {
if (isColumnDependentFieldType && dependent.ifString()?.ifNotEmpty()) {
return true
}
return (
dynamicValueDefinitions.ifObject()?.get('type').ifString()?.getValue() ===
'dependent_field'
)
})
.def(false),
element_reference: $.from('elementReference').toBoolean().def(false),
function_definition: $.from('functionDefinition'),
function_field: $.from('functionDefinition')
.map((f) => f.isString() && f.getValue() !== '')
.def(false),
column_label: $.val(
getLabelForDefaultLanguage(documentationRecords, config.defaultLanguage) ??
generateLabel(columnName)
),
mandatory: $.toBoolean().def(false),
max_length: $.from('maxLength').map(
(maxLength) => maxLength.ifNumber()?.getValue() ?? maxLength.ifString()?.getValue()
),
primary: $.toBoolean().def(false),
read_only: $.from('readOnly', 'readOnlyOption')
.map((readOnly, readOnlyOption) => {
const readOnlyValue = readOnly.ifBoolean()?.getValue()
const readOnlyOptionValue = readOnlyOption.ifString()?.getValue()
if (readOnlyValue === false && readOnlyOptionValue) {
diagnostics.error(
readOnly,
`readOnly cannot be false with readOnlyOption ${readOnlyOptionValue}.`
)
}
if (readOnlyValue === true && readOnlyOptionValue) {
diagnostics.hint(
readOnly,
`readOnly is unnecessary when readOnlyOption '${readOnlyOptionValue}' is declared.`
)
}
return readOnlyValue ?? !!readOnlyOptionValue
})
.def(false),
// Pair read_only_option with read_only so the platform's
// DictionaryReadOnlyOptionListener doesn't clear read_only on subsequent
// installs when it sees a previously-set option go to null.
read_only_option: $.from('readOnly', 'readOnlyOption').map(
(readOnly, readOnlyOption) => {
const opt = readOnlyOption.ifString()?.getValue()
if (opt) {
return opt
}
return readOnly.ifBoolean()?.getValue() === true
? 'instance_configured'
: undefined
}
),
reference: $.from('referenceTable'),
spell_check: $.from('spellCheck').toBoolean().def(false),
table_reference: $.from('tableReference').toBoolean().def(false),
text_index: $.from('textIndex').toBoolean().def(false),
unique: $.toBoolean().def(false),
widget: $,
xml_view: $.from('xmlView').toBoolean().def(false),
formula: $.from('formula').toCdata(),
virtual_type: $.from('virtualType', 'formula')
.map((virtualType, formula) => {
const explicit = virtualType.ifString()?.getValue()
const hasFormula = formula.isString() && formula.getValue() !== ''
const derived = hasFormula ? 'formula' : 'script'
if (explicit && explicit !== derived) {
const reason = hasFormula
? `formula is set, implying 'formula'`
: `formula is not set, implying 'script'`
diagnostics.warn(
virtualType,
`virtualType is set to '${explicit}' but ${reason}. The explicit value '${explicit}' will be used.`
)
return explicit
}
return derived
})
.def('script'),
use_reference_qualifier: $.from('useReferenceQualifier', 'referenceQual')
.map((useReferenceQualifier, referenceQual) => {
const explicit = useReferenceQualifier.ifString()?.getValue()
const hasDynamic = !!dynamicRefQualRef
const hasRef = referenceQual.isString() && referenceQual.getValue() !== ''
const derived = hasDynamic ? 'dynamic' : hasRef ? 'advanced' : 'simple'
if (explicit && explicit !== derived) {
const reason =
hasDynamic && hasRef
? `both dynamicRefQual and referenceQual are set`
: hasDynamic
? `dynamicRefQual is set, implying 'dynamic'`
: hasRef
? `referenceQual is set, implying 'advanced'`
: `neither dynamicRefQual nor referenceQual is set, implying 'simple'`
diagnostics.hint(
useReferenceQualifier,
`useReferenceQualifier is set to '${explicit}' but ${reason}. The explicit value '${explicit}' will be used.`
)
return explicit
}
return derived
})
.def('simple'),
dynamic_ref_qual: $.val(dynamicRefQualRef),
})),
})
).with(...documentationRecords, ...choiceRecords, ...choiceSetRecords),
}
},
},
],
})
export function columnToCallExpression(
column: Record,
{ choices, documentation, defaultLanguage }: { choices: Record[]; documentation: Record[]; defaultLanguage: string }
): CallExpressionShape {
const internalType = column.get('internal_type').asString().getValue()
// Normalize string_full_utf8 to string for column type lookup
const normalizedType = internalType === 'string_full_utf8' ? 'string' : internalType
const callExpression = COLUMN_TYPE_TO_API[normalizedType] ?? GenericColumn.name
return new CallExpressionShape({
source: column,
callee: callExpression,
args: [
column
.transform(({ $ }) => ({
active: $.toBoolean().def(true),
array: $.toBoolean().def(false),
attributes: $.map((attributes) => {
if (!attributes.isString()) {
return undefined
}
const result: { [key: string]: string | boolean } = {}
attributes
.toString()
.getValue()
.split(',')
.forEach((attr) => {
if (attr === '') {
return
}
const [key, value] = attr.split('=').map((s) => s.trim())
// Filter out special parameters that have their own fields
if (key === 'scale' || key === 'script') {
return
}
if (!key || value === undefined) {
return
}
if (value === 'true') {
result[key] = true
} else if (value === 'false') {
result[key] = false
} else {
result[key] = value
}
})
return result
}).def({}),
// Extract special parameters from attributes
scale: $.from('attributes').map((attributes) => {
if (!attributes.isString()) {
return undefined
}
const scaleMatch = attributes
.asString()
.getValue()
.match(/scale=([^,]+)/)
return scaleMatch ? Number(scaleMatch[1]) : undefined
}),
script: $.from('attributes').map((attributes) => {
if (!attributes.isString()) {
return undefined
}
const scriptMatch = attributes
.asString()
.getValue()
.match(/script=([^,]+)/)
return scriptMatch ? scriptMatch[1] : undefined
}),
source_table: $.from('attributes').map((attributes) => {
if (!attributes.isString()) {
return undefined
}
const sourceTableMatch = attributes
.asString()
.getValue()
.match(/source_table=([^,]+)/)
return sourceTableMatch ? sourceTableMatch[1] : undefined
}),
isFullUTF8: $.from('internal_type').map((internalType) =>
internalType?.isString() && internalType.asString().getValue() === 'string_full_utf8'
? true
: undefined
),
audit: $.toBoolean().def(false),
cascadeRule: $.from('reference_cascade_rule').def(''),
columnType: callExpression === GenericColumn.name ? $.from('internal_type') : $.val(undefined),
default: $.from('default_value')
.map((defaultValue) => {
// TODO Solve default type inference generally
if (callExpression === BooleanColumn.name) {
if (defaultValue.isBoolean()) {
return defaultValue
} else if (defaultValue.isString()) {
const defaultString = defaultValue.asString().getValue().trim()
if (defaultString === 'true') {
return true
}
if (defaultString === 'false') {
return false
}
}
}
if (defaultValue.isNumber()) {
return defaultValue.getValue().toString()
}
return defaultValue
})
.def(''),
choices: choices.length ? $.val(generateChoices(choices, defaultLanguage)) : $.val(undefined),
dropdown: $.from('choice')
.map((choice) => {
let dropdownIndex = -1
if (choice.isNumber()) {
dropdownIndex = choice.asNumber().getValue()
} else if (choice.isString()) {
const parsedChoice = Number(choice.asString().getValue())
if (!isNaN(parsedChoice)) {
dropdownIndex = parsedChoice
}
}
return dropdownIndex !== -1 ? choiceDropdown[dropdownIndex] : undefined
})
.def('none'),
dynamicValueDefinitions: $.from(
'calculation',
'choice_table',
'choice_field',
'virtual_type',
'virtual'
).map((calculation, choiceTable, choiceField, virtualType, virtual) => {
// Server only reads calculation when virtual_type='script' and virtual=true
const vType = virtualType.ifString()?.getValue()
const isVirtual = virtual.ifBoolean()?.getValue() ?? virtual.ifString()?.getValue() === 'true'
if (
isVirtual &&
vType !== 'formula' &&
calculation.ifString()?.getValue() &&
calculation.asString().getValue() !== DEFAULT_COLUMN_CALCULATION
) {
return new ObjectShape({
source: calculation,
properties: {
type: 'calculated_value',
calculatedValue: calculation,
},
}).withAliasedKeys(calculationAliases)
}
if (choiceTable.ifString()?.getValue() && choiceField.ifString()?.getValue()) {
return {
type: 'choices_from_other_table',
table: choiceTable.asString().getValue(),
field: choiceField.asString().getValue(),
}
}
return undefined
}),
// Direct dependent parameter for DocumentIdColumn, TemplateValueColumn, and FieldListColumn
dependent: $.from('dependent').map((dependent) => {
if (dependent?.isString()) {
const value = dependent.asString().getValue()
return value !== '' ? value : undefined
}
return undefined
}),
elementReference: $.from('element_reference').toBoolean().def(false),
functionDefinition: $.from('function_definition').def(''),
label: documentationToLabelShape(
$,
documentation,
column.get('column_label').ifString()?.getValue(),
generateLabel(column.get('element').asString().getValue()),
defaultLanguage
),
mandatory: $.toBoolean().def(false),
maxLength: $.from('max_length')
.map((maxLength) => {
if (maxLength.isNumber()) {
return maxLength.asNumber()
}
if (maxLength.isString()) {
const numVal = parseInt(maxLength.asString().getValue())
return isNaN(numVal) ? maxLength : numVal
}
return undefined
})
.def(''),
primary: $.toBoolean().def(false),
readOnly: $.from('read_only').toBoolean().def(false),
readOnlyOption: $.from('read_only_option').def(''),
referenceTable: $.from('reference').def(''),
referenceFloats: $.from('reference_floats').toBoolean().def(false),
referenceKey: $.from('reference_key').def(''),
mtom: $.from('mtom').def(''),
referenceQual: $.from('reference_qual').def(''),
dynamicCreation: $.from('dynamic_creation').toBoolean().def(false),
dynamicCreationScript: $.from('dynamic_creation_script').def(''),
spellCheck: $.from('spell_check').toBoolean().def(false),
tableReference: $.from('table_reference').toBoolean().def(false),
textIndex: $.from('text_index').toBoolean().def(false),
unique: $.toBoolean().def(false),
widget: $.def(''),
xmlView: $.from('xml_view').toBoolean().def(false),
formula: $.from('virtual_type', 'formula').map((virtualType, formula) => {
// Server only reads formula when virtual_type='formula'
if (virtualType.ifString()?.getValue() !== 'formula') {
return undefined
}
return formula.ifString()?.ifNotEmpty()
}),
virtualType: $.from('virtual_type', 'formula').map((virtualType, formula) => {
const qualifier = virtualType.ifString()?.getValue()
const hasFormula = formula.isString() && formula.getValue() !== ''
if (hasFormula && qualifier === 'formula') {
return undefined
}
if (!hasFormula && (!qualifier || qualifier === 'script')) {
return undefined
}
return qualifier
}),
useReferenceQualifier: $.from('use_reference_qualifier', 'dynamic_ref_qual', 'reference_qual').map(
(useReferenceQualifier, dynamicRefQual, referenceQual) => {
const qualifier = useReferenceQualifier.ifString()?.getValue()
const hasDynamicRefQual = dynamicRefQual.isString() && dynamicRefQual.getValue() !== ''
const hasReferenceQual = referenceQual.isString() && referenceQual.getValue() !== ''
if (hasDynamicRefQual && qualifier === 'dynamic') {
return undefined
}
if (hasReferenceQual && qualifier === 'advanced') {
return undefined
}
if (!hasDynamicRefQual && !hasReferenceQual && (!qualifier || qualifier === 'simple')) {
return undefined
}
// A qualifier field is populated but use_reference_qualifier doesn't select
// it (absent, 'simple', or pointing at the other qualifier type) - that
// qualifier data is inert on the platform. Emit the real value explicitly
// (defaulting a truly absent field to 'simple') so a rebuild can't mistake
// inert leftover data (e.g. from a hand-authored bootstrap XML that never
// set use_reference_qualifier) for developer intent to activate it.
return qualifier ?? 'simple'
}
),
dynamicRefQual: $.from('dynamic_ref_qual').def(''),
}))
.withAliasedKeys(columnAliases),
],
})
}
// Taken from DBNamePrefixes.java
function stripPrefix(name: string) {
if (name.startsWith('u_')) {
return name.substring(2)
}
if (name.startsWith('sn_')) {
return name.substring(3)
}
if (name.startsWith('x_')) {
name = name.substring(2)
const firstUnderscore = name.indexOf('_')
if (firstUnderscore > 0) {
name = name.substring(firstUnderscore + 1)
}
}
return name
}
// Taken from LabelGenerator.java
function applyAcronyms(label: string) {
return label
.split(' ')
.map((word) => (ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word))
.join(' ')
}
// Taken from LabelGenerator.java
export function generatePlural(label: string): string {
for (const suffix of PLURAL_EQUALS_LABEL) {
if (label.endsWith(suffix)) {
return label
}
}
for (const suffix of PLURAL_SPECIAL_CASE_Y_TO_IES) {
if (label.endsWith(suffix)) {
return `${label.substring(0, label.lastIndexOf('y'))}ies`
}
}
for (const suffix of PLURAL_SPECIAL_CASE_ADD_ES) {
if (label.endsWith(suffix)) {
return `${label}es`
}
}
return `${label}s`
}
// Taken from LabelGenerator.java
export function generateLabel(name: string): string {
return applyAcronyms(_.startCase(stripPrefix(name)))
}
export function documentationToLabelShape(
$: ShapeTransform,
documentation: Record[],
labelFromParent: string | undefined,
defaultLabel: string,
defaultLanguage: string
) {
const labels = documentation.map((doc) => {
const label = doc.get('label').asString().getValue()
return doc
.transform(({ $ }) => ({
label: $.def(defaultLabel),
plural: $.def(generatePlural(label)),
language: $.def(defaultLanguage),
hint: $.def(''),
help: $.def(''),
url: $.def(''),
urlTarget: $.from('url_target').def(''),
}))
.withAliasedKeys(documentationAliases)
})
const [firstLabel] = labels
const nonDefaultFirstLabelKeys = firstLabel?.keys(true) ?? []
const [firstNonDefaultKey, ...otherNonDefaultKeys] = nonDefaultFirstLabelKeys
return !firstLabel
? $.val(labelFromParent).def(defaultLabel) // No documentation provided, so just use the label from the parent record
: labels.length === 1 &&
otherNonDefaultKeys.length < 1 &&
(firstNonDefaultKey === 'label' || firstNonDefaultKey === undefined)
? $.val(firstLabel.get('label')).def(defaultLabel) // One documentation record with only the label value changed, so use shorthand syntax
: $.val(labels.filter((label) => label.keys(true).length > 0)).def([])
}
export async function labelShapeToDocumentation(
labelShape: Shape,
tableName: string,
columnName: string | undefined,
defaultLanguage: string,
factory: Factory,
diagnostics: Diagnostics
): Promise {
const labels: Shape[] = labelShape.isArray()
? labelShape.getElements()
: labelShape.isString()
? [Shape.from(labelShape, { label: labelShape })]
: [Shape.from(labelShape, {})]
const defaultLabel = generateLabel(columnName ?? tableName)
const documentation: Record[] = []
const languages = new Set()
for (const label of labels) {
const labelObject = label.asObject().withAliasedKeys(documentationAliases)
generateDeprecatedDiagnostics(labelObject, diagnostics)
const language = labelObject.get('language').ifString()?.getValue() || defaultLanguage
if (!BCP47_LANGUAGE_TAG_PATTERN.test(language)) {
diagnostics.error(
label.getOriginalNode(),
`'language' must be a valid BCP 47 language tag (e.g., 'en', 'es', 'en-US', 'zh-Hans')`
)
}
if (languages.has(language)) {
diagnostics.error(
label.getOriginalNode(),
`Duplicate language '${language}' found. Each language must be unique per label entry.`
)
}
languages.add(language)
const labelValue = labelObject.get('label').ifString()?.ifNotEmpty()?.getValue() ?? defaultLabel
documentation.push(
await factory.createRecord({
source: label,
table: 'sys_documentation',
properties: labelObject.transform(({ $ }) => ({
name: $.val(tableName),
element: $.val(columnName),
label: $.val(labelValue).def(defaultLabel),
plural: $.def(generatePlural(labelValue)),
language: $.def(defaultLanguage),
help: $,
hint: $,
url: $,
url_target: $.from('urlTarget'),
})),
})
)
}
return documentation
}