Skip to content

Commit d9f54e2

Browse files
committed
improving validation for touchchat too
1 parent 714a2a4 commit d9f54e2

2 files changed

Lines changed: 271 additions & 35 deletions

File tree

src/processors/touchchatProcessor.ts

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -653,20 +653,131 @@ class TouchChatProcessor extends BaseProcessor {
653653
'processTexts is only supported in Node.js environments for TouchChat files.'
654654
);
655655
}
656-
// Load the tree, apply translations, and save to new file
656+
/**
657+
* TouchChat .ce files are ZIP archives containing a SQLite .c4v database.
658+
* Rebuilding the database can drop tables/metadata/resources that we don't
659+
* currently model in the tree, which can corrupt the file.
660+
*
661+
* For file paths, we preserve the original archive and update text in-place
662+
* within the embedded SQLite database, ensuring assets and metadata remain intact.
663+
*/
664+
if (typeof filePathOrBuffer === 'string') {
665+
const fs = getFs();
666+
const path = getPath();
667+
const os = getOs();
668+
const AdmZip = getNodeRequire()('adm-zip') as typeof import('adm-zip');
669+
670+
const inputPath = filePathOrBuffer;
671+
const outputDir = path.dirname(outputPath);
672+
if (!fs.existsSync(outputDir)) {
673+
fs.mkdirSync(outputDir, { recursive: true });
674+
}
675+
if (fs.existsSync(outputPath)) {
676+
fs.unlinkSync(outputPath);
677+
}
678+
679+
const zip = new AdmZip(inputPath);
680+
const entries = zip.getEntries();
681+
const vocabEntry = entries.find((entry) => entry.entryName.endsWith('.c4v'));
682+
if (!vocabEntry) {
683+
throw new Error('No .c4v vocab DB found in TouchChat export');
684+
}
685+
686+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchchat-translate-'));
687+
const dbPath = path.join(tempDir, 'vocab.c4v');
688+
try {
689+
fs.writeFileSync(dbPath, vocabEntry.getData());
690+
691+
const Database = requireBetterSqlite3();
692+
const db = new Database(dbPath, { readonly: false });
693+
try {
694+
const getColumns = (tableName: string): Set<string> => {
695+
try {
696+
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
697+
name: string;
698+
}>;
699+
return new Set(rows.map((row) => row.name));
700+
} catch {
701+
return new Set();
702+
}
703+
};
704+
705+
const resourceColumns = getColumns('resources');
706+
const pageColumns = getColumns('pages');
707+
const buttonColumns = getColumns('buttons');
708+
709+
const updatePageResourceName = resourceColumns.has('name')
710+
? db.prepare(
711+
'UPDATE resources SET name = ? WHERE name = ? AND id IN (SELECT resource_id FROM pages)'
712+
)
713+
: null;
714+
const updatePageName = pageColumns.has('name')
715+
? db.prepare('UPDATE pages SET name = ? WHERE name = ?')
716+
: null;
717+
const updateButtonLabel = buttonColumns.has('label')
718+
? db.prepare('UPDATE buttons SET label = ? WHERE label = ?')
719+
: null;
720+
const updateButtonMessage = buttonColumns.has('message')
721+
? db.prepare('UPDATE buttons SET message = ? WHERE message = ?')
722+
: null;
723+
724+
const entriesToUpdate = Array.from(translations.entries());
725+
const applyUpdates = db.transaction(() => {
726+
entriesToUpdate.forEach(([original, translated]) => {
727+
if (!translated || translated === original) {
728+
return;
729+
}
730+
if (updatePageResourceName) {
731+
updatePageResourceName.run(translated, original);
732+
}
733+
if (updatePageName) {
734+
updatePageName.run(translated, original);
735+
}
736+
if (updateButtonLabel) {
737+
updateButtonLabel.run(translated, original);
738+
}
739+
if (updateButtonMessage) {
740+
updateButtonMessage.run(translated, original);
741+
}
742+
});
743+
});
744+
applyUpdates();
745+
} finally {
746+
db.close();
747+
}
748+
749+
const outputZip = new AdmZip();
750+
entries.forEach((entry) => {
751+
if (entry.entryName === vocabEntry.entryName) {
752+
return;
753+
}
754+
const data = entry.isDirectory ? Buffer.alloc(0) : entry.getData();
755+
outputZip.addFile(entry.entryName, data, entry.comment || '');
756+
});
757+
outputZip.addFile(vocabEntry.entryName, fs.readFileSync(dbPath));
758+
outputZip.writeZip(outputPath);
759+
} finally {
760+
try {
761+
fs.rmSync(tempDir, { recursive: true, force: true });
762+
} catch {
763+
// Best-effort cleanup
764+
}
765+
}
766+
767+
return fs.readFileSync(outputPath);
768+
}
769+
770+
// Fallback for buffer inputs: rebuild from tree (may drop TouchChat metadata)
657771
const tree = await this.loadIntoTree(filePathOrBuffer);
658772

