@willwade/aac-processors 0.1.5 → 0.1.7

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 (55) hide show
  1. package/README.md +14 -0
  2. package/dist/browser/index.browser.js +15 -1
  3. package/dist/browser/processors/gridset/password.js +11 -0
  4. package/dist/browser/processors/gridsetProcessor.js +42 -46
  5. package/dist/browser/processors/obfProcessor.js +47 -63
  6. package/dist/browser/processors/snapProcessor.js +1031 -0
  7. package/dist/browser/processors/touchchatProcessor.js +1004 -0
  8. package/dist/browser/utils/io.js +36 -2
  9. package/dist/browser/utils/sqlite.js +109 -0
  10. package/dist/browser/utils/zip.js +54 -0
  11. package/dist/browser/validation/gridsetValidator.js +7 -27
  12. package/dist/browser/validation/obfValidator.js +9 -4
  13. package/dist/browser/validation/snapValidator.js +197 -0
  14. package/dist/browser/validation/touchChatValidator.js +201 -0
  15. package/dist/index.browser.d.ts +7 -0
  16. package/dist/index.browser.js +19 -2
  17. package/dist/processors/gridset/helpers.js +3 -4
  18. package/dist/processors/gridset/index.d.ts +1 -1
  19. package/dist/processors/gridset/index.js +3 -2
  20. package/dist/processors/gridset/password.d.ts +3 -2
  21. package/dist/processors/gridset/password.js +12 -0
  22. package/dist/processors/gridset/wordlistHelpers.js +107 -51
  23. package/dist/processors/gridsetProcessor.js +40 -44
  24. package/dist/processors/obfProcessor.js +46 -62
  25. package/dist/processors/snapProcessor.js +60 -54
  26. package/dist/processors/touchchatProcessor.js +38 -36
  27. package/dist/utils/io.d.ts +4 -0
  28. package/dist/utils/io.js +40 -2
  29. package/dist/utils/sqlite.d.ts +21 -0
  30. package/dist/utils/sqlite.js +137 -0
  31. package/dist/utils/zip.d.ts +7 -0
  32. package/dist/utils/zip.js +80 -0
  33. package/dist/validation/applePanelsValidator.js +11 -28
  34. package/dist/validation/astericsValidator.js +11 -30
  35. package/dist/validation/dotValidator.js +11 -30
  36. package/dist/validation/excelValidator.js +5 -6
  37. package/dist/validation/gridsetValidator.js +29 -26
  38. package/dist/validation/index.d.ts +2 -1
  39. package/dist/validation/index.js +9 -32
  40. package/dist/validation/obfValidator.js +8 -3
  41. package/dist/validation/obfsetValidator.js +11 -30
  42. package/dist/validation/opmlValidator.js +11 -30
  43. package/dist/validation/snapValidator.js +6 -9
  44. package/dist/validation/touchChatValidator.js +6 -7
  45. package/docs/BROWSER_USAGE.md +2 -10
  46. package/examples/README.md +3 -75
  47. package/examples/vitedemo/README.md +13 -7
  48. package/examples/vitedemo/index.html +51 -2
  49. package/examples/vitedemo/package-lock.json +9 -0
  50. package/examples/vitedemo/package.json +1 -0
  51. package/examples/vitedemo/src/main.ts +132 -2
  52. package/examples/vitedemo/src/vite-env.d.ts +1 -0
  53. package/examples/vitedemo/vite.config.ts +26 -7
  54. package/package.json +3 -1
  55. package/examples/browser-test-server.js +0 -81
@@ -1,5 +1,6 @@
1
1
  let cachedFs = null;
2
2
  let cachedPath = null;
3
+ let cachedOs = null;
3
4
  let cachedRequire = undefined;
