UNPKG

bc-webclient-mcp

Version:

Model Context Protocol (MCP) server for Microsoft Dynamics 365 Business Central via WebUI protocol. Enables AI assistants to interact with BC through the web client protocol, supporting Card, List, and Document pages with full line item support and server

1,063 lines (1,062 loc) 60.6 kB
/** * Page Data Extractor * * Extracts actual data records from BC pages (both card and list types). * Uses patterns from Tell Me search for list data extraction. */ import { ok, err, isOk } from '../core/result.js'; import { LogicalFormParseError } from '../core/errors.js'; import { isDataRefreshChangeType, isDataRowInsertedType, isDataRowUpdatedType, isPropertyChangesType, isPropertyChangeType, } from '../types/bc-type-discriminators.js'; import { logger } from '../core/logger.js'; /** * Field control types that contain data values. */ const FIELD_CONTROL_TYPES = [ 'sc', // String Control 'dc', // Decimal Control 'bc', // Boolean Control 'i32c', // Integer32 Control 'sec', // Select/Enum Control 'dtc', // DateTime Control 'pc', // Percent Control ]; /** * Repeater control types (list data). */ const REPEATER_CONTROL_TYPES = [ 'rc', // Repeater Control 'lrc', // List Repeater Control ]; /** * System fields that should NOT be extracted for user display. * These are internal BC fields used for tracking/metadata. */ const SYSTEM_FIELD_BLOCKLIST = [ 'SystemId', 'SystemCreatedAt', 'SystemModifiedAt', 'Entity State', ]; /** * Regex to detect BC SystemId-style GUIDs * Format: 8-4-4-4-12 hexadecimal with dashes */ const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; /** * Check if a value looks like a BC SystemId GUID */ function looksLikeSystemIdGuid(value) { if (typeof value !== 'string') return false; return GUID_REGEX.test(value); } /** * Extracts data records from BC pages. */ export class PageDataExtractor { /** * Determines if a LogicalForm represents a list page. * * Uses CacheKey and ViewMode to distinguish: * - List pages: typically have ViewMode other than 2 (View/Edit mode) * - Card pages: ViewMode 2 (shows single record) * * Note: Some card pages have embedded repeaters (for line items), * so we can't rely solely on the presence of repeater controls. */ isListPage(logicalForm) { // ViewMode: 0 = Browse (List), 1 = Create, 2 = Edit/View (Card) // If ViewMode is explicitly 0, it's definitely a list page if (logicalForm.ViewMode === 0) { return true; } // If ViewMode is 2 (Edit/View), it's likely a card page if (logicalForm.ViewMode === 2) { return false; } // Fallback: check for repeater control at top level // (This catches list pages that don't set ViewMode correctly) return this.hasTopLevelRepeater(logicalForm); } /** * Checks if LogicalForm has a repeater control at the top level * (not nested in tabs/parts). */ hasTopLevelRepeater(logicalForm) { if (!logicalForm.Children || !Array.isArray(logicalForm.Children)) { return false; } // Check only immediate children (top-level controls) for (const child of logicalForm.Children) { const control = child; if (this.isRepeaterControl(control.t)) { return true; } } return false; } /** * Finds all repeater controls in a LogicalForm tree with their control paths. * Used for Document pages to identify line item sections. * * @param logicalForm - The BC LogicalForm to search * @returns Array of repeater info with path, caption, and DesignName */ findAllRepeatersWithPaths(logicalForm) { const repeaters = []; const walkControl = (control, path) => { if (!control || typeof control !== 'object') return; // Check if this is a repeater control const controlType = control.t; if (controlType === 'rc' || controlType === 'lrc') { repeaters.push({ path, caption: String(control.Caption || control.DesignName || 'Unnamed'), designName: String(control.DesignName || ''), controlType: controlType, }); // Don't walk into repeater children - they're the line data itself return; } // Walk children recursively if (Array.isArray(control.Children)) { for (let i = 0; i < control.Children.length; i++) { const childPath = path ? `${path}/c[${i}]` : `c[${i}]`; walkControl(control.Children[i], childPath); } } }; // Start walk from root walkControl(logicalForm, 'server'); return repeaters; } /** * Extracts data from a card page (single record). * * IMPORTANT: BC uses different patterns depending on how the page was opened: * - OpenForm (get_page_metadata): Values in control.StringValue/ObjectValue * - InvokeAction (drill-down): Values in DataRowUpdated.cells (list pattern) * * This method handles both patterns automatically. */ extractCardPageData(logicalForm, handlers) { try { // Build field metadata map from LogicalForm for control ID → semantic name mapping const fieldMetadata = this.buildFieldMetadataMap(logicalForm); logger.info(`fieldMetadata size: ${fieldMetadata.size}`); // Extract column mappings from LogicalForm (runtime ID → semantic name) let columnMappings = this.extractColumnMappings(logicalForm); // For Card pages, extractColumnMappings returns empty (no repeaters) // Extract mappings from ColumnBinder.Name on simple controls instead if (!columnMappings || columnMappings.size === 0) { logger.info(`No repeater columnMappings, extracting from Card controls' ColumnBinder...`); columnMappings = this.extractCardControlColumnMappings(logicalForm); } logger.info(`columnMappings size: ${columnMappings?.size || 0}`); if (columnMappings && columnMappings.size > 0) { const first5 = Array.from(columnMappings.entries()).slice(0, 5); logger.info(`First 5 columnMappings: ${JSON.stringify(first5).substring(0, 200)}`); } // Build controlId → semanticName map for Card pages (prefer Table/Column metadata) // This prevents Status values from appearing under "Your Reference" or other caption-based keys const controlIdToName = new Map(); // 1) From fieldMetadata (DesignName/Name ↔ ControlIdentifier) for (const [semanticName, meta] of fieldMetadata.entries()) { if (meta.controlId) { controlIdToName.set(String(meta.controlId), semanticName); } } // 2) From ColumnBinder-based mappings (runtimeId ↔ controlId) if (columnMappings && columnMappings.size > 0) { for (const mapping of columnMappings.values()) { if (mapping.controlId != null) { controlIdToName.set(String(mapping.controlId), mapping.semanticName); } } } logger.info(`controlIdToName (Card) size: ${controlIdToName.size}`); // Card pages use PropertyChanges for field data (not DataRowUpdated) // DataRowUpdated is only for child list controls within Card pages logger.info('Extracting Card data using PropertyChanges pattern'); // Apply PropertyChanges to LogicalForm if available (drill-down pattern) let effectiveForm = logicalForm; if (handlers) { const applied = this.applyPropertyChangesToLogicalForm(logicalForm, handlers); if (applied.appliedCount > 0) { effectiveForm = applied.updatedForm; logger.info(`Applied ${applied.appliedCount} PropertyChanges to LogicalForm for Card page`); } } const fields = {}; // Walk control tree and extract field values from field controls only const fieldEncounters = new Map(); // Track duplicate field names this.walkControls(effectiveForm, (control) => { if (this.isFieldControl(control.t)) { // Derive a stable control ID (BC tends to use ControlIdentifier or ControlId/ControlID) // Use type assertion for optional runtime properties const ctrlAny = control; const controlId = ctrlAny.ControlIdentifier != null ? String(ctrlAny.ControlIdentifier) : ctrlAny.ControlId != null ? String(ctrlAny.ControlId) : ctrlAny.ControlID != null ? String(ctrlAny.ControlID) : undefined; let fieldName = null; // 1) Prefer semantic name from controlId→name mapping when available if (controlId && controlIdToName.has(controlId)) { fieldName = controlIdToName.get(controlId); } else { // 2) Fallback to heuristic name resolution fieldName = this.getFieldName(control); } if (!fieldName) { return; } const fieldValue = this.extractFieldValueFromControl(control); if (fieldValue === null) { return; } // Debug: Log when we find "ÅBEN" to track Status field if (fieldValue.value === 'ÅBEN' || String(fieldValue.value).includes('ÅBEN')) { logger.info(`Status candidate control found in extractCardPageData: ` + `t=${control.t}, DesignName=${control.DesignName}, Name=${control.Name}, Caption=${control.Caption}, ` + `controlId=${control.ControlIdentifier ?? control.ControlId ?? control.ControlID}, ` + `resolvedFieldName=${fieldName}`); } // Track if we're overwriting a field const encounterCount = (fieldEncounters.get(fieldName) || 0) + 1; fieldEncounters.set(fieldName, encounterCount); // Handle duplicate field names: Don't overwrite non-empty value with empty value const existingValue = fields[fieldName]; const newValueIsEmpty = fieldValue.value === null || fieldValue.value === '' || fieldValue.value === undefined; const existingValueIsNotEmpty = existingValue && existingValue.value !== null && existingValue.value !== '' && existingValue.value !== undefined; if (encounterCount > 1) { if (existingValueIsNotEmpty && newValueIsEmpty) { logger.debug(`Field "${fieldName}" encountered ${encounterCount} times - SKIPPING empty value (keeping existing non-empty value)`); return; // Skip this control, keep existing value } else { logger.warn(`Field "${fieldName}" encountered ${encounterCount} times! Previous value will be overwritten.`); logger.warn(` Previous value: ${JSON.stringify(existingValue?.value)}, New value: ${JSON.stringify(fieldValue.value)}`); } } fields[fieldName] = fieldValue; } }); // Extract bookmark from LogicalForm Properties (card/document pages) // Use effectiveForm which has PropertyChanges applied (Bookmark is set via PropertyChanges) const formWithProps = effectiveForm; const bookmark = formWithProps.Properties?.Bookmark; logger.info(`extractCardPageData: effectiveForm.Properties = ${JSON.stringify(formWithProps.Properties)?.substring(0, 200)}`); logger.info(`extractCardPageData: Bookmark = ${bookmark}`); const record = { bookmark, fields }; return ok({ pageType: 'card', records: [record], totalCount: 1, }); } catch (error) { return err(new LogicalFormParseError(`Failed to extract card page data: ${error instanceof Error ? error.message : String(error)}`, { originalError: error })); } } /** * Extracts data from a list page (multiple records). * Data arrives via DataRefreshChange handlers (async). * * @param handlers - Handlers containing DataRefreshChange with row data * @param logicalForm - Optional LogicalForm metadata to filter visible fields only */ extractListPageData(handlers, logicalForm) { try { // Find LogicalClientChangeHandler with DataRefreshChange const changeHandler = handlers.find((h) => h.handlerType === 'DN.LogicalClientChangeHandler'); if (!changeHandler) { // No data yet - return empty result return ok({ pageType: 'list', records: [], totalCount: 0, }); } // Get changes array (parameters[1]) const changes = changeHandler.parameters?.[1]; if (!Array.isArray(changes)) { return ok({ pageType: 'list', records: [], totalCount: 0, }); } // Find DataRefreshChange for main repeater // BC27+ uses full type name 'DataRefreshChange' instead of shorthand 'drch' // BC27 uses lowercase 'controlPath' not 'ControlPath' const dataChange = changes.find((c) => isDataRefreshChangeType(c.t) && !!c.ControlReference?.controlPath); if (!dataChange || !Array.isArray(dataChange.RowChanges)) { return ok({ pageType: 'list', records: [], totalCount: 0, }); } // Build field metadata map from LogicalForm (if provided) for visibility filtering let fieldMetadata = null; let columnMappings = null; if (logicalForm) { fieldMetadata = this.buildFieldMetadataMap(logicalForm); columnMappings = this.extractColumnMappings(logicalForm); } // Extract records from row changes // Note: RowChanges contains DataRowInsertedChange with custom serialization // The actual row data is in a legacy DataRowInserted property (array format) const rowChanges = dataChange.RowChanges; const records = rowChanges .filter((row) => // BC27+ uses full type name 'DataRowInserted' instead of shorthand 'drich' isDataRowInsertedType(row.t) && Array.isArray(row.DataRowInserted)) .map((row) => this.extractRecordFromRow(row.DataRowInserted[1], fieldMetadata, columnMappings)) .filter((record) => record !== null); return ok({ pageType: 'list', records, totalCount: records.length, }); } catch (error) { return err(new LogicalFormParseError(`Failed to extract list page data: ${error instanceof Error ? error.message : String(error)}`, { originalError: error })); } } /** * Extracts data from a Document page (header + lines). * * Document pages (Sales Orders, Purchase Orders) have: * - Header fields (card-like) - extracted from PropertyChanges * - Line sections (list-like repeaters) - extracted from DataRefreshChange * * @param logicalForm - The BC LogicalForm * @param handlers - All handlers from OpenForm + LoadForm * @returns Document page extraction result with header and linesBlocks */ extractDocumentPageData(logicalForm, handlers) { try { // Step 1: Extract header fields using card page extraction logic logger.info(`Extracting Document page header fields...`); const headerResult = this.extractCardPageData(logicalForm, handlers); if (!isOk(headerResult)) { return headerResult; } const headerRecord = headerResult.value.records[0]; logger.info(`Extracted ${Object.keys(headerRecord?.fields || {}).length} header fields`); // Step 2: Find all repeater controls (potential line sections) const repeaters = this.findAllRepeatersWithPaths(logicalForm); logger.info(`Found ${repeaters.length} repeater(s) in Document page`); // Step 3: Extract lines from DataRefreshChange handlers // NOTE: We don't try to match DataRefreshChange to specific repeaters because // BC uses different path formats (server:c[1] vs server/c[2]/c[0]/c[1]) const linesBlocks = []; // Find ALL DataRefreshChange handlers const dataRefreshHandlers = handlers.filter((h) => { const handler = h; if (handler.handlerType !== 'DN.LogicalClientChangeHandler') return false; const changeHandler = handler; const params = changeHandler.parameters?.[1]; if (!Array.isArray(params)) return false; // BC27+ uses full type name 'DataRefreshChange' instead of shorthand 'drch' return params.some((change) => isDataRefreshChangeType(change.t)); }); logger.info(`Found ${dataRefreshHandlers.length} DataRefreshChange handler(s)`); if (dataRefreshHandlers.length > 0) { // Try to extract list data from ALL DataRefreshChange handlers const linesResult = this.extractListPageData(dataRefreshHandlers, logicalForm); if (isOk(linesResult) && linesResult.value.totalCount > 0) { // Use the first repeater's caption as a fallback, or "Lines" if no repeaters found const caption = repeaters.length > 0 ? repeaters[0].caption : 'Lines'; const path = repeaters.length > 0 ? repeaters[0].path : 'unknown'; linesBlocks.push({ repeaterPath: path, caption, lines: linesResult.value.records, totalCount: linesResult.value.totalCount, }); logger.info(`Extracted ${linesResult.value.totalCount} line(s) from DataRefreshChange`); } else { logger.info(`No lines found in DataRefreshChange handlers`); } } else { logger.info(`No DataRefreshChange handlers found (empty order)`); } // Step 4: Return structured result // Note: We also populate the `records` array for backwards compatibility // with existing tools that expect records[0] to be the main data return ok({ pageType: 'document', header: headerRecord, linesBlocks, records: [headerRecord], // Backwards compatibility totalCount: 1, // Header is always 1 record }); } catch (error) { return err(new LogicalFormParseError(`Failed to extract document page data: ${error instanceof Error ? error.message : String(error)}`, { originalError: error })); } } // ============================================================================ // Private Helper Methods // ============================================================================ /** * Checks if control type is a field control. */ isFieldControl(type) { return FIELD_CONTROL_TYPES.includes(type); } /** * Checks if control type is a repeater control. */ isRepeaterControl(type) { return REPEATER_CONTROL_TYPES.includes(type); } /** * Walks control tree and calls visitor for each control. */ walkControls(control, visitor) { if (!control || typeof control !== 'object') { return; } // Visit current control visitor(control); // Walk children if (Array.isArray(control.Children)) { for (const child of control.Children) { this.walkControls(child, visitor); } } } /** * Gets the field name from a control. * Prefers DesignName → Name → Caption (only as last resort). * Caption should only be used when we have no better identifier to avoid mixing field values. */ getFieldName(control) { // Prefer underlying design/name over captions to avoid mixing values if (typeof control.DesignName === 'string' && control.DesignName.trim().length > 0) { return control.DesignName; } if (typeof control.Name === 'string' && control.Name.trim().length > 0) { return control.Name; } // Fallback: only use Caption when there really is no internal name if (typeof control.Caption === 'string' && control.Caption.trim().length > 0) { return control.Caption; } return null; } /** * Extracts field value from a LogicalForm control (card page pattern). * * IMPORTANT: BC sends values in different locations depending on the operation: * - OpenForm (get_page_metadata): Values in control.StringValue/ObjectValue * - InvokeAction (drill-down): Values in control.Properties.Value * * This method checks BOTH locations to support both scenarios. */ extractFieldValueFromControl(control) { const type = control.t; // Use type assertion to access runtime properties that may not be in the base type const ctrlRecord = control; try { // PropertyChanges sets Properties.StringValue/ObjectValue (drill-down pattern) // OpenForm sets direct StringValue/ObjectValue const props = ctrlRecord.Properties; const propertiesStringValue = props?.StringValue; const propertiesObjectValue = props?.ObjectValue; switch (type) { case 'bc': // Boolean return { value: Boolean(propertiesObjectValue ?? control.ObjectValue ?? false), type: 'boolean', }; case 'dc': // Decimal case 'pc': // Percent const decimalStr = propertiesStringValue ?? control.StringValue ?? '0'; return { value: parseFloat(String(decimalStr)), displayValue: String(decimalStr), type: 'number', }; case 'i32c': // Integer const intStr = propertiesStringValue ?? control.StringValue ?? '0'; return { value: parseInt(String(intStr), 10), displayValue: String(intStr), type: 'number', }; case 'sec': // Select/Enum return this.extractSelectValue(control, props); case 'dtc': // DateTime const dateVal = propertiesStringValue ?? control.StringValue ?? null; return { value: dateVal != null ? String(dateVal) : null, type: 'date', }; case 'sc': // String default: const strVal = propertiesStringValue ?? propertiesObjectValue ?? control.StringValue ?? control.ObjectValue ?? null; return { value: strVal != null ? String(strVal) : null, type: 'string', }; } } catch (error) { logger.warn(`Failed to extract value from control ${this.getFieldName(control)}: ${error instanceof Error ? error.message : String(error)}`); return null; } } /** * Extracts value from a select/enum control. */ extractSelectValue(control, props) { // PropertyChanges sets Properties.CurrentIndex/StringValue (drill-down pattern) // OpenForm sets direct CurrentIndex/StringValue const ctrlRecord = control; const currentIndex = props?.CurrentIndex ?? ctrlRecord.CurrentIndex; const items = (props?.Items ?? ctrlRecord.Items); const stringValue = props?.StringValue ?? control.StringValue; const objectValue = props?.ObjectValue ?? control.ObjectValue; // CRITICAL: Prefer explicit StringValue from Properties (set by PropertyChanges) // over Items lookup. BC sends the localized display string via StringValue // when the value changes, and we should respect that. // Only use Items array as fallback when no explicit string value is provided. if (stringValue !== null && stringValue !== undefined && stringValue !== '') { // BC provided explicit string value via PropertyChanges return { value: String(stringValue), type: 'string', }; } // No explicit string value, try to look up from Items array if (typeof currentIndex !== 'number' || !Array.isArray(items)) { // No Items array available, fallback to object value return { value: objectValue != null ? String(objectValue) : null, type: 'string', }; } const selectedItem = items[currentIndex]; if (!selectedItem) { return { value: null, type: 'string', }; } return { value: selectedItem.Value != null ? String(selectedItem.Value) : String(selectedItem), displayValue: selectedItem.Caption ?? String(selectedItem), type: 'string', }; } /** * Extracts a record from a DataRowInserted row (list page pattern). * * @param rowData - Row data with cells object * @param fieldMetadata - Optional field metadata for visibility filtering * @param columnMappings - Optional column mappings for runtime ID → semantic name translation */ extractRecordFromRow(rowData, fieldMetadata = null, columnMappings) { if (!rowData) { return null; } // BC has two cell patterns: // 1. Nested: rowData.cells = { fieldName: cellValue } // 2. Flat: rowData[fieldName] = cellValue (ClientDataRow style) const bcRow = rowData; const clientRow = rowData; // Get cells - prefer nested 'cells' property, fall back to flat structure const cells = bcRow.cells ?? this.extractFlatCells(clientRow); if (!cells || Object.keys(cells).length === 0) { return null; } // Build reverse map: controlId → { semanticName, metadata } const controlIdToName = new Map(); if (fieldMetadata) { for (const [semanticName, meta] of fieldMetadata.entries()) { if (meta.controlId) { controlIdToName.set(meta.controlId, { name: semanticName, visible: meta.visible, hasCaption: meta.hasCaption }); } } } const fields = {}; // Extract cell values with filtering let cellIndex = 0; for (const [cellKey, cellValue] of Object.entries(cells)) { // Extract cell value early to check for GUID pattern const extractedValue = this.extractCellValue(cellValue); // POSITIONAL + PATTERN FILTER: Skip first cell if it's a GUID (likely SystemId) // This is a pragmatic heuristic since we cannot map control IDs to semantic names if (cellIndex === 0 && extractedValue !== null && looksLikeSystemIdGuid(extractedValue.value)) { logger.debug(`Filtered SystemId GUID at position 0: ${extractedValue.value}`); cellIndex++; continue; } // 1) Prefer columnMappings: runtime ID → semanticName let semanticName = cellKey; let visible = true; let hasCaption = true; let colMapping; if (columnMappings && columnMappings.size > 0) { colMapping = columnMappings.get(cellKey); if (colMapping) { semanticName = colMapping.semanticName || cellKey; // We don't have explicit visible/hasCaption here; derive hasCaption from caption presence hasCaption = !!(colMapping.caption && colMapping.caption.trim().length > 0); } } // 2) Fallback: old controlId-based mapping using fieldMetadata const nameInfo = controlIdToName.get(cellKey); if (!colMapping && nameInfo) { semanticName = nameInfo.name; visible = nameInfo.visible; hasCaption = nameInfo.hasCaption; } // Filter system fields (SystemId, etc.) if (SYSTEM_FIELD_BLOCKLIST.includes(semanticName)) { logger.debug(`Filtered system field: ${semanticName}`); cellIndex++; continue; } // Filter hidden / non-caption fields when we have metadata if (nameInfo) { if (!visible) { logger.debug(`Filtered hidden field: ${semanticName}`); cellIndex++; continue; } if (!hasCaption) { logger.debug(`Filtered field without caption: ${semanticName}`); cellIndex++; continue; } } if (extractedValue !== null) { fields[semanticName] = extractedValue; } cellIndex++; } // Get bookmark - BC uses both lowercase and uppercase const bookmark = bcRow.bookmark ?? clientRow.Bookmark; return { bookmark, fields, }; } /** * Extracts flat cells from ClientDataRow (skipping known metadata fields). */ extractFlatCells(row) { const cells = {}; const metadataFields = ['Bookmark', 'Selected', 'Draft', 'Expanded', 'CanExpand', 'Depth']; for (const [key, value] of Object.entries(row)) { // Skip metadata fields if (metadataFields.includes(key)) continue; // Value could be a cell value object or direct primitive if (value && typeof value === 'object') { cells[key] = value; } else if (value !== undefined) { // Wrap primitive in cell value object cells[key] = { sv: String(value) }; } } return cells; } /** * Extracts field value from a cell (DataRefreshChange pattern). */ extractCellValue(cell) { if (!cell || typeof cell !== 'object') { return null; } // BC27+ List Page Format: Check for BC-specific property names first // These are used in DataRefreshChange/DataRowInserted for list pages if (cell.sv !== undefined) { return { value: cell.sv, type: 'string', }; } if (cell.i32v !== undefined) { return { value: cell.i32v, type: 'number', }; } if (cell.dcv !== undefined) { return { value: cell.dcv, type: 'number', }; } if (cell.bv !== undefined) { return { value: cell.bv, type: 'boolean', }; } if (cell.dtv !== undefined) { return { value: cell.dtv, type: 'date', }; } // Card Page Format: Check for typed value properties if (cell.stringValue !== undefined) { return { value: cell.stringValue, type: 'string', }; } if (cell.decimalValue !== undefined) { return { value: cell.decimalValue, type: 'number', }; } if (cell.intValue !== undefined) { return { value: cell.intValue, type: 'number', }; } if (cell.boolValue !== undefined) { return { value: cell.boolValue, type: 'boolean', }; } if (cell.dateTimeValue !== undefined) { return { value: cell.dateTimeValue, type: 'date', }; } // Check for objectValue (used in DataRowUpdated) if (cell.objectValue !== undefined) { // Determine type from objectValue if (typeof cell.objectValue === 'boolean') { return { value: cell.objectValue, type: 'boolean' }; } else if (typeof cell.objectValue === 'number') { return { value: cell.objectValue, displayValue: cell.stringValue, type: 'number' }; } else if (typeof cell.objectValue === 'string') { return { value: cell.objectValue, type: 'string' }; } else if (cell.objectValue === null) { return { value: null, type: 'string' }; } else { // Unknown object type - convert to string return { value: String(cell.objectValue), type: 'string' }; } } // No value found return null; } /** * Finds DataRowUpdated from handlers (drill-down pattern). */ findDataRowUpdated(handlers) { logger.info(`findDataRowUpdated: Searching ${handlers.length} handlers...`); for (const handler of handlers) { const h = handler; if (h.handlerType === 'DN.LogicalClientChangeHandler') { const changes = h.parameters?.[1]; if (Array.isArray(changes)) { logger.info(` Checking ${changes.length} changes...`); const dataRowUpdated = changes.find((c) => isDataRowUpdatedType(c.t)); if (dataRowUpdated) { logger.info(` Found DataRowUpdated! Keys: ${Object.keys(dataRowUpdated).join(', ')}`); return dataRowUpdated; } } } } logger.info(` No DataRowUpdated found`); return null; } /** * Builds a field metadata map from LogicalForm for visibility filtering. * * Implements Visibility & Relevance Heuristic: * - Checks Visible property (false = hidden field) * - Checks Caption presence (no caption = likely internal anchor) * - Prioritizes Field controls over Group/Container controls */ buildFieldMetadataMap(logicalForm) { const metadata = new Map(); this.walkControls(logicalForm, (control) => { const fieldName = this.getFieldName(control); if (!fieldName) return; // Check visibility (default true if not specified) const visible = control.Visible !== false; // Check if control has a caption (user-facing fields typically have captions) const hasCaption = typeof control.Caption === 'string' && control.Caption.trim().length > 0; // Store control ID for mapping cells keys to semantic names const controlId = control.ControlIdentifier ? String(control.ControlIdentifier) : undefined; metadata.set(fieldName, { visible, hasCaption, controlId }); }); logger.debug(`Built field metadata map with ${metadata.size} fields`); return metadata; } /** * Extracts runtime cell ID → column metadata mappings from LogicalForm repeater Columns[]. * * Supports both: * - Composite IDs: ColumnBinder.Name = "{controlId}_c{tableFieldNo}" * - Simple IDs: Name = "{tableFieldNo}" or symbolic names ("Icon", "Name") * * Returns a Map keyed by runtimeId used in DataRowUpdated/DataRowInserted.cells. */ extractColumnMappings(logicalForm) { const mappings = new Map(); try { // Recursively walk the control tree to find ALL repeaters // (not just direct children, as Document pages have nested repeaters) const walkControl = (control) => { if (!control || typeof control !== 'object') return; const controlType = control.t; const isRepeater = controlType === 'rc' || controlType === 'lrc' || Array.isArray(control.Columns); // If this is a repeater with columns, extract mappings if (isRepeater && Array.isArray(control.Columns)) { const columns = control.Columns; for (let index = 0; index < columns.length; index++) { const col = columns[index]; if (!col || typeof col !== 'object') continue; let runtimeId = null; // Pattern 1: Composite ID from ColumnBinder.Name if (col.ColumnBinder && typeof col.ColumnBinder.Name === 'string') { runtimeId = col.ColumnBinder.Name; } // Pattern 2: Simple ID from Name else if (typeof col.Name === 'string') { runtimeId = col.Name; } if (!runtimeId) { // No usable runtime ID - skip continue; } const caption = typeof col.Caption === 'string' ? col.Caption : null; const designName = typeof col.DesignName === 'string' ? col.DesignName : null; // Semantic name: prefer Caption, then DesignName, then Name const semanticName = caption && caption.trim().length > 0 ? caption : designName && designName.trim().length > 0 ? designName : typeof col.Name === 'string' ? col.Name : runtimeId; const mapping = { runtimeId, semanticName, caption, designName, controlId: typeof col.ControlId === 'number' ? col.ControlId : typeof col.ControlID === 'number' ? col.ControlID : null, tableFieldNo: typeof col.TableFieldNo === 'number' ? col.TableFieldNo : typeof col.FieldNo === 'number' ? col.FieldNo : null, columnIndex: Number.isInteger(index) ? index : null, }; // Prefer first-seen mapping; log if overriding (should be rare) if (mappings.has(runtimeId)) { const existing = mappings.get(runtimeId); logger.debug(`Duplicate column mapping for runtimeId "${runtimeId}".` + ` Keeping existing semanticName="${existing.semanticName}",` + ` ignoring new semanticName="${mapping.semanticName}"`); continue; } mappings.set(runtimeId, mapping); } } // Recursively walk children if (Array.isArray(control.Children)) { for (const child of control.Children) { walkControl(child); } } }; // Start the recursive walk from the LogicalForm root walkControl(logicalForm); logger.debug(`Built column mappings map with ${mappings.size} entries from LogicalForm.Columns[]`); return mappings; } catch (error) { logger.warn(`Failed to extract column mappings from LogicalForm: ${error instanceof Error ? error.message : String(error)}`); return mappings; } } /** * Extracts column mappings from Card page field controls' ColumnBinder.Name properties. * Unlike List pages (which use repeater Columns), Card pages have ColumnBinder on individual field controls. * * @param logicalForm - The Card page LogicalForm * @returns Map of runtime ID (from ColumnBinder.Name) to semantic field name */ extractCardControlColumnMappings(logicalForm) { const mappings = new Map(); try { let index = 0; // Walk all controls looking for ColumnBinder.Name properties this.walkControls(logicalForm, (control) => { const walkable = control; if (walkable.ColumnBinder && typeof walkable.ColumnBinder.Name === 'string') { const runtimeId = walkable.ColumnBinder.Name; const caption = typeof walkable.Caption === 'string' ? walkable.Caption : null; const designName = typeof walkable.DesignName === 'string' ? walkable.DesignName : null; // Semantic name: prefer Caption, then DesignName, then Name const semanticName = (caption && caption.trim().length > 0) ? caption : (designName && designName.trim().length > 0) ? designName : (typeof walkable.Name === 'string') ? walkable.Name : runtimeId; const mapping = { runtimeId, semanticName, caption, designName, controlId: typeof walkable.ControlId === 'number' ? walkable.ControlId : typeof walkable.ControlID === 'number' ? walkable.ControlID : null, tableFieldNo: typeof walkable.TableFieldNo === 'number' ? walkable.TableFieldNo : typeof walkable.FieldNo === 'number' ? walkable.FieldNo : null, columnIndex: index++, }; // Prefer first-seen mapping; skip duplicates if (mappings.has(runtimeId)) { const existing = mappings.get(runtimeId); logger.debug(`Duplicate ColumnBinder mapping for runtimeId "${runtimeId}".` + ` Keeping existing semanticName="${existing.semanticName}",` + ` ignoring new semanticName="${mapping.semanticName}"`); return; } mappings.set(runtimeId, mapping); logger.debug(` ColumnBinder mapping: ${runtimeId} -> ${semanticName}`); } }); logger.info(`Extracted ${mappings.size} Card control ColumnBinder mappings`); } catch (error) { logger.warn({ error }, 'Failed to extract Card control column mappings'); } return mappings; } /** * Extracts card data from DataRowUpdated (drill-down pattern). * Uses the same cell extraction and field mapping logic as list pages. */ extractFromDataRowUpdated(dataRowUpdated, fieldMetadata, columnMappings) { try { logger.info(`extractFromDataRowUpdated: dataRowUpdated keys = ${Object.keys(dataRowUpdated).join(', ')}`); logger.info(`dataRowUpdated.DataRowUpdated type = ${Array.isArray(dataRowUpdated.DataRowUpdated) ? 'array' : typeof dataRowUpdated.DataRowUpdated}`); if (Array.isArray(dataRowUpdated.DataRowUpdated)) { logger.info(`dataRowUpdated.DataRowUpdated length = ${dataRowUpdated.DataRowUpdated.length}`); } // DataRowUpdated structure: [index, rowData] const rowData = dataRowUpdated.DataRowUpdated?.[1]; logger.info(`rowData exists? ${!!rowData}, rowData keys = ${rowData ? Object.keys(rowData).join(', ') : 'N/A'}`); logger.info(`rowData.cells exists? ${!!(rowData?.cells)}, cells keys = ${rowData?.cells ? Object.keys(rowData.cells).join(', ').substring(0, 100) : 'N/A'}`); if (!rowData || !rowData.cells) { logger.info(`No rowData or cells found, returning empty records`); return ok({ pageType: 'card', records: [], totalCount: 0, }); } // Build reverse map: controlId → { semanticName, metadata} (same as List pages) const controlIdToName = new Map(); if (fieldMetadata) { for (const [semanticName, meta] of fieldMetadata.entries()) { if (meta.controlId) { controlIdToName.set(meta.controlId, { name: semanticName, visible: meta.visible, hasCaption: meta.hasCaption }); } } } logger.info(`controlIdToName size: ${controlIdToName.size}`); if (controlIdToName.size > 0) { const first5 = Array.from(controlIdToName.entries()).slice(0, 5); logger.info(`First 5 controlIdToName: ${JSON.stringify(first5).substring(0, 200)}`); } const fields = {}; logger.info(`Processing ${Object.keys(rowData.cells).length} cells, columnMappings size = ${columnMappings?.size || 0}`); for (const [cellKey, cellValue] of Object.entries(rowData.cells)) { const extractedValue = this.extractCellValue(cellValue); logger.info(` Cell ${cellKey}: extractedValue = ${JSON.stringify(extractedValue)?.substring(0, 100)}`); if (extractedValue === null) { logger.info(` Skipped (null value)`); continue; } // 1) Prefer columnMappings: runtime ID → semanticName let semanticName = cellKey; let visible = true; let hasCaption = true; let colMapping; if (columnMappings && columnMappings.size > 0) { colMapping = columnMappings.get(cellKey); if (colMapping) { semanticName = colMapping.semanticName || cellKey; hasCaption = !!(colMapping.caption && colMapping.caption.trim().length > 0); logger.info(` Mapped to semantic name: ${semanticName}`); } else { logger.info(` No columnMapping found for ${cellKey}`); } } else { logger.info(` No columnMappings available`); } // 2) Fallback: old metadata map based on ControlIdentifier const nameInfo = controlIdToName.get(cellKey); if (!colMapping && nameInfo) { logger.info(` Fallback: controlIdToName[${cellKey}] = ${nameInfo.name}`); semanticName = nameInfo.name; visible = nameInfo.visible; hasCaption = nameInfo.hasCaption; } else if (!colMapping) { logger.info(` No match in controlIdToName for ${cellKey}`); } // Filter system fields if (SYSTEM_FIELD_BLOCKLIST.includes(semanticName)) { logger.debug(`Filtered system field on Card page: ${semanticName}`); continue;