659-
// Apply translations to all text content
660773
Object.values(tree.pages).forEach((page) => {
661-
// Translate page names
662774
if (page.name && translations.has(page.name)) {
663775
const translatedName = translations.get(page.name);
664776
if (translatedName !== undefined) {
665777
page.name = translatedName;
666778
}
667779
}
668780

669-
// Translate button labels and messages
670781
page.buttons.forEach((button) => {
671782
if (button.label && translations.has(button.label)) {
672783
const translatedLabel = translations.get(button.label);
@@ -683,7 +794,6 @@ class TouchChatProcessor extends BaseProcessor {
683794
});
684795
});
685796

686-
// Save the translated tree and return its content
687797
await this.saveFromTree(tree, outputPath);
688798
const fs = getFs();
689799
return fs.readFileSync(outputPath);

src/validation/touchChatValidator.ts

Lines changed: 156 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import * as xml2js from 'xml2js';
55
import { BaseValidator } from './baseValidator';
66
import { ValidationResult } from './validationTypes';
77
import { decodeText, getBasename, getFs, readBinaryFromInput, toUint8Array } from '../utils/io';
8+
import { openZipFromInput } from '../utils/zip';
9+
import { openSqliteDatabase } from '../utils/sqlite';
810

911
/**
1012
* Validator for TouchChat files (.ce)
11-
* TouchChat files are XML-based
13+
* TouchChat files are ZIP archives that contain a .c4v SQLite database.
14+
* Some legacy exports may be XML, so we support both formats.
1215
*/
1316
export class TouchChatValidator extends BaseValidator {
1417
constructor() {
@@ -34,6 +37,17 @@ export class TouchChatValidator extends BaseValidator {
3437
return true;
3538
}
3639

40+
// Try to parse as ZIP and check for .c4v database
41+
try {
42+
const { zip } = await openZipFromInput(content);
43+
const entries = zip.listFiles();
44+
if (entries.some((entry) => entry.toLowerCase().endsWith('.c4v'))) {
45+
return true;
46+
}
47+
} catch {
48+
// Fall back to XML detection
49+
}
50+
3751
// Try to parse as XML and check for TouchChat structure
3852
try {
3953
const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content));
@@ -62,45 +76,48 @@ export class TouchChatValidator extends BaseValidator {
6276
}
6377
});
6478

65-
let xmlObj: any = null;
66-
await this.add_check('xml_parse', 'valid XML', async () => {
67-
try {
68-
const parser = new xml2js.Parser();
69-
const contentStr = decodeText(content);
70-
xmlObj = await parser.parseStringPromise(contentStr);
71-
} catch (e: any) {
72-
this.err(`Failed to parse XML: ${e.message}`, true);
79+
const zipped = await this.tryValidateZipSqlite(content);
80+
if (!zipped) {
81+
let xmlObj: any = null;
82+
await this.add_check('xml_parse', 'valid XML', async () => {
83+
try {
84+
const parser = new xml2js.Parser();
85+
const contentStr = decodeText(content);
86+
xmlObj = await parser.parseStringPromise(contentStr);
87+
} catch (e: any) {
88+
this.err(`Failed to parse XML: ${e.message}`, true);
89+
}
90+
});
91+
92+
if (!xmlObj) {
93+
return this.buildResult(filename, filesize, 'touchchat');
7394
}
74-
});
7595

76-
if (!xmlObj) {
77-
return this.buildResult(filename, filesize, 'touchchat');
78-
}
96+
await this.add_check('xml_structure', 'TouchChat root element', async () => {
97+
// TouchChat can have different root elements
98+
const hasValidRoot =
99+
xmlObj.PageSet ||
100+
xmlObj.Pageset ||
101+
xmlObj.page ||
102+
xmlObj.Page ||
103+
xmlObj.pages ||
104+
xmlObj.Pages;
79105

80-
await this.add_check('xml_structure', 'TouchChat root element', async () => {
81-
// TouchChat can have different root elements
82-
const hasValidRoot =
106+
if (!hasValidRoot) {
107+
this.err('file does not contain a recognized TouchChat structure');
108+
}
109+
});
110+
111+
const root =
83112
xmlObj.PageSet ||
84113
xmlObj.Pageset ||
85114
xmlObj.page ||
86115
xmlObj.Page ||
87116
xmlObj.pages ||
88117
xmlObj.Pages;
89-
90-
if (!hasValidRoot) {
91-
this.err('file does not contain a recognized TouchChat structure');
118+
if (root) {
119+
await this.validateTouchChatStructure(root);
92120
}
93-
});
94-
95-
const root =
96-
xmlObj.PageSet ||
97-
xmlObj.Pageset ||
98-
xmlObj.page ||
99-
xmlObj.Page ||
100-
xmlObj.pages ||
101-
xmlObj.Pages;
102-
if (root) {
103-
await this.validateTouchChatStructure(root);
104121
}
105122

106123
return this.buildResult(filename, filesize, 'touchchat');
@@ -243,4 +260,113 @@ export class TouchChatValidator extends BaseValidator {
243260
}
244261
);
245262
}
263+
264+
private isSQLiteBuffer(content: Buffer | Uint8Array): boolean {
265+
const header = 'SQLite format 3\u0000';
266+
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
267+
if (bytes.length < header.length) {
268+
return false;
269+
}
270+
for (let i = 0; i < header.length; i++) {
271+
if (bytes[i] !== header.charCodeAt(i)) {
272+
return false;
273+
}
274+
}
275+
return true;
276+
}
277+
278+
private async tryValidateZipSqlite(content: Buffer | Uint8Array): Promise<boolean> {
279+
let usedZip = false;
280+
await this.add_check('zip', 'TouchChat ZIP package', async () => {
281+
try {
282+
const { zip } = await openZipFromInput(content);
283+
const entries = zip.listFiles();
284+
const vocabEntry = entries.find((name) => name.toLowerCase().endsWith('.c4v'));
285+
if (!vocabEntry) {
286+
this.err('TouchChat package missing .c4v database', true);
287+
return;
288+
}
289+
const dbBuffer = await zip.readFile(vocabEntry);
290+
if (!this.isSQLiteBuffer(dbBuffer)) {
291+
this.err('TouchChat .c4v is not a valid SQLite database', true);
292+
return;
293+
}
294+
usedZip = true;
295+
await this.validateSqliteStructure(dbBuffer);
296+
} catch (e: any) {
297+
this.err(`file is not a valid TouchChat ZIP package: ${e.message}`, true);
298+
}
299+
});
300+
return usedZip;
301+
}
302+
303+
private async validateSqliteStructure(content: Buffer | Uint8Array): Promise<void> {
304+
await this.add_check('sqlite', 'valid TouchChat SQLite database', async () => {
305+
let cleanup: (() => void) | undefined;
306+
try {
307+
const result = await openSqliteDatabase(content, { readonly: true });
308+
const db = result.db;
309+
cleanup = result.cleanup;
310+
311+
const tableRows = db
312+
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
313+
.all() as Array<{ name: string }>;
314+
const tables = new Set(tableRows.map((row) => row.name));
315+
316+
const requiredTables = [
317+
'resources',
318+
'pages',
319+
'buttons',
320+
'button_boxes',
321+
'button_box_cells',
322+
'button_box_instances',
323+
];
324+
const missingTables = requiredTables.filter((t) => !tables.has(t));
325+
if (missingTables.length > 0) {
326+
this.err(`Missing required TouchChat tables: ${missingTables.join(', ')}`);
327+
}
328+
329+
const resourcesCols = new Set(
330+
db
331+
.prepare('PRAGMA table_info(resources)')
332+
.all()
333+
.map((row: any) => row.name)
334+
);
335+
if (!resourcesCols.has('id') || !resourcesCols.has('name')) {
336+
this.err('resources table missing id/name columns');
337+
}
338+
339+
const pagesCols = new Set(
340+
db
341+
.prepare('PRAGMA table_info(pages)')
342+
.all()
343+
.map((row: any) => row.name)
344+
);
345+
if (!pagesCols.has('id') || !pagesCols.has('resource_id')) {
346+
this.err('pages table missing id/resource_id columns');
347+
}
348+
349+
const buttonsCols = new Set(
350+
db
351+
.prepare('PRAGMA table_info(buttons)')
352+
.all()
353+
.map((row: any) => row.name)
354+
);
355+
if (!buttonsCols.has('id') || !buttonsCols.has('resource_id')) {
356+
this.err('buttons table missing id/resource_id columns');
357+
}
358+
359+
const pageCount = db.prepare('SELECT COUNT(*) as c FROM pages').get() as { c: number };
360+
if (!pageCount || pageCount.c === 0) {
361+
this.warn('TouchChat database has no pages');
362+
}
363+
} catch (e: any) {
364+
this.err(`TouchChat database validation failed: ${e.message}`, true);
365+
} finally {
366+
if (cleanup) {
367+
cleanup();
368+
}
369+
}
370+
});
371+
}
246372
}

0 commit comments

Comments
 (0)