@willwade/aac-processors 0.0.21 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +19 -27
  2. package/dist/core/treeStructure.d.ts +2 -2
  3. package/dist/core/treeStructure.js +4 -1
  4. package/dist/processors/applePanelsProcessor.js +166 -123
  5. package/dist/processors/astericsGridProcessor.js +121 -105
  6. package/dist/processors/dotProcessor.js +83 -65
  7. package/dist/processors/gridsetProcessor.js +2 -0
  8. package/dist/processors/obfProcessor.js +11 -4
  9. package/dist/processors/opmlProcessor.js +82 -44
  10. package/dist/processors/snapProcessor.js +19 -9
  11. package/dist/processors/touchchatProcessor.js +72 -21
  12. package/dist/utilities/analytics/metrics/core.d.ts +1 -1
  13. package/dist/utilities/analytics/metrics/core.js +191 -212
  14. package/dist/validation/applePanelsValidator.d.ts +10 -0
  15. package/dist/validation/applePanelsValidator.js +124 -0
  16. package/dist/validation/astericsValidator.d.ts +16 -0
  17. package/dist/validation/astericsValidator.js +115 -0
  18. package/dist/validation/dotValidator.d.ts +10 -0
  19. package/dist/validation/dotValidator.js +113 -0
  20. package/dist/validation/excelValidator.d.ts +10 -0
  21. package/dist/validation/excelValidator.js +89 -0
  22. package/dist/validation/index.d.ts +14 -1
  23. package/dist/validation/index.js +104 -1
  24. package/dist/validation/obfsetValidator.d.ts +10 -0
  25. package/dist/validation/obfsetValidator.js +103 -0
  26. package/dist/validation/opmlValidator.d.ts +10 -0
  27. package/dist/validation/opmlValidator.js +107 -0
  28. package/dist/validation/validationTypes.d.ts +22 -0
  29. package/dist/validation/validationTypes.js +38 -1
  30. package/dist/validation.d.ts +8 -2
  31. package/dist/validation.js +16 -1
  32. package/package.json +1 -1
