Skip to content

Commit 7adc3c3

Browse files
committed
add better validation
1 parent d90f412 commit 7adc3c3

22 files changed

Lines changed: 1334 additions & 531 deletions

README.md

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -267,30 +267,18 @@ console.log(`Average Effort: ${result.total_words}`);
267267
Validate AAC files against format specifications to ensure data integrity:
268268

269269
```typescript
270-
import { ObfProcessor, GridsetProcessor } from "aac-processors";
270+
import { validateFileOrBuffer, getValidatorForFile } from "@willwade/aac-processors/validation";
271271

272-
// Validate OBF/OBZ files
273-
const obfProcessor = new ObfProcessor();
274-
const result = await obfProcessor.validate("board.obf");
275-
276-
console.log(`Valid: ${result.valid}`);
277-
console.log(`Errors: ${result.errors}`);
278-
console.log(`Warnings: ${result.warnings}`);
279-
280-
// Detailed validation results
281-
if (!result.valid) {
282-
result.results
283-
.filter((check) => !check.valid)
284-
.forEach((check) => {
285-
console.log(`✗ ${check.description}: ${check.error}`);
286-
});
287-
}
272+
// Works in Node, Vite, and esbuild (pass Buffers from the browser/CLI)
273+
const fileName = "board.obf";
274+
const validator = getValidatorForFile(fileName);
275+
const bufferOrPath = new Uint8Array(await file.arrayBuffer()); // or fs path in Node
276+
const result = await validateFileOrBuffer(bufferOrPath, fileName);
288277

289-
// Validate Gridset files (with optional password for encrypted files)
290-
const gridsetProcessor = new GridsetProcessor({
291-
gridsetPassword: "optional-password",
278+
console.log(result.valid, result.errors, result.warnings);
279+
result.results.forEach((check) => {
280+
if (!check.valid) console.log(`✗ ${check.description}: ${check.error}`);
292281
});
293-
const gridsetResult = await gridsetProcessor.validate("vocab.gridsetx");
294282
```
295283

296284
#### Using the CLI
@@ -312,11 +300,15 @@ aacprocessors validate board.gridsetx --gridset-password <password>
312300
#### What Gets Validated?
313301

314302
- **OBF/OBZ**: Spec compliance (Open Board Format)
315-
- Required fields (format, id, locale, buttons, grid, images, sounds)
316-
- Grid structure (rows, columns, order)
317-
- Button references (image_id, sound_id, load_board paths)
318-
- Color formats (RGB/RGBA)
319-
- Cross-reference validation
303+
- **Gridset/Gridsetx**: ZIP/XML structure, required Smartbox assets
304+
- **Snap**: ZIP/package content, settings/pages/images
305+
- **TouchChat**: ZIP structure, vocab metadata, nested boards
306+
- **Asterics (.grd)**: JSON parse, grids, elements, coordinates
307+
- **Excel (.xlsx/.xls)**: Workbook readability and worksheet content
308+
- **OPML**: XML validity and outline hierarchy
309+
- **DOT**: Graph nodes/edges present and text content
310+
- **Apple Panels (.plist/.ascconfig)**: PanelDefinitions presence and buttons
311+
- **OBFSet**: Bundled board layout checks
320312

