@@ -187,6 +187,42 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
187187 return ;
188188 }
189189 }
190+
191+ /** The SCORM data model for this attempt.
192+ */
193+ scorm_cmi ( ) {
194+ const suspend_data = this . content ( ) ;
195+ const cmi = {
196+ 'cmi.entry' : 'resume' ,
197+ 'cmi.mode' : 'review' ,
198+ 'cmi.suspend_data' : JSON . stringify ( suspend_data ) ,
199+ 'cmi.learner_name' : this . student_name ( ) ,
200+ 'cmi.score.raw' : this . score ( ) ,
201+ }
202+
203+ var partAcc = 0 ;
204+ function visit_part ( p , path ) {
205+ const prepath = `cmi.interactions.${ partAcc } .` ;
206+ cmi [ prepath + 'id' ] = path ;
207+ cmi [ prepath + 'learner_response' ] = p . student_answer ;
208+ partAcc += 1 ;
209+ if ( p . gaps ) {
210+ p . gaps . forEach ( ( g , i ) => visit_part ( g , `${ path } g${ i } ` ) ) ;
211+ }
212+ if ( p . steps ) {
213+ p . steps . forEach ( ( s , i ) => visit_part ( s , `${ path } s${ i } ` ) ) ;
214+ }
215+ }
216+ suspend_data . questions . forEach ( ( q , i ) => {
217+ const qid = `q${ i } ` ;
218+ cmi [ `cmi.objectives.${ i } .id` ] = qid ;
219+ cmi [ `cmi.objectives.${ i } .score.raw` ] = q . score ;
220+ q . parts . forEach ( ( p , j ) => visit_part ( p , `${ qid } p${ j } ` ) ) ;
221+ } ) ;
222+ cmi [ 'cmi.objectives._count' ] = suspend_data . questions . length ;
223+ cmi [ 'cmi.interactions._count' ] = partAcc ;
224+ return cmi ;
225+ }
190226 }
191227
192228 class ViewModel {
@@ -217,6 +253,10 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
217253 /** The current tab the user is on. Toggles between 'upload', 'list_files', and 'table'.*/
218254 this . current_tab = ko . observable ( 'list_files' ) ;
219255
256+ this . current_tab . subscribe ( ( v ) => {
257+ document . body . dataset . currentTab = v ;
258+ } ) ;
259+
220260 /** The potential options for table display, along with description*/
221261 /** Descriptive names for each column in the full table.
222262 * This is an array of four rows, containing cells that span several rows or columns.
@@ -236,6 +276,26 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
236276 /** The currently selected table format, from the above labels.
237277 */
238278 this . table_format = ko . observable ( this . table_format_options ( ) [ 0 ] ) ;
279+
280+ this . reviewing_file = ko . observable ( null ) ;
281+
282+ this . review_file = ( file ) => {
283+ const changed = file != this . reviewing_file ( ) ;
284+ this . reviewing_file ( file ) ;
285+ this . current_tab ( 'review' ) ;
286+ if ( changed ) {
287+ window . API_1484_11 = new SCORM_API ( { scorm_cmi : file . scorm_cmi ( ) } ) ;
288+ document . getElementById ( 'review-frame' ) . contentWindow . location . reload ( ) ;
289+ }
290+ } ;
291+
292+ this . reviewing_attempt_text = ko . computed ( function ( ) {
293+ const file = this . reviewing_file ( ) ;
294+ if ( ! file ) {
295+ return '' ;
296+ }
297+ return R ( 'analysis.reviewing attempt' , { student_name : file . student_name ( ) } ) ;
298+ } , this ) ;
239299
240300 /** The uploaded files, sorted by status and then by student name.
241301 */
@@ -425,16 +485,23 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
425485 if ( state . files !== undefined ) {
426486 this . uploaded_files ( state . files . map ( fd => {
427487 const af = new AttemptFile ( new File ( [ fd . raw_text ] , fd . filename ) , this ) ;
428- af . decrypt ( ) ;
488+ af . decryptPromise = af . decrypt ( ) ;
429489 return af ;
430490 } ) ) ;
431491 }
492+ if ( state . reviewing_file !== undefined && state . reviewing_file >= 0 ) {
493+ const af = this . uploaded_files ( ) [ state . reviewing_file ] ;
494+ af . decryptPromise . then ( ( ) => {
495+ this . review_file ( af ) ;
496+ } ) ;
497+ }
432498
433499 ko . computed ( ( ) => {
434500 const state = {
435501 current_tab : this . current_tab ( ) ,
436502 table_format : this . table_format ( ) . label ,
437- files : this . uploaded_files ( ) . map ( f => f . as_json ( ) )
503+ files : this . uploaded_files ( ) . map ( f => f . as_json ( ) ) ,
504+ reviewing_file : this . uploaded_files ( ) . indexOf ( this . reviewing_file ( ) )
438505 } ;
439506
440507 if ( window . history . state ?. current_tab != state . current_tab ) {
@@ -534,7 +601,6 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
534601 /** Handler for the 'change' event on the upload files input.
535602 */
536603 input_files ( vm , evt ) {
537- console . log ( evt . target . files ) ;
538604 this . add_files ( Array . from ( evt . target . files ) ) ;
539605 }
540606
@@ -564,6 +630,199 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
564630 }
565631 }
566632
633+
634+
635+
636+ /** A SCORM API.
637+ * It provides the `window.API_1484_11` object, which SCORM packages use to interact with the data model.
638+ */
639+ function SCORM_API ( options ) {
640+ var data = options . scorm_cmi ;
641+
642+ this . callbacks = new CallbackHandler ( ) ;
643+
644+ this . initialise_data ( data ) ;
645+
646+ this . initialise_api ( ) ;
647+ }
648+ SCORM_API . prototype = {
649+ /** Has the API been initialised?
650+ */
651+ initialized : false ,
652+
653+ /** Has the API been terminated?
654+ */
655+ terminated : false ,
656+
657+ /** The code of the last error that was raised
658+ */
659+ last_error : 0 ,
660+
661+ /** Setup the SCORM data model.
662+ * Merge in elements loaded from the page with elements saved to localStorage, taking the most recent value when there's a clash.
663+ */
664+ initialise_data : function ( data ) {
665+ // create the data model
666+ this . data = { } ;
667+ for ( var key in data ) {
668+ this . data [ key ] = data [ key ] ;
669+ }
670+
671+ /** SCORM display mode - 'normal' or 'review'
672+ */
673+ this . mode = this . data [ 'cmi.mode' ] ;
674+
675+ /** Is the client allowed to change data model elements?
676+ * Not allowed in review mode.
677+ */
678+ this . allow_set = this . mode == 'normal' ;
679+
680+ this . callbacks . trigger ( 'initialise_data' ) ;
681+ } ,
682+
683+ /** Initialise the SCORM API and expose it to the SCORM activity
684+ */
685+ initialise_api : function ( ) {
686+ var sc = this ;
687+
688+ /** The API object to expose to the SCORM activity
689+ */
690+ this . API_1484_11 = { } ;
691+ [ 'Initialize' , 'Terminate' , 'GetLastError' , 'GetErrorString' , 'GetDiagnostic' , 'GetValue' , 'SetValue' , 'Commit' ] . forEach ( function ( fn ) {
692+ sc . API_1484_11 [ fn ] = function ( ) {
693+ return sc [ fn ] . apply ( sc , arguments ) ;
694+ } ;
695+ } ) ;
696+
697+ /** Counts for the various lists in the data model
698+ */
699+ this . counts = {
700+ 'comments_from_learner' : 0 ,
701+ 'comments_from_lms' : 0 ,
702+ 'interactions' : 0 ,
703+ 'objectives' : 0 ,
704+ }
705+ this . interaction_counts = [ ] ;
706+
707+ /** Set the counts based on the existing data model
708+ */
709+ for ( var key in this . data ) {
710+ this . check_key_counts_something ( key ) ;
711+ }
712+
713+ this . callbacks . trigger ( 'initialise_api' ) ;
714+ } ,
715+
716+ /** For a given data model key, if it belongs to a list, update the counter for that list
717+ */
718+ check_key_counts_something : function ( key ) {
719+ var m ;
720+ if ( m = key . match ( / ^ c m i .( \w + ) .( \d + ) / ) ) {
721+ var ckey = m [ 1 ] ;
722+ var n = parseInt ( m [ 2 ] ) ;
723+ this . counts [ ckey ] = Math . max ( n + 1 , this . counts [ ckey ] ) ;
724+ this . data [ 'cmi.' + ckey + '._count' ] = this . counts [ ckey ] ;
725+ if ( ckey == 'interactions' && this . interaction_counts [ n ] === undefined ) {
726+ this . interaction_counts [ n ] = {
727+ 'objectives' : 0 ,
728+ 'correct_responses' : 0
729+ }
730+ }
731+ }
732+ if ( m = key . match ( / ^ c m i .i n t e r a c t i o n s .( \d + ) .( o b j e c t i v e s | c o r r e c t _ r e s p o n s e s ) .( \d + ) / ) ) {
733+ var n1 = parseInt ( m [ 1 ] ) ;
734+ var skey = m [ 2 ] ;
735+ var n2 = parseInt ( m [ 3 ] ) ;
736+ this . interaction_counts [ n1 ] [ skey ] = Math . max ( n2 + 1 , this . interaction_counts [ n1 ] [ skey ] ) ;
737+ this . data [ 'cmi.interactions.' + n1 + '.' + skey + '._count' ] = this . interaction_counts [ n1 ] [ skey ] ;
738+ }
739+ } ,
740+
741+ Initialize : function ( b ) {
742+ this . callbacks . trigger ( 'Initialize' , b ) ;
743+ if ( b != '' || this . initialized || this . terminated ) {
744+ return false ;
745+ }
746+ this . initialized = true ;
747+ return true ;
748+ } ,
749+
750+ Terminate : function ( b ) {
751+ this . callbacks . trigger ( 'Terminate' , b ) ;
752+ if ( b != '' || ! this . initialized || this . terminated ) {
753+ return false ;
754+ }
755+ this . terminated = true ;
756+
757+ return true ;
758+ } ,
759+
760+ GetLastError : function ( ) {
761+ return this . last_error ;
762+ } ,
763+
764+ GetErrorString : function ( code ) {
765+ return "I haven't written any error strings yet." ;
766+ } ,
767+
768+ GetDiagnostic : function ( code ) {
769+ return "I haven't written any error handling yet." ;
770+ } ,
771+
772+ GetValue : function ( key ) {
773+ var v = this . data [ key ] ;
774+ if ( v === undefined ) {
775+ return '' ;
776+ } else {
777+ return v ;
778+ }
779+ } ,
780+
781+ SetValue : function ( key , value ) {
782+ if ( ! this . allow_set ) {
783+ return ;
784+ }
785+ value = ( value + '' ) ;
786+ var changed = value != this . data [ key ] ;
787+ if ( changed ) {
788+ this . data [ key ] = value ;
789+ this . check_key_counts_something ( key ) ;
790+ }
791+ this . callbacks . trigger ( 'SetValue' , key , value , changed ) ;
792+ } ,
793+
794+ Commit : function ( s ) {
795+ this . callbacks . trigger ( 'Commit' ) ;
796+ return true ;
797+ }
798+ }
799+
800+ function CallbackHandler ( ) {
801+ this . callbacks = { } ;
802+ }
803+ CallbackHandler . prototype = {
804+ on : function ( key , fn ) {
805+ if ( this . callbacks [ key ] === undefined ) {
806+ this . callbacks [ key ] = [ ] ;
807+ }
808+ this . callbacks [ key ] . push ( fn ) ;
809+ } ,
810+ trigger : function ( key ) {
811+ if ( ! this . callbacks [ key ] ) {
812+ return ;
813+ }
814+ var args = Array . prototype . slice . call ( arguments , 1 ) ;
815+ this . callbacks [ key ] . forEach ( function ( fn ) {
816+ fn . apply ( this , args ) ;
817+ } ) ;
818+ }
819+ }
820+
821+
822+
823+
824+
825+
567826 Numbas . analysis . init = async function ( ) {
568827
569828 Numbas . display . localisePage ( ) ;
@@ -584,7 +843,11 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
584843 document . body . addEventListener ( 'dragover' , evt => evt . preventDefault ( ) ) ;
585844 document . body . addEventListener ( 'drop' , evt => {
586845 evt . preventDefault ( ) ;
587- viewModel . add_files ( Array . from ( evt . dataTransfer . items ) . map ( i => i . getAsFile ( ) ) ) ;
846+ const files = Array . from ( evt . dataTransfer . items )
847+ . filter ( f => f . kind == 'file' )
848+ . map ( i => i . getAsFile ( ) )
849+ ;
850+ viewModel . add_files ( files ) ;
588851 } ) ;
589852
590853 ko . applyBindings ( viewModel , document . querySelector ( 'body > main#analysis' ) ) ;
0 commit comments