@@ -5,10 +5,13 @@ import * as xml2js from 'xml2js';
55import { BaseValidator } from './baseValidator' ;
66import { ValidationResult } from './validationTypes' ;
77import { 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 */
1316export 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