@@ -221,44 +221,26 @@ class MetricsCalculator {
221
221
  * Count scan items for visual scanning effort
222
222
  * When block scanning is enabled, count unique scan blocks instead of individual buttons
223
223
  */
224
- countScanItems(board, currentRowIndex, currentColIndex, priorScanBlocks, blockScanEnabled) {
225
- if (!blockScanEnabled) {
226
- // Linear scanning: count all buttons before current position
227
- let count = 0;
228
- for (let r = 0; r <= currentRowIndex; r++) {
229
- const row = board.grid[r];
230
- if (!row)
231
- continue;
232
- for (let c = 0; c < row.length; c++) {
233
- if (r === currentRowIndex && c === currentColIndex)
234
- return count;
235
- const btn = row[c];
236
- if (btn && (btn.label || btn.id).length > 0) {
237
- count++;
238
- }
239
- }
240
- }
241
- return count;
242
- }
224
+ countScanBlocks(board, currentRowIndex, currentColIndex, priorScanBlocks) {
243
225
  // Block scanning: count unique scan blocks before current position
244
- const seenBlocks = new Set();
226
+ // Reuse the priorScanBlocks set from the parent scope
245
227
  for (let r = 0; r <= currentRowIndex; r++) {
246
228
  const row = board.grid[r];
247
229
  if (!row)
248
230
  continue;
249
231
  for (let c = 0; c < row.length; c++) {
250
232
  if (r === currentRowIndex && c === currentColIndex)
251
- return seenBlocks.size;
233
+ return priorScanBlocks.size;
252
234
  const btn = row[c];
253
235
  if (btn && (btn.label || btn.id).length > 0) {
254
236
  const block = btn.scanBlock ||
255
237
  (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
256
238
  if (block !== null)
257
- seenBlocks.add(block);
239
+ priorScanBlocks.add(block);
258
240
  }
259
241
  }
260
242
  }
261
- return seenBlocks.size;
243
+ return priorScanBlocks.size;
262
244
  }
263
245
  /**
264
246
  * Analyze starting from a specific board
@@ -317,216 +299,213 @@ class MetricsCalculator {
317
299
  const scanningConfig = options.scanningConfig || board.scanningConfig;
318
300
  const blockScanEnabled = scanningConfig?.blockScanEnabled || false;
319
301
  // Process each button
320
- const priorScanBlocks = new Set();
321
302
  const btnHeight = 1.0 / rows;
322
303
  const btnWidth = 1.0 / cols;
323
- // Iterate over all buttons on the page to handle overlapping cells (common in some gridsets like Super Core 50)
324
- // Sort buttons by position to ensure consistent prior_buttons/scanning calculation
325
- const sortedButtons = [...board.buttons]
326
- .filter((b) => (b.label || '').length > 0)
327
- .sort((a, b) => {
328
- if ((a.y ?? 0) !== (b.y ?? 0))
329
- return (a.y ?? 0) - (b.y ?? 0);
330
- return (a.x ?? 0) - (b.x ?? 0);
331
- });
332
- sortedButtons.forEach((btn) => {
333
- const rowIndex = btn.y ?? 0;
334
- const colIndex = btn.x ?? 0;
335
- const x = btnWidth / 2 + btnWidth * colIndex;
336
- const y = btnHeight / 2 + btnHeight * rowIndex;
337
- // Calculate prior items for visual scan effort
338
- // If block scanning enabled, count unique scan blocks instead of individual buttons
339
- const priorItems = this.countScanItems(board, rowIndex, colIndex, priorScanBlocks, blockScanEnabled);
340
- // Calculate button-level effort
341
- let buttonEffort = boardEffort;
342
- // Debug for specific button (disabled for production)
343
- const debugSpecificButton = btn.label === '$938c2cc0dc';
344
- if (debugSpecificButton) {
345
- console.log(`\nšŸ” DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
346
- console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
347
- console.log(` Current level: ${level}`);
348
- console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
349
- }
350
- // Apply semantic_id discounts
351
- if (btn.semantic_id && boardPcts[btn.semantic_id]) {
352
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
353
- const old = buttonEffort;
354
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
355
- if (debugSpecificButton)
356
- console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
357
- }
358
- else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
359
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
360
- boardPcts[`upstream-${btn.semantic_id}`];
361
- const old = buttonEffort;
362
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
363
- if (debugSpecificButton)
364
- console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
365
- }
366
- // Apply clone_id discounts
367
- if (btn.clone_id && boardPcts[btn.clone_id]) {
368
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
369
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
370
- }
371
- else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
372
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
373
- boardPcts[`upstream-${btn.clone_id}`];
374
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
375
- }
376
- // Calculate button effort based on access method (Touch vs Scanning)
377
- const isScanning = !!scanningConfig || !!board.scanType;
378
- if (isScanning) {
379
- const { steps, selections, loopSteps } = this.calculateScanSteps(board, btn, rowIndex, colIndex, scanningConfig);
380
- // Determine effective costs based on selection method
381
- let currentStepCost = options.scanStepCost ?? effort_1.EFFORT_CONSTANTS.SCAN_STEP_COST;
382
- const currentSelectionCost = options.scanSelectionCost ?? effort_1.EFFORT_CONSTANTS.SCAN_SELECTION_COST;
383
- // Step Scan 2 Switch: Every step is a physical selection with Switch 1
384
- if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan2Switch) {
385
- // The cost of moving is now a selection cost
386
- currentStepCost = currentSelectionCost;
387
- }
388
- else if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan1Switch) {
389
- // Single switch step scan: every step is a physical selection
390
- currentStepCost = currentSelectionCost;
391
- }
392
- let sEffort = (0, effort_1.scanningEffort)(steps, selections, currentStepCost, currentSelectionCost);
393
- // Factor in error correction if enabled
394
- if (scanningConfig?.errorCorrectionEnabled) {
395
- const errorRate = scanningConfig.errorRate ?? effort_1.EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE;
396
- // A "miss" results in needing to wait for a loop (or part of one)
397
- // We model this as errorRate * (loopSteps * stepCost)
398
- const retryPenalty = loopSteps * currentStepCost;
399
- sEffort += errorRate * retryPenalty;
304
+ // Track scan blocks for block scanning
305
+ const priorScanBlocks = new Set();
306
+ // Iterate over grid positions directly (not just buttons)
307
+ // This matches Ruby's nested loop: rows.times do |row_idx|; columns.times do |col_idx|
308
+ for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
309
+ for (let colIndex = 0; colIndex < cols; colIndex++) {
310
+ const btn = board.grid[rowIndex]?.[colIndex];
311
+ if (!btn)
312
+ continue; // Skip empty cells
313
+ const x = btnWidth / 2 + btnWidth * colIndex;
314
+ const y = btnHeight / 2 + btnHeight * rowIndex;
315
+ // Calculate prior grid positions (not just buttons)
316
+ // This matches Ruby's prior_buttons which increments for each grid position
317
+ const priorGridPositions = rowIndex * cols + colIndex;
318
+ // For block scanning, count unique scan blocks instead
319
+ const priorItems = blockScanEnabled
320
+ ? this.countScanBlocks(board, rowIndex, colIndex, priorScanBlocks)
321
+ : priorGridPositions;
322
+ // Calculate button-level effort
323
+ let buttonEffort = boardEffort;
324
+ // Debug for specific button (disabled for production)
325
+ const debugSpecificButton = btn.label === '$938c2cc0dc';
326
+ if (debugSpecificButton) {
327
+ console.log(`\nšŸ” DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
328
+ console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
329
+ console.log(` Current level: ${level}`);
330
+ console.log(` Prior positions: ${priorItems}`);
331
+ console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
400
332
  }
401
- // Apply discounts to scanning effort (similar to touch)
333
+ // Apply semantic_id discounts
402
334
  if (btn.semantic_id && boardPcts[btn.semantic_id]) {
403
335
  const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
404
- sEffort = Math.min(sEffort, sEffort * discount);
336
+ const old = buttonEffort;
337
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
338
+ if (debugSpecificButton)
339
+ console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
405
340
  }
406
- else if (btn.clone_id && boardPcts[btn.clone_id]) {
341
+ else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
342
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
343
+ boardPcts[`upstream-${btn.semantic_id}`];
344
+ const old = buttonEffort;
345
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
346
+ if (debugSpecificButton)
347
+ console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
348
+ }
349
+ // Apply clone_id discounts
350
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
407
351
  const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
408
- sEffort = Math.min(sEffort, sEffort * discount);
352
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
409
353
  }
410
- buttonEffort += sEffort;
411
- }
412
- else {
413
- // Add distance effort (Touch only)
414
- let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
415
- // Apply distance discounts
416
- if (btn.semantic_id) {
417
- if (boardPcts[btn.semantic_id]) {
418
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
419
- distance = Math.min(distance, distance * discount);
420
- }
421
- else if (boardPcts[`upstream-${btn.semantic_id}`]) {
422
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
423
- boardPcts[`upstream-${btn.semantic_id}`];
424
- distance = Math.min(distance, distance * discount);
354
+ else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
355
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
356
+ boardPcts[`upstream-${btn.clone_id}`];
357
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
358
+ }
359
+ // Calculate button effort based on access method (Touch vs Scanning)
360
+ const isScanning = !!scanningConfig || !!board.scanType;
361
+ if (isScanning) {
362
+ const { steps, selections, loopSteps } = this.calculateScanSteps(board, btn, rowIndex, colIndex, scanningConfig);
363
+ // Determine effective costs based on selection method
364
+ let currentStepCost = options.scanStepCost ?? effort_1.EFFORT_CONSTANTS.SCAN_STEP_COST;
365
+ const currentSelectionCost = options.scanSelectionCost ?? effort_1.EFFORT_CONSTANTS.SCAN_SELECTION_COST;
366
+ // Step Scan 2 Switch: Every step is a physical selection with Switch 1
367
+ if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan2Switch) {
368
+ // The cost of moving is now a selection cost
369
+ currentStepCost = currentSelectionCost;
425
370
  }
426
- else if (level > 0 && setPcts[btn.semantic_id]) {
427
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
428
- setPcts[btn.semantic_id];
429
- distance = Math.min(distance, distance * discount);
371
+ else if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan1Switch) {
372
+ // Single switch step scan: every step is a physical selection
373
+ currentStepCost = currentSelectionCost;
430
374
  }
431
- }
432
- if (btn.clone_id) {
433
- if (boardPcts[btn.clone_id]) {
434
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
435
- distance = Math.min(distance, distance * discount);
375
+ let sEffort = (0, effort_1.scanningEffort)(steps, selections, currentStepCost, currentSelectionCost);
376
+ // Factor in error correction if enabled
377
+ if (scanningConfig?.errorCorrectionEnabled) {
378
+ const errorRate = scanningConfig.errorRate ?? effort_1.EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE;
379
+ // A "miss" results in needing to wait for a loop (or part of one)
380
+ // We model this as errorRate * (loopSteps * stepCost)
381
+ const retryPenalty = loopSteps * currentStepCost;
382
+ sEffort += errorRate * retryPenalty;
436
383
  }
437
- else if (boardPcts[`upstream-${btn.clone_id}`]) {
438
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
439
- boardPcts[`upstream-${btn.clone_id}`];
440
- distance = Math.min(distance, distance * discount);
384
+ // Apply discounts to scanning effort (similar to touch)
385
+ if (btn.semantic_id && boardPcts[btn.semantic_id]) {
386
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
387
+ sEffort = Math.min(sEffort, sEffort * discount);
441
388
  }
442
- else if (level > 0 && setPcts[btn.clone_id]) {
443
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
444
- distance = Math.min(distance, distance * discount);
389
+ else if (btn.clone_id && boardPcts[btn.clone_id]) {
390
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
391
+ sEffort = Math.min(sEffort, sEffort * discount);
445
392
  }
446
- }
447
- buttonEffort += distance;
448
- // Add visual scan or local scan effort
449
- if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
450
- (entryX === 1.0 && entryY === 1.0)) {
451
- buttonEffort += (0, effort_1.visualScanEffort)(priorItems);
393
+ buttonEffort += sEffort;
452
394
  }
453
395
  else {
454
- buttonEffort += (0, effort_1.localScanEffort)(distance);
455
- }
456
- }
457
- // Add cumulative prior effort
458
- buttonEffort += priorEffort;
459
- // Track scan blocks for block scanning, otherwise track individual buttons
460
- if (blockScanEnabled) {
461
- const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
462
- if (scanBlockId !== undefined && scanBlockId !== null) {
463
- priorScanBlocks.add(scanBlockId);
464
- }
465
- }
466
- // Handle navigation buttons
467
- if (btn.targetPageId) {
468
- const nextBoard = tree.getPage(btn.targetPageId);
469
- if (nextBoard) {
470
- // Only add to toVisit if this board hasn't been visited yet at any level
471
- // The visitedBoardIds map stores the *lowest* level a board was visited.
472
- // If it's already in the map, it means we've processed it or scheduled it at a lower level.
473
- if (visitedBoardIds.get(nextBoard.id) === undefined) {
474
- const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
475
- const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
476
- ? board.id
477
- : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
478
- ? btn.targetPageId
479
- : temporaryHomeId;
480
- toVisit.push({
481
- board: nextBoard,
482
- level: level + 1,
483
- priorEffort: buttonEffort + changeEffort,
484
- temporaryHomeId: tempHomeId,
485
- entryX: x,
486
- entryY: y,
487
- entryCloneId: btn.clone_id,
488
- entrySemanticId: btn.semantic_id,
489
- });
396
+ // Add distance effort (Touch only)
397
+ let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
398
+ // Apply distance discounts
399
+ if (btn.semantic_id) {
400
+ if (boardPcts[btn.semantic_id]) {
401
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
402
+ distance = Math.min(distance, distance * discount);
403
+ }
404
+ else if (boardPcts[`upstream-${btn.semantic_id}`]) {
405
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
406
+ boardPcts[`upstream-${btn.semantic_id}`];
407
+ distance = Math.min(distance, distance * discount);
408
+ }
409
+ else if (level > 0 && setPcts[btn.semantic_id]) {
410
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
411
+ setPcts[btn.semantic_id];
412
+ distance = Math.min(distance, distance * discount);
413
+ }
414
+ }
415
+ if (btn.clone_id) {
416
+ if (boardPcts[btn.clone_id]) {
417
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
418
+ distance = Math.min(distance, distance * discount);
419
+ }
420
+ else if (boardPcts[`upstream-${btn.clone_id}`]) {
421
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
422
+ boardPcts[`upstream-${btn.clone_id}`];
423
+ distance = Math.min(distance, distance * discount);
424
+ }
425
+ else if (level > 0 && setPcts[btn.clone_id]) {
426
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
427
+ distance = Math.min(distance, distance * discount);
428
+ }
429
+ }
430
+ buttonEffort += distance;
431
+ // Add visual scan or local scan effort
432
+ if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
433
+ (entryX === 1.0 && entryY === 1.0)) {
434
+ buttonEffort += (0, effort_1.visualScanEffort)(priorItems);
435
+ }
436
+ else {
437
+ buttonEffort += (0, effort_1.localScanEffort)(distance);
490
438
  }
491
439
  }
492
- }
493
- // Track word if it speaks or adds to sentence
494
- const intent = String(btn.semanticAction?.intent || '');
495
- const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION ||
496
- intent === 'SPEAK_TEXT' ||
497
- intent === 'SPEAK_IMMEDIATE' ||
498
- intent === 'INSERT_TEXT' ||
499
- btn.semanticAction?.fallback?.type === 'SPEAK';
500
- const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
501
- btn.semanticAction?.fallback?.add_to_sentence;
502
- if (isSpeak || addToSentence) {
503
- let finalEffort = buttonEffort;
504
- // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
505
- const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
506
- if (btn.clone_id && boardPcts[btn.clone_id]) {
507
- const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
508
- finalEffort -= discount;
440
+ // Add cumulative prior effort
441
+ buttonEffort += priorEffort;
442
+ // Track scan blocks for block scanning, otherwise track individual buttons
443
+ if (blockScanEnabled) {
444
+ const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
445
+ if (scanBlockId !== undefined && scanBlockId !== null) {
446
+ priorScanBlocks.add(scanBlockId);
447
+ }
509
448
  }
510
- else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
511
- const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
512
- finalEffort -= discount;
449
+ // Handle navigation buttons
450
+ if (btn.targetPageId) {
451
+ const nextBoard = tree.getPage(btn.targetPageId);
452
+ if (nextBoard) {
453
+ // Only add to toVisit if this board hasn't been visited yet at any level
454
+ // The visitedBoardIds map stores the *lowest* level a board was visited.
455
+ // If it's already in the map, it means we've processed it or scheduled it at a lower level.
456
+ if (visitedBoardIds.get(nextBoard.id) === undefined) {
457
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
458
+ const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
459
+ ? board.id
460
+ : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
461
+ ? btn.targetPageId
462
+ : temporaryHomeId;
463
+ toVisit.push({
464
+ board: nextBoard,
465
+ level: level + 1,
466
+ priorEffort: buttonEffort + changeEffort,
467
+ temporaryHomeId: tempHomeId,
468
+ entryX: x,
469
+ entryY: y,
470
+ entryCloneId: btn.clone_id,
471
+ entrySemanticId: btn.semantic_id,
472
+ });
473
+ }
474
+ }
513
475
  }
514
- const existing = knownButtons.get(btn.label);
515
- const knownBtn = {
516
- id: btn.id,
517
- label: btn.label,
518
- level,
519
- effort: finalEffort,
520
- count: (existing?.count || 0) + 1,
521
- semantic_id: btn.semantic_id,
522
- clone_id: btn.clone_id,
523
- temporary_home_id: temporaryHomeId || undefined,
524
- };
525
- if (!existing || finalEffort < existing.effort) {
526
- knownButtons.set(btn.label, knownBtn);
476
+ // Track word if it speaks or adds to sentence
477
+ const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION && !btn.targetPageId; // Must not be a navigation button
478
+ const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
479
+ btn.semanticAction?.fallback?.add_to_sentence;
480
+ if (isSpeak || addToSentence) {
481
+ let finalEffort = buttonEffort;
482
+ // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
483
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
484
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
485
+ const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
486
+ finalEffort -= discount;
487
+ }
488
+ else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
489
+ const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
490
+ finalEffort -= discount;
491
+ }
492
+ const existing = knownButtons.get(btn.label);
493
+ const knownBtn = {
494
+ id: btn.id,
495
+ label: btn.label,
496
+ level,
497
+ effort: finalEffort,
498
+ count: (existing?.count || 0) + 1,
499
+ semantic_id: btn.semantic_id,
500
+ clone_id: btn.clone_id,
501
+ temporary_home_id: temporaryHomeId || undefined,
502
+ };
503
+ if (!existing || finalEffort < existing.effort) {
504
+ knownButtons.set(btn.label, knownBtn);
505
+ }
527
506
  }
528
507
  }
529
- });
508
+ }
530
509
  }
531
510
  // Convert to array and group by level
532
511
  const buttons = Array.from(knownButtons.values());
@@ -0,0 +1,10 @@
1
+ import { BaseValidator } from './baseValidator';
2
+ import { ValidationResult } from './validationTypes';
3
+ /**
4
+ * Validator for Apple Panels (.plist or .ascconfig directory)
5
+ */
6
+ export declare class ApplePanelsValidator extends BaseValidator {
7
+ static validateFile(filePath: string): Promise<ValidationResult>;
8
+ static identifyFormat(content: any, filename: string): Promise<boolean>;
9
+ validate(content: Buffer | Uint8Array, filename: string, filesize: number): Promise<ValidationResult>;
10
+ }
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.ApplePanelsValidator = void 0;
30
+ /* eslint-disable @typescript-eslint/require-await */
31
+ const fs = __importStar(require("fs"));
32
+ const path = __importStar(require("path"));
33
+ const plist_1 = __importDefault(require("plist"));
34
+ const baseValidator_1 = require("./baseValidator");
35
+ /**
36
+ * Validator for Apple Panels (.plist or .ascconfig directory)
37
+ */
38
+ class ApplePanelsValidator extends baseValidator_1.BaseValidator {
39
+ static async validateFile(filePath) {
40
+ const validator = new ApplePanelsValidator();
41
+ let content;
42
+ const filename = path.basename(filePath);
43
+ let size = 0;
44
+ const stats = fs.existsSync(filePath) ? fs.statSync(filePath) : null;
45
+ if (stats?.isDirectory() && filename.toLowerCase().endsWith('.ascconfig')) {
46
+ const panelPath = path.join(filePath, 'Contents', 'Resources', 'PanelDefinitions.plist');
47
+ if (!fs.existsSync(panelPath)) {
48
+ return validator.validate(Buffer.alloc(0), filename, 0);
49
+ }
50
+ content = fs.readFileSync(panelPath);
51
+ size = fs.statSync(panelPath).size;
52
+ }
53
+ else {
54
+ content = fs.readFileSync(filePath);
55
+ size = stats?.size || content.byteLength;
56
+ }
57
+ return validator.validate(content, filename, size);
58
+ }
59
+ static async identifyFormat(content, filename) {
60
+ const name = filename.toLowerCase();
61
+ if (name.endsWith('.plist') || name.endsWith('.ascconfig')) {
62
+ return true;
63
+ }
64
+ try {
65
+ const str = Buffer.isBuffer(content) ? content.toString('utf-8') : String(content);
66
+ const parsed = plist_1.default.parse(str);
67
+ return Boolean(parsed.panels || parsed.Panels);
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ async validate(content, filename, filesize) {
74
+ this.reset();
75
+ await this.add_check('filename', 'file extension', async () => {
76
+ if (!filename.toLowerCase().match(/\.(plist|ascconfig)$/)) {
77
+ this.warn('filename should end with .plist or .ascconfig');
78
+ }
79
+ });
80
+ let parsed = null;
81
+ await this.add_check('plist_parse', 'valid plist/XML', async () => {
82
+ try {
83
+ const str = Buffer.isBuffer(content) ? content.toString('utf-8') : String(content);
84
+ parsed = plist_1.default.parse(str);
85
+ }
86
+ catch (e) {
87
+ this.err(`Failed to parse plist: ${e.message}`, true);
88
+ }
89
+ });
90
+ if (!parsed) {
91
+ return this.buildResult(filename, filesize, 'applepanels');
92
+ }
93
+ let panels = [];
94
+ await this.add_check('panels', 'panels present', async () => {
95
+ if (Array.isArray(parsed?.panels)) {
96
+ panels = parsed?.panels;
97
+ }
98
+ else if (parsed?.Panels && typeof parsed.Panels === 'object') {
99
+ panels = Object.values(parsed.Panels);
100
+ }
101
+ else {
102
+ this.err('missing panels/PanelDefinitions content', true);
103
+ }
104
+ });
105
+ panels.slice(0, 5).forEach((panel, idx) => {
106
+ const prefix = `panel[${idx}]`;
107
+ this.add_check_sync(`${prefix}_id`, `${prefix} id`, () => {
108
+ if (!panel?.ID && !panel?.id) {
109
+ this.err('panel missing ID');
110
+ }
111
+ });
112
+ this.add_check_sync(`${prefix}_buttons`, `${prefix} buttons`, () => {
113
+ const buttons = Array.isArray(panel?.PanelObjects)
114
+ ? panel.PanelObjects.filter((obj) => obj?.PanelObjectType === 'Button')
115
+ : [];
116
+ if (buttons.length === 0) {
117
+ this.warn('panel has no buttons');
118
+ }
119
+ });
120
+ });
121
+ return this.buildResult(filename, filesize, 'applepanels');
122
+ }
123
+ }
124
+ exports.ApplePanelsValidator = ApplePanelsValidator;
@@ -0,0 +1,16 @@
1
+ import { BaseValidator } from './baseValidator';
2
+ import { ValidationResult } from './validationTypes';
3
+ /**
4
+ * Validator for Asterics Grid (.grd) JSON files
5
+ */
6
+ export declare class AstericsGridValidator extends BaseValidator {
7
+ /**
8
+ * Validate from disk
9
+ */
10
+ static validateFile(filePath: string): Promise<ValidationResult>;
11
+ /**
12
+ * Identify whether the content appears to be an Asterics .grd file
13
+ */
14
+ static identifyFormat(content: any, filename: string): Promise<boolean>;
15
+ validate(content: Buffer | Uint8Array, filename: string, filesize: number): Promise<ValidationResult>;
16
+ }