321313
- **Gridset**: XML structure
322314
- Required elements (gridset, pages, cells)
@@ -901,4 +893,4 @@ Want to help with any of these items? See our [Contributing Guidelines](#-contri
901893

902894
### Credits
903895

904-
Some of the OBF work is directly from https://github.com/open-aac/obf and https://github.com/open-aac/aac-metrics - OBLA too https://www.openboardformat.org/logs
896+
Some of the OBF work is directly from https://github.com/open-aac/obf and https://github.com/open-aac/aac-metrics - OBLA too https://www.openboardformat.org/logs

src/processors/applePanelsProcessor.ts

Lines changed: 171 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import plist, { PlistValue } from 'plist';
1818
import fs from 'fs';
1919
import path from 'path';
20+
import { ValidationFailureError, buildValidationResultFromMessage } from '../validation';
2021

2122
interface ApplePanelsActionParameters {
2223
CharString?: string;
@@ -216,152 +217,195 @@ class ApplePanelsProcessor extends BaseProcessor {
216217
}
217218

218219
loadIntoTree(filePathOrBuffer: string | Buffer): AACTree {
219-
let content: string;
220-
221-
if (Buffer.isBuffer(filePathOrBuffer)) {
222-
content = filePathOrBuffer.toString('utf8');
223-
} else if (typeof filePathOrBuffer === 'string') {
224-
// Check if it's a .ascconfig folder or a direct .plist file
225-
if (filePathOrBuffer.endsWith('.ascconfig')) {
226-
// Read from proper Apple Panels structure: *.ascconfig/Contents/Resources/PanelDefinitions.plist
227-
const panelDefsPath = `${filePathOrBuffer}/Contents/Resources/PanelDefinitions.plist`;
228-
if (fs.existsSync(panelDefsPath)) {
229-
content = fs.readFileSync(panelDefsPath, 'utf8');
220+
const filename =
221+
typeof filePathOrBuffer === 'string' ? path.basename(filePathOrBuffer) : 'upload.plist';
222+
let buffer: Buffer;
223+
224+
try {
225+
if (Buffer.isBuffer(filePathOrBuffer)) {
226+
buffer = filePathOrBuffer;
227+
} else if (typeof filePathOrBuffer === 'string') {
228+
if (filePathOrBuffer.endsWith('.ascconfig')) {
229+
const panelDefsPath = `${filePathOrBuffer}/Contents/Resources/PanelDefinitions.plist`;
230+
if (fs.existsSync(panelDefsPath)) {
231+
buffer = fs.readFileSync(panelDefsPath);
232+
} else {
233+
const validation = buildValidationResultFromMessage({
234+
filename,
235+
filesize: 0,
236+
format: 'applepanels',
237+
message: `Apple Panels file not found: ${panelDefsPath}`,
238+
type: 'missing',
239+
description: 'PanelDefinitions.plist',
240+
});
241+
throw new ValidationFailureError('Apple Panels file not found', validation);
242+
}
230243
} else {
231-
throw new Error(`Apple Panels file not found: ${panelDefsPath}`);
244+
buffer = fs.readFileSync(filePathOrBuffer);
232245
}
233246
} else {
234-
// Fallback: treat as direct .plist file
235-
content = fs.readFileSync(filePathOrBuffer, 'utf8');
247+
const validation = buildValidationResultFromMessage({
248+
filename,
249+
filesize: 0,
250+
format: 'applepanels',
251+
message: 'Invalid input: expected string path or Buffer',
252+
type: 'input',
253+
description: 'Apple Panels input',
254+
});
255+
throw new ValidationFailureError('Invalid Apple Panels input', validation);
236256
}
237-
} else {
238-
throw new Error('Invalid input: expected string path or Buffer');
239-
}
240257

241-
const parsedData = plist.parse(content) as ApplePanelsParsedDocument;
258+
const content = buffer.toString('utf8');
259+
const parsedData = plist.parse(content) as ApplePanelsParsedDocument;
242260

243-
// Handle both old format (panels array) and new Apple Panels format (Panels dict)
244-
let panelsData: ApplePanelsPanel[] = [];
245-
if (Array.isArray(parsedData.panels)) {
246-
panelsData = parsedData.panels.map((panel, index) => {
247-
if (isNormalizedPanel(panel)) {
248-
return panel;
249-
}
250-
const panelData = panel || {
251-
PanelObjects: [],
252-
};
253-
return normalizePanel(panelData, `panel_${index}`);
254-
});
255-
} else if (parsedData.Panels) {
256-
const panelsDict = parsedData.Panels;
257-
panelsData = Object.keys(panelsDict).map((panelId) => {
258-
const rawPanel = panelsDict[panelId] || { PanelObjects: [] };
259-
return normalizePanel(rawPanel, panelId);
260-
});
261-
}
262-
263-
const data: ApplePanelsDocument = { panels: panelsData };
264-
const tree = new AACTree();
265-
tree.metadata.format = 'applepanels';
266-
267-
data.panels.forEach((panel) => {
268-
const page = new AACPage({
269-
id: panel.id,
270-
name: panel.name,
271-
grid: [],
272-
buttons: [],
273-
parentId: null,
274-
});
261+
let panelsData: ApplePanelsPanel[] = [];
262+
if (Array.isArray(parsedData.panels)) {
263+
panelsData = parsedData.panels.map((panel, index) => {
264+
if (isNormalizedPanel(panel)) {
265+
return panel;
266+
}
267+
const panelData = panel || {
268+
PanelObjects: [],
269+
};
270+
return normalizePanel(panelData, `panel_${index}`);
271+
});
272+
} else if (parsedData.Panels) {
273+
const panelsDict = parsedData.Panels;
274+
panelsData = Object.keys(panelsDict).map((panelId) => {
275+
const rawPanel = panelsDict[panelId] || { PanelObjects: [] };
276+
return normalizePanel(rawPanel, panelId);
277+
});
278+
}
275279

276-
// Create a 2D grid to track button positions
277-
const gridLayout: (AACButton | null)[][] = [];
278-
const maxRows = 20; // Reasonable default for Apple Panels
279-
const maxCols = 20;
280-
for (let r = 0; r < maxRows; r++) {
281-
gridLayout[r] = new Array(maxCols).fill(null);
280+
if (panelsData.length === 0) {
281+
const validation = buildValidationResultFromMessage({
282+
filename,
283+
filesize: buffer.byteLength,
284+
format: 'applepanels',
285+
message: 'No panels found in Apple Panels file',
286+
type: 'structure',
287+
description: 'Panels definition',
288+
});
289+
throw new ValidationFailureError('Apple Panels has no panels', validation);
282290
}
283291

284-
panel.buttons.forEach((btn, idx) => {
285-
// Create semantic action from Apple Panels button
286-
let semanticAction: AACSemanticAction | undefined;
287-
288-
if (btn.targetPanel) {
289-
semanticAction = {
290-
category: AACSemanticCategory.NAVIGATION,
291-
intent: AACSemanticIntent.NAVIGATE_TO,
292-
targetId: btn.targetPanel,
293-
platformData: {
294-
applePanels: {
295-
actionType: 'ActionOpenPanel',
296-
parameters: { PanelID: `USER.${btn.targetPanel}` },
292+
const data: ApplePanelsDocument = { panels: panelsData };
293+
const tree = new AACTree();
294+
tree.metadata.format = 'applepanels';
295+
296+
data.panels.forEach((panel) => {
297+
const page = new AACPage({
298+
id: panel.id,
299+
name: panel.name,
300+
grid: [],
301+
buttons: [],
302+
parentId: null,
303+
});
304+
305+
const gridLayout: (AACButton | null)[][] = [];
306+
const maxRows = 20;
307+
const maxCols = 20;
308+
for (let r = 0; r < maxRows; r++) {
309+
gridLayout[r] = new Array(maxCols).fill(null);
310+
}
311+
312+
panel.buttons.forEach((btn, idx) => {
313+
let semanticAction: AACSemanticAction | undefined;
314+
315+
if (btn.targetPanel) {
316+
semanticAction = {
317+
category: AACSemanticCategory.NAVIGATION,
318+
intent: AACSemanticIntent.NAVIGATE_TO,
319+
targetId: btn.targetPanel,
320+
platformData: {
321+
applePanels: {
322+
actionType: 'ActionOpenPanel',
323+
parameters: { PanelID: `USER.${btn.targetPanel}` },
324+
},
297325
},
298-
},
299-
fallback: {
300-
type: 'NAVIGATE',
301-
targetPageId: btn.targetPanel,
302-
},
303-
};
304-
} else {
305-
semanticAction = {
306-
category: AACSemanticCategory.COMMUNICATION,
307-
intent: AACSemanticIntent.SPEAK_TEXT,
308-
text: btn.message || btn.label,
309-
platformData: {
310-
applePanels: {
311-
actionType: 'ActionPressKeyCharSequence',
312-
parameters: {
313-
CharString: btn.message || btn.label || '',
314-
isStickyKey: false,
326+
fallback: {
327+
type: 'NAVIGATE',
328+
targetPageId: btn.targetPanel,
329+
},
330+
};
331+
} else {
332+
semanticAction = {
333+
category: AACSemanticCategory.COMMUNICATION,
334+
intent: AACSemanticIntent.SPEAK_TEXT,
335+
text: btn.message || btn.label,
336+
platformData: {
337+
applePanels: {
338+
actionType: 'ActionPressKeyCharSequence',
339+
parameters: {
340+
CharString: btn.message || btn.label || '',
341+
isStickyKey: false,
342+
},
315343
},
316344
},
317-
},
318-
fallback: {
319-
type: 'SPEAK',
320-
message: btn.message || btn.label,
321-
},
322-
};
323-
}
345+
fallback: {
346+
type: 'SPEAK',
347+
message: btn.message || btn.label,
348+
},
349+
};
350+
}
324351

325-
const button = new AACButton({
326-
id: `${panel.id}_btn_${idx}`,
327-
label: btn.label,
328-
message: btn.message || btn.label,
329-
targetPageId: btn.targetPanel,
330-
semanticAction: semanticAction,
331-
style: {
332-
backgroundColor: btn.DisplayColor,
333-
fontSize: btn.FontSize,
334-
fontWeight: btn.DisplayImageWeight === 'bold' ? 'bold' : 'normal',
335-
},
336-
});
337-
page.addButton(button);
338-
339-
// Place button in grid layout using Rect position data
340-
if (btn.Rect) {
341-
const rect = this.parseRect(btn.Rect);
342-
if (rect) {
343-
const gridPos = this.pixelToGrid(rect.x, rect.y);
344-
const gridWidth = Math.max(1, Math.ceil(rect.width / 25));
345-
const gridHeight = Math.max(1, Math.ceil(rect.height / 25));
346-
347-
// Place button in grid (handle width/height span)
348-
for (let r = gridPos.gridY; r < gridPos.gridY + gridHeight && r < maxRows; r++) {
349-
for (let c = gridPos.gridX; c < gridPos.gridX + gridWidth && c < maxCols; c++) {
350-
if (gridLayout[r] && gridLayout[r][c] === null) {
351-
gridLayout[r][c] = button;
352+
const button = new AACButton({
353+
id: `${panel.id}_btn_${idx}`,
354+
label: btn.label,
355+
message: btn.message || btn.label,
356+
targetPageId: btn.targetPanel,
357+
semanticAction: semanticAction,
358+
style: {
359+
backgroundColor: btn.DisplayColor,
360+
fontSize: btn.FontSize,
361+
fontWeight: btn.DisplayImageWeight === 'bold' ? 'bold' : 'normal',
362+
},
363+
});
364+
page.addButton(button);
365+
366+
if (btn.Rect) {
367+
const rect = this.parseRect(btn.Rect);
368+
if (rect) {
369+
const gridPos = this.pixelToGrid(rect.x, rect.y);
370+
const gridWidth = Math.max(1, Math.ceil(rect.width / 25));
371+
const gridHeight = Math.max(1, Math.ceil(rect.height / 25));
372+
373+
for (let r = gridPos.gridY; r < gridPos.gridY + gridHeight && r < maxRows; r++) {
374+
for (let c = gridPos.gridX; c < gridPos.gridX + gridWidth && c < maxCols; c++) {
375+
if (gridLayout[r] && gridLayout[r][c] === null) {
376+
gridLayout[r][c] = button;
377+
}
352378
}
353379
}
354380
}
355381
}
356-
}
357-
});
382+
});
358383

359-
// Set the page's grid layout
360-
page.grid = gridLayout;
361-
tree.addPage(page);
362-
});
384+
page.grid = gridLayout;
385+
tree.addPage(page);
386+
});
363387

364-
return tree;
388+
return tree;
389+
} catch (err: any) {
390+
if (err instanceof ValidationFailureError) {
391+
throw err;
392+
}
393+
const validation = buildValidationResultFromMessage({
394+
filename,
395+
filesize: Buffer.isBuffer(filePathOrBuffer)
396+
? filePathOrBuffer.byteLength
397+
: typeof filePathOrBuffer === 'string'
398+
? fs.existsSync(filePathOrBuffer)
399+
? fs.statSync(filePathOrBuffer).size
400+
: 0
401+
: 0,
402+
format: 'applepanels',
403+
message: err?.message || 'Failed to parse Apple Panels file',
404+
type: 'parse',
405+
description: 'Parse Apple Panels plist',
406+
});
407+
throw new ValidationFailureError('Failed to load Apple Panels file', validation, err);
408+
}
365409
}
366410

367411
processTexts(

0 commit comments

Comments
 (0)