@@ -207,6 +207,13 @@ export class TextToSpeechToolApp {
207207 voiceAgePresetDefaults : TEXT_TO_SPEECH_VOICE_AGE_PRESET_DEFAULTS
208208 } ) ;
209209 this . actionNav . mount ( {
210+ onCopyJson : ( ) => {
211+ void this . copyJson ( ) ;
212+ } ,
213+ onExportJson : ( ) => this . exportJson ( ) ,
214+ onImportJson : ( file ) => {
215+ void this . importJson ( file ) ;
216+ } ,
210217 onPause : ( ) => this . pause ( ) ,
211218 onResume : ( ) => this . resume ( ) ,
212219 onReturnToWorkspace : ( url ) => {
@@ -330,18 +337,18 @@ export class TextToSpeechToolApp {
330337 this . actionNav . setSpeakEnabled ( false ) ;
331338 return ;
332339 }
333- const validation = validateQueue ( queueDataResult . payload . queue ) ;
340+ const validation = validateQueue ( queueDataResult . payload ) ;
334341 if ( ! validation . ok ) {
335342 this . statusLog . fail ( validation . message ) ;
336343 this . actionNav . setSpeakEnabled ( false ) ;
337344 return ;
338345 }
339- this . queueControl . populate ( queueDataResult . payload . queue ) ;
340- this . applyQueueItem ( this . queueControl . selectedItem ( ) || queueDataResult . payload . queue [ 0 ] , "queue-loaded" ) ;
346+ this . queueControl . populate ( queueDataResult . payload ) ;
347+ this . applyQueueItem ( this . queueControl . selectedItem ( ) || queueDataResult . payload [ 0 ] , "queue-loaded" ) ;
341348 this . statusLog . ok ( `Loaded ${ TEXT_TO_SPEECH_DISPLAY_NAME } payload source: ${ queueDataResult . sourcePath } .` ) ;
342- this . statusLog . ok ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } schema validation result: ${ TEXT_TO_SPEECH_SCHEMA_ID } valid; queue=${ queueDataResult . payload . queue . length } .` ) ;
349+ this . statusLog . ok ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } schema validation result: ${ TEXT_TO_SPEECH_SCHEMA_ID } valid; queue=${ queueDataResult . payload . length } .` ) ;
343350 this . statusLog . ok ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } dirty state: ${ queueDataResult . dirtyState } .` ) ;
344- this . statusLog . ok ( `Loaded ${ queueDataResult . payload . queue . length } schema-complete ${ TEXT_TO_SPEECH_DISPLAY_NAME } queue items.` ) ;
351+ this . statusLog . ok ( `Loaded ${ queueDataResult . payload . length } schema-complete ${ TEXT_TO_SPEECH_DISPLAY_NAME } queue items.` ) ;
345352 }
346353
347354 queueData ( ) {
@@ -366,7 +373,10 @@ export class TextToSpeechToolApp {
366373 }
367374 try {
368375 const toolState = JSON . parse ( rawToolState ) ;
369- if ( ! isPlainObject ( toolState ?. data ) ) {
376+ if ( ! isPlainObject ( toolState ) ) {
377+ return { ok : false , message : `${ WORKSPACE_TOOL_STATE_KEY } must contain the normalized workspace toolState object before render.` } ;
378+ }
379+ if ( ! Object . prototype . hasOwnProperty . call ( toolState , "data" ) ) {
370380 return { ok : false , message : `${ WORKSPACE_TOOL_STATE_KEY } .data must contain the ${ TEXT_TO_SPEECH_DISPLAY_NAME } payload before render.` } ;
371381 }
372382 return {
@@ -455,10 +465,7 @@ export class TextToSpeechToolApp {
455465 this . statusLog . fail ( `Cannot mark ${ TEXT_TO_SPEECH_DISPLAY_NAME } dirty: ${ WORKSPACE_TOOL_STATE_KEY } is not an object.` ) ;
456466 return ;
457467 }
458- const nextData = {
459- ...( isPlainObject ( toolState . data ) ? toolState . data : { } ) ,
460- queue : this . queueControl . selectedQueue ( )
461- } ;
468+ const nextData = this . queueControl . selectedQueue ( ) ;
462469 if ( this . payloadSchema ) {
463470 const validation = this . validatePayload ( nextData , toolState . workspace ?. gameManifestPath || WORKSPACE_TOOL_STATE_KEY ) ;
464471 if ( ! validation . ok ) {
@@ -476,13 +483,125 @@ export class TextToSpeechToolApp {
476483 changedKeys
477484 }
478485 } ) ) ;
479- this . statusLog . ok ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } dirty state: true; reason=${ reason } ; changedKeys=${ changedKeys . join ( ", " ) } ; queue=${ nextData . queue . length } .` ) ;
486+ this . statusLog . ok ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } dirty state: true; reason=${ reason } ; changedKeys=${ changedKeys . join ( ", " ) } ; queue=${ nextData . length } .` ) ;
480487 this . statusLog . ok ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } manifest write-back target: ${ toolState . workspace ?. gameManifestPath || "(missing manifest path)" } .` ) ;
481488 } catch ( error ) {
482489 this . statusLog . fail ( `Cannot mark ${ TEXT_TO_SPEECH_DISPLAY_NAME } dirty: ${ error . message } ` ) ;
483490 }
484491 }
485492
493+ async ensurePayloadSchemaForAction ( actionLabel ) {
494+ const schemaResult = await this . loadPayloadSchema ( ) ;
495+ if ( ! schemaResult . ok ) {
496+ this . statusLog . fail ( `${ actionLabel } blocked: ${ schemaResult . message } ` ) ;
497+ return false ;
498+ }
499+ return true ;
500+ }
501+
502+ validateCurrentPayloadForAction ( actionLabel ) {
503+ const payload = this . queueControl . selectedQueue ( ) ;
504+ const payloadValidation = this . validatePayload ( payload , `${ TEXT_TO_SPEECH_DISPLAY_NAME } current UI payload` ) ;
505+ if ( ! payloadValidation . ok ) {
506+ this . statusLog . fail ( `${ actionLabel } blocked: ${ payloadValidation . message } ` ) ;
507+ return { ok : false } ;
508+ }
509+ const queueValidation = validateQueue ( payload ) ;
510+ if ( ! queueValidation . ok ) {
511+ this . statusLog . fail ( `${ actionLabel } blocked: ${ queueValidation . message } ` ) ;
512+ return { ok : false } ;
513+ }
514+ return { ok : true , payload } ;
515+ }
516+
517+ async importJson ( file ) {
518+ if ( this . isWorkspaceLaunch ( ) ) {
519+ this . statusLog . fail ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } Import JSON is only available during standalone launch.` ) ;
520+ return ;
521+ }
522+ if ( ! file ) {
523+ this . statusLog . fail ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } Import JSON blocked: choose a JSON file first.` ) ;
524+ return ;
525+ }
526+ if ( ! ( await this . ensurePayloadSchemaForAction ( "Import JSON" ) ) ) {
527+ return ;
528+ }
529+
530+ let payload ;
531+ try {
532+ payload = JSON . parse ( await file . text ( ) ) ;
533+ } catch ( error ) {
534+ this . statusLog . fail ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } Import JSON failed: ${ file . name || "selected file" } is invalid JSON: ${ error . message } ` ) ;
535+ return ;
536+ }
537+ const sourcePath = file . name || "selected JSON file" ;
538+ const payloadValidation = this . validatePayload ( payload , sourcePath ) ;
539+ if ( ! payloadValidation . ok ) {
540+ this . statusLog . fail ( `Import JSON blocked: ${ payloadValidation . message } ` ) ;
541+ return ;
542+ }
543+ const queueValidation = validateQueue ( payload ) ;
544+ if ( ! queueValidation . ok ) {
545+ this . statusLog . fail ( `Import JSON blocked: ${ queueValidation . message } ` ) ;
546+ return ;
547+ }
548+ this . queueControl . populate ( payload ) ;
549+ this . applyQueueItem ( this . queueControl . selectedItem ( ) || payload [ 0 ] , "json-imported" ) ;
550+ this . refreshVoices ( "json-imported" ) ;
551+ this . refreshOutputSummary ( "json-imported" ) ;
552+ this . statusLog . ok ( `Imported ${ payload . length } ${ TEXT_TO_SPEECH_DISPLAY_NAME } item${ payload . length === 1 ? "" : "s" } from ${ sourcePath } ; schema validation result: ${ TEXT_TO_SPEECH_SCHEMA_ID } valid.` ) ;
553+ }
554+
555+ async copyJson ( ) {
556+ if ( this . isWorkspaceLaunch ( ) ) {
557+ this . statusLog . fail ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } Copy JSON is only available during standalone launch.` ) ;
558+ return ;
559+ }
560+ if ( ! ( await this . ensurePayloadSchemaForAction ( "Copy JSON" ) ) ) {
561+ return ;
562+ }
563+ const validation = this . validateCurrentPayloadForAction ( "Copy JSON" ) ;
564+ if ( ! validation . ok ) {
565+ return ;
566+ }
567+ if ( ! this . window . navigator ?. clipboard || typeof this . window . navigator . clipboard . writeText !== "function" ) {
568+ this . statusLog . fail ( "Copy JSON failed: Clipboard API is unavailable." ) ;
569+ return ;
570+ }
571+ const json = JSON . stringify ( validation . payload , null , 2 ) ;
572+ try {
573+ await this . window . navigator . clipboard . writeText ( json ) ;
574+ this . statusLog . ok ( `Copied ${ TEXT_TO_SPEECH_DISPLAY_NAME } JSON root array to clipboard (${ validation . payload . length } item${ validation . payload . length === 1 ? "" : "s" } ).` ) ;
575+ } catch ( error ) {
576+ this . statusLog . fail ( `Copy JSON failed: ${ error . message } ` ) ;
577+ }
578+ }
579+
580+ async exportJson ( ) {
581+ if ( this . isWorkspaceLaunch ( ) ) {
582+ this . statusLog . fail ( `${ TEXT_TO_SPEECH_DISPLAY_NAME } Export JSON is only available during standalone launch.` ) ;
583+ return ;
584+ }
585+ if ( ! ( await this . ensurePayloadSchemaForAction ( "Export JSON" ) ) ) {
586+ return ;
587+ }
588+ const validation = this . validateCurrentPayloadForAction ( "Export JSON" ) ;
589+ if ( ! validation . ok ) {
590+ return ;
591+ }
592+ const json = JSON . stringify ( validation . payload , null , 2 ) ;
593+ const blob = new Blob ( [ json ] , { type : "application/json" } ) ;
594+ const url = URL . createObjectURL ( blob ) ;
595+ const link = this . window . document . createElement ( "a" ) ;
596+ link . href = url ;
597+ link . download = "text-to-speech-v2.json" ;
598+ this . window . document . body . append ( link ) ;
599+ link . click ( ) ;
600+ link . remove ( ) ;
601+ URL . revokeObjectURL ( url ) ;
602+ this . statusLog . ok ( `Exported ${ TEXT_TO_SPEECH_DISPLAY_NAME } JSON root array (${ validation . payload . length } item${ validation . payload . length === 1 ? "" : "s" } ).` ) ;
603+ }
604+
486605 addSpeechItem ( ) {
487606 const requestedName = this . queueControl . itemName ( ) ;
488607 if ( ! requestedName ) {
0 commit comments