diff --git a/main/exercice/export/exercise_import.inc.php b/main/exercice/export/exercise_import.inc.php new file mode 100755 index 0000000000..67d1a8985e --- /dev/null +++ b/main/exercice/export/exercise_import.inc.php @@ -0,0 +1,750 @@ + + * @author Guillaume Lederer + */ + +/** + * function to create a temporary directory (SAME AS IN MODULE ADMIN) + */ + +function tempdir($dir, $prefix='tmp', $mode=0777) +{ + if (substr($dir, -1) != '/') $dir .= '/'; + + do + { + $path = $dir.$prefix.mt_rand(0, 9999999); + } while (!mkdir($path, $mode)); + + return $path; +} + + +/** + * @return the path of the temporary directory where the exercise was uploaded and unzipped + */ + +function get_and_unzip_uploaded_exercise() +{ + $backlog_message = array(); + + //Check if the file is valid (not to big and exists) + + if( !isset($_FILES['uploadedExercise']) + || !is_uploaded_file($_FILES['uploadedExercise']['tmp_name'])) + { + $backlog_message[] = get_lang('Problem with file upload'); + } + else + { + $backlog_message[] = get_lang('Temporary file is : ') . $_FILES['uploadedExercise']['tmp_name']; + } + //1- Unzip folder in a new repository in claroline/module + + include_once (realpath(dirname(__FILE__) . '/../../inc/lib/pclzip/') . '/pclzip.lib.php'); + + //unzip files + + $exerciseRepositorySys = get_conf('rootSys') . get_conf('exerciseRepository','cache/'); + //create temp dir for upload + claro_mkdir($exerciseRepositorySys); + $uploadDirFullPath = tempdir($exerciseRepositorySys); + $uploadDir = str_replace($exerciseRepositorySys,'',$uploadDirFullPath); + $exercisePath = $exerciseRepositorySys.$uploadDir.'/'; + + if ( preg_match('/.zip$/i', $_FILES['uploadedExercise']['name']) && treat_uploaded_file($_FILES['uploadedExercise'],$exerciseRepositorySys, $uploadDir, get_conf('maxFilledSpaceForExercise' , 10000000),'unzip',true)) + { + $backlog_message[] = get_lang('Files dezipped sucessfully in ' ) . $exercisePath; + + if (!function_exists('gzopen')) + { + $backlog_message[] = get_lang('Error : no zlib extension found'); + claro_delete_file($exercisePath); + return claro_failure::set_failure($backlog_message); + } + } + else + { + $backlog_message[] = get_lang('Impossible to unzip file'); + claro_delete_file($exercisePath); + return claro_failure::set_failure($backlog_message); + } + + return $exercisePath; +} +/** + * main function to import an exercise, + * + * @return an array as a backlog of what was really imported, and error or debug messages to display + */ + +function import_exercise($file) +{ + + global $exercise_info; + global $element_pile; + global $non_HTML_tag_to_avoid; + global $record_item_body; + global $backlog_message; + + //get required table names + + $tbl_cdb_names = claro_sql_get_course_tbl(); + $tbl_quiz_exercise = $tbl_cdb_names['qwz_exercise']; + $tbl_quiz_question = $tbl_cdb_names['qwz_question']; + + //set some default values for the new exercise + + $exercise_info = array(); + $exercise_info['name'] = preg_replace('/.zip$/i','' ,$file); + $exercise_info['description'] = get_lang('undefined description'); + $exercise_info['question'] = array(); + $element_pile = array(); + $backlog_message = array(); + + //create parser and array to retrieve info from manifest + + $element_pile = array(); //pile to known the depth in which we are + $module_info = array(); //array to store the info we need + + //unzip the uploaded file in a tmp directory + + $exercisePath = get_and_unzip_uploaded_exercise(); + + //find the different manifests for each question and parse them. + + $exerciseHandle = opendir($exercisePath); + + //find each question repository in the uploaded exercise folder + + array_push ($backlog_message, get_lang('XML question files found : ')); + + $question_number = 0; + + //used to specify the question directory where files could be found in relation in any question + + global $questionTempDir; + + + //1- parse the parent directory + + $questionHandle = opendir($exercisePath); + + while (false !== ($questionFile = readdir($questionHandle))) + { + if (preg_match('/.xml$/i' ,$questionFile)) + { + array_push ($backlog_message, get_lang("XML question file found : ".$questionFile)); + parse_file($exercisePath, '', $questionFile); + }//end if xml question file found + }//end while question rep + + + //2- parse every subdirectory to search xml question files + + while (false !== ($file = readdir($exerciseHandle))) + { + + if (is_dir($exercisePath.$file) && $file != "." && $file != "..") + { + //find each manifest for each question repository found + + $questionHandle = opendir($exercisePath.$file); + + while (false !== ($questionFile = readdir($questionHandle))) + { + if (preg_match('/.xml$/i' ,$questionFile)) + { + parse_file($exercisePath, $file, $questionFile); + }//end if xml question file found + }//end while question rep + } //if is_dir + }//end while loop to find each question data's + + + //Display data found + + array_push ($backlog_message, 'Exercise name : ' . $exercise_info['name'] . ''); + array_push ($backlog_message, 'Exercise description : ' . $exercise_info['description']); + + foreach ($exercise_info['question'] as $key => $question) + { + $question_number++; + array_push ($backlog_message, ''.$question_number.'- Question found (' .$key. ') : ' . $question['title'] . ''); + if (isset($question['statement'])) array_push ($backlog_message, '* Statement : ' . $question['statement']); + array_push ($backlog_message, '* Type : ' . $question['type']); + + foreach ($exercise_info['question'][$key]['answer'] as $answer) + { + if ($question['type']=="MATCHING") + { + array_push ($backlog_message, '** Matchset : '); + foreach ($answer as $matchSetElement) + { + array_push ($backlog_message, '*** Element ' . $matchSetElement); + } + } + else + { + array_push ($backlog_message, '** Answer found : ' . $answer['value']); + if (isset($answer['feedback'])) array_push ($backlog_message, '*** Answer feedback : ' . $answer['feedback']); + } + } + + if (isset($question['weighting'])) + { + array_push ($backlog_message, '* WEIGHTING for Answers :'); + foreach ($question['weighting'] as $key => $weighting) + { + array_push ($backlog_message, '** Answer : '.$key.' ==> weighting : '.$weighting); + } + } + + if (isset($question['correct_answers'])) + { + array_push ($backlog_message, '* CORRECT ANSWERS :'); + foreach ($question['correct_answers'] as $answerIdent) + { + array_push ($backlog_message, '* Answer : '.$answerIdent); + } + } + + if (isset($question['response_text'])) + { + array_push ($backlog_message, '* Text to fill in : '.$question['response_text'] ); + } + } + + //--------------------- + //add exercise in tool + //--------------------- + + //1.create exercise + + $exercise = new Exercise(); + + $exercise->setTitle($exercise_info['name']); + $exercise->setDescription($exercise_info['description']); + + if ($exercise->validate()) + { + $exercise_id = $exercise->save(); + } + else + { + array_push ($backlog_message, 'EXERCISE DATA INVALID !!!'); + } + + //For each question found... + + foreach($exercise_info['question'] as $key => $question_array) + { + //2.create question + + $question = new ImsQuestion(); + + if (isset($question_array['title'])) $question->setTitle($question_array['title']); + if (isset($question_array['statement'])) $question->setDescription($question_array['statement']); + $question->setType($question_array['type']); + + if ($question->validate()) + { + $question_id = $question->save(); + + if ($question_id) + { + //3.create answers + + $question->setAnswer(); + $question->import($exercise_info['question'][$key], $exercise_info['question'][$key]['tempdir']); + $exercise->addQuestion($question_id); + $question->answer->save(); + $question->save(); + } + else + { + array_push ($backlog_message, 'IMPOSSIBLE TO SAVE QUESTION !!!'); + } + } + else + { + array_push ($backlog_message, 'QUESTION DATA INVALID !!!'); + } + } + $link = "
".get_lang('See the exercise')."
"; + array_push ($backlog_message, $link); + + //delete the temp dir where the exercise was unzipped + + claro_delete_file($exercisePath); + + return $backlog_message; +} + + + +function parse_file($exercisePath, $file, $questionFile) +{ + global $exercise_info; + global $element_pile; + global $non_HTML_tag_to_avoid; + global $record_item_body; + + $questionTempDir = $exercisePath.$file.'/'; + $questionFilePath = $questionTempDir.$questionFile; + $backlog_message = array(); + array_push ($backlog_message, "* ".$questionFile); + + if (!($fp = @fopen($questionFilePath, 'r'))) + { + array_push ($backlog_message, get_lang("Error opening question's XML file")); + return $backlog_message; + } + else + { + $data = fread($fp, filesize( $questionFilePath)); + } + + //parse XML question file + + //used global variable start values declaration : + + $record_item_body = false; + $non_HTML_tag_to_avoid = array( + "SIMPLECHOICE", + "CHOICEINTERACTION", + "INLINECHOICEINTERACTION", + "INLINECHOICE", + "SIMPLEMATCHSET", + "SIMPLEASSOCIABLECHOICE", + "TEXTENTRYINTERACTION", + "FEEDBACKINLINE", + "MATCHINTERACTION", + "ITEMBODY", + "BR", + "IMG" + ); + + //this array to detect tag not supported by claroline import in the xml file to warn the user. + + $non_supported_content_in_question = array( + "GAPMATCHINTERACTION", + "EXTENDEDTEXTINTERACTION", + "HOTTEXTINTERACTION", + "HOTSPOTINTERACTION", + "SELECTPOINTINTERACTION", + "GRAPHICORDERINTERACTION", + "GRAPHICASSOCIATIONINTERACTION", + "GRAPHICGAPMATCHINTERACTION", + "POSITIONOBJECTINTERACTION", + "SLIDERINTERACTION", + "DRAWINGINTERACTION", + "UPLOADINTERACTION", + "RESPONSECONDITION", + "RESPONSEIF" + ); + $question_format_supported = true; + + $xml_parser = xml_parser_create(); + xml_set_element_handler($xml_parser, 'startElement', 'endElement'); + xml_set_character_data_handler($xml_parser, 'elementData'); + + if (!xml_parse($xml_parser, $data, feof($fp))) + { + // if reading of the xml file in not successfull : + // set errorFound, set error msg, break while statement + + array_push ($backlog_message, get_lang('Error reading XML file') ); + return $backlog_message; + } + + //close file + + fclose($fp); + + if ($question_format_supported) + { + array_push ($backlog_message, get_lang('Question format found') ); + } + else + { + array_push ($backlog_message, get_lang('ERROR in:'.$questionFile.' Question format unknown') ); + } +} + + +/** + * Function used by the SAX xml parser when the parser meets a opening tag + * + * @param unknown_type $parser xml parser created with "xml_parser_create()" + * @param unknown_type $name name of the element + * @param unknown_type $attributes + */ + +function startElement($parser, $name, $attributes) +{ + global $element_pile; + global $exercise_info; + global $current_question_ident; + global $current_answer_id; + global $current_match_set; + global $currentAssociableChoice; + global $current_question_item_body; + global $record_item_body; + global $non_HTML_tag_to_avoid; + global $current_inlinechoice_id; + global $cardinality; + global $questionTempDir; + + array_push($element_pile,$name); + $current_element = end($element_pile); + if (sizeof($element_pile)>=2) $parent_element = $element_pile[sizeof($element_pile)-2]; else $parent_element = ""; + if (sizeof($element_pile)>=3) $grant_parent_element = $element_pile[sizeof($element_pile)-3]; else $grant_parent_element =""; + + if ($record_item_body) + { + + if ((!in_array($current_element,$non_HTML_tag_to_avoid))) + { + $current_question_item_body .= "<".$name; + + foreach ($attributes as $attribute_name => $attribute_value) + { + $current_question_item_body .= " ".$attribute_name."=\"".$attribute_value."\""; + } + $current_question_item_body .= ">"; + } + else + { + //in case of FIB question, we replace the IMS-QTI tag b y the correct answer between "[" "]", + //we first save with claroline tags ,then when the answer will be parsed, the claroline tags will be replaced + + if ($current_element=='INLINECHOICEINTERACTION') + { + + $current_question_item_body .="**claroline_start**".$attributes['RESPONSEIDENTIFIER']."**claroline_end**"; + } + if ($current_element=='TEXTENTRYINTERACTION') + { + $correct_answer_value = $exercise_info['question'][$current_question_ident]['correct_answers'][$current_answer_id]; + $current_question_item_body .= "[".$correct_answer_value."]"; + + } + if ($current_element=='BR') + { + $current_question_item_body .= "
"; + } + } + + } + + switch ($current_element) + { + case 'ASSESSMENTITEM' : + { + //retrieve current question + + $current_question_ident = $attributes['IDENTIFIER']; + $exercise_info['question'][$current_question_ident] = array(); + $exercise_info['question'][$current_question_ident]['answer'] = array(); + $exercise_info['question'][$current_question_ident]['correct_answers'] = array(); + $exercise_info['question'][$current_question_ident]['title'] = $attributes['TITLE']; + $exercise_info['question'][$current_question_ident]['tempdir'] = $questionTempDir; + } + break; + + case 'SECTION' : + { + //retrieve exercise name + + $exercise_info['name'] = $attributes['TITLE']; + + } + break; + + case 'RESPONSEDECLARATION' : + { + //retrieve question type + + if ( "multiple" == $attributes['CARDINALITY']) + { + $exercise_info['question'][$current_question_ident]['type'] = 'MCMA'; + $cardinality = 'multiple'; + } + if ( "single" == $attributes['CARDINALITY']) + { + $exercise_info['question'][$current_question_ident]['type'] = 'MCUA'; + $cardinality = 'single'; + } + + //needed for FIB + + $current_answer_id = $attributes['IDENTIFIER']; + + } + break; + + case 'INLINECHOICEINTERACTION' : + { + $exercise_info['question'][$current_question_ident]['type'] = 'FIB'; + $exercise_info['question'][$current_question_ident]['subtype'] = 'LISTBOX_FILL'; + $current_answer_id = $attributes['RESPONSEIDENTIFIER']; + + } + break; + + case 'INLINECHOICE' : + { + $current_inlinechoice_id = $attributes['IDENTIFIER']; + } + break; + + case 'TEXTENTRYINTERACTION' : + { + $exercise_info['question'][$current_question_ident]['type'] = 'FIB'; + $exercise_info['question'][$current_question_ident]['subtype'] = 'TEXTFIELD_FILL'; + $exercise_info['question'][$current_question_ident]['response_text'] = $current_question_item_body; + + //replace claroline tags + + } + break; + + case 'MATCHINTERACTION' : + { + $exercise_info['question'][$current_question_ident]['type'] = 'MATCHING'; + } + break; + + case 'SIMPLEMATCHSET' : + { + if (!isset($current_match_set)) + { + $current_match_set = 1; + } + else + { + $current_match_set++; + } + $exercise_info['question'][$current_question_ident]['answer'][$current_match_set] = array(); + } + break; + + case 'SIMPLEASSOCIABLECHOICE' : + { + $currentAssociableChoice = $attributes['IDENTIFIER']; + } + break; + + //retrieve answers id for MCUA and MCMA questions + + case 'SIMPLECHOICE': + { + $current_answer_id = $attributes['IDENTIFIER']; + if (!isset($exercise_info['question'][$current_question_ident]['answer'][$current_answer_id])) + { + $exercise_info['question'][$current_question_ident]['answer'][$current_answer_id] = array(); + } + } + break; + + case 'MAPENTRY': + { + if ($parent_element == "MAPPING") + { + $answer_id = $attributes['MAPKEY']; + + if (!isset($exercise_info['question'][$current_question_ident]['weighting'])) + { + $exercise_info['question'][$current_question_ident]['weighting'] = array(); + } + $exercise_info['question'][$current_question_ident]['weighting'][$answer_id] = $attributes['MAPPEDVALUE']; + } + } + break; + + case 'MAPPING': + { + if (isset($attributes['DEFAULTVALUE'])) + { + $exercise_info['question'][$current_question_ident]['default_weighting'] = $attributes['DEFAULTVALUE']; + } + } + + case 'ITEMBODY': + { + $record_item_body = true; + $current_question_item_body = ''; + } + break; + + case 'IMG' : + { + $exercise_info['question'][$current_question_ident]['attached_file_url'] = $attributes['SRC']; + } + break; + } +} + +/** + * Function used by the SAX xml parser when the parser meets a closing tag + * + * @param $parser xml parser created with "xml_parser_create()" + * @param $name name of the element + */ + +function endElement($parser,$name) +{ + global $element_pile; + global $exercise_info; + global $current_question_ident; + global $record_item_body; + global $current_question_item_body; + global $non_HTML_tag_to_avoid; + global $cardinality; + + $current_element = end($element_pile); + + //treat the record of the full content of itembody tag : + + if ($record_item_body && (!in_array($current_element,$non_HTML_tag_to_avoid))) + { + $current_question_item_body .= ""; + } + + switch ($name) + { + case 'ITEMBODY': + { + $record_item_body = false; + if ($exercise_info['question'][$current_question_ident]['type']=='FIB') + { + $exercise_info['question'][$current_question_ident]['response_text'] = $current_question_item_body; + } + else + { + $exercise_info['question'][$current_question_ident]['statement'] = $current_question_item_body; + } + } + break; + } + array_pop($element_pile); + +} + +function elementData($parser,$data) +{ + + global $element_pile; + global $exercise_info; + global $current_question_ident; + global $current_answer_id; + global $current_match_set; + global $currentAssociableChoice; + global $current_question_item_body; + global $record_item_body; + global $non_HTML_tag_to_avoid; + global $current_inlinechoice_id; + global $cardinality; + + $current_element = end($element_pile); + if (sizeof($element_pile)>=2) $parent_element = $element_pile[sizeof($element_pile)-2]; else $parent_element = ""; + if (sizeof($element_pile)>=3) $grant_parent_element = $element_pile[sizeof($element_pile)-3]; else $grant_parent_element = ""; + + //treat the record of the full content of itembody tag (needed for question statment and/or FIB text: + + if ($record_item_body && (!in_array($current_element,$non_HTML_tag_to_avoid))) + { + $current_question_item_body .= $data; + } + + switch ($current_element) + { + case 'SIMPLECHOICE': + { + if (!isset($exercise_info['question'][$current_question_ident]['answer'][$current_answer_id]['value'])) + { + $exercise_info['question'][$current_question_ident]['answer'][$current_answer_id]['value'] = trim($data); + } + else + { + $exercise_info['question'][$current_question_ident]['answer'][$current_answer_id]['value'] .= ' '.trim($data); + } + } + break; + + case 'FEEDBACKINLINE' : + { + if (!isset($exercise_info['question'][$current_question_ident]['answer'][$current_answer_id]['feedback'])) + { + $exercise_info['question'][$current_question_ident]['answer'][$current_answer_id]['feedback'] = trim($data); + } + else + { + $exercise_info['question'][$current_question_ident]['answer'][$current_answer_id]['feedback'] .= ' '.trim($data); + } + } + break; + + case 'SIMPLEASSOCIABLECHOICE' : + { + $exercise_info['question'][$current_question_ident]['answer'][$current_match_set][$currentAssociableChoice] = trim($data); + } + break; + + case 'VALUE': + { + if ($parent_element=="CORRECTRESPONSE") + { + if ($cardinality=="single") + { + $exercise_info['question'][$current_question_ident]['correct_answers'][$current_answer_id] = $data; + } + else + { + $exercise_info['question'][$current_question_ident]['correct_answers'][] = $data; + } + } + } + break; + + case 'ITEMBODY' : + { + $current_question_item_body .= $data; + + } + break; + + case 'INLINECHOICE' : + { + + // if this is the right answer, then we must replace the claroline tags in the FIB text bye the answer between "[" and "]" : + + $answer_identifier = $exercise_info['question'][$current_question_ident]['correct_answers'][$current_answer_id]; + + if ($current_inlinechoice_id == $answer_identifier) + { + + $current_question_item_body = str_replace("**claroline_start**".$current_answer_id."**claroline_end**", "[".$data."]", $current_question_item_body); + } + else // save wrong answers in an array + { + if(!isset($exercise_info['question'][$current_question_ident]['wrong_answers'])) + { + $exercise_info['question'][$current_question_ident]['wrong_answers'] = array(); + } + $exercise_info['question'][$current_question_ident]['wrong_answers'][] = $data; + } + } + break; + } +} +?> \ No newline at end of file diff --git a/main/exercice/export/exercise_import.php b/main/exercice/export/exercise_import.php new file mode 100755 index 0000000000..5c9330671d --- /dev/null +++ b/main/exercice/export/exercise_import.php @@ -0,0 +1,116 @@ + + */ + +require '../../inc/global.inc.php'; + +//SECURITY CHECK + +if ( api_is_platform_admin() ) api_not_allowed(); + +//DECLARE NEEDED LIBRARIES + +require_once api_get_path(LIBRARY_PATH) . 'fileManage.lib.php'; +require_once api_get_path(LIBRARY_PATH) . 'fileUpload.lib.php'; + +require_once 'exercise_import.inc.php'; +include_once '../exercise.class.php'; +include_once '../question.class.php'; +include_once 'qti/qti_classes.php'; + +//SQL table name + +$tbl_exercise = Database::get_course_table(TABLE_QUIZ_TEST); +$tbl_question = Database::get_course_table(TABLE_QUIZ_QUESTION); +$tbl_rel_exercise_question = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION); + +// tool libraries + +include_once '../exercise.class.php'; + +//Tool title + +$nameTools = get_lang('ImportExercise'); + +//bredcrump + +$interbredcrump[]= array ('url' => '../exercise.php','name' => get_lang('Exercises')); + +//---------------------------------- +// EXECUTE COMMAND +//---------------------------------- + +$cmd = (isset($_REQUEST['cmd'])? $_REQUEST['cmd'] : 'show_import'); + +switch ( $cmd ) +{ + case 'show_import' : + { + $display = '

' + . get_lang('Imported exercises must consist of a zip or an XML file (IMS-QTI) and be compatible with your Claroline version.') . '
' + . '

' + . '
' + . '' + . '

' + . get_lang('Import exercise') . ' : ' + . ' ' + . claro_html_button( $_SERVER['PHP_SELF'], get_lang('Cancel')) + . '

' + . '' . get_lang('Max file size') . ' : 2 MB' + . '
'; + } + break; + + case 'import' : + { + //include needed librabries for treatment + + $result_log = import_exercise($_FILES['uploadedExercise']['name']); + + //display the result message (fail or success) + + $dialogBox = ''; + + foreach ($result_log as $log) + { + $dialogBox .= $log . '
'; + } + + } + break; +} + +//---------------------------------- +// FIND INFORMATION +//---------------------------------- + +//empty! + +//---------------------------------- +// DISPLAY +//---------------------------------- + +include api_get_path(INCLUDE_PATH) . '/header.inc.php'; + +//display title + +Display::display_introduction_section(TOOL_QUIZ); + +//Display Forms or dialog box(if needed) + +if ( isset($dialogBox) ) echo Display::display_normal_message($dialogBox,false); + +//display content + +if (isset($display) ) echo $display; + +//footer display + +include api_get_path(INCLUDE_PATH) . '/footer.inc.php'; +?> \ No newline at end of file diff --git a/main/exercice/export/index.php b/main/exercice/export/index.php new file mode 100755 index 0000000000..416d3a2998 --- /dev/null +++ b/main/exercice/export/index.php @@ -0,0 +1,13 @@ + + * @package dokeos.exercise + * + */ +header("Location: ../../../"); +exit(); +?> \ No newline at end of file diff --git a/main/exercice/export/qti/qti_classes.php b/main/exercice/export/qti/qti_classes.php new file mode 100755 index 0000000000..3dbdc2e405 --- /dev/null +++ b/main/exercice/export/qti/qti_classes.php @@ -0,0 +1,565 @@ + + * @author Yannick Warnier + * @package dokeos.exercise + */ +require_once('../../exercise.class.php'); +require_once('../../question.class.php'); +require_once('../../answer.class.php'); +require_once('../../unique_answer.class.php'); +require_once('../../multiple_answer.class.php'); +require_once('../../fill_blanks.class.php'); +require_once('../../freeanswer.class.php'); +require_once('../../hotspot.class.php'); +require_once('../../matching.class.php'); +require_once('../../hotspot.class.php'); + +// answer types +define('UNIQUE_ANSWER', 1); +define('MCUA', 1); +define('TF', 1); +define('MULTIPLE_ANSWER', 2); +define('MCMA', 2); +define('FILL_IN_BLANKS', 3); +define('FIB', 3); +define('MATCHING', 4); +define('FREE_ANSWER', 5); +define('HOTSPOT', 6); + +class ImsQuestion extends Question +{ + /** + * Include the correct answer class and create answer + */ + function setAnswer() + { + switch($this->type) + { + case MCUA : + $this->answer = new ImsAnswerMultipleChoice($this->id, false); + break; + case MCMA : + $this->answer = new ImsAnswerMultipleChoice($this->id, true); + break; + case TF : + $this->answer = new ImsAnswerTrueFalse($this->id); + break; + case FIB : + $this->answer = new ImsAnswerFillInBlanks($this->id); + break; + case MATCHING : + $this->answer = new ImsAnswerMatching($this->id); + break; + default : + $this->answer = null; + break; + } + + return true; + } + + /** + * allow to import the question + * + * @param questionArray is an array that must contain all the information needed to build the question + * @author Guillaume Lederer + */ + + function import($questionArray, $exerciseTempPath) + { + //import answers + + $this->answer->import($questionArray); + + //import attached file, if any + + if (isset($questionArray['attached_file_url'])) + { + $file= array(); + $file['name'] = $questionArray['attached_file_url']; + $file['tmp_name'] = $exerciseTempPath.$file['name']; + + $this->setAttachment($file); + } + } +} + +class ImsAnswerMultipleChoice extends answerMultipleChoice +{ + /** + * Return the XML flow for the possible answers. + * That's one , containing several + * + * @author Amand Tihon + */ + function imsExportResponses($questionIdent) + { + // Opening of the response block. + if( $this->multipleAnswer ) + { + $out = '' . "\n" + . '' . "\n"; + } + else + { + $out = '' . "\n"; + } + + // Loop over answers + foreach( $this->answerList as $answer ) + { + $responseIdent = $questionIdent . "_A_" . $answer['id']; + + $out.= ' '.(!$this->multipleAnswer ? '':'').'' . "\n" + . ' ' . "\n" + . ' '.(!$this->multipleAnswer ? '':'').'' . "\n"; + } + $out.= "\n"; + + return $out; + } + + /** + * Return the XML flow of answer processing : a succession of . + * + * @author Amand Tihon + */ + function imsExportProcessing($questionIdent) + { + $out = ''; + + foreach( $this->answerList as $answer ) + { + $responseIdent = $questionIdent . "_A_" . $answer['id']; + $feedbackIdent = $questionIdent . "_F_" . $answer['id']; + $conditionIdent = $questionIdent . "_C_" . $answer['id']; + + if( $this->multipleAnswer ) + { + $out .= '' . "\n" + . ' ' . $responseIdent . '' . "\n"; + } + else + { + $out .= '' . "\n" + . ' ' . $responseIdent . '' . "\n"; + } + + $out .= " \n" . ' ' . $answer['grade'] . "\n"; + + // Only add references for actually existing comments/feedbacks. + if( !empty($answer['comment']) ) + { + $out .= ' ' . "\n"; + } + $out .= "\n"; + } + return $out; + } + + /** + * Export the feedback (comments to selected answers) to IMS/QTI + * + * @author Amand Tihon + */ + function imsExportFeedback($questionIdent) + { + $out = ""; + foreach( $this->answerList as $answer ) + { + if( !empty($answer['comment']) ) + { + $feedbackIdent = $questionIdent . "_F_" . $answer['id']; + $out.= '' . "\n" + . ' \n" + . "\n"; + } + } + return $out; + } + + /** + * allow to import the answers, feedbacks, and grades of a question + * @param questionArray is an array that must contain all the information needed to build the question + * @author Guillaume Lederer + */ + + function import($questionArray) + { + + $answerArray = $questionArray['answer']; + + $this->answerList = array(); //re-initialize answer object content + + + + foreach ($answerArray as $key => $answer) + { + if (!isset($answer['feedback'])) $answer['feedback'] = ""; + if (!isset($questionArray['weighting'][$key])) + { + if (isset($questionArray['default_weighting'])) + { + $grade = $questionArray['default_weighting']; + } + else + { + $grade = 0; + } + } + else + { + $grade = $questionArray['weighting'][$key]; + } + if (in_array($key,$questionArray['correct_answers'])) $is_correct = true; else $is_correct = false; + $addedAnswer = array( + 'answer' => $answer['value'], + 'correct' => $is_correct, + 'grade' => $grade, + 'comment' => $answer['feedback'], + ); + + $this->answerList[] = $addedAnswer; + } + } +} + +class ImsAnswerTrueFalse extends answerTrueFalse +{ + /** + * Return the XML flow for the possible answers. + * That's one , containing several + * + * @author Amand Tihon + */ + function imsExportResponses($questionIdent) + { + // Opening of the response block. + $out = '' . "\n"; + + // true + $response_ident = $questionIdent . '_A_true'; + $out .= + ' ' . "\n" + . ' ' . "\n" + . ' ' . "\n"; + + // false + $response_ident = $questionIdent . '_A_false'; + $out .= + ' ' . "\n" + . ' ' . "\n" + . ' ' . "\n"; + + $out .= '' . "\n"; + + return $out; + } + + /** + * Return the XML flow of answer processing : a succession of . + * + * @author Amand Tihon + */ + function imsExportProcessing($questionIdent) + { + $out = ''; + + // true + $response_ident = $questionIdent. '_A_true'; + $feedback_ident = $questionIdent . '_F_true'; + $condition_ident = $questionIdent . '_C_true'; + + $out .= + '' . "\n" + . ' ' . $response_ident . '' . "\n" + . ' ' . "\n" . ' ' . $this->trueGrade . '' . "\n"; + + // Only add references for actually existing comments/feedbacks. + if( !empty($this->trueFeedback) ) + { + $out.= ' ' . "\n"; + } + + $out .= '' . "\n"; + + // false + $response_ident = $questionIdent. '_A_false'; + $feedback_ident = $questionIdent . '_F_false'; + $condition_ident = $questionIdent . '_C_false'; + + $out .= + '' . "\n" + . ' ' . $response_ident . '' . "\n" + . ' ' . "\n" . ' ' . $this->falseGrade . '' . "\n"; + + // Only add references for actually existing comments/feedbacks. + if( !empty($this->falseFeedback) ) + { + $out.= ' ' . "\n"; + } + + $out .= '' . "\n"; + + return $out; + } + + /** + * Export the feedback (comments to selected answers) to IMS/QTI + * + * @author Amand Tihon + */ + function imsExportFeedback($questionIdent) + { + $out = ""; + + if( !empty($this->trueFeedback) ) + { + $feedback_ident = $questionIdent . '_F_true'; + $out.= '' . "\n" + . ' trueFeedback . "]]>\n" + . "\n"; + } + + if( !empty($this->falseFeedback) ) + { + $feedback_ident = $questionIdent . '_F_false'; + $out.= '' . "\n" + . ' falseFeedback . "]]>\n" + . "\n"; + } + return $out; + } +} + +class ImsAnswerFillInBlanks extends answerFillInBlanks +{ + /** + * Export the text with missing words. + * + * As a side effect, it stores two lists in the class : + * the missing words and their respective weightings. + * + * @author Amand Tihon + */ + function imsExportResponses($questionIdent) + { + global $charset; + + $out = "\n"; + + $responsePart = explode(']', $this->answer); + $i = 0; // Used for the reference generation. + foreach($responsePart as $part) + { + $response_ident = $questionIdent . "_A_" . $i; + + if( strpos($part,'[') !== false ) + { + list($rawText, $blank) = explode('[', $part); + } + else + { + $rawText = $part; + $blank = ""; + } + + if ($rawText!="") + { + $out.=" \n"; + } + + if ($blank!="") + { + $out.= ' ' . "\n" + . ' ' . "\n" + . ' ' . "\n" + . " \n" + . " \n"; + } + $i++; + } + $out.="\n"; + + return $out; + + } + + /** + * Exports the response processing. + * + * It uses the two lists build by export_responses(). This implies that export_responses MUST + * be called before. + * + * @author Amand Tihon + */ + function imsExportProcessing($questionIdent) + { + $out = ""; + + $answerCount = count($this->answerList); + + for( $i = 0; $i < $answerCount ; $i++ ) + { + $response_ident = $questionIdent . "_A_" . $i; + $out.= ' ' . "\n" + . ' answerList[$i] . ']]>' . "\n" + . ' ' . $this->gradeList[$i] . "\n" + . " \n"; + } + return $out; + } + + /** + * Export the feedback (comments to selected answers) to IMS/QTI + * + * @author Amand Tihon + */ + function imsExportFeedback($questionIdent) + { + // no feedback in this question type + return ''; + } + + /** + * allow to import the answers, feedbacks, and grades of a question + * + * @param questionArray is an array that must contain all the information needed to build the question + * @author Guillaume Lederer + */ + + function import($questionArray) + { + $answerArray = $questionArray['answer']; + $this->answerText = str_replace ("\n","",$questionArray['response_text']); + if ($questionArray['subtype'] == "TEXTFIELD_FILL") $this->type = TEXTFIELD_FILL; + if ($questionArray['subtype'] == "LISTBOX_FILL") + { + $this->wrongAnswerList = $questionArray['wrong_answers']; + $this->type = LISTBOX_FILL; + } + + //build correct_answsers array + + if (isset($questionArray['weighting'])) + { + $this->gradeList = $questionArray['weighting']; + } + } +} + +class ImsAnswerMatching extends answerMatching +{ + /** + * Export the question part as a matrix-choice, with only one possible answer per line. + * @author Amand Tihon + */ + function imsExportResponses($questionIdent) + { + $out = ""; + // Now, loop again, finding questions (rows) + foreach( $this->leftList as $leftElt ) + { + $responseIdent = $questionIdent . "_A_" . $leftElt['code']; + $out.= '' . "\n" + . '\n" + . ' ' . "\n"; + + foreach( $this->rightList as $rightElt ) + { + $out.= ' ' . "\n" + . " \n" + . " \n"; + } + + $out.= "\n"; + } + + return $out; + } + + /** + * Export the response processing part + * @author Amand Tihon + */ + function imsExportProcessing($questionIdent) + { + $out = ""; + foreach( $this->leftList as $leftElt ) + { + $responseIdent = $questionIdent . "_A_" . $leftElt['code']; + $out.= ' ' . "\n" + . ' ' . $leftElt['match'] . "\n" + . ' ' . $leftElt['grade'] . "\n" + . " \n"; + } + return $out; + } + + /** + * Export the feedback (comments to selected answers) to IMS/QTI + * + * @author Amand Tihon + */ + function imsExportFeedback($questionIdent) + { + // no feedback in this question type + return ''; + } + + /** + * allow to import the answers, feedbacks, and grades of a question + * + * @param questionArray is an array that must contain all the information needed to build the question + * @author Guillaume Lederer + */ + + function import($questionArray) + { + $answerArray = $questionArray['answer']; + + //This tick to remove examples in the answers!!!! + $this->leftList = array(); + $this->rightList = array(); + + //find right and left column + + $right_column = array_pop($answerArray); + $left_column = array_pop($answerArray); + + //1- build answers + + foreach ($right_column as $right_key => $right_element) + { + $code = $this->addRight($right_element); + + foreach ($left_column as $left_key => $left_element) + { + $matched_pattern = $left_key." ".$right_key; + $matched_pattern_inverted = $right_key." ".$left_key; + + + if (in_array($matched_pattern, $questionArray['correct_answers']) || in_array($matched_pattern_inverted, $questionArray['correct_answers'])) + { + if (isset($questionArray['weighting'][$matched_pattern])) + { + $grade = $questionArray['weighting'][$matched_pattern]; + } + else + { + $grade = 0; + } + $this->addLeft($left_element, $code, $grade); + } + } + } + } +} +?> \ No newline at end of file diff --git a/main/exercice/export/qti/qti_export.php b/main/exercice/export/qti/qti_export.php new file mode 100755 index 0000000000..6485e6e6c3 --- /dev/null +++ b/main/exercice/export/qti/qti_export.php @@ -0,0 +1,351 @@ + + * @author Amand Tihon + * @author Sebastien Piraux + * @author Yannick Warnier + * + */ + +include dirname(__FILE__) . '/qti_classes.php'; +/*-------------------------------------------------------- + Classes + --------------------------------------------------------*/ + +/** + * This class represents an entire exercise to be exported in IMS/QTI. + * It will be represented by a single
containing several . + * + * Some properties cannot be exported, as IMS does not support them : + * - type (one page or multiple pages) + * - start_date and end_date + * - max_attempts + * - show_answer + * - anonymous_attempts + * + * @author Amand Tihon + */ +class ImsSection +{ + var $exercise; + + /** + * Constructor. + * @param $exe The Exercise instance to export + * @author Amand Tihon + */ + function ImsSection($exe) + { + $this->exercise = $exe; + } + + function start_section() + { + $out = '
' . "\n"; + return $out; + } + + function end_section() + { + return "
\n"; + } + + function export_duration() + { + if ($max_time = $this->exercise->getTimeLimit()) + { + // return exercise duration in ISO8601 format. + $minutes = floor($max_time / 60); + $seconds = $max_time % 60; + return 'PT' . $minutes . 'M' . $seconds . "S\n"; + } + else + { + return ''; + } + } + + /** + * Export the presentation (Exercise's description) + * @author Amand Tihon + */ + function export_presentation() + { + $out = "\n" + . " exercise->getDescription() . "]]>\n" + . "\n"; + return $out; + } + + /** + * Export the ordering information. + * Either sequential, through all questions, or random, with a selected number of questions. + * @author Amand Tihon + */ + function export_ordering() + { + $out = ''; + if ($n = $this->exercise->getShuffle()) { + $out.= "" + . " \n" + . " " . $n . "\n" + . " \n" + . ' ' + . "\n\n"; + } + else + { + $out.= '' . "\n" + . " \n" + . "\n"; + } + + return $out; + } + + /** + * Export the questions, as a succession of + * @author Amand Tihon + */ + function export_questions() + { + $out = ""; + foreach ($this->exercise->getQuestionList() as $q) + { + $out .= export_question($q['id'], False); + } + return $out; + } + + /** + * Export the exercise in IMS/QTI. + * + * @param bool $standalone Wether it should include XML tag and DTD line. + * @return a string containing the XML flow + * @author Amand Tihon + */ + function export($standalone) + { + global $charset; + + $head = $foot = ""; + if ($standalone) { + $head = '' . "\n" + . '' . "\n" + . "\n"; + $foot = "\n"; + } + + $out = $head + . $this->start_section() + . $this->export_duration() + . $this->export_presentation() + . $this->export_ordering() + . $this->export_questions() + . $this->end_section() + . $foot; + + return $out; + } +} + +/* + Some quick notes on identifiers generation. + The IMS format requires some blocks, like items, responses, feedbacks, to be uniquely + identified. + The unicity is mandatory in a single XML, of course, but it's prefered that the identifier stays + coherent for an entire site. + + Here's the method used to generate those identifiers. + Question identifier :: "QST_" + + "_" + + Response identifier :: + "_A_" + + Condition identifier :: + "_C_" + + Feedback identifier :: + "_F_" + +*/ +/** + * An IMS/QTI item. It corresponds to a single question. + * This class allows export from Claroline to IMS/QTI XML format. + * It is not usable as-is, but must be subclassed, to support different kinds of questions. + * + * Every start_*() and corresponding end_*(), as well as export_*() methods return a string. + * + * @warning Attached files are NOT exported. + * @author Amand Tihon + */ +class ImsItem +{ + var $question; + var $question_ident; + var $answer; + + /** + * Constructor. + * + * @param $question The Question object we want to export. + * @author Anamd Tihon + */ + function ImsItem($question) + { + $this->question = $question; + $this->answer = $question->answer; + $this->questionIdent = "QST_" . $question->getId() ; + } + + /** + * Start the XML flow. + * + * This opens the block, with correct attributes. + * + * @author Amand Tihon + */ + function start_item() + { + return '' . "\n"; + } + + /** + * End the XML flow, closing the tag. + * + * @author Amand Tihon + */ + function end_item() + { + return "\n"; + } + + /** + * Create the opening, with the question itself. + * + * This means it opens the but doesn't close it, as this is the role of end_presentation(). + * Inbetween, the export_responses from the subclass should have been called. + * + * @author Amand Tihon + */ + function start_presentation() + { + return '' . "\n" + . 'question->getDescription() . "]]>\n"; + } + + /** + * End the part, opened by export_header. + * + * @author Amand Tihon + */ + function end_presentation() + { + return "\n"; + } + + /** + * Start the response processing, and declare the default variable, SCORE, at 0 in the outcomes. + * + * @author Amand Tihon + */ + function start_processing() + { + return '' . "\n"; + } + + /** + * End the response processing part. + * + * @author Amand Tihon + */ + function end_processing() + { + return "\n"; + } + + + /** + * Export the question as an IMS/QTI Item. + * + * This is a default behaviour, some classes may want to override this. + * + * @param $standalone: Boolean stating if it should be exported as a stand-alone question + * @return A string, the XML flow for an Item. + * @author Amand Tihon + */ + function export($standalone = False) + { + global $charset; + $head = $foot = ""; + + if( $standalone ) + { + $head = '' . "\n" + . '' . "\n" + . "\n"; + $foot = "\n"; + } + + return $head + . $this->start_item() + . $this->start_presentation() + . $this->answer->imsExportResponses($this->questionIdent) + . $this->end_presentation() + . $this->start_processing() + . $this->answer->imsExportProcessing($this->questionIdent) + . $this->end_processing() + . $this->answer->imsExportFeedback($this->questionIdent) + . $this->end_item() + . $foot; + } +} + + + + +/*-------------------------------------------------------- + Functions + --------------------------------------------------------*/ + +/** + * Send a complete exercise in IMS/QTI format, from its ID + * + * @param int $exerciseId The exercise to exporte + * @param boolean $standalone Wether it should include XML tag and DTD line. + * @return The XML as a string, or an empty string if there's no exercise with given ID. + * @author Amand Tihon + */ +function export_exercise($exerciseId, $standalone=True) +{ + $exercise = new Exercise(); + if (! $exercise->load($exerciseId)) + { + return ''; + } + $ims = new ImsSection($exercise); + $xml = $ims->export($standalone); + return $xml; +} + +/** + * Returns the XML flow corresponding to one question + * + * @param int The question ID + * @param bool standalone (ie including XML tag, DTD declaration, etc) + * @author Amand Tihon + */ +function export_question($questionId, $standalone=True) +{ + $question = new ImsQuestion(); + if( !$question->load($questionId) ) + { + return ''; + } + + $ims = new ImsItem($question); + + return $ims->export($standalone); + +} + +?> \ No newline at end of file diff --git a/main/exercice/export/qti2/qti2_classes.php b/main/exercice/export/qti2/qti2_classes.php new file mode 100755 index 0000000000..81f87c9fd8 --- /dev/null +++ b/main/exercice/export/qti2/qti2_classes.php @@ -0,0 +1,427 @@ + + * @author Yannick Warnier - updated ImsAnswerHotspot to match QTI norms + */ +// answer types +define('UNIQUE_ANSWER', 1); +define('MCUA', 1); +define('TF', 1); +define('MULTIPLE_ANSWER', 2); +define('MCMA', 2); +define('FILL_IN_BLANKS', 3); +define('FIB', 3); +define('MATCHING', 4); +define('FREE_ANSWER', 5); +define('HOTSPOT', 6); + +require_once(api_get_path(SYS_CODE_PATH).'/exercice/answer.class.php'); +//include_once $path . '/../../lib/answer_multiplechoice.class.php'; +//include_once $path . '/../../lib/answer_truefalse.class.php'; +//include_once $path . '/../../lib/answer_fib.class.php'; +//include_once $path . '/../../lib/answer_matching.class.php'; + +class Ims2Question extends Question +{ + /** + * Include the correct answer class and create answer + */ + function setAnswer() + { + switch($this->type) + { + case MCUA : + $answer = new ImsAnswerMultipleChoice($this->id); + return $answer; + case MCMA : + $answer = new ImsAnswerMultipleChoice($this->id); + return $answer; + case TF : + $answer = new ImsAnswerMultipleChoice($this->id); + return $answer; + case FIB : + $answer = new ImsAnswerFillInBlanks($this->id); + return $answer; + case MATCHING : + $answer = new ImsAnswerMatching($this->id); + return $answer; + case HOTSPOT : + $answer = new ImsAnswerHotspot($this->id); + return $answer; + default : + $answer = null; + break; + } + return $answer; + } +} + +class ImsAnswerMultipleChoice extends Answer +{ + /** + * Return the XML flow for the possible answers. + * + */ + function imsExportResponses($questionIdent, $questionStatment) + { + $this->answerList = $this->getAnswersList(); + $out = ' ' . "\n"; + $out .= ' ' . $questionStatment . ' '. "\n"; + + foreach ($this->answerList as $current_answer) + { + $out .= ' ' . $current_answer['answer']; + if (isset($current_answer['comment']) && $current_answer['comment'] != '') + { + $out .= '' . $current_answer['comment'] . ''; + } + $out .= ''. "\n"; + } + + $out .= ' '. "\n"; + return $out; + } + + /** + * Return the XML flow of answer ResponsesDeclaration + * + */ + function imsExportResponsesDeclaration($questionIdent) + { + $this->answerList = $this->getAnswersList(); + $type = $this->getQuestionType(); + if ($type == MCMA) $cardinality = 'multiple'; else $cardinality = 'single'; + + $out = ' ' . "\n"; + + //Match the correct answers + + $out .= ' '. "\n"; + + foreach($this->answerList as $current_answer) + { + if ($current_answer['correct']) + { + $out .= ' answer_'. $current_answer['id'] .''. "\n"; + } + } + $out .= ' '. "\n"; + + //Add the grading + + $out .= ' '. "\n"; + + foreach($this->answerList as $current_answer) + { + if (isset($current_answer['grade'])) + { + $out .= ' '. "\n"; + } + } + $out .= ' '. "\n"; + + $out .= ' '. "\n"; + + return $out; + } +} + +class ImsAnswerFillInBlanks extends Answer +{ + /** + * Export the text with missing words. + * + * + */ + function imsExportResponses($questionIdent, $questionStatment) + { + global $charset; + $this->answerList = $this->getAnswersList(); + + //switch ($this->type) + //{ + // case TEXTFIELD_FILL : + // { + $text = ''; + $text .= $this->answerText; + + foreach ($this->answerList as $key=>$answer) + { + $key = $answer['id']; + $answer = $answer['answer']; + $len = strlen($answer); + $text = str_replace('['.$answer.']','', $text); + } + $out = $text; + // } + // break; + + /* + case LISTBOX_FILL : + { + $text = $this->answerText; + + foreach ($this->answerList as $answerKey=>$answer) + { + + //build inlinechoice list + + $inlineChoiceList = ''; + + //1-start interaction tag + + $inlineChoiceList .= ''. "\n"; + + //2- add wrong answer array + + foreach ($this->wrongAnswerList as $choiceKey=>$wrongAnswer) + { + $inlineChoiceList .= ' '.$wrongAnswer.''. "\n"; + } + + //3- add correct answers array + foreach ($this->answerList as $choiceKey=>$correctAnswer) + { + $inlineChoiceList .= ' '.$correctAnswer.''. "\n"; + } + + //4- finish interaction tag + + $inlineChoiceList .= ''; + + $text = str_replace('['.$answer.']',$inlineChoiceList, $text); + } + $out = $text; + + } + break; + */ + //} + + return $out; + + } + + /** + * + */ + function imsExportResponsesDeclaration($questionIdent) + { + + $this->answerList = $this->getAnswersList(); + $this->gradeList = $this->getGradesList(); + $out = ''; + + foreach ($this->answerList as $answerKey=>$answer) + { + $answerKey = $answer['id']; + $answer = $answer['answer']; + $out .= ' ' . "\n"; + $out .= ' '. "\n"; + + //if ($this->type==TEXTFIELD_FILL) + //{ + $out .= ' '.$answer.''. "\n"; + //} + /* + else + { + //find correct answer key to apply in manifest and output it + + foreach ($this->answerList as $choiceKey=>$correctAnswer) + { + if ($correctAnswer==$answer) + { + $out .= ' choice_c_'.$answerKey.'_'.$choiceKey.''. "\n"; + } + } + } + */ + $out .= ' '. "\n"; + + if (isset($this->gradeList[$answerKey])) + { + $out .= ' '. "\n"; + $out .= ' '. "\n"; + $out .= ' '. "\n"; + } + + $out .= ' '. "\n"; + } + + return $out; + } +} + +class ImsAnswerMatching extends Answer +{ + /** + * Export the question part as a matrix-choice, with only one possible answer per line. + */ + function imsExportResponses($questionIdent, $questionStatment) + { + $this->answerList = $this->getAnswersList(); + $maxAssociation = max(count($this->leftList), count($this->rightList)); + + $out = ""; + + $out .= ''. "\n"; + $out .= $questionStatment; + + //add left column + + $out .= ' '. "\n"; + + foreach ($this->leftList as $leftKey=>$leftElement) + { + $out .= ' '. $leftElement['answer'] .''. "\n"; + } + + $out .= ' '. "\n"; + + //add right column + + $out .= ' '. "\n"; + + $i = 0; + + foreach($this->rightList as $rightKey=>$rightElement) + { + $out .= ' '. $rightElement['answer'] .''. "\n"; + $i++; + } + + $out .= ' '. "\n"; + + $out .= ''. "\n"; + + return $out; + } + + /** + * + */ + function imsExportResponsesDeclaration($questionIdent) + { + $this->answerList = $this->getAnswersList(); + $out = ' ' . "\n"; + $out .= ' ' . "\n"; + + $gradeArray = array(); + + foreach ($this->leftList as $leftKey=>$leftElement) + { + $i=0; + foreach ($this->rightList as $rightKey=>$rightElement) + { + if( ($leftElement['match'] == $rightElement['code'])) + { + $out .= ' left_' . $leftKey . ' right_'.$i.''. "\n"; + + $gradeArray['left_' . $leftKey . ' right_'.$i] = $leftElement['grade']; + } + $i++; + } + } + $out .= ' '. "\n"; + $out .= ' ' . "\n"; + foreach ($gradeArray as $gradeKey=>$grade) + { + $out .= ' ' . "\n"; + } + $out .= ' ' . "\n"; + $out .= ' '. "\n"; + + return $out; + } + +} + +class ImsAnswerHotspot extends Answer +{ + /** + * TODO update this to match hotspots instead of copying matching + * Export the question part as a matrix-choice, with only one possible answer per line. + */ + function imsExportResponses($questionIdent, $questionStatment, $questionDesc='', $questionMedia='') + { + global $charset; + $this->answerList = $this->getAnswersList(); + $questionMedia = api_get_path(WEB_COURSE_PATH).api_get_course_path().'/document/images/'.$questionMedia; + $mimetype = mime_content_type($questionMedia); + if(empty($mimetype)){ + $mimetype = 'image/jpeg'; + } + + $text = '

'.$questionStatment.'

'."\n"; + $text .= ' '."\n"; + $text .= ' '.$questionDesc.''."\n"; + $text .= ' -'."\n"; + foreach ($this->answerList as $key=>$answer) + { + $key = $answer['id']; + $answerTxt = $answer['answer']; + $len = strlen($answerTxt); + //coords are transformed according to QTIv2 rules here: http://www.imsproject.org/question/qtiv2p1pd/imsqti_infov2p1pd.html#element10663 + $coords = ''; + $type = 'default'; + switch($answer['hotspot_type']){ + case 'square': + $type = 'rect'; + $res = array(); + $coords = preg_match('/^\s*(\d+);(\d+)\|(\d+)\|(\d+)\s*$/',$answer['hotspot_coord'],$res); + $coords = $res[1].','.$res[2].','.((int)$res[1]+(int)$res[3]).",".((int)$res[2]+(int)$res[4]); + break; + case 'circle': + $type = 'circle'; + $res = array(); + $coords = preg_match('/^\s*(\d+);(\d+)\|(\d+)\|(\d+)\s*$/',$answer['hotspot_coord'],$res); + $coords = $res[1].','.$res[2].','.sqrt(pow(($res[1]-$res[3]),2)+pow(($res[2]-$res[4]))); + break; + case 'poly': + $type = 'poly'; + $coords = str_replace(array(';','|'),array(',',','),$answer['hotspot_coord']); + break; + } + $text .= ' '."\n"; + } + $text .= ' '."\n"; + $out = $text; + + + return $out; + + } + + /** + * + */ + function imsExportResponsesDeclaration($questionIdent) + { + + $this->answerList = $this->getAnswersList(); + $this->gradeList = $this->getGradesList(); + $out = ''; + $out .= ' ' . "\n"; + $out .= ' '. "\n"; + + foreach ($this->answerList as $answerKey=>$answer) + { + $answerKey = $answer['id']; + $answer = $answer['answer']; + $out .= ' '.$answerKey.''. "\n"; + + } + $out .= ' '. "\n"; + $out .= ' '. "\n"; + + return $out; + } +} +?> \ No newline at end of file diff --git a/main/exercice/export/qti2/qti2_export.php b/main/exercice/export/qti2/qti2_export.php new file mode 100755 index 0000000000..0558f17671 --- /dev/null +++ b/main/exercice/export/qti2/qti2_export.php @@ -0,0 +1,187 @@ + + * @author Yannick Warnier + */ + +require dirname(__FILE__) . '/qti2_classes.php'; + +/*-------------------------------------------------------- + Classes + --------------------------------------------------------*/ + +/** + * An IMS/QTI item. It corresponds to a single question. + * This class allows export from Claroline to IMS/QTI2.0 XML format of a single question. + * It is not usable as-is, but must be subclassed, to support different kinds of questions. + * + * Every start_*() and corresponding end_*(), as well as export_*() methods return a string. + * + * @warning Attached files are NOT exported. + */ +class ImsAssessmentItem +{ + var $question; + var $question_ident; + var $answer; + + /** + * Constructor. + * + * @param $question The Question object we want to export. + */ + function ImsAssessmentItem($question) + { + $this->question = $question; + //$this->answer = new Answer($question->id); + $this->answer = $this->question->setAnswer(); + $this->questionIdent = "QST_" . $question->id ; + } + + /** + * Start the XML flow. + * + * This opens the block, with correct attributes. + * + */ + function start_item() + { + /* + return ''."\n"; + */ + $string = ''."\n"; + return $string; + + } + + /** + * End the XML flow, closing the tag. + * + */ + function end_item() + { + return "\n"; + } + + /** + * Start the itemBody + * + */ + function start_item_body() + { + return ' ' . "\n"; + } + + /** + * End the itemBody part. + * + */ + function end_item_body() + { + return " \n"; + } + + /** + * add the response processing template used. + * + */ + + function add_response_processing() + { + //return ' ' . "\n"; + return ' ' . "\n"; + } + + + /** + * Export the question as an IMS/QTI Item. + * + * This is a default behaviour, some classes may want to override this. + * + * @param $standalone: Boolean stating if it should be exported as a stand-alone question + * @return A string, the XML flow for an Item. + */ + function export($standalone = False) + { + global $charset; + $head = $foot = ""; + + if( $standalone ) + { + $head = '' . "\n"; + } + + $res = $head + . $this->start_item() + .$this->answer->imsExportResponsesDeclaration($this->questionIdent) + . $this->start_item_body() + . $this->answer->imsExportResponses($this->questionIdent, $this->question->question, $this->question->description, $this->question->picture) + . $this->end_item_body() + . $this->add_response_processing() + . $this->end_item() + . $foot; + return $res; + } +} + + + + +/*-------------------------------------------------------- + Functions + --------------------------------------------------------*/ + +/** + * Send a complete exercise in IMS/QTI format, from its ID + * + * @param int $exerciseId The exercise to exporte + * @param boolean $standalone Wether it should include XML tag and DTD line. + * @return The XML as a string, or an empty string if there's no exercise with given ID. + */ +function export_exercise($exerciseId, $standalone=True) +{ + $exercise = new Exercise(); + if (! $exercise->read($exerciseId)) + { + return ''; + } + $ims = new ImsSection($exercise); + $xml = $ims->export($standalone); + return $xml; +} + +/** + * Returns the XML flow corresponding to one question + * + * @param int The question ID + * @param bool standalone (ie including XML tag, DTD declaration, etc) + */ +function export_question($questionId, $standalone=True) +{ + $question = new Ims2Question(); + if( !$question->read($questionId) ) + { + return ''; + } + + $ims = new ImsAssessmentItem($question); + + return $ims->export($standalone); + +} + +?> \ No newline at end of file diff --git a/main/exercice/export/scorm/scorm_classes.php b/main/exercice/export/scorm/scorm_classes.php new file mode 100755 index 0000000000..6d8b7ed99c --- /dev/null +++ b/main/exercice/export/scorm/scorm_classes.php @@ -0,0 +1,369 @@ + + * @author Yannick Warnier + */ +require_once('../../exercise.class.php'); +require_once('../../question.class.php'); +require_once('../../answer.class.php'); +require_once('../../unique_answer.class.php'); +require_once('../../multiple_answer.class.php'); +require_once('../../fill_blanks.class.php'); +require_once('../../freeanswer.class.php'); +require_once('../../hotspot.class.php'); +require_once('../../matching.class.php'); +require_once('../../hotspot.class.php'); + +// answer types +define('UNIQUE_ANSWER', 1); +define('MCUA', 1); +define('TF', 1); +define('MULTIPLE_ANSWER', 2); +define('MCMA', 2); +define('FILL_IN_BLANKS', 3); +define('FIB', 3); +define('MATCHING', 4); +define('FREE_ANSWER', 5); +define('HOTSPOT', 6); + +class ScormQuestion extends Question +{ + /** + * Include the correct answer class and create answer + */ + function setAnswer() + { + switch($this->type) + { + case MCUA : + $this->answer = new ScormAnswerMultipleChoice($this->id, false); + break; + case MCMA : + $this->answer = new ScormAnswerMultipleChoice($this->id, true); + break; + case TF : + $this->answer = new ScormAnswerTrueFalse($this->id); + break; + case FIB : + $this->answer = new ScormAnswerFillInBlanks($this->id); + break; + case MATCHING : + $this->answer = new ScormAnswerMatching($this->id); + break; + default : + $this->answer = null; + break; + } + + return true; + } + + function export() + { + $out = $this->getQuestionHtml(); + + if( is_object($this->answer) ) + { + $out .= $this->answer->export(); + } + + return $out; + + } +} + +class ScormAnswerMultipleChoice extends answerMultipleChoice +{ + /** + * Return the XML flow for the possible answers. + * That's one , containing several + * + * @author Amand Tihon + */ + function export() + { + $out = + '' . "\n\n"; + + + if( $this->multipleAnswer ) + { + $questionTypeLang = get_lang('Multiple choice (Multiple answers)'); + + + foreach( $this->answerList as $answer ) + { + $identifier = 'multiple_'.$this->questionId.'_'.$answer['id']; + $scormIdentifier = 'scorm_'.getIdCounter(); + + $out .= + '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n\n"; + } + + } + else + { + $questionTypeLang = get_lang('Multiple choice (Unique answer)'); + $identifier = 'unique_'.$this->questionId.'_x'; + + foreach( $this->answerList as $answer ) + { + $scormIdentifier = 'scorm_'.getIdCounter(); + + $out .= + '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n\n"; + } + + } + + $out .= + '
' . "\n" + . 'response == 'TRUE'? 'checked="checked"':'') + . '/>' . "\n" + . '' . "\n" + . '' . "\n" + . '
' . "\n" + . 'response == 'TRUE'? 'checked="checked"':'') + . '/>' . "\n" + . '' . "\n" + . '' . "\n" + . '
' . "\n" + . '

' . $questionTypeLang . '

' . "\n"; + + return $out; + } +} + +class ScormAnswerTrueFalse extends answerTrueFalse +{ + /** + * Return the XML flow for the possible answers. + * That's one , containing several + * + * @author Amand Tihon + */ + function export() + { + $identifier = 'unique_'.$this->questionId.'_x'; + + $out = + '' . "\n\n"; + + $scormIdentifier = 'scorm_'.getIdCounter(); + + $out .= + '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n\n"; + + $scormIdentifier = 'scorm_'.getIdCounter(); + + $out .= + '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n\n" + + . '
' . "\n" + . 'response == 'TRUE'? 'checked="checked"':'') + . '/>' . "\n" + . '' . "\n" + . '' . "\n" + . '
' . "\n" + . 'response == 'FALSE'? 'checked="checked"':'') + . '/>' . "\n" + . '' . "\n" + . '' . "\n" + . '
' . "\n" + . '

' . get_lang('True/False') . '

' . "\n"; + + return $out; + } +} + +class ScormAnswerFillInBlanks extends answerFillInBlanks +{ + /** + * Export the text with missing words. + * + * As a side effect, it stores two lists in the class : + * the missing words and their respective weightings. + * + * @author Amand Tihon + */ + function export() + { + // get all enclosed answers + foreach( $this->answerList as $answer ) + { + $blankList[] = '['.$answer.']'; + } + $answerCount = count($blankList); + + // build replacement + $replacementList = array(); + + if( $this->type == LISTBOX_FILL ) + { + // build the list shown in list box + // prepare option list using good and wrong answers + $allAnswerList = array_merge($this->answerList, $this->wrongAnswerList); + + // alphabetical sort of the list + natcasesort($allAnswerList); + + $optionList[''] = ''; + + foreach( $allAnswerList as $answer ) + { + $optionList[htmlspecialchars($answer)] = htmlspecialchars($answer); + } + + for( $i = 0; $i < $answerCount; $i++ ) + { + $identifier = 'fill_' . $this->questionId . '_' . $i; + $attr['id'] = 'scorm_'.getIdCounter(); + + $replacementList[] = claro_html_form_select($identifier, $optionList, null, $attr); + } + } + else + { + for( $i = 0; $i < $answerCount; $i++ ) + { + $identifier = 'fill_' . $this->questionId . '_' . $i; + $scormIdentifier = 'scorm_'.getIdCounter(); + + $replacementList[] = "\n" . ' ' . "\n"; + } + } + + + // apply replacement on answer + $displayedAnswer = str_replace( $blankList, $replacementList, claro_parse_user_text($this->answerText) ); + + // some javascript must be added for that kind of questions + $out = + '' . "\n" + . '' . "\n\n" + + . '' . "\n" + . '' . "\n" + . '' . "\n\n" + + . '
' . "\n" + + . $displayedAnswer . "\n" + + . '
' . "\n" + . '

' . get_lang('Fill in blanks') . '

' . "\n"; + + return $out; + + } + +} + +class ScormAnswerMatching extends answerMatching +{ + /** + * Export the question part as a matrix-choice, with only one possible answer per line. + * @author Amand Tihon + */ + function export() + { + // prepare list of right proposition to allow + // - easiest display + // - easiest randomisation if needed one day + // (here I use array_values to change array keys from $code1 $code2 ... to 0 1 ...) + $displayedRightList = array_values($this->rightList); + + // get max length of displayed array + $arrayLength = max( count($this->leftList), count($this->rightList) ); + + $out = '' . "\n\n"; + + $leftCpt = 1; + $rightCpt = 'A'; + for( $i = 0; $i < $arrayLength; $i++ ) + { + if( isset($this->leftList[$i]['answer']) ) + { + // build html option list + $optionList = array(); + $optionCpt = 'A'; + $optionList[0] = '--'; + + foreach( $this->rightList as $rightElt ) + { + $optionList[$optionCpt] = $this->leftList[$i]['grade']; + + $optionCpt++; + } + + $leftHtml = $leftCpt . '. ' . $this->leftList[$i]['answer']; + + $attr['id'] = 'scorm_'.getIdCounter(); + $centerHtml = claro_html_form_select('matching_'.$this->questionId.'_'.$this->leftList[$i]['code'], $optionList, null, $attr); + } + else + { + $leftHtml = ' '; + $centerHtml = ' '; + } + + if( isset($displayedRightList[$i]['answer']) ) + { + $rightHtml = $rightCpt . '. ' . $displayedRightList[$i]['answer']; + } + else + { + $rightHtml = ' '; + } + + $out .= + '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n\n"; + + $leftCpt++; + $rightCpt++; + } + + + $out .= + '
' . "\n" . $leftHtml . "\n" . '' . "\n" . $centerHtml . "\n" . '' . "\n" . $rightHtml . "\n" . '
' . "\n" + . '

' . get_lang('Matching') . '

' . "\n"; + + return $out; + } +} +?> \ No newline at end of file