diff --git a/src/processors/gridsetProcessor.ts b/src/processors/gridsetProcessor.ts index fefb3ef..0f6b1de 100644 --- a/src/processors/gridsetProcessor.ts +++ b/src/processors/gridsetProcessor.ts @@ -2497,6 +2497,7 @@ class GridsetProcessor extends BaseProcessor { /** * Save a modified tree while preserving all original files (settings, images, assets) * This method only updates the grid.xml files for pages in the tree, keeping everything else intact. + * It preserves the original grid structure and only updates button labels and messages. * * @param originalPath - Path to the original gridset file * @param tree - Modified AACTree with pages to save @@ -2516,106 +2517,244 @@ class GridsetProcessor extends BaseProcessor { const originalZip = new AdmZip(originalPath); const outputZip = new AdmZip(); - // Collect styles from the tree for grid.xml files - const uniqueStyles = new Map(); - let styleIdCounter = 1; - - const addStyle = (style: AACStyle | undefined): string => { - if (!style) return ''; - const normalizedStyle: AACStyle = { ...style }; - const styleKey = JSON.stringify(normalizedStyle); - const existing = uniqueStyles.get(styleKey); - if (existing) return existing.id; - - const styleId = `Style${styleIdCounter++}`; - uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle }); - return styleId; - }; - - // Collect all styles from pages and buttons - Object.values(tree.pages).forEach((page) => { - addStyle(page.style); - page.buttons.forEach((button) => { - addStyle(button.style); - }); - }); + // Create a map of pages by name for easy lookup + const pagesByName = new Map(); + for (const page of Object.values(tree.pages)) { + pagesByName.set(page.name, page); + } // Track which grid files we're modifying const modifiedGridFiles = new Set(); - // Generate grid.xml files for pages in the tree + // Generate updated grid.xml files for pages in the tree const newGridFiles = new Map(); + // Create XML parser and builder + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }); + const gridBuilder = new XMLBuilder({ + ignoreAttributes: false, + format: true, + indentBy: ' ', + suppressEmptyNode: true, + // Preserve Grid 3 XML formatting requirements + suppressBooleanAttributes: false, + }); + for (const page of Object.values(tree.pages)) { const gridPath = `Grids/${page.name}/grid.xml`; modifiedGridFiles.add(gridPath); - // Build the grid XML content - const gridData = { - Grid: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - GridGuid: page.id, - ColumnDefinitions: this.calculateColumnDefinitions(page), - RowDefinitions: this.calculateRowDefinitions(page, false), - AutoContentCommands: '', - Cells: - page.buttons.length > 0 - ? { - Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => { - const buttonStyleId = button.style ? addStyle(button.style) : ''; - const position = this.findButtonPosition(page, button, btnIndex); + // Try to get the original grid.xml file + const originalEntry = originalZip.getEntry(gridPath); - const captionAndImage: Record = { - Caption: button.label || '', - }; + if (!originalEntry) { + // If original doesn't exist, create a new basic grid + const basicGrid = this.createBasicGridXml(page); + newGridFiles.set(gridPath, basicGrid); + continue; + } - // Handle image references - if (button.image) { - captionAndImage.Image = `${button.image}`; - } + // Parse the original grid XML + const originalContent = originalEntry.getData().toString('utf-8'); + const originalGrid = parser.parse(originalContent); - const cell: Record = { - '@_Column': position.x, - '@_Row': position.y, - captionAndImage, - }; + if (!originalGrid.Grid) { + // Invalid grid structure, create a basic one + const basicGrid = this.createBasicGridXml(page); + newGridFiles.set(gridPath, basicGrid); + continue; + } - if (position.columnSpan > 1) { - cell['@_ColumnSpan'] = position.columnSpan; - } - if (position.rowSpan > 1) { - cell['@_RowSpan'] = position.rowSpan; - } + // Create a map of buttons by their position for easy lookup + const buttonsByPosition = new Map(); + for (const button of page.buttons) { + const pos = this.findButtonPosition(page, button, 0); + const key = `${pos.x},${pos.y}`; + buttonsByPosition.set(key, button); + } - if (buttonStyleId) { - cell.CellStyle = buttonStyleId; - } + // Update cells in the original grid + const originalCells = originalGrid.Grid.Cells?.Cell; + if (originalCells) { + const cellArray = Array.isArray(originalCells) ? originalCells : [originalCells]; + + for (const cell of cellArray) { + if (!cell.Content) continue; + + // Get cell position + const x = parseInt(String(cell['@_X'] || cell['@_Column'] || '0'), 10); + const y = parseInt(String(cell['@_Y'] || cell['@_Row'] || '0'), 10); + const key = `${x},${y}`; + + // Check if there's a modified button for this position + const modifiedButton = buttonsByPosition.get(key); + if (modifiedButton) { + // Check if this is an AutoContent/WordList cell + const contentType = cell.Content.ContentType || cell.Content.contentType; + const contentSubType = cell.Content.ContentSubType || cell.Content.contentsubtype; + + const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList'; + + const isPredictionCell = + contentType === 'AutoContent' && contentSubType === 'Prediction'; + + if (isWordListCell) { + // For WordList cells, we need to add the word to the page's WordList + // instead of modifying the cell directly. The cell will automatically + // populate from the WordList. + // Note: WordList updates are handled by collecting all new words + // and adding them to the WordList.Items array later. + continue; // Skip cell modification for WordList cells + } - if (button.message && button.message !== button.label) { - // Use spoken message if different from label - const spoken = button.message; - const cellContent: Record = { - spoken, - type: 'text', - }; - cell['ContentCell'] = cellContent; - } + if (isPredictionCell) { + // Prediction cells are populated dynamically by Grid 3's prediction system. + // They should remain as and not be modified. + continue; // Skip cell modification for Prediction cells + } - return cell; - }), + // For regular cells, update the caption directly + // CDATA wrapping for empty captions will be done in post-processing + if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) { + const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage; + + // Check if the label is a placeholder (generated during extraction) + const isPlaceholderLabel = + !modifiedButton.label || + modifiedButton.label.startsWith('Cell_') || + modifiedButton.label.startsWith('AutoContent_') || + modifiedButton.label.startsWith('Prediction '); + + if (!isPlaceholderLabel) { + // Only update caption with real content, not placeholders + captionAndImage.Caption = modifiedButton.label; + + // Remove xsi:nil attribute when adding content + if (captionAndImage['@_xsi:nil'] || captionAndImage['xsi:nil']) { + delete captionAndImage['@_xsi:nil']; + delete captionAndImage['xsi:nil']; } - : undefined, - }, - }; + } + } - const gridBuilder = new XMLBuilder({ - ignoreAttributes: false, - format: true, - indentBy: ' ', - suppressEmptyNode: true, - }); + // Update the message if different from label + // But skip placeholder labels + const isPlaceholderMessage = + !modifiedButton.message || + modifiedButton.message.startsWith('Cell_') || + modifiedButton.message.startsWith('AutoContent_') || + modifiedButton.message.startsWith('Prediction '); + + if ( + !isPlaceholderMessage && + modifiedButton.message && + modifiedButton.message !== modifiedButton.label + ) { + // For simple text content + if (!cell.Content.Commands) { + cell.Content['#text'] = modifiedButton.message; + } + } + + // Update image if present + if (modifiedButton.image) { + if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) { + const captionAndImage = + cell.Content.CaptionAndImage || cell.Content.captionAndImage; + captionAndImage.Image = modifiedButton.image; + } + } + } + } + } + + // Update the page's WordList with new words from modified buttons + // Collect all modified buttons that should be added to the WordList + const newWordListItems: any[] = []; + + for (const button of page.buttons) { + const pos = this.findButtonPosition(page, button, 0); + + // Check if this button corresponds to a WordList cell + const cellArray = Array.isArray(originalGrid.Grid.Cells?.Cell) + ? originalGrid.Grid.Cells.Cell + : originalGrid.Grid.Cells?.Cell + ? [originalGrid.Grid.Cells.Cell] + : []; + + const cell = cellArray.find((c: any) => { + const cellX = parseInt(String(c['@_X'] || '0'), 10); + const cellY = parseInt(String(c['@_Y'] || '0'), 10); + return cellX === pos.x && cellY === pos.y; + }); + + if (cell) { + const contentType = cell.Content?.ContentType || cell.Content?.contentType; + const contentSubType = cell.Content?.ContentSubType || cell.Content?.contentsubtype; + + const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList'; + + // Note: Prediction cells are already skipped earlier, so they won't reach here + + if (isWordListCell) { + // Add this button to the WordList with proper Grid 3 format + // Format: label + newWordListItems.push({ + Text: { + s: { + r: button.label, + }, + }, + Image: '', // No image for user-added words + PartOfSpeech: 'Unknown', + }); + } + } + } + + // Add new items to the existing WordList + if (newWordListItems.length > 0) { + const existingWordList = originalGrid.Grid.WordList; + if (existingWordList && existingWordList.Items) { + const existingItems = + existingWordList.Items.WordListItem || existingWordList.Items.wordlistitem || []; + const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems]; - newGridFiles.set(gridPath, gridBuilder.build(gridData)); + // Merge existing and new items + const allItems = [...itemsArray, ...newWordListItems]; + + // Update the WordList + if (!originalGrid.Grid.WordList) { + originalGrid.Grid.WordList = {}; + } + if (!originalGrid.Grid.WordList.Items) { + originalGrid.Grid.WordList.Items = {}; + } + originalGrid.Grid.WordList.Items.WordListItem = allItems; + } + } + + // Build the updated grid XML and convert to Windows line endings + let builtXml = gridBuilder.build(originalGrid); + // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility + builtXml = builtXml.replace(/\n/g, '\r\n'); + // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility + // Grid 3 cannot parse - it requires + builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2>'); + // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility + // Grid 3 requires for empty captions, not plain text + builtXml = builtXml.replace(/<\/Caption>/g, ''); + builtXml = builtXml.replace(/ <\/Caption>/g, ''); + builtXml = builtXml.replace(/ {2}<\/Caption>/g, ''); + // Preserve CDATA in tags for text parameters + // Spaces in tags must use CDATA or they get stripped during rendering + // e.g., becomes + builtXml = builtXml.replace(/ <\/r>/g, ''); + builtXml = builtXml.replace(/ {2}<\/r>/g, ''); + newGridFiles.set(gridPath, builtXml); } // Copy all files from original zip, replacing modified grid files @@ -2640,6 +2779,68 @@ class GridsetProcessor extends BaseProcessor { await writeBinaryToPath(outputPath, outputBuffer); } + /** + * Create a basic grid XML for a page when original doesn't exist + */ + private createBasicGridXml(page: AACPage): string { + const gridData = { + Grid: { + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + GridGuid: page.id, + ColumnDefinitions: this.calculateColumnDefinitions(page), + RowDefinitions: this.calculateRowDefinitions(page, false), + AutoContentCommands: '', + Cells: + page.buttons.length > 0 + ? { + Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => { + const position = this.findButtonPosition(page, button, btnIndex); + + const cell: Record = { + '@_X': position.x, + '@_Y': position.y, + Content: { + CaptionAndImage: { + Caption: button.label || '', + }, + }, + }; + + if (button.image) { + (cell.Content as any).CaptionAndImage.Image = button.image; + } + + if (position.columnSpan > 1) { + cell['@_ColumnSpan'] = position.columnSpan; + } + if (position.rowSpan > 1) { + cell['@_RowSpan'] = position.rowSpan; + } + + return cell; + }), + } + : undefined, + }, + }; + + const gridBuilder = new XMLBuilder({ + ignoreAttributes: false, + format: true, + indentBy: ' ', + suppressEmptyNode: true, + // Preserve Grid 3 XML formatting requirements + suppressBooleanAttributes: false, + }); + + // Build the grid XML and convert to Windows line endings for Grid 3 compatibility + let builtXml = gridBuilder.build(gridData); + builtXml = builtXml.replace(/\n/g, '\r\n'); + // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility + builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2>'); + return builtXml; + } + // Helper method to find button position with span information private findButtonPosition( page: AACPage,