11import { appState , summarizeStudioOutputs , studioIconNames } from "./state.js" ;
22import { matchesQuery , showToast , escapeHtml , applyOfflineIcons } from "./dom.js" ;
33import { prepareStudioView } from "./studio.js" ;
4+ import { launchQuizPlayer } from "./quizplayer.js" ;
45
56function artifactKindToStudio ( kind ) {
67 const normalized = String ( kind || "" ) . toLowerCase ( ) ;
@@ -346,14 +347,10 @@ function quizAnswerText(answer, options) {
346347 return text ;
347348}
348349
349- function renderQuizArtifact ( content ) {
350- let data ;
351- try {
352- data = JSON . parse ( content ) ;
353- } catch ( error ) {
354- return renderRawArtifact ( content ) ;
355- }
356- const questions = Array . isArray ( data )
350+ // Pull the array of question objects out of the many shapes quiz JSON can take
351+ // (bare array, or wrapped under .questions / .quiz / .items).
352+ function quizQuestionList ( data ) {
353+ return Array . isArray ( data )
357354 ? data
358355 : Array . isArray ( data && data . questions )
359356 ? data . questions
@@ -362,6 +359,85 @@ function renderQuizArtifact(content) {
362359 : Array . isArray ( data && data . items )
363360 ? data . items
364361 : [ ] ;
362+ }
363+
364+ // True/false detection for questions that carry no options list but a boolean
365+ // (or "true"/"false") answer — so the player can still present two choices.
366+ function quizBooleanAnswer ( answer ) {
367+ if ( answer === true ) return "true" ;
368+ if ( answer === false ) return "false" ;
369+ const values = Array . isArray ( answer ) ? answer : [ answer ] ;
370+ for ( const candidate of values ) {
371+ if ( candidate === true ) return "true" ;
372+ if ( candidate === false ) return "false" ;
373+ const text = String ( candidate == null ? "" : candidate ) . trim ( ) . toLowerCase ( ) ;
374+ if ( text === "true" || text === "t" || text === "yes" ) return "true" ;
375+ if ( text === "false" || text === "f" || text === "no" ) return "false" ;
376+ }
377+ return null ;
378+ }
379+
380+ // Normalize one raw question object into the structure the player consumes.
381+ // All key-variant handling (question/prompt/text, options/choices/answers,
382+ // answer/correct/correctIndex/…) is reused from the previewer helpers so the two
383+ // stay in lock-step. Returns { text, options:[{text,correct}], correctIndex,
384+ // answerText, explanation }. options is [] for open-ended questions.
385+ function normalizeQuizQuestion ( question ) {
386+ const text = ( question && ( question . question ?? question . prompt ?? question . text ?? question . title ) ) ?? "" ;
387+ const rawOptions = ( question && ( question . options ?? question . choices ?? question . answers ?? question . alternatives ) ) ?? [ ] ;
388+ const options = Array . isArray ( rawOptions ) ? rawOptions : [ ] ;
389+ const answer = ( question
390+ && ( question . answer ?? question . correct ?? question . correctAnswer ?? question . correct_answer
391+ ?? question . correctOption ?? question . correct_option ?? question . solution
392+ ?? question . correctIndex ?? question . correct_index ) ) ?? null ;
393+ let normalizedOptions = options . map ( ( option , index ) => {
394+ const optionText = quizOptionText ( option ) ;
395+ const correct = quizOptionMarkedCorrect ( option ) || optionMatchesAnswer ( optionText , index , answer ) ;
396+ return { text : optionText , correct } ;
397+ } ) ;
398+ // Synthesize True/False choices for boolean questions with no options list.
399+ if ( normalizedOptions . length === 0 ) {
400+ const boolean = quizBooleanAnswer ( answer ) ;
401+ const type = String ( ( question && ( question . type ?? question . questionType ?? question . kind ) ) ?? "" ) . toLowerCase ( ) ;
402+ if ( boolean !== null || type . includes ( "true" ) || type . includes ( "bool" ) ) {
403+ normalizedOptions = [
404+ { text : "True" , correct : boolean === "true" } ,
405+ { text : "False" , correct : boolean === "false" } ,
406+ ] ;
407+ }
408+ }
409+ const correctIndex = normalizedOptions . findIndex ( ( option ) => option . correct ) ;
410+ const answerText = quizAnswerText ( answer , options ) ;
411+ const explanation = question && ( question . explanation ?? question . rationale ?? question . reason ) ;
412+ return {
413+ text : String ( text ) ,
414+ options : normalizedOptions ,
415+ correctIndex,
416+ answerText : answerText ? String ( answerText ) : "" ,
417+ explanation : explanation ? String ( explanation ) : "" ,
418+ } ;
419+ }
420+
421+ // Parse a quiz artifact's file content into normalized questions for the player.
422+ // Returns [] when the content is not parseable quiz JSON.
423+ function parseQuizQuestions ( content ) {
424+ let data ;
425+ try {
426+ data = JSON . parse ( content ) ;
427+ } catch ( error ) {
428+ return [ ] ;
429+ }
430+ return quizQuestionList ( data ) . map ( normalizeQuizQuestion ) ;
431+ }
432+
433+ function renderQuizArtifact ( content ) {
434+ let data ;
435+ try {
436+ data = JSON . parse ( content ) ;
437+ } catch ( error ) {
438+ return renderRawArtifact ( content ) ;
439+ }
440+ const questions = quizQuestionList ( data ) ;
365441 if ( questions . length === 0 ) return renderRawArtifact ( content ) ;
366442 const items = questions . map ( ( question ) => {
367443 const text = ( question && ( question . question ?? question . prompt ?? question . text ?? question . title ) ) ?? "" ;
@@ -633,6 +709,34 @@ async function openArtifactPreview(artifact) {
633709 }
634710}
635711
712+ // Resolve the local quiz file for a quiz artifact, parse it, and hand the
713+ // normalized questions to the interactive player (quizplayer.js). Reuses the
714+ // same local-path resolution and JSON parsing as the previewer.
715+ async function openQuizPlayer ( artifact ) {
716+ if ( ! artifact ) return ;
717+ const studioName = artifactKindToStudio ( artifact . kind ) ;
718+ const local = resolveLocalArtifactPath ( artifact , "quiz" ) ;
719+ if ( ! local || ! local . path ) {
720+ showToast ( "This quiz has not been downloaded locally yet." ) ;
721+ return ;
722+ }
723+ try {
724+ const result = await window . electronAPI . readFile ( local . path ) ;
725+ if ( ! result || ! result . success ) {
726+ showToast ( "The local quiz file could not be read." ) ;
727+ return ;
728+ }
729+ const questions = parseQuizQuestions ( result . content ) ;
730+ if ( ! questions . length ) {
731+ showToast ( "No quiz questions could be read from this file." ) ;
732+ return ;
733+ }
734+ launchQuizPlayer ( questions , artifact . title || "Quiz" ) ;
735+ } catch ( error ) {
736+ showToast ( "Something went wrong while loading this quiz." ) ;
737+ }
738+ }
739+
636740// --- Entry point: inject a preview button into each Studio artifact row ------
637741// The rows themselves are rendered by studio.js (off-limits), so we observe the
638742// list container and augment freshly rendered rows. Ordering mirrors
@@ -652,6 +756,23 @@ function buildArtifactPreviewButton(artifact) {
652756 return button ;
653757}
654758
759+ // A "Play" button shown only on quiz artifact rows — launches the interactive
760+ // player instead of the static preview.
761+ function buildQuizPlayButton ( artifact ) {
762+ const button = document . createElement ( "button" ) ;
763+ button . type = "button" ;
764+ button . className = "artifact-play-btn" ;
765+ button . title = "Play quiz" ;
766+ button . setAttribute ( "aria-label" , "Play quiz" ) ;
767+ button . innerHTML = '<span class="material-symbols-outlined" data-icon-name="play_circle">play_circle</span>' ;
768+ button . addEventListener ( "click" , ( event ) => {
769+ event . preventDefault ( ) ;
770+ event . stopPropagation ( ) ;
771+ void openQuizPlayer ( artifact ) ;
772+ } ) ;
773+ return button ;
774+ }
775+
655776function augmentStudioArtifactRows ( list ) {
656777 const artifacts = filteredStudioArtifacts ( ) ;
657778 const rows = list . querySelectorAll ( ":scope > div" ) ;
@@ -673,6 +794,11 @@ function augmentStudioArtifactRows(list) {
673794 } else {
674795 row . appendChild ( actions ) ;
675796 }
797+ if ( artifactKindToStudio ( artifact . kind ) === "quiz" ) {
798+ const playButton = buildQuizPlayButton ( artifact ) ;
799+ actions . appendChild ( playButton ) ;
800+ applyOfflineIcons ( playButton ) ;
801+ }
676802 const button = buildArtifactPreviewButton ( artifact ) ;
677803 actions . appendChild ( button ) ;
678804 applyOfflineIcons ( button ) ;
@@ -697,12 +823,15 @@ export {
697823 toggleStudioArtifactSelection ,
698824 deleteSelectedStudioArtifacts ,
699825 openArtifactPreview ,
826+ openQuizPlayer ,
827+ parseQuizQuestions ,
700828 initArtifactPreview ,
701829} ;
702830
703831// Self-register (app.js is off-limits; other modules use this pattern too).
704832if ( typeof window !== "undefined" ) {
705833 window . openArtifactPreview = openArtifactPreview ;
834+ window . openQuizPlayer = openQuizPlayer ;
706835 if ( typeof document !== "undefined" ) {
707836 if ( document . readyState === "loading" ) {
708837 document . addEventListener ( "DOMContentLoaded" , initArtifactPreview , { once : true } ) ;
0 commit comments