4
5
  export function getNodeRequire() {
5
6
  if (cachedRequire === undefined) {
@@ -51,9 +52,42 @@ export function getPath() {
51
52
  }
52
53
  return cachedPath;
53
54
  }
55
+ export function getOs() {
56
+ if (!cachedOs) {
57
+ try {
58
+ const nodeRequire = getNodeRequire();
59
+ const osModule = 'os';
60
+ cachedOs = nodeRequire(osModule);
61
+ }
62
+ catch {
63
+ throw new Error('OS utilities are not available in this environment.');
64
+ }
65
+ }
66
+ if (!cachedOs) {
67
+ throw new Error('OS utilities are not available in this environment.');
68
+ }
69
+ return cachedOs;
70
+ }
71
+ export function isNodeRuntime() {
72
+ return typeof process !== 'undefined' && !!process.versions?.node;
73
+ }
54
74
  export function getBasename(filePath) {
55
- const parts = filePath.split(/[/\\]/);
56
- return parts[parts.length - 1] || filePath;
75
+ const trimmed = filePath.replace(/[/\\]+$/, '') || filePath;
76
+ const parts = trimmed.split(/[/\\]/);
77
+ return parts[parts.length - 1] || trimmed;
78
+ }
79
+ export function toUint8Array(input) {
80
+ if (input instanceof Uint8Array) {
81
+ return input;
82
+ }
83
+ return new Uint8Array(input);
84
+ }
85
+ export function toArrayBuffer(input) {
86
+ if (input instanceof ArrayBuffer) {
87
+ return input;
88
+ }
89
+ const view = input instanceof Uint8Array ? input : new Uint8Array(input);
90
+ return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
57
91
  }
58
92
  export function decodeText(input) {
59
93
  if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) {
@@ -0,0 +1,109 @@
1
+ import { getFs, getNodeRequire, getOs, getPath, isNodeRuntime, readBinaryFromInput } from './io';
2
+ let sqlJsConfig = null;
3
+ let sqlJsPromise = null;
4
+ export function configureSqlJs(config) {
5
+ sqlJsConfig = { ...(sqlJsConfig ?? {}), ...config };
6
+ }
7
+ async function getSqlJs() {
8
+ if (!sqlJsPromise) {
9
+ sqlJsPromise = import('sql.js').then((module) => {
10
+ const initSqlJs = module.default || module;
11
+ return initSqlJs(sqlJsConfig ?? {});
12
+ });
13
+ }
14
+ return sqlJsPromise;
15
+ }
16
+ function createSqlJsAdapter(db) {
17
+ return {
18
+ prepare(sql) {
19
+ return {
20
+ all(...params) {
21
+ const stmt = db.prepare(sql);
22
+ if (params.length > 0) {
23
+ stmt.bind(params);
24
+ }
25
+ const rows = [];
26
+ while (stmt.step()) {
27
+ rows.push(stmt.getAsObject());
28
+ }
29
+ stmt.free();
30
+ return rows;
31
+ },
32
+ get(...params) {
33
+ const stmt = db.prepare(sql);
34
+ if (params.length > 0) {
35
+ stmt.bind(params);
36
+ }
37
+ const row = stmt.step() ? stmt.getAsObject() : undefined;
38
+ stmt.free();
39
+ return row;
40
+ },
41
+ run(...params) {
42
+ const stmt = db.prepare(sql);
43
+ if (params.length > 0) {
44
+ stmt.bind(params);
45
+ }
46
+ stmt.step();
47
+ stmt.free();
48
+ return undefined;
49
+ },
50
+ };
51
+ },
52
+ exec(sql) {
53
+ db.exec(sql);
54
+ },
55
+ close() {
56
+ db.close();
57
+ },
58
+ };
59
+ }
60
+ function getBetterSqlite3() {
61
+ try {
62
+ const nodeRequire = getNodeRequire();
63
+ return nodeRequire('better-sqlite3');
64
+ }
65
+ catch {
66
+ throw new Error('better-sqlite3 is not available in this environment.');
67
+ }
68
+ }
69
+ export function requireBetterSqlite3() {
70
+ return getBetterSqlite3();
71
+ }
72
+ export async function openSqliteDatabase(input, options = {}) {
73
+ if (typeof input === 'string') {
74
+ if (!isNodeRuntime()) {
75
+ throw new Error('SQLite file paths are not supported in browser environments.');
76
+ }
77
+ const Database = getBetterSqlite3();
78
+ const db = new Database(input, { readonly: options.readonly ?? true });
79
+ return { db };
80
+ }
81
+ const data = readBinaryFromInput(input);
82
+ if (!isNodeRuntime()) {
83
+ const SQL = await getSqlJs();
84
+ const db = new SQL.Database(data);
85
+ return { db: createSqlJsAdapter(db) };
86
+ }
87
+ const fs = getFs();
88
+ const path = getPath();
89
+ const os = getOs();
90
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aac-sqlite-'));
91
+ const dbPath = path.join(tempDir, 'input.sqlite');
92
+ fs.writeFileSync(dbPath, data);
93
+ const Database = getBetterSqlite3();
94
+ const db = new Database(dbPath, { readonly: options.readonly ?? true });
95
+ const cleanup = () => {
96
+ try {
97
+ db.close();
98
+ }
99
+ finally {
100
+ try {
101
+ fs.rmSync(tempDir, { recursive: true, force: true });
102
+ }
103
+ catch (error) {
104
+ console.warn('Failed to clean up temporary SQLite files:', error);
105
+ }
106
+ }
107
+ };
108
+ return { db, cleanup };
109
+ }
@@ -0,0 +1,54 @@
1
+ import { isNodeRuntime, readBinaryFromInput, getNodeRequire } from './io';
2
+ export async function openZipFromInput(input) {
3
+ if (typeof input === 'string') {
4
+ if (!isNodeRuntime()) {
5
+ throw new Error('Zip file paths are not supported in browser environments.');
6
+ }
7
+ const AdmZip = getNodeRequire()('adm-zip');
8
+ const admZip = new AdmZip(input);
9
+ return {
10
+ zip: {
11
+ listFiles: () => admZip.getEntries().map((entry) => entry.entryName),
12
+ readFile: (name) => {
13
+ const entry = admZip.getEntry(name);
14
+ if (!entry) {
15
+ throw new Error(`Zip entry not found: ${name}`);
16
+ }
17
+ return Promise.resolve(entry.getData());
18
+ },
19
+ },
20
+ };
21
+ }
22
+ const data = readBinaryFromInput(input);
23
+ if (isNodeRuntime()) {
24
+ const AdmZip = getNodeRequire()('adm-zip');
25
+ const admZip = new AdmZip(Buffer.from(data));
26
+ return {
27
+ zip: {
28
+ listFiles: () => admZip.getEntries().map((entry) => entry.entryName),
29
+ readFile: (name) => {
30
+ const entry = admZip.getEntry(name);
31
+ if (!entry) {
32
+ throw new Error(`Zip entry not found: ${name}`);
33
+ }
34
+ return Promise.resolve(entry.getData());
35
+ },
36
+ },
37
+ };
38
+ }
39
+ const module = await import('jszip');
40
+ const init = module.default || module;
41
+ const zip = await init.loadAsync(data);
42
+ return {
43
+ zip: {
44
+ listFiles: () => Object.keys(zip.files),
45
+ readFile: async (name) => {
46
+ const file = zip.file(name);
47
+ if (!file) {
48
+ throw new Error(`Zip entry not found: ${name}`);
49
+ }
50
+ return file.async('uint8array');
51
+ },
52
+ },
53
+ };
54
+ }
@@ -2,24 +2,9 @@
2
2
  /* eslint-disable @typescript-eslint/no-unsafe-argument */
3
3
  /* eslint-disable @typescript-eslint/no-unsafe-return */
4
4
  import JSZip from 'jszip';
5
+ import * as xml2js from 'xml2js';
5
6
  import { BaseValidator } from './baseValidator';
6
- import { getFs, getNodeRequire, getPath } from '../utils/io';
7
- let cachedXml2js = null;
8
- function getXml2js() {
9
- if (cachedXml2js)
10
- return cachedXml2js;
11
- try {
12
- const nodeRequire = getNodeRequire();
13
- // eslint-disable-next-line @typescript-eslint/no-var-requires
14
- const module = nodeRequire('xml2js');
15
- const resolved = module.default || module;
16
- cachedXml2js = resolved;
17
- return resolved;
18
- }
19
- catch {
20
- throw new Error('Validator requires Xml2js in this environment.');
21
- }
22
- }
7
+ import { decodeText, getBasename, getFs, toUint8Array } from '../utils/io';
23
8
  /**
24
9
  * Validator for Grid3/Smartbox Gridset files (.gridset, .gridsetx)
25
10
  */
@@ -33,10 +18,9 @@ export class GridsetValidator extends BaseValidator {
33
18
  static async validateFile(filePath) {
34
19
  const validator = new GridsetValidator();
35
20
  const fs = getFs();
36
- const path = getPath();
37
21
  const content = fs.readFileSync(filePath);
38
22
  const stats = fs.statSync(filePath);
39
- return validator.validate(content, path.basename(filePath), stats.size);
23
+ return validator.validate(content, getBasename(filePath), stats.size);
40
24
  }
41
25
  /**
42
26
  * Check if content is Gridset format
@@ -48,8 +32,7 @@ export class GridsetValidator extends BaseValidator {
48
32
  }
49
33
  // Try to parse as XML and check for gridset structure
50
34
  try {
51
- const contentStr = Buffer.isBuffer(content) ? content.toString('utf-8') : content;
52
- const xml2js = getXml2js();
35
+ const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content));
53
36
  const parser = new xml2js.Parser();
54
37
  const result = await parser.parseStringPromise(contentStr);
55
38
  return result && (result.gridset || result.Gridset);
@@ -102,9 +85,8 @@ export class GridsetValidator extends BaseValidator {
102
85
  let xmlObj = null;
103
86
  await this.add_check('xml_parse', 'valid XML', async () => {
104
87
  try {
105
- const xml2js = getXml2js();
106
88
  const parser = new xml2js.Parser();
107
- const contentStr = content.toString('utf-8');
89
+ const contentStr = decodeText(content);
108
90
  xmlObj = await parser.parseStringPromise(contentStr);
109
91
  }
110
92
  catch (e) {
@@ -129,7 +111,7 @@ export class GridsetValidator extends BaseValidator {
129
111
  async validateZipArchive(content, filename, _filesize) {
130
112
  let zip;
131
113
  try {
132
- zip = await JSZip.loadAsync(Buffer.from(content));
114
+ zip = await JSZip.loadAsync(toUint8Array(content));
133
115
  }
134
116
  catch (e) {
135
117
  this.err(`Failed to open ZIP archive: ${e.message}`, true);
@@ -145,7 +127,6 @@ export class GridsetValidator extends BaseValidator {
145
127
  else {
146
128
  try {
147
129
  const gridsetXml = await gridsetEntry.async('string');
148
- const xml2js = getXml2js();
149
130
  const parser = new xml2js.Parser();
150
131
  const xmlObj = await parser.parseStringPromise(gridsetXml);
151
132
  const gridset = xmlObj.gridset || xmlObj.Gridset;
@@ -153,7 +134,7 @@ export class GridsetValidator extends BaseValidator {
153
134
  this.err('Invalid gridset.xml structure', true);
154
135
  }
155
136
  else {
156
- await this.validateGridsetStructure(gridset, filename, Buffer.from(gridsetXml));
137
+ await this.validateGridsetStructure(gridset, filename, new Uint8Array());
157
138
  }
158
139
  }
159
140
  catch (e) {
@@ -170,7 +151,6 @@ export class GridsetValidator extends BaseValidator {
170
151
  else {
171
152
  try {
172
153
  const settingsXml = await settingsEntry.async('string');
173
- const xml2js = getXml2js();
174
154
  const parser = new xml2js.Parser();
175
155
  const xmlObj = await parser.parseStringPromise(settingsXml);
176
156
  const settings = xmlObj.GridSetSettings || xmlObj.gridSetSettings || xmlObj.GridsetSettings;
@@ -5,7 +5,7 @@
5
5
  /* eslint-disable @typescript-eslint/restrict-template-expressions */
6
6
  import JSZip from 'jszip';
7
7
  import { BaseValidator } from './baseValidator';
8
- import { getFs, getPath, readBinaryFromInput } from '../utils/io';
8
+ import { decodeText, getBasename, getFs, readBinaryFromInput, toUint8Array } from '../utils/io';
9
9
  const OBF_FORMAT = 'open-board-0.1';
10
10
  const OBF_FORMAT_CURRENT_VERSION = 0.1;
11
11
  /**
@@ -22,7 +22,7 @@ export class ObfValidator extends BaseValidator {
22
22
  const validator = new ObfValidator();
23
23
  const content = readBinaryFromInput(filePath);
24
24
  const stats = getFs().statSync(filePath);
25
- return validator.validate(content, getPath().basename(filePath), stats.size);
25
+ return validator.validate(content, getBasename(filePath), stats.size);
26
26
  }
27
27
  /**
28
28
  * Check if content is OBF format
@@ -34,7 +34,12 @@ export class ObfValidator extends BaseValidator {
34
34
  }
35
35
  // Try to parse as JSON and check format
36
36
  try {
37
- const contentStr = Buffer.isBuffer(content) ? content.toString() : content;
37
+ if (typeof content !== 'string' &&
38
+ !(content instanceof ArrayBuffer) &&
39
+ !(content instanceof Uint8Array)) {
40
+ return false;
41
+ }
42
+ const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content));
38
43
  const json = JSON.parse(contentStr);
39
44
  return json && json.format && json.format.startsWith('open-board-');
40
45
  }
@@ -68,7 +73,7 @@ export class ObfValidator extends BaseValidator {
68
73
  let json = null;
69
74
  await this.add_check('valid_json', 'JSON file', async () => {
70
75
  try {
71
- json = JSON.parse(content.toString());
76
+ json = JSON.parse(decodeText(content));
72
77
  }
73
78
  catch {
74
79
  this.err("Couldn't parse as JSON", true);
@@ -0,0 +1,197 @@
1
+ /* eslint-disable @typescript-eslint/require-await */
2
+ /* eslint-disable @typescript-eslint/no-unsafe-argument */
3
+ import * as xml2js from 'xml2js';
4
+ import JSZip from 'jszip';
5
+ import { BaseValidator } from './baseValidator';
6
+ import { getBasename, getFs, readBinaryFromInput, toUint8Array } from '../utils/io';
7
+ /**
8
+ * Validator for Snap files (.spb, .sps)
9
+ * Snap files are zipped packages containing XML configuration
10
+ */
11
+ export class SnapValidator extends BaseValidator {
12
+ constructor() {
13
+ super();
14
+ }
15
+ /**
16
+ * Validate a Snap file from disk
17
+ */
18
+ static async validateFile(filePath) {
19
+ const validator = new SnapValidator();
20
+ const content = readBinaryFromInput(filePath);
21
+ const stats = getFs().statSync(filePath);
22
+ return validator.validate(content, getBasename(filePath), stats.size);
23
+ }
24
+ /**
25
+ * Check if content is Snap format
26
+ */
27
+ // eslint-disable-next-line @typescript-eslint/require-await
28
+ static async identifyFormat(content, filename) {
29
+ const name = filename.toLowerCase();
30
+ if (name.endsWith('.spb') || name.endsWith('.sps')) {
31
+ return true;
32
+ }
33
+ // Try to parse as ZIP and check for Snap structure
34
+ try {
35
+ const zip = await JSZip.loadAsync(toUint8Array(content));
36
+ const entries = Object.values(zip.files).filter((entry) => !entry.dir);
37
+ return entries.some((entry) => entry.name.includes('settings') || entry.name.includes('.xml'));
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /**
44
+ * Main validation method
45
+ */
46
+ async validate(content, filename, filesize) {
47
+ this.reset();
48
+ await this.add_check('filename', 'file extension', async () => {
49
+ if (!filename.match(/\.(spb|sps)$/)) {
50
+ this.warn('filename should end with .spb or .sps');
51
+ }
52
+ });
53
+ let zip = null;
54
+ let validZip = false;
55
+ await this.add_check('zip', 'valid zip package', async () => {
56
+ try {
57
+ zip = await JSZip.loadAsync(toUint8Array(content));
58
+ const entries = Object.values(zip.files);
59
+ validZip = entries.length > 0;
60
+ }
61
+ catch (e) {
62
+ this.err(`file is not a valid zip package: ${e.message}`, true);
63
+ }
64
+ });
65
+ if (!validZip || !zip) {
66
+ return this.buildResult(filename, filesize, 'snap');
67
+ }
68
+ await this.validateSnapStructure(zip, filename);
69
+ return this.buildResult(filename, filesize, 'snap');
70
+ }
71
+ /**
72
+ * Validate Snap package structure
73
+ */
74
+ async validateSnapStructure(zip, _filename) {
75
+ // Check for required files
76
+ await this.add_check('required_files', 'required package files', async () => {
77
+ const entries = Object.values(zip.files);
78
+ const entryNames = entries.map((e) => e.name);
79
+ // Look for common Snap files
80
+ const hasSettings = entryNames.some((n) => n.toLowerCase().includes('settings'));
81
+ const hasXml = entryNames.some((n) => n.toLowerCase().endsWith('.xml'));
82
+ if (!hasSettings && !hasXml) {
83
+ this.err('Snap package must contain settings.xml or similar configuration file');
84
+ }
85
+ if (entries.length === 0) {
86
+ this.err('Snap package is empty');
87
+ }
88
+ });
89
+ // Try to parse and validate the main settings file
90
+ const settingsEntry = Object.values(zip.files).find((entry) => !entry.dir && entry.name.toLowerCase().includes('settings'));
91
+ if (settingsEntry) {
92
+ await this.validateSettingsFile(settingsEntry);
93
+ }
94
+ // Check for pages
95
+ const pageEntries = Object.values(zip.files).filter((entry) => !entry.dir && entry.name.toLowerCase().includes('page'));
96
+ await this.add_check('pages', 'pages in package', async () => {
97
+ if (pageEntries.length === 0) {
98
+ this.warn('Snap package should contain at least one page file');
99
+ }
100
+ });
101
+ // Validate a sample of pages
102
+ const samplePages = pageEntries.slice(0, 5); // Limit to first 5 pages
103
+ for (let i = 0; i < samplePages.length; i++) {
104
+ await this.validatePageFile(samplePages[i], i);
105
+ }
106
+ // Check for images
107
+ const imageEntries = Object.values(zip.files).filter((entry) => !entry.dir && entry.name.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp)$/i));
108
+ await this.add_check('images', 'image files', async () => {
109
+ if (imageEntries.length === 0) {
110
+ this.warn('Snap package should contain image files for buttons');
111
+ }
112
+ });
113
+ // Check for audio files
114
+ const audioEntries = Object.values(zip.files).filter((entry) => !entry.dir && entry.name.toLowerCase().match(/\.(wav|mp3|m4a|ogg)$/i));
115
+ await this.add_check('audio', 'audio files', async () => {
116
+ // Audio files are optional, so just warn if missing
117
+ if (audioEntries.length === 0) {
118
+ // This is informational, not a warning
119
+ }
120
+ });
121
+ // Check for unexpected files
122
+ await this.add_check('unexpected_files', 'unexpected file types', async () => {
123
+ const entries = Object.values(zip.files).filter((entry) => !entry.dir);
124
+ const unexpectedFiles = entries.filter((entry) => {
125
+ const name = entry.name.toLowerCase();
126
+ // Skip common system files and directories
127
+ if (name.startsWith('__macosx') || name.startsWith('.ds_store')) {
128
+ return false;
129
+ }
130
+ // Allowed file types
131
+ return !name.match(/\.(xml|png|jpg|jpeg|gif|bmp|wav|mp3|m4a|ogg|json)$/i);
132
+ });
133
+ if (unexpectedFiles.length > 0) {
134
+ const unexpectedNames = unexpectedFiles.map((f) => f.name).slice(0, 5);
135
+ this.warn(`Package contains unexpected file types: ${unexpectedNames.join(', ')}`);
136
+ }
137
+ });
138
+ }
139
+ /**
140
+ * Validate the main settings file
141
+ */
142
+ async validateSettingsFile(entry) {
143
+ await this.add_check('settings_format', 'settings file format', async () => {
144
+ try {
145
+ const content = await entry.async('string');
146
+ const parser = new xml2js.Parser();
147
+ const xml = await parser.parseStringPromise(content);
148
+ // Check for expected root element
149
+ if (!xml.settings && !xml.Settings && !xml.page && !xml.Page) {
150
+ this.warn('settings file does not contain expected root element');
151
+ }
152
+ // Check for required settings attributes if present
153
+ const settings = xml.settings || xml.Settings;
154
+ if (settings) {
155
+ const id = settings.$?.id || settings.$?.Id;
156
+ const name = settings.$?.name || settings.$?.Name;
157
+ if (!id && !name) {
158
+ this.warn('settings should have an id or name attribute');
159
+ }
160
+ }
161
+ }
162
+ catch (e) {
163
+ this.err(`Failed to parse settings file: ${e.message}`);
164
+ }
165
+ });
166
+ }
167
+ /**
168
+ * Validate a page file
169
+ */
170
+ async validatePageFile(entry, index) {
171
+ await this.add_check(`page[${index}]`, `page file ${index}: ${entry.name}`, async () => {
172
+ try {
173
+ const content = await entry.async('string');
174
+ const parser = new xml2js.Parser();
175
+ const xml = await parser.parseStringPromise(content);
176
+ const page = xml.page || xml.Page;
177
+ if (!page) {
178
+ this.err(`Page file ${entry.name} does not contain a page element`);
179
+ return;
180
+ }
181
+ // Check page attributes
182
+ const pageId = page.$?.id || page.$?.Id;
183
+ if (!pageId) {
184
+ this.warn(`Page ${entry.name} is missing an id attribute`);
185
+ }
186
+ // Check for cells/buttons
187
+ const cells = page.cells || page.Cells || page.button || page.Button;
188
+ if (!cells || (Array.isArray(cells) && cells.length === 0)) {
189
+ this.warn(`Page ${entry.name} has no cells or buttons`);
190
+ }
191
+ }
192
+ catch (e) {
193
+ this.err(`Failed to parse page file ${entry.name}: ${e.message}`);
194
+ }
195
+ });
196
+ }
197
+ }