export const APPLIES_TO_CATALOG_ITEM = 'item' import { type Record, PropertyAccessShape, IdentifierShape, ts, type Database, TemplateExpressionShape, Shape, TemplateSpanShape, type ObjectShape, type Diagnostics, CallExpressionShape, type Transform, isGUID, } from '@servicenow/sdk-build-core' import { NowIncludeShape } from '../now-include-plugin' import { VariableTypeName, VARIABLE_TYPE_TO_NAME } from './variable-helper' // Note: Database, ObjectShape, Diagnostics, and CallExpressionShape are imported for validateRequestedForVariable // and other utility functions that validate variable configurations /** * Validates that there's at most one RequestedForVariable in a variables configuration (used only for VariableSet) * @param variablesShape - The ObjectShape containing the variables configuration * @param diagnostics - Diagnostics instance for reporting errors * @param context - Context where the validation is being performed ('VariableSet') * @returns True if validation passes, false otherwise */ export function validateRequestedForVariable( variablesShape: ObjectShape, diagnostics: Diagnostics, context: 'VariableSet' = 'VariableSet' ): boolean { let requestedForCount = 0 const entries = Array.from(variablesShape.entries()) for (const [, value] of entries) { const callExpr = value.as(CallExpressionShape) const calleeName = callExpr.getCallee() if (calleeName === VariableTypeName.REQUESTED_FOR) { requestedForCount++ if (requestedForCount > 1) { diagnostics.error( variablesShape.getOriginalNode(), `Only one RequestedForVariable is allowed in a ${context}` ) return false } } } return true } /** * Combined validation for variable sets that performs multiple checks in a single loop: * - At most one RequestedForVariable * - Variable types not supported in multiRow variable sets * @param variablesShape - The ObjectShape containing the variables configuration * @param variableSetType - The type of the variable set ('singleRow' or 'multiRow') * @param diagnostics - Diagnostics instance for reporting errors * @param context - Context where the validation is being performed ('VariableSet') * @returns True if validation passes, false otherwise */ export function validateVariableSetVariables( variablesShape: ObjectShape, variableSetType: string, diagnostics: Diagnostics, context: 'VariableSet' = 'VariableSet' ): boolean { let requestedForCount = 0 // Only validate multiRow restrictions for multiRow variable sets const isMultiRow = variableSetType === 'multiRow' const unsupportedVariableTypes = isMultiRow ? new Set([ VariableTypeName.ATTACHMENT, VariableTypeName.BREAK, VariableTypeName.CONTAINER_END, VariableTypeName.CONTAINER_START, VariableTypeName.CONTAINER_SPLIT, VariableTypeName.HTML, VariableTypeName.LABEL, VariableTypeName.CUSTOM, // Macro VariableTypeName.CUSTOM_WITH_LABEL, // Macro with label VariableTypeName.RICH_TEXT_LABEL, VariableTypeName.UI_PAGE, ]) : null const entries = Array.from(variablesShape.entries()) for (const [variableName, value] of entries) { const callExpr = value.as(CallExpressionShape) const calleeName = callExpr.getCallee() // Check RequestedForVariable count if (calleeName === VariableTypeName.REQUESTED_FOR) { requestedForCount++ if (requestedForCount > 1) { diagnostics.error( variablesShape.getOriginalNode(), `Only one RequestedForVariable is allowed in a ${context}` ) return false } } // Check multiRow variable type restrictions if (isMultiRow && unsupportedVariableTypes && unsupportedVariableTypes.has(calleeName)) { diagnostics.error( value.getOriginalNode(), `Variable type '${calleeName}' is not supported in multiRow variable sets. Variable '${variableName}' cannot be used.` ) return false } } return true } /** * @deprecated Use validateVariableSetVariables instead * Validates that certain variable types are not used in multiRow variable sets. * MultiRow variable sets don't support certain display/layout variables. * @param variablesShape - The ObjectShape containing the variables configuration * @param variableSetType - The type of the variable set ('singleRow' or 'multiRow') * @param diagnostics - Diagnostics instance for reporting errors * @returns True if validation passes, false otherwise */ export function validateVariableTypesForMultiRow( variablesShape: ObjectShape, variableSetType: string, diagnostics: Diagnostics ): boolean { return validateVariableSetVariables(variablesShape, variableSetType, diagnostics, 'VariableSet') } /** * Optimized validation that checks all RequestedForVariable constraints in a single pass: * - At most one RequestedForVariable in direct variables * - Not used in both direct variables and attached variable sets * - Only one RequestedForVariable across all attached variable sets * @param arg - The ObjectShape containing the catalog item/record producer configuration * @param diagnostics - Diagnostics instance for reporting errors * @param context - Context where the validation is being performed ('CatalogItem' or 'RecordProducer') * @returns True if validation passes, false otherwise */ export function validateRequestedForVariableConflict( arg: ObjectShape, diagnostics: Diagnostics, context: 'CatalogItem' | 'RecordProducer' ): boolean { // Single pass through direct variables: count RequestedForVariable instances let requestedForCountInDirectVariables = 0 if (arg.get('variables').isDefined()) { const variablesConfig = arg.get('variables').asObject() const entries = Array.from(variablesConfig.entries()) for (const [, value] of entries) { const callExpr = value.as(CallExpressionShape) const calleeName = callExpr.getCallee() if (calleeName === 'RequestedForVariable') { requestedForCountInDirectVariables++ // Validate at most one in direct variables if (requestedForCountInDirectVariables > 1) { diagnostics.error( variablesConfig.getOriginalNode(), `Only one RequestedForVariable is allowed in a ${context}` ) return false } } } } // Check for RequestedForVariable conflicts across variable sets and direct variables if (arg.get('variableSets').isDefined()) { const variableSets = arg.get('variableSets').ifArray()?.getElements() ?? [] let requestedForVariableSetCount = 0 for (const variableSet of variableSets) { const varObj = variableSet.asObject() if (varObj.get('variableSet').isRecord()) { const vsRecord = varObj.get('variableSet').asRecord() const variableSetRelRecords = vsRecord.flat().filter((r: Record) => r.getTable() === 'item_option_new') if (variableSetRelRecords && variableSetRelRecords.length > 0) { for (const variableRecord of variableSetRelRecords) { const varType = variableRecord.get('type')?.getValue() const varTypeName = VARIABLE_TYPE_TO_NAME[varType as string] if (varTypeName === 'RequestedForVariable') { requestedForVariableSetCount++ // If direct variables also have RequestedForVariable if (requestedForCountInDirectVariables > 0) { diagnostics.error( arg.get('variables').getOriginalNode(), `RequestedForVariable cannot be used in both ${context} variables and attached VariableSets. Remove RequestedForVariable from either the ${context} variables or the attached VariableSet.` ) return false } // If multiple variable sets have RequestedForVariable if (requestedForVariableSetCount > 1) { diagnostics.error( arg.get('variableSets').getOriginalNode(), `RequestedForVariable cannot be used in multiple VariableSets attached to the same ${context}. Only one RequestedForVariable is allowed across all VariableSets.` ) return false } } } } } } } return true } /** * Validates that variable names (keys) in attached variable sets don't conflict with direct variable names * @param arg - The ObjectShape containing the catalog item/record producer configuration * @param diagnostics - Diagnostics instance for reporting errors * @param context - Context where the validation is being performed ('CatalogItem' or 'RecordProducer') * @returns True if validation passes, false otherwise */ export function validateVariableNameConflicts( arg: ObjectShape, diagnostics: Diagnostics, context: 'CatalogItem' | 'RecordProducer' ): boolean { // Collect all variable names from direct variables const directVariableNames = new Set() if (arg.get('variables').isDefined()) { const variablesConfig = arg.get('variables').asObject() const entries = Array.from(variablesConfig.entries()) for (const [key] of entries) { directVariableNames.add(key) } } // If no direct variables, no conflict possible if (directVariableNames.size === 0) { return true } // Check for name conflicts in attached variable sets if (arg.get('variableSets').isDefined()) { const variableSets = arg.get('variableSets').ifArray()?.getElements() ?? [] for (const variableSet of variableSets) { const varObj = variableSet.asObject() if (varObj.get('variableSet').isRecord()) { const vsRecord = varObj.get('variableSet').asRecord() const variableSetRelRecords = vsRecord.flat().filter((r: Record) => r.getTable() === 'item_option_new') if (variableSetRelRecords && variableSetRelRecords.length > 0) { for (const variableRecord of variableSetRelRecords) { const varName = variableRecord.get('name')?.getValue() as string if (varName && directVariableNames.has(varName)) { diagnostics.error( arg.get('variables').getOriginalNode(), `Variable name conflict: '${varName}' is defined in both ${context} variables and an attached VariableSet. Each variable must have a unique name across the ${context} and all attached VariableSets.` ) return false } } } } } } return true } /** * Safely converts a value to a number with a default fallback * Handles empty strings and undefined values * @param value - The value to convert * @param defaultValue - The default value to use if conversion fails or value is empty/undefined * @returns The converted number or default value */ export function convertToNumber(value: Shape, defaultValue: number = 0): number { if (!value || value.isUndefined() || value.ifString()?.isEmpty()) { return defaultValue } return value.toNumber()?.getValue() ?? defaultValue } export function getUITypeFromId(id: number): 'desktop' | 'mobileOrServicePortal' | 'all' { switch (id) { case 0: return 'desktop' case 1: return 'mobileOrServicePortal' case 10: return 'all' default: throw Error('Invalid UI Type encountered, check XML data before transforming again.') } } export function getUITypeId(value: string): number { switch (value) { case 'desktop': return 0 case 'mobileOrServicePortal': return 1 case 'all': return 10 default: return 10 // Default to all } } /** * Mapping between ServiceNow variable set type database values and TypeScript enum values */ const VariableSetTypeMapping = { one_to_one: 'singleRow', one_to_many: 'multiRow', } as const /** * Converts ServiceNow variable set type database value to camelCase enum value * @param dbValue - Database value ('one_to_one' or 'one_to_many') * @returns TypeScript enum value ('singleRow' or 'multiRow') */ export function getVariableSetTypeFromDb(dbValue: string): 'singleRow' | 'multiRow' { const type = VariableSetTypeMapping[dbValue as keyof typeof VariableSetTypeMapping] if (!type) { return 'singleRow' // Default to singleRow if invalid } return type as 'singleRow' | 'multiRow' } /** * Converts TypeScript enum value to ServiceNow variable set type database value * @param value - TypeScript enum value ('singleRow' or 'multiRow') * @returns Database value ('one_to_one' or 'one_to_many') */ export function getVariableSetTypeToDb(value: string): string { switch (value) { case 'singleRow': return 'one_to_one' case 'multiRow': return 'one_to_many' default: return 'one_to_one' // Default to one_to_one if invalid } } /** * Mapping between ServiceNow redirect URL database values and TypeScript enum values */ const RedirectUrlMapping = { generated_record: 'generatedRecord', catalog_home: 'catalogHomePage', } as const /** * Converts ServiceNow redirect URL database value to camelCase enum value * @param dbValue - Database value ('generated_record' or 'catalog_home') * @returns TypeScript enum value ('generatedRecord' or 'catalogHomePage') */ export function getRedirectUrlFromDb(dbValue: string): 'generatedRecord' | 'catalogHomePage' { const type = RedirectUrlMapping[dbValue as keyof typeof RedirectUrlMapping] if (!type) { return 'generatedRecord' // Default to generatedRecord if invalid } return type as 'generatedRecord' | 'catalogHomePage' } /** * Converts TypeScript enum value to ServiceNow redirect URL database value * @param value - TypeScript enum value ('generatedRecord' or 'catalogHomePage') * @returns Database value ('generated_record' or 'catalog_home') */ export function getRedirectUrlToDb(value: string): string { switch (value) { case 'generatedRecord': return 'generated_record' case 'catalogHomePage': return 'catalog_home' default: return 'generated_record' // Default to generated_record if invalid } } /** * Mapping between ServiceNow fulfillment automation level database values and TypeScript enum values */ const FulfillmentAutomationLevelMapping = { unspecified: 'unspecified', manual: 'manual', semi_automated: 'semiAutomated', fully_automated: 'fullyAutomated', } as const /** * Converts ServiceNow fulfillment automation level database value to camelCase enum value * @param dbValue - Database value ('unspecified', 'manual', 'semi_automated', or 'fully_automated') * @returns TypeScript enum value ('unspecified', 'manual', 'semiAutomated', or 'fullyAutomated') */ export function getFulfillmentAutomationLevelFromDb( dbValue: string ): 'unspecified' | 'manual' | 'semiAutomated' | 'fullyAutomated' { const type = FulfillmentAutomationLevelMapping[dbValue as keyof typeof FulfillmentAutomationLevelMapping] if (!type) { return 'unspecified' // Default to unspecified if invalid } return type as 'unspecified' | 'manual' | 'semiAutomated' | 'fullyAutomated' } /** * Converts TypeScript enum value to ServiceNow fulfillment automation level database value * @param value - TypeScript enum value ('unspecified', 'manual', 'semiAutomated', or 'fullyAutomated') * @returns Database value ('unspecified', 'manual', 'semi_automated', or 'fully_automated') */ export function getFulfillmentAutomationLevelToDb(value: string): string { switch (value) { case 'unspecified': return 'unspecified' case 'manual': return 'manual' case 'semiAutomated': return 'semi_automated' case 'fullyAutomated': return 'fully_automated' default: return 'unspecified' // Default to unspecified if invalid } } /** * Mapping between ServiceNow availability database values and TypeScript enum values */ const AvailabilityMapping = { on_desktop: 'desktopOnly', on_mobile: 'mobileOnly', on_both: 'both', } as const /** * Converts ServiceNow availability database value to camelCase enum value * @param dbValue - Database value ('on_desktop', 'on_mobile', or 'on_both') * @returns TypeScript enum value ('desktopOnly', 'mobileOnly', or 'both') */ export function getAvailabilityFromDb(dbValue: string): 'desktopOnly' | 'mobileOnly' | 'both' { const type = AvailabilityMapping[dbValue as keyof typeof AvailabilityMapping] if (!type) { return 'desktopOnly' // Default to desktopOnly if invalid } return type as 'desktopOnly' | 'mobileOnly' | 'both' } /** * Converts TypeScript enum value to ServiceNow availability database value * @param value - TypeScript enum value ('desktopOnly', 'mobileOnly', or 'both') * @returns Database value ('on_desktop', 'on_mobile', or 'on_both') */ export function getAvailabilityToDb(value: string): string { switch (value) { case 'desktopOnly': return 'on_desktop' case 'mobileOnly': return 'on_mobile' case 'both': return 'on_both' default: return 'on_desktop' // Default to on_desktop if invalid } } /** * Mapping between ServiceNow mobile picture type database values and TypeScript enum values */ const MobilePictureTypeMapping = { use_desktop_picture: 'desktopPicture', use_mobile_picture: 'mobilePicture', use_no_picture: 'noPicture', } as const /** * Converts ServiceNow mobile picture type database value to camelCase enum value * @param dbValue - Database value ('use_desktop_picture', 'use_mobile_picture', or 'use_no_picture') * @returns TypeScript enum value ('desktopPicture', 'mobilePicture', or 'noPicture') */ export function getMobilePictureTypeFromDb(dbValue: string): 'desktopPicture' | 'mobilePicture' | 'noPicture' { const type = MobilePictureTypeMapping[dbValue as keyof typeof MobilePictureTypeMapping] if (!type) { return 'desktopPicture' // Default to desktopPicture if invalid } return type as 'desktopPicture' | 'mobilePicture' | 'noPicture' } /** * Converts TypeScript enum value to ServiceNow mobile picture type database value * @param value - TypeScript enum value ('desktopPicture', 'mobilePicture', or 'noPicture') * @returns Database value ('use_desktop_picture', 'use_mobile_picture', or 'use_no_picture') */ export function getMobilePictureTypeToDb(value: string): string { switch (value) { case 'desktopPicture': return 'use_desktop_picture' case 'mobilePicture': return 'use_mobile_picture' case 'noPicture': return 'use_no_picture' default: return 'use_desktop_picture' // Default to use_desktop_picture if invalid } } /** * Mapping between ServiceNow value action database values and TypeScript enum values */ const ValueActionMapping = { clear_value: 'clearValue', set_value: 'setValue', } as const /** * Converts ServiceNow value action database value to camelCase enum value * @param dbValue - Database value ('clear_value', 'set_value', or 'ignore') * @returns TypeScript enum value ('clearValue', 'setValue'), or undefined if 'ignore' */ export function getValueActionFromDb(dbValue: string): 'clearValue' | 'setValue' | undefined { if (dbValue === 'ignore') { return undefined } const type = ValueActionMapping[dbValue as keyof typeof ValueActionMapping] if (!type) { return undefined // Return undefined for 'ignore' or invalid values } return type as 'clearValue' | 'setValue' } /** * Converts TypeScript enum value to ServiceNow value action database value * @param value - TypeScript enum value ('clearValue', 'setValue', or 'ignore') * @returns Database value ('clear_value', 'set_value', or 'ignore') */ export function getValueActionToDb(value: string): string { switch (value) { case 'clearValue': return 'clear_value' case 'setValue': return 'set_value' case 'ignore': return 'ignore' default: return 'ignore' // Default to ignore if invalid } } const VisibilityTypeMapping = { Always: 1, Bundle: 2, Standalone: 3, } as const export function getVisibilityFromId(id: number): 'Always' | 'Bundle' | 'Standalone' { const entry = Object.entries(VisibilityTypeMapping).find(([, value]) => value === id) if (!entry) { return 'Always' // Default to Always if invalid } return entry[0] as 'Always' | 'Bundle' | 'Standalone' } export function getVisibilityId(value: string): number { if (value in VisibilityTypeMapping) { return VisibilityTypeMapping[value as keyof typeof VisibilityTypeMapping] } return 1 // Default to Always } /** * Finds a variable record from a catalog item's related records by variable name * @param catalogItemRecord - The catalog item Record to search in * @param variableName - The name of the variable to find * @returns The variable record if found, undefined otherwise */ export function findNameInParent(catalogItemRecord: Record, variableName: string) { const relatedRecords = catalogItemRecord.flat() return relatedRecords.find( (record: Record) => record.getTable() === 'item_option_new' && record.get('name')?.ifString()?.getValue() === variableName ) } /** * Resolves a variable ID from a parent record and variable name with fallback logic * @param parentRecord - The resolved parent record (may or may not be a Record instance) * @param variableName - The name of the variable to resolve * @returns The formatted variable ID with IO: prefix */ export function resolveVariableId(parentRecord: Record, variableName: string): string { if (parentRecord.isRecord()) { const variableRecord = findNameInParent(parentRecord, variableName) if (variableRecord) { return `IO:${variableRecord.getId().getValue()}` } } // Fallback to string value if variable record not found or parentRecord is not a Record return `IO:${variableName}` } /** * Resolves catalog item and variable set references based on the applies_to field * @param record - The source record containing catalog_item/cat_item and variable_set fields * @param database - The database instance for looking up records * @returns Object with catalogItemReference and variableSetReference */ export function resolveCatalogReferences( record: Record, database: Database ): { catalogItemReference: IdentifierShape | string | undefined variableSetReference: IdentifierShape | string | undefined } { const appliesTo = record.get('applies_to')?.ifString()?.getValue() || APPLIES_TO_CATALOG_ITEM let catalogItemReference: IdentifierShape | string | undefined let variableSetReference: IdentifierShape | string | undefined const targetRecord = getTargetRecord(record, database) if (targetRecord?.isRecord()) { const identifier = parentIdentifier(targetRecord) const recordId = targetRecord.getId().getValue() if (appliesTo === APPLIES_TO_CATALOG_ITEM) { catalogItemReference = identifier || recordId } else if (appliesTo === 'set') { variableSetReference = identifier || recordId } } else { if (appliesTo === APPLIES_TO_CATALOG_ITEM) { catalogItemReference = record.get('catalog_item')?.ifString()?.getValue() || record.get('cat_item')?.ifString()?.getValue() } else if (appliesTo === 'set') { variableSetReference = record.get('variable_set')?.ifString()?.getValue() } } return { catalogItemReference, variableSetReference } } /** * Resolves a record reference (catalog item or variable set) to an IdentifierShape * @param recordId - The sys_id of the record to resolve * @param tableName - The table name ('sc_cat_item' or 'item_option_new_set') * @param database - The database instance for looking up records * @returns IdentifierShape if the record exists and has a name, otherwise the original sys_id or undefined */ export function resolveRecordReference( recordId: string | undefined, tableName: 'sc_cat_item' | 'item_option_new_set', database: Database ): IdentifierShape | string | undefined { if (!recordId || recordId.trim() === '') { return undefined } const record = database.get(tableName, recordId) if (record?.isRecord()) { const identifier = parentIdentifier(record) return identifier || recordId } return recordId } export function parentIdentifier(parentRecord: Record): IdentifierShape | undefined { // First try to get the name from the AST (for Fluent → platform transformation) if (parentRecord.getCreator()) { const parentName = parentRecord ?.getOriginalNode() ?.getFirstAncestorByKind(ts.SyntaxKind.VariableDeclaration) ?.getName() if (parentName) { return new IdentifierShape({ source: parentRecord.getOriginalNode(), name: parentName, value: parentRecord, }) } } // Fallback: generate identifier name from record's name field (for platform → Fluent transformation) const recordName = parentRecord.get('sys_class_name')?.ifString()?.getValue() === 'item_option_new_set' ? parentRecord.get('title')?.ifString()?.getValue() : parentRecord.get('name')?.ifString()?.getValue() if (recordName) { const identifierName = toValidIdentifier(recordName) return new IdentifierShape({ source: parentRecord, name: identifierName, value: parentRecord, }) } return undefined } /** * Creates a PropertyAccessShape for a catalog variable from an item record * @param itemId - The item_option_new record * @param source - The source record for the PropertyAccessShape * @param parent - The parent record from the record * @returns PropertyAccessShape representing catalogItem.variables.variableName */ export function createVariablePropertyAccess( variableRecord: Record, source: Record, parent: Record ): PropertyAccessShape | undefined { if (!variableRecord.getCreator()) { return undefined } // Extract the catalog item variable name from the AST const parentName = variableRecord ?.getOriginalNode() ?.getFirstAncestorByKind(ts.SyntaxKind.VariableDeclaration) ?.getName() if (!parentName) { return undefined } // Extract the variable property name const varName = variableRecord?.getOriginalNode()?.getParent()?.asKind(ts.SyntaxKind.PropertyAssignment)?.getName() if (!varName) { return undefined } // Create identifier for the catalog item using parent's original node for proper import tracking const parentIdentifier = new IdentifierShape({ source: parent.getOriginalNode(), name: parentName, value: parent, }) // Create and return the property access shape return new PropertyAccessShape({ source: source, elements: [parentIdentifier, 'variables', varName], }) } /** * Resolves the appropriate record for catalog item or variable set operations * @param record - The source record containing catalog_item/cat_item or variable_set references * @param database - The database instance for looking up records by ID * @returns The resolved record (sc_cat_item or item_option_new_set) or undefined if not found */ export function getTargetRecord(record: Record, database: Database): Record | undefined { if (record.get('applies_to')?.ifString()?.getValue() === APPLIES_TO_CATALOG_ITEM) { // Try catalog_item field (used in UI policies) if (record.get('catalog_item')?.isDefined() && record.get('catalog_item')?.isRecord()) { return record.get('catalog_item').asRecord() } // Try cat_item field (used in client scripts) else if (record.get('cat_item')?.isDefined() && record.get('cat_item')?.isRecord()) { return record.get('cat_item').asRecord() } // Try catalog_item string reference (used in UI policies) else if (record.get('catalog_item')?.isDefined() && !record.get('catalog_item')?.ifString()?.isEmpty()) { return database.get('sc_cat_item', record.get('catalog_item').asString()?.getValue()) } // Try cat_item string reference (used in client scripts) else if (record.get('cat_item')?.isDefined() && !record.get('cat_item')?.ifString()?.isEmpty()) { return database.get('sc_cat_item', record.get('cat_item').asString()?.getValue()) } //Fallback to sys_id return undefined } else { // Handle variable set record reference if (record.get('variable_set')?.isRecord()) { return record.get('variable_set').asRecord() } // Handle variable set string reference else if (!record.get('variable_set')?.ifString()?.isEmpty()) { return database.get('item_option_new_set', record.get('variable_set').asString()?.getValue()) } return undefined } } /** * Resolves a variable name from a PropertyAccessShape * @param variableNameShape - The shape containing the property access * @returns An object containing the parent record and variable name */ export function resolveVariableAccess(variableNameShape: PropertyAccessShape): | { parentRecord: Record variableName: string } | undefined { const propertyAccess = variableNameShape const variableName = propertyAccess.getLastElement().getName() const parentIdentifier = propertyAccess.getElements()[0] try { const resolved = parentIdentifier.resolve(true) if (!resolved || !resolved.isRecord()) { return undefined } const parentRecord = resolved.asRecord() return { parentRecord, variableName } } catch (error) { // Resolution failed, return undefined to allow fallback return undefined } } /** * Processes a catalog condition template expression * @param conditionShape - The shape containing the catalog condition * @param appliesTo - Optional appliesTo value for validation * @param variableSetShape - Optional variable set shape for validation * @param catalogItemShape - Optional catalog item shape for validation * @param diagnostics - Optional diagnostics instance for reporting validation errors * @returns The processed condition string with resolved variable references */ export function processCatalogCondition( conditionShape: Shape, appliesTo?: 'set' | 'item', variableSetShape?: Shape, catalogItemShape?: Shape, diagnostics?: Diagnostics ): string | undefined { // Handle template expressions with variable references if (conditionShape.is(TemplateExpressionShape)) { const templateExpr = conditionShape.as(TemplateExpressionShape) let result = templateExpr.getLiteralText() // Process each span (interpolated expression) for (const span of templateExpr.getSpans()) { const expr = span.getExpression() // Handle property access (e.g., catalogItem.variables.name) if (expr.is(PropertyAccessShape)) { const variableAccess = resolveVariableAccess(expr.as(PropertyAccessShape)) if (variableAccess) { const variableId = resolveVariableId(variableAccess.parentRecord, variableAccess.variableName) result += variableId // Validate variable belongs to target based on appliesTo const targetShape = appliesTo === 'set' ? variableSetShape : catalogItemShape if (appliesTo && targetShape?.is(IdentifierShape)) { validateVariableBelongsToTarget( targetShape.as(IdentifierShape), variableAccess.parentRecord, appliesTo, diagnostics ) } } else { result += expr.toString().getValue() } } else { // Fallback to string value result += expr.toString().getValue() } result += span.getLiteralText() } return result } else { // Handle plain string if (conditionShape.ifString()?.isEmpty()) { return undefined } const conditionValue = conditionShape.toString()?.getValue() if (!conditionValue) { return undefined } // Add IO: prefixes back for XML format // Handle patterns like: c34b1842b7321010e54deb56ee11a92b=21^ORc34b1842b7321010e54deb56ee11a92b=18^... // Should become: IO:c34b1842b7321010e54deb56ee11a92b=21^ORIO:c34b1842b7321010e54deb56ee11a92b=18^... // Match the correct ServiceNow catalog condition format return conditionValue .replace(/^(?!IO:)/, 'IO:') // Add IO: if not already present .replace(/\^OR(?!IO:)/g, '^ORIO:') // Convert ^OR to ^ORIO: (but not if already ^ORIO:) .replace(/\^NQ(?!IO:)/g, '^NQIO:') // Convert ^NQ to ^NQIO: (but not if already ^NQIO:) .replace(/\^(?!(ORIO:|NQIO:|EQ))/g, '^IO:') // Add IO: after ^ except for ORIO:, NQIO:, EQ .replace(/(?= 32) { // Try standard 32-character ID first const potentialId = remainingPart.substring(0, 32) if (isGUID(potentialId)) { conditionVarId = potentialId operatorSuffix = remainingPart.substring(32) } else { // Look for other patterns like variable names const varMatch = remainingPart.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(.*)/) if (varMatch?.[1]) { conditionVarId = varMatch[1] operatorSuffix = varMatch[2] || '' } } } const conditionVarRecord = conditionVarId.length === 32 ? database.get('item_option_new', conditionVarId) : null const appliesToRecord = getTargetRecord(record, database) if (conditionVarRecord && conditionVarId.length === 32 && appliesToRecord) { const propAccess = createVariablePropertyAccess(conditionVarRecord, record, appliesToRecord) if (propAccess) { return { logicalOperator, processedContent: propAccess, operatorSuffix, hasPropertyAccess: true, } } } // Fallback: treat as literal text if no variable found // Use remainingPart (without logical operator) to avoid duplicating the OR/NQ prefix return { logicalOperator, processedContent: remainingPart, operatorSuffix: '', hasPropertyAccess: false, } } /** * Processes catalog conditions and converts them to either a TemplateExpressionShape or a string * @param record - The record containing the catalog conditions * @param database - The database instance for looking up records * @returns The processed conditions as a TemplateExpressionShape or string, or undefined if no conditions */ export function processCatalogConditionsToShape( record: Record, database: Database ): TemplateExpressionShape | string | undefined { let conditionsValue = record.get('catalog_conditions')?.ifString()?.getValue() if (!conditionsValue) { return undefined } // Handle the real ServiceNow catalog condition format // Real format: ^ORIO:OR needs to become ^OR (not ^OROR!) conditionsValue = conditionsValue .replace(/\^ORIO:/g, '^OR') // Handle ^ORIO: -> ^OR .replace(/\^NQIO:/g, '^NQ') // Handle ^NQIO: -> ^NQ .replace(/IO:/g, '') // Remove all remaining IO: prefixes .replace(/(? { const parsed = parseConditionPart(part, record, database) if (!parsed) { return // Skip empty parts } if (parsed.hasPropertyAccess) { hasPropertyAccess = true } // Build the complete part string with logical operator and suffix let partString = '' // Add logical operator prefix if present if (parsed.logicalOperator) { partString += parsed.logicalOperator } // Add the main content (property access or literal) if (typeof parsed.processedContent === 'string') { partString += parsed.processedContent // Add operator suffix if present if (parsed.operatorSuffix) { partString += parsed.operatorSuffix } processedParts.push(partString) } else { // For property access, push logical operator prefix as literal text first if (partString) { processedParts.push(partString) } processedParts.push(parsed.processedContent) // Push operator suffix as literal text after the property access if (parsed.operatorSuffix) { processedParts.push(parsed.operatorSuffix) } } // Add separator between parts (but not after last) if (index < conditionParts.length - 1) { processedParts.push('^') } }) if (!hasPropertyAccess) { return processedParts.join('') } // Build template expression const spans: TemplateSpanShape[] = [] let currentIndex = 0 for (const part of processedParts) { if (part instanceof PropertyAccessShape) { const nextPropIndex = processedParts.findIndex( (p, idx) => idx > currentIndex && p instanceof PropertyAccessShape ) const endIndex = nextPropIndex === -1 ? processedParts.length : nextPropIndex const literalText = processedParts.slice(currentIndex + 1, endIndex).join('') spans.push( new TemplateSpanShape({ source: record, expression: part, literalText, }) ) } currentIndex++ } const firstPropIndex = processedParts.findIndex((p) => p instanceof PropertyAccessShape) const initialLiteral = processedParts.slice(0, firstPropIndex).join('') return new TemplateExpressionShape({ source: record, literalText: initialLiteral, spans, }) } /** * Converts a title to a valid ServiceNow internal name (snake_case) * @param title - The title to convert * @returns Snake_case internal name */ export function convertTitleToInternalName(title: string): string { return title .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') } /** * Validation constants for internal names */ export const INTERNAL_NAME_REGEX = /^[a-z][a-z0-9_]*$/ export const INTERNAL_NAME_MAX_LENGTH = 80 /** * Validates an internal name for ServiceNow compatibility * @param internalName - The internal name to validate * @returns Validation result with error message if invalid */ export function validateInternalName(internalName: string): { valid: boolean error?: string } { if (!INTERNAL_NAME_REGEX.test(internalName)) { return { valid: false, error: 'VariableSet internalName must start with a lowercase letter and contain only lowercase letters, numbers, and underscores (snake_case format). ' + 'No spaces, hyphens, or uppercase letters allowed.', } } if (internalName.length > INTERNAL_NAME_MAX_LENGTH) { return { valid: false, error: `VariableSet internalName must be ${INTERNAL_NAME_MAX_LENGTH} characters or less. ` + `Current length: ${internalName.length}`, } } return { valid: true } } /** * Converts role values (arrays or Role records) to comma-separated string for ServiceNow storage * Handles both string arrays and Role record references * @param v - Shape value containing role data * @returns Comma-separated role string or undefined */ export function convertRolesToString(v: Shape): string | undefined { return v .ifArray() ?.pipe((r) => Array.from( new Set(r.getElements().map((role) => role.ifRecord()?.get('name').getValue() ?? role.getValue())) ).join(',') ) } /** * Parses comma-separated string into an array * @param string - Comma-separated string (e.g., "admin,itil") * @returns Array of trimmed strings */ export function parseString(commaSeparatedString: Shape): string[] { if (commaSeparatedString.isUndefined() || commaSeparatedString.toString().isEmpty()) { return [] } return commaSeparatedString .toString() .getValue() .split(',') .map((s) => s.trim()) .filter(Boolean) } /** * Checks if the record should be written as a call expression * @param record - The record to check * @returns True if the record should be written as a call expression, false otherwise */ export function shouldWriteAsCallExpression(record: Record): boolean { const originalSource = record.getOriginalSource() return ts.Node.isNode(originalSource) && originalSource.isKind(ts.SyntaxKind.CallExpression) } /** * Converts a name to a valid JavaScript identifier * Examples: * "Backend item" -> "backendItem" * "Developer Workstation" -> "developerWorkstation" * "My-Special_Item 123" -> "mySpecialItem123" */ export function toValidIdentifier(name: string): string { return ( name .trim() // Replace non-alphanumeric characters with spaces .replace(/[^a-zA-Z0-9]+/g, ' ') // Convert to camelCase .split(' ') .filter(Boolean) .map((word, index) => { const lowerWord = word.toLowerCase() return index === 0 ? lowerWord : lowerWord.charAt(0).toUpperCase() + lowerWord.slice(1) }) .join('') // Ensure it doesn't start with a number .replace(/^[0-9]/, '_$&') ) } /** * Default delivery time value used when no delivery time is specified */ export const DEFAULT_DELIVERY_TIME = '1970-01-01 00:00:00' /** * Creates a NowIncludeShape with a custom file path suffix for script separation */ export async function createScript( record: Record, scriptContent: string | Shape, transform: Transform, suffix: string ): Promise { const baseName = await transform.getUpdateName(record) const content = scriptContent instanceof Shape ? scriptContent.toString().getValue() : scriptContent return new NowIncludeShape({ source: record, path: `./${baseName}-${suffix}.js`, includedText: content, }) } /** * Default script */ export const defaultScript = `/** This script is executed before the Record is generated * \`current\`- GlideRecord produced by Record Producer * Don't use \`current.update()\` or \`current.insert()\` as the record is generated by Record Producer * Don't use \`current.setValue('sys_class_name', 'xxx')\` as this will trigger reparent flow and can cause data loss * Avoid \`current.setAbortAction()\` and generate a separate record * Use \`producer.var1\` to access variables */` /** * Default post insert script */ export const defaultpostInsertScript = `/** * This script is executed after the record is generated. * \`current\` Is the GlideRecord produced by Record Producer. Use \`current.update()\` to update the record * To access the variables, use \`producer.var1\` where var1 is the name of the variable * To access the Record Producer use \`cat_item\` */` /** * Default save script */ export const defaultSaveScript = `/** * This script is executed at every step save in Catalog Builder. * This script is executed before \`Script\` is executed. * \`current\` Is the GlideRecord produced by Record Producer. * To access the variables, use \`producer.var1\` where var1 is the name of the variable * To access the Record Producer use \`cat_item\` */` /** * Validates that a variable belongs to a target record (variable set or catalog item) * Reports diagnostic error if validation fails * @param targetShape - The IdentifierShape for the target reference (variable set or catalog item) * @param parentRecord - The parent record where the variable is defined * @param appliesTo - Whether the validation is for 'set' (variable set) or 'item' (catalog item) * @param diagnostics - Optional diagnostics instance for reporting errors * @returns true if the variable belongs to the target, false otherwise */ export function validateVariableBelongsToTarget( targetShape: IdentifierShape, parentRecord: Record, appliesTo: 'set' | 'item', diagnostics?: Diagnostics ): boolean { const resolved = targetShape.resolve(true) if (!resolved?.isRecord()) { return false } const targetRecord = resolved.asRecord() // Validate that parentRecord ID matches target record ID if (parentRecord.getId().getValue() === targetRecord.getId().getValue()) { return true } if (appliesTo === 'item') { // Validate that variable is from variable set AND that variable set is added to the catalog item const isValid = targetRecord .flat() .some( (r: Record) => r.getTable() === 'io_set_item' && r.get('sc_cat_item')?.asRecord()?.getId()?.getValue() === targetRecord.getId()?.getValue() ) if (!isValid && diagnostics) { diagnostics.error(targetShape.getOriginalNode(), 'Variable is not in the catalog item') } return isValid } // For 'set' case if (diagnostics) { diagnostics.error(targetShape.getOriginalNode(), 'Variable is not in the variable set') } return false } /** * Resolves a variable ID from variableNameShape and validates it belongs to the variable set if appliesTo is 'set' * @param variableNameShape - The shape from get('variableName', false) * @param appliesTo - The value from get('appliesTo') * @param variableSetShape - The shape from get('variableSet', false) * @param diagnostics - Diagnostics instance for reporting errors * @returns The resolved variable ID with IO: prefix, or undefined if resolution fails */ export function resolveAndValidateVariableId( variableNameShape: Shape | undefined, appliesTo: 'set' | 'item', variableSetShape: Shape | undefined, catalogItemShape: Shape | undefined, diagnostics: Diagnostics ): string | undefined { if (!variableNameShape) { return undefined } let variableId: string | undefined if (variableNameShape.is(PropertyAccessShape)) { const variableAccess = resolveVariableAccess(variableNameShape.as(PropertyAccessShape)) if (variableAccess) { const { parentRecord, variableName } = variableAccess variableId = resolveVariableId(parentRecord, variableName) // Validate variable belongs to target based on appliesTo (default to 'item') const appliesToValue: 'set' | 'item' = appliesTo === 'set' ? 'set' : 'item' const targetShape = appliesToValue === 'set' ? variableSetShape : catalogItemShape if (targetShape?.is(IdentifierShape)) { validateVariableBelongsToTarget( targetShape.as(IdentifierShape), parentRecord, appliesToValue, diagnostics ) } } else { // Fallback to string value if resolution fails variableId = `IO:${variableNameShape.toString()?.getValue()}` } } else { // Handle non-PropertyAccessShape case variableId = `IO:${variableNameShape.toString()?.getValue()}` } return variableId } /** * Validates that each variable's 'field' value (when 'mapToField' is true) belongs to the record producer's target table. * Resolves the table record from arg.get('table'), collects field names from sys_documentation descendants via .flat(), * and checks each variable's 'field' against those keys. * @param arg - The ObjectShape containing the record producer configuration * @param diagnostics - Diagnostics instance for reporting errors * @param context - Context where the validation is being performed ('RecordProducer') * @returns True if all mapped fields belong to the table, false otherwise */ export function validateFieldNameBelongsToTable(arg: ObjectShape, diagnostics: Diagnostics, context: string): boolean { if (!arg.get('variables').isDefined()) { return true } // Resolve the table record from arg.get('table') // table can be a direct record reference (IdentifierShape) or a plain string table name const tableShape = arg.get('table') let tableRecord: Record | undefined if (tableShape.isRecord()) { tableRecord = tableShape.asRecord() } else if (tableShape.is(IdentifierShape)) { const resolved = tableShape.as(IdentifierShape).resolve(true) if (resolved?.isRecord()) { tableRecord = resolved.asRecord() } } // Collect field names from sys_documentation descendants via .flat() (only when table resolves to a record) let tableFieldSet: Set | undefined if (tableRecord) { const tableFields = tableRecord .flat() .filter((r: Record) => r.getTable() === 'sys_dictionary') .map((r: Record) => r.get('element')?.ifString()?.getValue()) .filter((element): element is string => !!element) if (tableFields.length > 0) { tableFieldSet = new Set(tableFields) } } // Iterate over variables and validate field names for those with mapToField: true const variablesConfig = arg.get('variables').asObject() const entries = Array.from(variablesConfig.entries()) // Track which fields have already been mapped to detect duplicates (always enforced) const mappedFields = new Map() // field -> first variableKey that mapped it for (const [variableKey, value] of entries) { const callExpr = value.as(CallExpressionShape) const config = callExpr.getArgument(0).asObject() const mapToField = config.get('mapToField') const isMapToFieldTrue = mapToField.isDefined() && mapToField.ifBoolean()?.getValue() === true if (!isMapToFieldTrue) { continue } const fieldShape = config.get('field') const fieldValue = fieldShape.isDefined() ? fieldShape.ifString()?.getValue() : undefined if (!fieldValue) { continue } // Check field belongs to table (only when table resolved to a record with sys_documentation) if (tableFieldSet && !tableFieldSet.has(fieldValue)) { diagnostics.error( fieldShape, `${context} variable '${variableKey}': field '${fieldValue}' does not belong to the target table. Valid fields are: ${[...tableFieldSet].join(', ')}.` ) return false } // Check if this field is already mapped by another variable (always enforced) const existingVariable = mappedFields.get(fieldValue) if (existingVariable) { diagnostics.error( fieldShape, `${context} variable '${variableKey}': field '${fieldValue}' is already mapped by variable '${existingVariable}'. Each table field can only be mapped by one variable.` ) return false } mappedFields.set(fieldValue, variableKey) } return true }