@willwade/aac-processors 0.1.12 → 0.1.14

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.
@@ -325,8 +325,8 @@ class GridsetProcessor extends BaseProcessor {
325
325
  if (typeof val === 'number')
326
326
  return String(val);
327
327
  if (typeof val === 'object') {
328
- if ('#text' in val)
329
- return String(val['#text']);
328
+ // Don't immediately return #text - it might be whitespace alongside structured content
329
+ // Process structured format first: <p><s><r>text</r></s></p>
330
330
  // Handle Grid3 structured format <p><s><r>text</r></s></p>
331
331
  // Can start at p, s, or r level
332
332
  const parts = [];
@@ -342,8 +342,17 @@ class GridsetProcessor extends BaseProcessor {
342
342
  }
343
343
  continue;
344
344
  }
345
- if (typeof r === 'object' && r !== null && '#text' in r) {
346
- parts.push(String(r['#text']));
345
+ if (typeof r === 'object' && r !== null) {
346
+ // Check for #text (regular text) or #cdata (CDATA sections)
347
+ if ('#text' in r) {
348
+ parts.push(String(r['#text']));
349
+ }
350
+ else if ('#cdata' in r) {
351
+ parts.push(String(r['#cdata']));
352
+ }
353
+ else {
354
+ parts.push(String(r));
355
+ }
347
356
  }
348
357
  else {
349
358
  parts.push(String(r));
@@ -396,7 +405,15 @@ class GridsetProcessor extends BaseProcessor {
396
405
  }
397
406
  const password = this.getGridsetPassword(filePathOrBuffer);
398
407
  const entries = getZipEntriesFromAdapter(zipResult.zip, password);
399
- const parser = new XMLParser({ ignoreAttributes: false });
408
+ const options = {
409
+ ignoreAttributes: false,
410
+ ignoreDeclaration: true,
411
+ parseTagValue: false,
412
+ trimValues: false,
413
+ textNodeName: '#text',
414
+ cdataProp: '#cdata',
415
+ };
416
+ const parser = new XMLParser(options);
400
417
  const isEncryptedArchive = typeof filePathOrBuffer === 'string' && filePathOrBuffer.toLowerCase().endsWith('.gridsetx');
401
418
  const encryptedContentPassword = this.getGridsetPassword(filePathOrBuffer);
402
419
  // Initialize metadata
@@ -634,6 +651,13 @@ class GridsetProcessor extends BaseProcessor {
634
651
  if (text) {
635
652
  const val = this.textOf(text);
636
653
  if (val) {
654
+ // Debug: log WordList items with spaces to check extraction
655
+ if (pageWordListItems.length < 3) {
656
+ console.log(`[WordList] Extracted text: "${val}" (length: ${val.length}, has spaces: ${val.includes(' ')})`);
657
+ console.log(`[WordList] Chars:`, Array.from(val)
658
+ .map((c) => `"${c}" (${c.charCodeAt(0)})`)
659
+ .join(', '));
660
+ }
637
661
  pageWordListItems.push({
638
662
  text: val,
639
663
  image: item.Image || item.image || undefined,
@@ -599,18 +599,90 @@ class SnapProcessor extends BaseProcessor {
599
599
  if (!isNodeRuntime()) {
600
600
  throw new Error('processTexts is only supported in Node.js environments for Snap files.');
601
601
  }
602
- // Load the tree, apply translations, and save to new file
602
+ const fs = getFs();
603
+ const path = getPath();
604
+ if (typeof filePathOrBuffer === 'string') {
605
+ const inputPath = filePathOrBuffer;
606
+ const outputDir = path.dirname(outputPath);
607
+ if (!fs.existsSync(outputDir)) {
608
+ fs.mkdirSync(outputDir, { recursive: true });
609
+ }
610
+ if (fs.existsSync(outputPath)) {
611
+ fs.unlinkSync(outputPath);
612
+ }
613
+ fs.copyFileSync(inputPath, outputPath);
614
+ const Database = requireBetterSqlite3();
615
+ const db = new Database(outputPath, { readonly: false });
616
+ try {
617
+ const getColumns = (tableName) => {
618
+ try {
619
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
620
+ return new Set(rows.map((row) => row.name));
621
+ }
622
+ catch {
623
+ return new Set();
624
+ }
625
+ };
626
+ const pageColumns = getColumns('Page');
627
+ const buttonColumns = getColumns('Button');
628
+ const pageUpdates = [];
629
+ const pageWhere = [];
630
+ const pageColumnsToUse = [];
631
+ if (pageColumns.has('Name')) {
632
+ pageUpdates.push('Name = ?');
633
+ pageWhere.push('Name = ?');
634
+ pageColumnsToUse.push('Name');
635
+ }
636
+ if (pageColumns.has('Title')) {
637
+ pageUpdates.push('Title = ?');
638
+ pageWhere.push('Title = ?');
639
+ pageColumnsToUse.push('Title');
640
+ }
641
+ const updatePage = pageUpdates.length > 0
642
+ ? db.prepare(`UPDATE Page SET ${pageUpdates.join(', ')} WHERE ${pageWhere.join(' OR ')}`)
643
+ : null;
644
+ const updateLabel = buttonColumns.has('Label')
645
+ ? db.prepare('UPDATE Button SET Label = ? WHERE Label = ?')
646
+ : null;
647
+ const updateMessage = buttonColumns.has('Message')
648
+ ? db.prepare('UPDATE Button SET Message = ? WHERE Message = ?')
649
+ : null;
650
+ const entries = Array.from(translations.entries());
651
+ const applyUpdates = db.transaction(() => {
652
+ entries.forEach(([original, translated]) => {
653
+ if (!translated || translated === original) {
654
+ return;
655
+ }
656
+ if (updatePage) {
657
+ const updateValues = [];
658
+ pageColumnsToUse.forEach(() => updateValues.push(translated));
659
+ pageColumnsToUse.forEach(() => updateValues.push(original));
660
+ updatePage.run(...updateValues);
661
+ }
662
+ if (updateLabel) {
663
+ updateLabel.run(translated, original);
664
+ }
665
+ if (updateMessage) {
666
+ updateMessage.run(translated, original);
667
+ }
668
+ });
669
+ });
670
+ applyUpdates();
671
+ }
672
+ finally {
673
+ db.close();
674
+ }
675
+ return fs.readFileSync(outputPath);
676
+ }
677
+ // Fallback for buffer inputs: rebuild from tree (may drop Snap assets)
603
678
  const tree = await this.loadIntoTree(filePathOrBuffer);
604
- // Apply translations to all text content
605
679
  Object.values(tree.pages).forEach((page) => {
606
- // Translate page names
607
680
  if (page.name && translations.has(page.name)) {
608
681
  const translatedName = translations.get(page.name);
609
682
  if (translatedName !== undefined) {
610
683
  page.name = translatedName;
611
684
  }
612
685
  }
613
- // Translate button labels and messages
614
686
  page.buttons.forEach((button) => {
615
687
  if (button.label && translations.has(button.label)) {
616
688
  const translatedLabel = translations.get(button.label);
@@ -626,9 +698,7 @@ class SnapProcessor extends BaseProcessor {
626
698
  }
627
699
  });
628
700
  });
629
- // Save the translated tree and return its content
630
701
  await this.saveFromTree(tree, outputPath);
631
- const fs = getFs();
632
702
  return fs.readFileSync(outputPath);
633
703
  }
634
704
  async saveFromTree(tree, outputPath) {
@@ -476,18 +476,119 @@ class TouchChatProcessor extends BaseProcessor {
476
476
  if (!isNodeRuntime()) {
477
477
  throw new Error('processTexts is only supported in Node.js environments for TouchChat files.');
478
478
  }
479
- // Load the tree, apply translations, and save to new file
479
+ /**
480
+ * TouchChat .ce files are ZIP archives containing a SQLite .c4v database.
481
+ * Rebuilding the database can drop tables/metadata/resources that we don't
482
+ * currently model in the tree, which can corrupt the file.
483
+ *
484
+ * For file paths, we preserve the original archive and update text in-place
485
+ * within the embedded SQLite database, ensuring assets and metadata remain intact.
486
+ */
487
+ if (typeof filePathOrBuffer === 'string') {
488
+ const fs = getFs();
489
+ const path = getPath();
490
+ const os = getOs();
491
+ const AdmZip = getNodeRequire()('adm-zip');
492
+ const inputPath = filePathOrBuffer;
493
+ const outputDir = path.dirname(outputPath);
494
+ if (!fs.existsSync(outputDir)) {
495
+ fs.mkdirSync(outputDir, { recursive: true });
496
+ }
497
+ if (fs.existsSync(outputPath)) {
498
+ fs.unlinkSync(outputPath);
499
+ }
500
+ const zip = new AdmZip(inputPath);
501
+ const entries = zip.getEntries();
502
+ const vocabEntry = entries.find((entry) => entry.entryName.endsWith('.c4v'));
503
+ if (!vocabEntry) {
504
+ throw new Error('No .c4v vocab DB found in TouchChat export');
505
+ }
506
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchchat-translate-'));
507
+ const dbPath = path.join(tempDir, 'vocab.c4v');
508
+ try {
509
+ fs.writeFileSync(dbPath, vocabEntry.getData());
510
+ const Database = requireBetterSqlite3();
511
+ const db = new Database(dbPath, { readonly: false });
512
+ try {
513
+ const getColumns = (tableName) => {
514
+ try {
515
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
516
+ return new Set(rows.map((row) => row.name));
517
+ }
518
+ catch {
519
+ return new Set();
520
+ }
521
+ };
522
+ const resourceColumns = getColumns('resources');
523
+ const pageColumns = getColumns('pages');
524
+ const buttonColumns = getColumns('buttons');
525
+ const updatePageResourceName = resourceColumns.has('name')
526
+ ? db.prepare('UPDATE resources SET name = ? WHERE name = ? AND id IN (SELECT resource_id FROM pages)')
527
+ : null;
528
+ const updatePageName = pageColumns.has('name')
529
+ ? db.prepare('UPDATE pages SET name = ? WHERE name = ?')
530
+ : null;
531
+ const updateButtonLabel = buttonColumns.has('label')
532
+ ? db.prepare('UPDATE buttons SET label = ? WHERE label = ?')
533
+ : null;
534
+ const updateButtonMessage = buttonColumns.has('message')
535
+ ? db.prepare('UPDATE buttons SET message = ? WHERE message = ?')
536
+ : null;
537
+ const entriesToUpdate = Array.from(translations.entries());
538
+ const applyUpdates = db.transaction(() => {
539
+ entriesToUpdate.forEach(([original, translated]) => {
540
+ if (!translated || translated === original) {
541
+ return;
542
+ }
543
+ if (updatePageResourceName) {
544
+ updatePageResourceName.run(translated, original);
545
+ }
546
+ if (updatePageName) {
547
+ updatePageName.run(translated, original);
548
+ }
549
+ if (updateButtonLabel) {
550
+ updateButtonLabel.run(translated, original);
551
+ }
552
+ if (updateButtonMessage) {
553
+ updateButtonMessage.run(translated, original);
554
+ }
555
+ });
556
+ });
557
+ applyUpdates();
558
+ }
559
+ finally {
560
+ db.close();
561
+ }
562
+ const outputZip = new AdmZip();
563
+ entries.forEach((entry) => {
564
+ if (entry.entryName === vocabEntry.entryName) {
565
+ return;
566
+ }
567
+ const data = entry.isDirectory ? Buffer.alloc(0) : entry.getData();
568
+ outputZip.addFile(entry.entryName, data, entry.comment || '');
569
+ });
570
+ outputZip.addFile(vocabEntry.entryName, fs.readFileSync(dbPath));
571
+ outputZip.writeZip(outputPath);
572
+ }
573
+ finally {
574
+ try {
575
+ fs.rmSync(tempDir, { recursive: true, force: true });
576
+ }
577
+ catch {
578
+ // Best-effort cleanup
579
+ }
580
+ }
581
+ return fs.readFileSync(outputPath);
582
+ }
583
+ // Fallback for buffer inputs: rebuild from tree (may drop TouchChat metadata)
480
584
  const tree = await this.loadIntoTree(filePathOrBuffer);
481
- // Apply translations to all text content
482
585
  Object.values(tree.pages).forEach((page) => {
483
- // Translate page names
484
586
  if (page.name && translations.has(page.name)) {
485
587
  const translatedName = translations.get(page.name);
486
588
  if (translatedName !== undefined) {
487
589
  page.name = translatedName;
488
590
  }
489
591
  }
490
- // Translate button labels and messages
491
592
  page.buttons.forEach((button) => {
492
593
  if (button.label && translations.has(button.label)) {
493
594
  const translatedLabel = translations.get(button.label);
@@ -503,7 +604,6 @@ class TouchChatProcessor extends BaseProcessor {
503
604
  }
504
605
  });
505
606
  });
506
- // Save the translated tree and return its content
507
607
  await this.saveFromTree(tree, outputPath);
508
608
  const fs = getFs();
509
609
  return fs.readFileSync(outputPath);
@@ -4,6 +4,7 @@ import * as xml2js from 'xml2js';
4
4
  import JSZip from 'jszip';
5
5
  import { BaseValidator } from './baseValidator';
6
6
  import { getBasename, getFs, readBinaryFromInput, toUint8Array } from '../utils/io';
7
+ import { openSqliteDatabase } from '../utils/sqlite';
7
8
  /**
8
9
  * Validator for Snap files (.spb, .sps)
9
10
  * Snap files are zipped packages containing XML configuration
@@ -50,6 +51,10 @@ export class SnapValidator extends BaseValidator {
50
51
  this.warn('filename should end with .spb or .sps');
51
52
  }
52
53
  });
54
+ if (this.isSQLiteBuffer(content)) {
55
+ await this.validateSqliteStructure(content, filename);
56
+ return this.buildResult(filename, filesize, 'snap');
57
+ }
53
58
  let zip = null;
54
59
  let validZip = false;
55
60
  await this.add_check('zip', 'valid zip package', async () => {
@@ -136,6 +141,75 @@ export class SnapValidator extends BaseValidator {
136
141
  }
137
142
  });
138
143
  }
144
+ isSQLiteBuffer(content) {
145
+ const header = 'SQLite format 3\u0000';
146
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
147
+ if (bytes.length < header.length) {
148
+ return false;
149
+ }
150
+ for (let i = 0; i < header.length; i++) {
151
+ if (bytes[i] !== header.charCodeAt(i)) {
152
+ return false;
153
+ }
154
+ }
155
+ return true;
156
+ }
157
+ async validateSqliteStructure(content, _filename) {
158
+ await this.add_check('sqlite', 'valid SQLite database', async () => {
159
+ let cleanup;
160
+ try {
161
+ const result = await openSqliteDatabase(content, { readonly: true });
162
+ const db = result.db;
163
+ cleanup = result.cleanup;
164
+ const tableRows = db
165
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
166
+ .all();
167
+ const tables = new Set(tableRows.map((row) => row.name));
168
+ const requiredTables = [
169
+ 'Page',
170
+ 'Button',
171
+ 'ElementReference',
172
+ 'ElementPlacement',
173
+ 'PageSetProperties',
174
+ ];
175
+ const missingTables = requiredTables.filter((t) => !tables.has(t));
176
+ if (missingTables.length > 0) {
177
+ this.err(`Missing required Snap tables: ${missingTables.join(', ')}`);
178
+ }
179
+ const pageColumns = db.prepare('PRAGMA table_info(Page)').all();
180
+ const pageColumnNames = new Set(pageColumns.map((c) => c.name));
181
+ if (!pageColumnNames.has('UniqueId')) {
182
+ this.err('Page table missing UniqueId column');
183
+ }
184
+ if (!pageColumnNames.has('Name') && !pageColumnNames.has('Title')) {
185
+ this.err('Page table missing Name/Title columns');
186
+ }
187
+ const buttonColumns = db.prepare('PRAGMA table_info(Button)').all();
188
+ const buttonColumnNames = new Set(buttonColumns.map((c) => c.name));
189
+ if (!buttonColumnNames.has('Label') && !buttonColumnNames.has('Message')) {
190
+ this.err('Button table missing Label/Message columns');
191
+ }
192
+ const pageCount = db.prepare('SELECT COUNT(*) as c FROM Page').get();
193
+ if (!pageCount || pageCount.c === 0) {
194
+ this.warn('Snap database has no pages');
195
+ }
196
+ if (tables.has('PageSetData')) {
197
+ const dataCount = db.prepare('SELECT COUNT(*) as c FROM PageSetData').get();
198
+ if (!dataCount || dataCount.c === 0) {
199
+ this.warn('Snap database has no PageSetData assets (images/audio may be missing)');
200
+ }
201
+ }
202
+ }
203
+ catch (e) {
204
+ this.err(`file is not a valid SQLite database: ${e.message}`, true);
205
+ }
206
+ finally {
207
+ if (cleanup) {
208
+ cleanup();
209
+ }
210
+ }
211
+ });
212
+ }
139
213
  /**
140
214
  * Validate the main settings file
141
215
  */
@@ -4,9 +4,12 @@
4
4
  import * as xml2js from 'xml2js';
5
5
  import { BaseValidator } from './baseValidator';
6
6
  import { decodeText, getBasename, getFs, readBinaryFromInput, toUint8Array } from '../utils/io';
7
+ import { openZipFromInput } from '../utils/zip';
8
+ import { openSqliteDatabase } from '../utils/sqlite';
7
9
  /**
8
10
  * Validator for TouchChat files (.ce)
9
- * TouchChat files are XML-based
11
+ * TouchChat files are ZIP archives that contain a .c4v SQLite database.
12
+ * Some legacy exports may be XML, so we support both formats.
10
13
  */
11
14
  export class TouchChatValidator extends BaseValidator {
12
15
  constructor() {
@@ -29,6 +32,17 @@ export class TouchChatValidator extends BaseValidator {
29
32
  if (name.endsWith('.ce')) {
30
33
  return true;
31
34
  }
35
+ // Try to parse as ZIP and check for .c4v database
36
+ try {
37
+ const { zip } = await openZipFromInput(content);
38
+ const entries = zip.listFiles();
39
+ if (entries.some((entry) => entry.toLowerCase().endsWith('.c4v'))) {
40
+ return true;
41
+ }
42
+ }
43
+ catch {
44
+ // Fall back to XML detection
45
+ }
32
46
  // Try to parse as XML and check for TouchChat structure
33
47
  try {
34
48
  const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content));
@@ -51,40 +65,43 @@ export class TouchChatValidator extends BaseValidator {
51
65
  this.warn('filename should end with .ce');
52
66
  }
53
67
  });
54
- let xmlObj = null;
55
- await this.add_check('xml_parse', 'valid XML', async () => {
56
- try {
57
- const parser = new xml2js.Parser();
58
- const contentStr = decodeText(content);
59
- xmlObj = await parser.parseStringPromise(contentStr);
60
- }
61
- catch (e) {
62
- this.err(`Failed to parse XML: ${e.message}`, true);
68
+ const zipped = await this.tryValidateZipSqlite(content);
69
+ if (!zipped) {
70
+ let xmlObj = null;
71
+ await this.add_check('xml_parse', 'valid XML', async () => {
72
+ try {
73
+ const parser = new xml2js.Parser();
74
+ const contentStr = decodeText(content);
75
+ xmlObj = await parser.parseStringPromise(contentStr);
76
+ }
77
+ catch (e) {
78
+ this.err(`Failed to parse XML: ${e.message}`, true);
79
+ }
80
+ });
81
+ if (!xmlObj) {
82
+ return this.buildResult(filename, filesize, 'touchchat');
63
83
  }
64
- });
65
- if (!xmlObj) {
66
- return this.buildResult(filename, filesize, 'touchchat');
67
- }
68
- await this.add_check('xml_structure', 'TouchChat root element', async () => {
69
- // TouchChat can have different root elements
70
- const hasValidRoot = xmlObj.PageSet ||
84
+ await this.add_check('xml_structure', 'TouchChat root element', async () => {
85
+ // TouchChat can have different root elements
86
+ const hasValidRoot = xmlObj.PageSet ||
87
+ xmlObj.Pageset ||
88
+ xmlObj.page ||
89
+ xmlObj.Page ||
90
+ xmlObj.pages ||
91
+ xmlObj.Pages;
92
+ if (!hasValidRoot) {
93
+ this.err('file does not contain a recognized TouchChat structure');
94
+ }
95
+ });
96
+ const root = xmlObj.PageSet ||
71
97
  xmlObj.Pageset ||
72
98
  xmlObj.page ||
73
99
  xmlObj.Page ||
74
100
  xmlObj.pages ||
75
101
  xmlObj.Pages;
76
- if (!hasValidRoot) {
77
- this.err('file does not contain a recognized TouchChat structure');
102
+ if (root) {
103
+ await this.validateTouchChatStructure(root);
78
104
  }
79
- });
80
- const root = xmlObj.PageSet ||
81
- xmlObj.Pageset ||
82
- xmlObj.page ||
83
- xmlObj.Page ||
84
- xmlObj.pages ||
85
- xmlObj.Pages;
86
- if (root) {
87
- await this.validateTouchChatStructure(root);
88
105
  }
89
106
  return this.buildResult(filename, filesize, 'touchchat');
90
107
  }
@@ -198,4 +215,101 @@ export class TouchChatValidator extends BaseValidator {
198
215
  }
199
216
  });
200
217
  }
218
+ isSQLiteBuffer(content) {
219
+ const header = 'SQLite format 3\u0000';
220
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
221
+ if (bytes.length < header.length) {
222
+ return false;
223
+ }
224
+ for (let i = 0; i < header.length; i++) {
225
+ if (bytes[i] !== header.charCodeAt(i)) {
226
+ return false;
227
+ }
228
+ }
229
+ return true;
230
+ }
231
+ async tryValidateZipSqlite(content) {
232
+ let usedZip = false;
233
+ await this.add_check('zip', 'TouchChat ZIP package', async () => {
234
+ try {
235
+ const { zip } = await openZipFromInput(content);
236
+ const entries = zip.listFiles();
237
+ const vocabEntry = entries.find((name) => name.toLowerCase().endsWith('.c4v'));
238
+ if (!vocabEntry) {
239
+ this.err('TouchChat package missing .c4v database', true);
240
+ return;
241
+ }
242
+ const dbBuffer = await zip.readFile(vocabEntry);
243
+ if (!this.isSQLiteBuffer(dbBuffer)) {
244
+ this.err('TouchChat .c4v is not a valid SQLite database', true);
245
+ return;
246
+ }
247
+ usedZip = true;
248
+ await this.validateSqliteStructure(dbBuffer);
249
+ }
250
+ catch (e) {
251
+ this.err(`file is not a valid TouchChat ZIP package: ${e.message}`, true);
252
+ }
253
+ });
254
+ return usedZip;
255
+ }
256
+ async validateSqliteStructure(content) {
257
+ await this.add_check('sqlite', 'valid TouchChat SQLite database', async () => {
258
+ let cleanup;
259
+ try {
260
+ const result = await openSqliteDatabase(content, { readonly: true });
261
+ const db = result.db;
262
+ cleanup = result.cleanup;
263
+ const tableRows = db
264
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
265
+ .all();
266
+ const tables = new Set(tableRows.map((row) => row.name));
267
+ const requiredTables = [
268
+ 'resources',
269
+ 'pages',
270
+ 'buttons',
271
+ 'button_boxes',
272
+ 'button_box_cells',
273
+ 'button_box_instances',
274
+ ];
275
+ const missingTables = requiredTables.filter((t) => !tables.has(t));
276
+ if (missingTables.length > 0) {
277
+ this.err(`Missing required TouchChat tables: ${missingTables.join(', ')}`);
278
+ }
279
+ const resourcesCols = new Set(db
280
+ .prepare('PRAGMA table_info(resources)')
281
+ .all()
282
+ .map((row) => row.name));
283
+ if (!resourcesCols.has('id') || !resourcesCols.has('name')) {
284
+ this.err('resources table missing id/name columns');
285
+ }
286
+ const pagesCols = new Set(db
287
+ .prepare('PRAGMA table_info(pages)')
288
+ .all()
289
+ .map((row) => row.name));
290
+ if (!pagesCols.has('id') || !pagesCols.has('resource_id')) {
291
+ this.err('pages table missing id/resource_id columns');
292
+ }
293
+ const buttonsCols = new Set(db
294
+ .prepare('PRAGMA table_info(buttons)')
295
+ .all()
296
+ .map((row) => row.name));
297
+ if (!buttonsCols.has('id') || !buttonsCols.has('resource_id')) {
298
+ this.err('buttons table missing id/resource_id columns');
299
+ }
300
+ const pageCount = db.prepare('SELECT COUNT(*) as c FROM pages').get();
301
+ if (!pageCount || pageCount.c === 0) {
302
+ this.warn('TouchChat database has no pages');
303
+ }
304
+ }
305
+ catch (e) {
306
+ this.err(`TouchChat database validation failed: ${e.message}`, true);
307
+ }
308
+ finally {
309
+ if (cleanup) {
310
+ cleanup();
311
+ }
312
+ }
313
+ });
314
+ }
201
315
  }
@@ -351,8 +351,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
351
351
  if (typeof val === 'number')
352
352
  return String(val);
353
353
  if (typeof val === 'object') {
354
- if ('#text' in val)
355
- return String(val['#text']);
354
+ // Don't immediately return #text - it might be whitespace alongside structured content
355
+ // Process structured format first: <p><s><r>text</r></s></p>
356
356
  // Handle Grid3 structured format <p><s><r>text</r></s></p>
357
357
  // Can start at p, s, or r level
358
358
  const parts = [];
@@ -368,8 +368,17 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
368
368
  }
369
369
  continue;
370
370
  }
371
- if (typeof r === 'object' && r !== null && '#text' in r) {
372
- parts.push(String(r['#text']));
371
+ if (typeof r === 'object' && r !== null) {
372
+ // Check for #text (regular text) or #cdata (CDATA sections)
373
+ if ('#text' in r) {
374
+ parts.push(String(r['#text']));
375
+ }
376
+ else if ('#cdata' in r) {
377
+ parts.push(String(r['#cdata']));
378
+ }
379
+ else {
380
+ parts.push(String(r));
381
+ }
373
382
  }
374
383
  else {
375
384
  parts.push(String(r));
@@ -422,7 +431,15 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
422
431
  }
423
432
  const password = this.getGridsetPassword(filePathOrBuffer);
424
433
  const entries = (0, password_1.getZipEntriesFromAdapter)(zipResult.zip, password);
425
- const parser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false });
434
+ const options = {
435
+ ignoreAttributes: false,
436
+ ignoreDeclaration: true,
437
+ parseTagValue: false,
438
+ trimValues: false,
439
+ textNodeName: '#text',
440
+ cdataProp: '#cdata',
441
+ };
442
+ const parser = new fast_xml_parser_1.XMLParser(options);
426
443
  const isEncryptedArchive = typeof filePathOrBuffer === 'string' && filePathOrBuffer.toLowerCase().endsWith('.gridsetx');
427
444
  const encryptedContentPassword = this.getGridsetPassword(filePathOrBuffer);
428
445
  // Initialize metadata
@@ -660,6 +677,13 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
660
677
  if (text) {
661
678
  const val = this.textOf(text);
662
679
  if (val) {
680
+ // Debug: log WordList items with spaces to check extraction
681
+ if (pageWordListItems.length < 3) {
682
+ console.log(`[WordList] Extracted text: "${val}" (length: ${val.length}, has spaces: ${val.includes(' ')})`);
683
+ console.log(`[WordList] Chars:`, Array.from(val)
684
+ .map((c) => `"${c}" (${c.charCodeAt(0)})`)
685
+ .join(', '));
686
+ }
663
687
  pageWordListItems.push({
664
688
  text: val,
665
689
  image: item.Image || item.image || undefined,
@@ -602,18 +602,90 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
602
602
  if (!(0, io_1.isNodeRuntime)()) {
603
603
  throw new Error('processTexts is only supported in Node.js environments for Snap files.');
604
604
  }
605
- // Load the tree, apply translations, and save to new file
605
+ const fs = (0, io_1.getFs)();
606
+ const path = (0, io_1.getPath)();
607
+ if (typeof filePathOrBuffer === 'string') {
608
+ const inputPath = filePathOrBuffer;
609
+ const outputDir = path.dirname(outputPath);
610
+ if (!fs.existsSync(outputDir)) {
611
+ fs.mkdirSync(outputDir, { recursive: true });
612
+ }
613
+ if (fs.existsSync(outputPath)) {
614
+ fs.unlinkSync(outputPath);
615
+ }
616
+ fs.copyFileSync(inputPath, outputPath);
617
+ const Database = (0, sqlite_1.requireBetterSqlite3)();
618
+ const db = new Database(outputPath, { readonly: false });
619
+ try {
620
+ const getColumns = (tableName) => {
621
+ try {
622
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
623
+ return new Set(rows.map((row) => row.name));
624
+ }
625
+ catch {
626
+ return new Set();
627
+ }
628
+ };
629
+ const pageColumns = getColumns('Page');
630
+ const buttonColumns = getColumns('Button');
631
+ const pageUpdates = [];
632
+ const pageWhere = [];
633
+ const pageColumnsToUse = [];
634
+ if (pageColumns.has('Name')) {
635
+ pageUpdates.push('Name = ?');
636
+ pageWhere.push('Name = ?');
637
+ pageColumnsToUse.push('Name');
638
+ }
639
+ if (pageColumns.has('Title')) {
640
+ pageUpdates.push('Title = ?');
641
+ pageWhere.push('Title = ?');
642
+ pageColumnsToUse.push('Title');
643
+ }
644
+ const updatePage = pageUpdates.length > 0
645
+ ? db.prepare(`UPDATE Page SET ${pageUpdates.join(', ')} WHERE ${pageWhere.join(' OR ')}`)
646
+ : null;
647
+ const updateLabel = buttonColumns.has('Label')
648
+ ? db.prepare('UPDATE Button SET Label = ? WHERE Label = ?')
649
+ : null;
650
+ const updateMessage = buttonColumns.has('Message')
651
+ ? db.prepare('UPDATE Button SET Message = ? WHERE Message = ?')
652
+ : null;
653
+ const entries = Array.from(translations.entries());
654
+ const applyUpdates = db.transaction(() => {
655
+ entries.forEach(([original, translated]) => {
656
+ if (!translated || translated === original) {
657
+ return;
658
+ }
659
+ if (updatePage) {
660
+ const updateValues = [];
661
+ pageColumnsToUse.forEach(() => updateValues.push(translated));
662
+ pageColumnsToUse.forEach(() => updateValues.push(original));
663
+ updatePage.run(...updateValues);
664
+ }
665
+ if (updateLabel) {
666
+ updateLabel.run(translated, original);
667
+ }
668
+ if (updateMessage) {
669
+ updateMessage.run(translated, original);
670
+ }
671
+ });
672
+ });
673
+ applyUpdates();
674
+ }
675
+ finally {
676
+ db.close();
677
+ }
678
+ return fs.readFileSync(outputPath);
679
+ }
680
+ // Fallback for buffer inputs: rebuild from tree (may drop Snap assets)
606
681
  const tree = await this.loadIntoTree(filePathOrBuffer);
607
- // Apply translations to all text content
608
682
  Object.values(tree.pages).forEach((page) => {
609
- // Translate page names
610
683
  if (page.name && translations.has(page.name)) {
611
684
  const translatedName = translations.get(page.name);
612
685
  if (translatedName !== undefined) {
613
686
  page.name = translatedName;
614
687
  }
615
688
  }
616
- // Translate button labels and messages
617
689
  page.buttons.forEach((button) => {
618
690
  if (button.label && translations.has(button.label)) {
619
691
  const translatedLabel = translations.get(button.label);
@@ -629,9 +701,7 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
629
701
  }
630
702
  });
631
703
  });
632
- // Save the translated tree and return its content
633
704
  await this.saveFromTree(tree, outputPath);
634
- const fs = (0, io_1.getFs)();
635
705
  return fs.readFileSync(outputPath);
636
706
  }
637
707
  async saveFromTree(tree, outputPath) {
@@ -479,18 +479,119 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
479
479
  if (!(0, io_1.isNodeRuntime)()) {
480
480
  throw new Error('processTexts is only supported in Node.js environments for TouchChat files.');
481
481
  }
482
- // Load the tree, apply translations, and save to new file
482
+ /**
483
+ * TouchChat .ce files are ZIP archives containing a SQLite .c4v database.
484
+ * Rebuilding the database can drop tables/metadata/resources that we don't
485
+ * currently model in the tree, which can corrupt the file.
486
+ *
487
+ * For file paths, we preserve the original archive and update text in-place
488
+ * within the embedded SQLite database, ensuring assets and metadata remain intact.
489
+ */
490
+ if (typeof filePathOrBuffer === 'string') {
491
+ const fs = (0, io_1.getFs)();
492
+ const path = (0, io_1.getPath)();
493
+ const os = (0, io_1.getOs)();
494
+ const AdmZip = (0, io_1.getNodeRequire)()('adm-zip');
495
+ const inputPath = filePathOrBuffer;
496
+ const outputDir = path.dirname(outputPath);
497
+ if (!fs.existsSync(outputDir)) {
498
+ fs.mkdirSync(outputDir, { recursive: true });
499
+ }
500
+ if (fs.existsSync(outputPath)) {
501
+ fs.unlinkSync(outputPath);
502
+ }
503
+ const zip = new AdmZip(inputPath);
504
+ const entries = zip.getEntries();
505
+ const vocabEntry = entries.find((entry) => entry.entryName.endsWith('.c4v'));
506
+ if (!vocabEntry) {
507
+ throw new Error('No .c4v vocab DB found in TouchChat export');
508
+ }
509
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchchat-translate-'));
510
+ const dbPath = path.join(tempDir, 'vocab.c4v');
511
+ try {
512
+ fs.writeFileSync(dbPath, vocabEntry.getData());
513
+ const Database = (0, sqlite_1.requireBetterSqlite3)();
514
+ const db = new Database(dbPath, { readonly: false });
515
+ try {
516
+ const getColumns = (tableName) => {
517
+ try {
518
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
519
+ return new Set(rows.map((row) => row.name));
520
+ }
521
+ catch {
522
+ return new Set();
523
+ }
524
+ };
525
+ const resourceColumns = getColumns('resources');
526
+ const pageColumns = getColumns('pages');
527
+ const buttonColumns = getColumns('buttons');
528
+ const updatePageResourceName = resourceColumns.has('name')
529
+ ? db.prepare('UPDATE resources SET name = ? WHERE name = ? AND id IN (SELECT resource_id FROM pages)')
530
+ : null;
531
+ const updatePageName = pageColumns.has('name')
532
+ ? db.prepare('UPDATE pages SET name = ? WHERE name = ?')
533
+ : null;
534
+ const updateButtonLabel = buttonColumns.has('label')
535
+ ? db.prepare('UPDATE buttons SET label = ? WHERE label = ?')
536
+ : null;
537
+ const updateButtonMessage = buttonColumns.has('message')
538
+ ? db.prepare('UPDATE buttons SET message = ? WHERE message = ?')
539
+ : null;
540
+ const entriesToUpdate = Array.from(translations.entries());
541
+ const applyUpdates = db.transaction(() => {
542
+ entriesToUpdate.forEach(([original, translated]) => {
543
+ if (!translated || translated === original) {
544
+ return;
545
+ }
546
+ if (updatePageResourceName) {
547
+ updatePageResourceName.run(translated, original);
548
+ }
549
+ if (updatePageName) {
550
+ updatePageName.run(translated, original);
551
+ }
552
+ if (updateButtonLabel) {
553
+ updateButtonLabel.run(translated, original);
554
+ }
555
+ if (updateButtonMessage) {
556
+ updateButtonMessage.run(translated, original);
557
+ }
558
+ });
559
+ });
560
+ applyUpdates();
561
+ }
562
+ finally {
563
+ db.close();
564
+ }
565
+ const outputZip = new AdmZip();
566
+ entries.forEach((entry) => {
567
+ if (entry.entryName === vocabEntry.entryName) {
568
+ return;
569
+ }
570
+ const data = entry.isDirectory ? Buffer.alloc(0) : entry.getData();
571
+ outputZip.addFile(entry.entryName, data, entry.comment || '');
572
+ });
573
+ outputZip.addFile(vocabEntry.entryName, fs.readFileSync(dbPath));
574
+ outputZip.writeZip(outputPath);
575
+ }
576
+ finally {
577
+ try {
578
+ fs.rmSync(tempDir, { recursive: true, force: true });
579
+ }
580
+ catch {
581
+ // Best-effort cleanup
582
+ }
583
+ }
584
+ return fs.readFileSync(outputPath);
585
+ }
586
+ // Fallback for buffer inputs: rebuild from tree (may drop TouchChat metadata)
483
587
  const tree = await this.loadIntoTree(filePathOrBuffer);
484
- // Apply translations to all text content
485
588
  Object.values(tree.pages).forEach((page) => {
486
- // Translate page names
487
589
  if (page.name && translations.has(page.name)) {
488
590
  const translatedName = translations.get(page.name);
489
591
  if (translatedName !== undefined) {
490
592
  page.name = translatedName;
491
593
  }
492
594
  }
493
- // Translate button labels and messages
494
595
  page.buttons.forEach((button) => {
495
596
  if (button.label && translations.has(button.label)) {
496
597
  const translatedLabel = translations.get(button.label);
@@ -506,7 +607,6 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
506
607
  }
507
608
  });
508
609
  });
509
- // Save the translated tree and return its content
510
610
  await this.saveFromTree(tree, outputPath);
511
611
  const fs = (0, io_1.getFs)();
512
612
  return fs.readFileSync(outputPath);
@@ -22,6 +22,8 @@ export declare class SnapValidator extends BaseValidator {
22
22
  * Validate Snap package structure
23
23
  */
24
24
  private validateSnapStructure;
25
+ private isSQLiteBuffer;
26
+ private validateSqliteStructure;
25
27
  /**
26
28
  * Validate the main settings file
27
29
  */
@@ -33,6 +33,7 @@ const xml2js = __importStar(require("xml2js"));
33
33
  const jszip_1 = __importDefault(require("jszip"));
34
34
  const baseValidator_1 = require("./baseValidator");
35
35
  const io_1 = require("../utils/io");
36
+ const sqlite_1 = require("../utils/sqlite");
36
37
  /**
37
38
  * Validator for Snap files (.spb, .sps)
38
39
  * Snap files are zipped packages containing XML configuration
@@ -79,6 +80,10 @@ class SnapValidator extends baseValidator_1.BaseValidator {
79
80
  this.warn('filename should end with .spb or .sps');
80
81
  }
81
82
  });
83
+ if (this.isSQLiteBuffer(content)) {
84
+ await this.validateSqliteStructure(content, filename);
85
+ return this.buildResult(filename, filesize, 'snap');
86
+ }
82
87
  let zip = null;
83
88
  let validZip = false;
84
89
  await this.add_check('zip', 'valid zip package', async () => {
@@ -165,6 +170,75 @@ class SnapValidator extends baseValidator_1.BaseValidator {
165
170
  }
166
171
  });
167
172
  }
173
+ isSQLiteBuffer(content) {
174
+ const header = 'SQLite format 3\u0000';
175
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
176
+ if (bytes.length < header.length) {
177
+ return false;
178
+ }
179
+ for (let i = 0; i < header.length; i++) {
180
+ if (bytes[i] !== header.charCodeAt(i)) {
181
+ return false;
182
+ }
183
+ }
184
+ return true;
185
+ }
186
+ async validateSqliteStructure(content, _filename) {
187
+ await this.add_check('sqlite', 'valid SQLite database', async () => {
188
+ let cleanup;
189
+ try {
190
+ const result = await (0, sqlite_1.openSqliteDatabase)(content, { readonly: true });
191
+ const db = result.db;
192
+ cleanup = result.cleanup;
193
+ const tableRows = db
194
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
195
+ .all();
196
+ const tables = new Set(tableRows.map((row) => row.name));
197
+ const requiredTables = [
198
+ 'Page',
199
+ 'Button',
200
+ 'ElementReference',
201
+ 'ElementPlacement',
202
+ 'PageSetProperties',
203
+ ];
204
+ const missingTables = requiredTables.filter((t) => !tables.has(t));
205
+ if (missingTables.length > 0) {
206
+ this.err(`Missing required Snap tables: ${missingTables.join(', ')}`);
207
+ }
208
+ const pageColumns = db.prepare('PRAGMA table_info(Page)').all();
209
+ const pageColumnNames = new Set(pageColumns.map((c) => c.name));
210
+ if (!pageColumnNames.has('UniqueId')) {
211
+ this.err('Page table missing UniqueId column');
212
+ }
213
+ if (!pageColumnNames.has('Name') && !pageColumnNames.has('Title')) {
214
+ this.err('Page table missing Name/Title columns');
215
+ }
216
+ const buttonColumns = db.prepare('PRAGMA table_info(Button)').all();
217
+ const buttonColumnNames = new Set(buttonColumns.map((c) => c.name));
218
+ if (!buttonColumnNames.has('Label') && !buttonColumnNames.has('Message')) {
219
+ this.err('Button table missing Label/Message columns');
220
+ }
221
+ const pageCount = db.prepare('SELECT COUNT(*) as c FROM Page').get();
222
+ if (!pageCount || pageCount.c === 0) {
223
+ this.warn('Snap database has no pages');
224
+ }
225
+ if (tables.has('PageSetData')) {
226
+ const dataCount = db.prepare('SELECT COUNT(*) as c FROM PageSetData').get();
227
+ if (!dataCount || dataCount.c === 0) {
228
+ this.warn('Snap database has no PageSetData assets (images/audio may be missing)');
229
+ }
230
+ }
231
+ }
232
+ catch (e) {
233
+ this.err(`file is not a valid SQLite database: ${e.message}`, true);
234
+ }
235
+ finally {
236
+ if (cleanup) {
237
+ cleanup();
238
+ }
239
+ }
240
+ });
241
+ }
168
242
  /**
169
243
  * Validate the main settings file
170
244
  */
@@ -2,7 +2,8 @@ import { BaseValidator } from './baseValidator';
2
2
  import { ValidationResult } from './validationTypes';
3
3
  /**
4
4
  * Validator for TouchChat files (.ce)
5
- * TouchChat files are XML-based
5
+ * TouchChat files are ZIP archives that contain a .c4v SQLite database.
6
+ * Some legacy exports may be XML, so we support both formats.
6
7
  */
7
8
  export declare class TouchChatValidator extends BaseValidator {
8
9
  constructor();
@@ -30,4 +31,7 @@ export declare class TouchChatValidator extends BaseValidator {
30
31
  * Validate a single button
31
32
  */
32
33
  private validateButton;
34
+ private isSQLiteBuffer;
35
+ private tryValidateZipSqlite;
36
+ private validateSqliteStructure;
33
37
  }
@@ -30,9 +30,12 @@ exports.TouchChatValidator = void 0;
30
30
  const xml2js = __importStar(require("xml2js"));
31
31
  const baseValidator_1 = require("./baseValidator");
32
32
  const io_1 = require("../utils/io");
33
+ const zip_1 = require("../utils/zip");
34
+ const sqlite_1 = require("../utils/sqlite");
33
35
  /**
34
36
  * Validator for TouchChat files (.ce)
35
- * TouchChat files are XML-based
37
+ * TouchChat files are ZIP archives that contain a .c4v SQLite database.
38
+ * Some legacy exports may be XML, so we support both formats.
36
39
  */
37
40
  class TouchChatValidator extends baseValidator_1.BaseValidator {
38
41
  constructor() {
@@ -55,6 +58,17 @@ class TouchChatValidator extends baseValidator_1.BaseValidator {
55
58
  if (name.endsWith('.ce')) {
56
59
  return true;
57
60
  }
61
+ // Try to parse as ZIP and check for .c4v database
62
+ try {
63
+ const { zip } = await (0, zip_1.openZipFromInput)(content);
64
+ const entries = zip.listFiles();
65
+ if (entries.some((entry) => entry.toLowerCase().endsWith('.c4v'))) {
66
+ return true;
67
+ }
68
+ }
69
+ catch {
70
+ // Fall back to XML detection
71
+ }
58
72
  // Try to parse as XML and check for TouchChat structure
59
73
  try {
60
74
  const contentStr = typeof content === 'string' ? content : (0, io_1.decodeText)((0, io_1.toUint8Array)(content));
@@ -77,40 +91,43 @@ class TouchChatValidator extends baseValidator_1.BaseValidator {
77
91
  this.warn('filename should end with .ce');
78
92
  }
79
93
  });
80
- let xmlObj = null;
81
- await this.add_check('xml_parse', 'valid XML', async () => {
82
- try {
83
- const parser = new xml2js.Parser();
84
- const contentStr = (0, io_1.decodeText)(content);
85
- xmlObj = await parser.parseStringPromise(contentStr);
86
- }
87
- catch (e) {
88
- this.err(`Failed to parse XML: ${e.message}`, true);
94
+ const zipped = await this.tryValidateZipSqlite(content);
95
+ if (!zipped) {
96
+ let xmlObj = null;
97
+ await this.add_check('xml_parse', 'valid XML', async () => {
98
+ try {
99
+ const parser = new xml2js.Parser();
100
+ const contentStr = (0, io_1.decodeText)(content);
101
+ xmlObj = await parser.parseStringPromise(contentStr);
102
+ }
103
+ catch (e) {
104
+ this.err(`Failed to parse XML: ${e.message}`, true);
105
+ }
106
+ });
107
+ if (!xmlObj) {
108
+ return this.buildResult(filename, filesize, 'touchchat');
89
109
  }
90
- });
91
- if (!xmlObj) {
92
- return this.buildResult(filename, filesize, 'touchchat');
93
- }
94
- await this.add_check('xml_structure', 'TouchChat root element', async () => {
95
- // TouchChat can have different root elements
96
- const hasValidRoot = xmlObj.PageSet ||
110
+ await this.add_check('xml_structure', 'TouchChat root element', async () => {
111
+ // TouchChat can have different root elements
112
+ const hasValidRoot = xmlObj.PageSet ||
113
+ xmlObj.Pageset ||
114
+ xmlObj.page ||
115
+ xmlObj.Page ||
116
+ xmlObj.pages ||
117
+ xmlObj.Pages;
118
+ if (!hasValidRoot) {
119
+ this.err('file does not contain a recognized TouchChat structure');
120
+ }
121
+ });
122
+ const root = xmlObj.PageSet ||
97
123
  xmlObj.Pageset ||
98
124
  xmlObj.page ||
99
125
  xmlObj.Page ||
100
126
  xmlObj.pages ||
101
127
  xmlObj.Pages;
102
- if (!hasValidRoot) {
103
- this.err('file does not contain a recognized TouchChat structure');
128
+ if (root) {
129
+ await this.validateTouchChatStructure(root);
104
130
  }
105
- });
106
- const root = xmlObj.PageSet ||
107
- xmlObj.Pageset ||
108
- xmlObj.page ||
109
- xmlObj.Page ||
110
- xmlObj.pages ||
111
- xmlObj.Pages;
112
- if (root) {
113
- await this.validateTouchChatStructure(root);
114
131
  }
115
132
  return this.buildResult(filename, filesize, 'touchchat');
116
133
  }
@@ -224,5 +241,102 @@ class TouchChatValidator extends baseValidator_1.BaseValidator {
224
241
  }
225
242
  });
226
243
  }
244
+ isSQLiteBuffer(content) {
245
+ const header = 'SQLite format 3\u0000';
246
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
247
+ if (bytes.length < header.length) {
248
+ return false;
249
+ }
250
+ for (let i = 0; i < header.length; i++) {
251
+ if (bytes[i] !== header.charCodeAt(i)) {
252
+ return false;
253
+ }
254
+ }
255
+ return true;
256
+ }
257
+ async tryValidateZipSqlite(content) {
258
+ let usedZip = false;
259
+ await this.add_check('zip', 'TouchChat ZIP package', async () => {
260
+ try {
261
+ const { zip } = await (0, zip_1.openZipFromInput)(content);
262
+ const entries = zip.listFiles();
263
+ const vocabEntry = entries.find((name) => name.toLowerCase().endsWith('.c4v'));
264
+ if (!vocabEntry) {
265
+ this.err('TouchChat package missing .c4v database', true);
266
+ return;
267
+ }
268
+ const dbBuffer = await zip.readFile(vocabEntry);
269
+ if (!this.isSQLiteBuffer(dbBuffer)) {
270
+ this.err('TouchChat .c4v is not a valid SQLite database', true);
271
+ return;
272
+ }
273
+ usedZip = true;
274
+ await this.validateSqliteStructure(dbBuffer);
275
+ }
276
+ catch (e) {
277
+ this.err(`file is not a valid TouchChat ZIP package: ${e.message}`, true);
278
+ }
279
+ });
280
+ return usedZip;
281
+ }
282
+ async validateSqliteStructure(content) {
283
+ await this.add_check('sqlite', 'valid TouchChat SQLite database', async () => {
284
+ let cleanup;
285
+ try {
286
+ const result = await (0, sqlite_1.openSqliteDatabase)(content, { readonly: true });
287
+ const db = result.db;
288
+ cleanup = result.cleanup;
289
+ const tableRows = db
290
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
291
+ .all();
292
+ const tables = new Set(tableRows.map((row) => row.name));
293
+ const requiredTables = [
294
+ 'resources',
295
+ 'pages',
296
+ 'buttons',
297
+ 'button_boxes',
298
+ 'button_box_cells',
299
+ 'button_box_instances',
300
+ ];
301
+ const missingTables = requiredTables.filter((t) => !tables.has(t));
302
+ if (missingTables.length > 0) {
303
+ this.err(`Missing required TouchChat tables: ${missingTables.join(', ')}`);
304
+ }
305
+ const resourcesCols = new Set(db
306
+ .prepare('PRAGMA table_info(resources)')
307
+ .all()
308
+ .map((row) => row.name));
309
+ if (!resourcesCols.has('id') || !resourcesCols.has('name')) {
310
+ this.err('resources table missing id/name columns');
311
+ }
312
+ const pagesCols = new Set(db
313
+ .prepare('PRAGMA table_info(pages)')
314
+ .all()
315
+ .map((row) => row.name));
316
+ if (!pagesCols.has('id') || !pagesCols.has('resource_id')) {
317
+ this.err('pages table missing id/resource_id columns');
318
+ }
319
+ const buttonsCols = new Set(db
320
+ .prepare('PRAGMA table_info(buttons)')
321
+ .all()
322
+ .map((row) => row.name));
323
+ if (!buttonsCols.has('id') || !buttonsCols.has('resource_id')) {
324
+ this.err('buttons table missing id/resource_id columns');
325
+ }
326
+ const pageCount = db.prepare('SELECT COUNT(*) as c FROM pages').get();
327
+ if (!pageCount || pageCount.c === 0) {
328
+ this.warn('TouchChat database has no pages');
329
+ }
330
+ }
331
+ catch (e) {
332
+ this.err(`TouchChat database validation failed: ${e.message}`, true);
333
+ }
334
+ finally {
335
+ if (cleanup) {
336
+ cleanup();
337
+ }
338
+ }
339
+ });
340
+ }
227
341
  }
228
342
  exports.TouchChatValidator = TouchChatValidator;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "A comprehensive TypeScript library for processing AAC (Augmentative and Alternative Communication) file formats with translation support",
5
5
  "main": "dist/index.js",
6
6
  "browser": "dist/browser/index.browser.js",