@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.
- package/dist/browser/processors/gridsetProcessor.js +29 -5
- package/dist/browser/processors/snapProcessor.js +76 -6
- package/dist/browser/processors/touchchatProcessor.js +105 -5
- package/dist/browser/validation/snapValidator.js +74 -0
- package/dist/browser/validation/touchChatValidator.js +142 -28
- package/dist/processors/gridsetProcessor.js +29 -5
- package/dist/processors/snapProcessor.js +76 -6
- package/dist/processors/touchchatProcessor.js +105 -5
- package/dist/validation/snapValidator.d.ts +2 -0
- package/dist/validation/snapValidator.js +74 -0
- package/dist/validation/touchChatValidator.d.ts +5 -1
- package/dist/validation/touchChatValidator.js +142 -28
- package/package.json +1 -1
|
@@ -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
|
-
|
|
329
|
-
|
|
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
|
|
346
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
77
|
-
this.
|
|
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
|
-
|
|
355
|
-
|
|
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
|
|
372
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 (
|
|
103
|
-
this.
|
|
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.
|
|
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",
|