public/main/exercise/export/exercise_import.inc.php
<?php
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Component\Utils\ChamiloApi;
use PhpZip\ZipFile;
use Symfony\Component\DomCrawler\Crawler;
/**
* @copyright (c) 2001-2006 Universite catholique de Louvain (UCL)
* @author claro team <cvs@claroline.net>
* @author Guillaume Lederer <guillaume@claroline.net>
* @author Yannick Warnier <yannick.warnier@beeznest.com>
*/
/**
* Imports an exercise in QTI format if the XML structure can be found in it.
*
* @param string $file
*
* @return string|array as a backlog of what was really imported, and error or debug messages to display
*/
function import_exercise($file)
{
global $exerciseInfo;
global $resourcesLinks;
// set some default values for the new exercise
$exerciseInfo = [];
$exerciseInfo['name'] = preg_replace('/.zip$/i', '', $file);
$exerciseInfo['question'] = [];
// if file is not a .zip, then we cancel all
if (!preg_match('/.zip$/i', $file)) {
return 'UplZipCorrupt';
}
$zipFile = new ZipFile();
$zipFile->openFile($file);
$zipContentArray = $zipFile->getEntries();
$fileFound = false;
$result = false;
$filePath = null;
$resourcesLinks = [];
// parse every subdirectory to search xml question files and other assets to be imported
// The assets-related code is a bit fragile as it has to deal with files renamed by Chamilo and it only works if
// the imsmanifest.xml file is read.
foreach ($zipContentArray as $item) {
$file = $item->getName();
if ($item->isDirectory()) {
continue;
}
$data = $item->getData();
$isQti = isQtiQuestionBank($data);
if ($isQti) {
$result = qti_parse_file($data);
$fileFound = true;
} else {
$isManifest = isQtiManifest($data);
if ($isManifest) {
$resourcesLinks = qtiProcessManifest($data);
}
}
}
if (!$fileFound) {
return 'NoXMLFileFoundInTheZip';
}
if (false == $result) {
return false;
}
// 1. Create exercise.
$exercise = new Exercise();
$exercise->exercise = $exerciseInfo['name'];
// Random QTI support
if (isset($exerciseInfo['order_type'])) {
if ('Random' === $exerciseInfo['order_type']) {
$exercise->setQuestionSelectionType(2);
$exercise->random = -1;
}
}
if (!empty($exerciseInfo['description'])) {
$exercise->updateDescription(formatText(strip_tags($exerciseInfo['description'])));
}
$exercise->save();
$last_exercise_id = $exercise->getId();
$courseId = api_get_course_int_id();
if (!empty($last_exercise_id)) {
// For each question found...
foreach ($exerciseInfo['question'] as $question_array) {
if (!in_array($question_array['type'], [UNIQUE_ANSWER, MULTIPLE_ANSWER, FREE_ANSWER])) {
continue;
}
//2. Create question
$question = new Ims2Question();
$question->type = $question_array['type'];
if (empty($question->type)) {
// If the type was not provided, assume this is a multiple choice, unique answer type (the most basic)
$question->type = MCUA;
}
$question->setAnswer();
$description = '';
$question->updateTitle(formatText(strip_tags($question_array['title'])));
if (isset($question_array['category'])) {
$category = formatText(strip_tags($question_array['category']));
if (!empty($category)) {
$categoryId = TestCategory::get_category_id_for_title(
$category,
$courseId
);
if (empty($categoryId)) {
$cat = new TestCategory();
$cat->name = $category;
$cat->description = '';
$categoryId = $cat->save($courseId);
if ($categoryId) {
$question->category = $categoryId;
}
} else {
$question->category = $categoryId;
}
}
}
if (!empty($question_array['description'])) {
$description .= $question_array['description'];
}
$question->updateDescription($description);
$question->save($exercise);
$last_question_id = $question->getId();
//3. Create answer
$answer = new Answer($last_question_id);
$answerList = $question_array['answer'];
$answer->new_nbrAnswers = count($answerList);
$totalCorrectWeight = 0;
$j = 1;
$matchAnswerIds = [];
if (!empty($answerList)) {
foreach ($answerList as $key => $answers) {
if (preg_match('/_/', $key)) {
$split = explode('_', $key);
$i = $split[1];
} else {
$i = $j;
$j++;
$matchAnswerIds[$key] = $j;
}
// Answer
$answer->new_answer[$i] = isset($answers['value']) ? formatText($answers['value']) : '';
// Comment
$answer->new_comment[$i] = isset($answers['feedback']) ? formatText($answers['feedback']) : null;
// Position
$answer->new_position[$i] = $i;
// Correct answers
if (in_array($key, $question_array['correct_answers'])) {
$answer->new_correct[$i] = 1;
} else {
$answer->new_correct[$i] = 0;
}
$answer->new_weighting[$i] = 0;
if (isset($question_array['weighting'][$key])) {
$answer->new_weighting[$i] = $question_array['weighting'][$key];
}
if ($answer->new_correct[$i]) {
$totalCorrectWeight += $answer->new_weighting[$i];
}
}
}
if (FREE_ANSWER == $question->type) {
$totalCorrectWeight = $question_array['weighting'][0];
}
$question->updateWeighting($totalCorrectWeight);
$question->save($exercise);
$answer->save();
}
return $last_exercise_id;
}
return false;
}
/**
* We assume the file charset is UTF8.
*/
function formatText($text)
{
return api_html_entity_decode($text);
}
/**
* Parses a given XML file and fills global arrays with the elements.
*
* @param string $exercisePath
* @param string $file
* @param string $questionFile
*
* @return bool
*/
function qti_parse_file($data)
{
global $record_item_body;
global $questionTempDir;
if (empty($data)) {
Display::addFlash(Display::return_message(get_lang('Error opening question\'s XML file'), 'error'));
return false;
}
//parse XML question file
//$data = str_replace(array('<p>', '</p>', '<front>', '</front>'), '', $data);
$data = ChamiloApi::stripGivenTags($data, ['p', 'front']);
$qtiVersion = [];
$match = preg_match('/ims_qtiasiv(\d)p(\d)/', $data, $qtiVersion);
$qtiMainVersion = 2; //by default, assume QTI version 2
if ($match) {
$qtiMainVersion = $qtiVersion[1];
}
//used global variable start values declaration:
$record_item_body = false;
if (2 != $qtiMainVersion) {
Display::addFlash(
Display::return_message(
get_lang('UnsupportedQtiVersion'),
'error'
)
);
return false;
}
parseQti2($data);
return true;
}
/**
* Function used to parser a QTI2 xml file.
*
* @param string $xmlData
*/
function parseQti2($xmlData)
{
global $exerciseInfo;
global $questionTempDir;
global $resourcesLinks;
$crawler = new Crawler($xmlData);
$nodes = $crawler->filter('*');
$currentQuestionIdent = '';
$currentAnswerId = '';
$currentQuestionItemBody = '';
$cardinality = '';
$nonHTMLTagToAvoid = [
'prompt',
'simpleChoice',
'choiceInteraction',
'inlineChoiceInteraction',
'inlineChoice',
'soMPLEMATCHSET',
'simpleAssociableChoice',
'textEntryInteraction',
'feedbackInline',
'matchInteraction',
'extendedTextInteraction',
'itemBody',
'br',
'img',
];
$currentMatchSet = null;
/** @var DOMElement $node */
foreach ($nodes as $node) {
if ('#text' === $node->nodeName) {
continue;
}
switch ($node->nodeName) {
case 'assessmentItem':
$currentQuestionIdent = $node->getAttribute('identifier');
$exerciseInfo['question'][$currentQuestionIdent] = [
'answer' => [],
'correct_answers' => [],
'title' => $node->getAttribute('title'),
'category' => $node->getAttribute('category'),
'type' => '',
'tempdir' => $questionTempDir,
'description' => null,
];
break;
case 'section':
$title = $node->getAttribute('title');
if (!empty($title)) {
$exerciseInfo['name'] = $title;
}
break;
case 'responseDeclaration':
if ('multiple' === $node->getAttribute('cardinality')) {
$exerciseInfo['question'][$currentQuestionIdent]['type'] = MCMA;
$cardinality = 'multiple';
}
if ('single' === $node->getAttribute('cardinality')) {
$exerciseInfo['question'][$currentQuestionIdent]['type'] = MCUA;
$cardinality = 'single';
}
$currentAnswerId = $node->getAttribute('identifier');
break;
case 'inlineChoiceInteraction':
$exerciseInfo['question'][$currentQuestionIdent]['type'] = FIB;
$exerciseInfo['question'][$currentQuestionIdent]['subtype'] = 'LISTBOX_FILL';
$currentAnswerId = $node->getAttribute('responseIdentifier');
break;
case 'inlineChoice':
$answerIdentifier = $exerciseInfo['question'][$currentQuestionIdent]['correct_answers'][$currentAnswerId];
if ($node->getAttribute('identifier') == $answerIdentifier) {
$currentQuestionItemBody = str_replace(
'**claroline_start**'.$currentAnswerId.'**claroline_end**',
'['.$node->nodeValue.']',
$currentQuestionItemBody
);
} else {
if (!isset($exerciseInfo['question'][$currentQuestionIdent]['wrong_answers'])) {
$exerciseInfo['question'][$currentQuestionIdent]['wrong_answers'] = [];
}
$exerciseInfo['question'][$currentQuestionIdent]['wrong_answers'][] = $node->nodeValue;
}
break;
case 'textEntryInteraction':
$exerciseInfo['question'][$currentQuestionIdent]['type'] = FIB;
$exerciseInfo['question'][$currentQuestionIdent]['subtype'] = 'TEXTFIELD_FILL';
$exerciseInfo['question'][$currentQuestionIdent]['response_text'] = $currentQuestionItemBody;
break;
case 'matchInteraction':
$exerciseInfo['question'][$currentQuestionIdent]['type'] = MATCHING;
break;
case 'extendedTextInteraction':
$exerciseInfo['question'][$currentQuestionIdent]['type'] = FREE_ANSWER;
$exerciseInfo['question'][$currentQuestionIdent]['description'] = $node->nodeValue;
break;
case 'simpleMatchSet':
if (!isset($currentMatchSet)) {
$currentMatchSet = 1;
} else {
$currentMatchSet++;
}
$exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentMatchSet] = [];
break;
case 'simpleAssociableChoice':
$currentAssociableChoice = $node->getAttribute('identifier');
$exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentMatchSet][$currentAssociableChoice] = trim($node->nodeValue);
break;
case 'simpleChoice':
$currentAnswerId = $node->getAttribute('identifier');
if (!isset($exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId])) {
$exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId] = [];
}
//$simpleChoiceValue = $node->nodeValue;
$simpleChoiceValue = '';
/** @var DOMElement $childNode */
foreach ($node->childNodes as $childNode) {
if ('feedbackInline' === $childNode->nodeName) {
continue;
}
$simpleChoiceValue .= $childNode->nodeValue;
}
$simpleChoiceValue = trim($simpleChoiceValue);
if (!isset($exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['value'])) {
$exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['value'] = $simpleChoiceValue;
} else {
$exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['value'] .= $simpleChoiceValue;
}
break;
case 'mapEntry':
if (in_array($node->parentNode->nodeName, ['mapping', 'mapEntry'])) {
$answer_id = $node->getAttribute('mapKey');
if (!isset($exerciseInfo['question'][$currentQuestionIdent]['weighting'])) {
$exerciseInfo['question'][$currentQuestionIdent]['weighting'] = [];
}
$exerciseInfo['question'][$currentQuestionIdent]['weighting'][$answer_id] = $node->getAttribute(
'mappedValue'
);
}
break;
case 'mapping':
$defaultValue = $node->getAttribute('defaultValue');
if (!empty($defaultValue)) {
$exerciseInfo['question'][$currentQuestionIdent]['default_weighting'] = $defaultValue;
}
// no break ?
case 'itemBody':
$nodeValue = $node->nodeValue;
$currentQuestionItemBody = '';
/** @var DOMElement $childNode */
foreach ($node->childNodes as $childNode) {
if ('#text' === $childNode->nodeName) {
continue;
}
if (!in_array($childNode->nodeName, $nonHTMLTagToAvoid)) {
$currentQuestionItemBody .= '<'.$childNode->nodeName;
if ($childNode->attributes) {
foreach ($childNode->attributes as $attribute) {
$currentQuestionItemBody .= ' '.$attribute->nodeName.'="'.$attribute->nodeValue.'"';
}
}
$currentQuestionItemBody .= '>'.$childNode->nodeValue.'</'.$node->nodeName.'>';
continue;
}
if ('inlineChoiceInteraction' === $childNode->nodeName) {
$currentQuestionItemBody .= '**claroline_start**'.$childNode->getAttribute('responseIdentifier')
.'**claroline_end**';
continue;
}
if ('textEntryInteraction' === $childNode->nodeName) {
$correct_answer_value = $exerciseInfo['question'][$currentQuestionIdent]['correct_answers'][$currentAnswerId];
$currentQuestionItemBody .= '['.$correct_answer_value.']';
continue;
}
if ('br' === $childNode->nodeName) {
$currentQuestionItemBody .= '<br>';
}
}
// Replace relative links by links to the documents in the course
// $resourcesLinks is only defined by qtiProcessManifest()
if (isset($resourcesLinks) && isset($resourcesLinks['manifest']) && isset($resourcesLinks['web'])) {
foreach ($resourcesLinks['manifest'] as $key => $value) {
$nodeValue = preg_replace('|'.$value.'|', $resourcesLinks['web'][$key], $nodeValue);
}
}
$currentQuestionItemBody .= $node->firstChild->nodeValue;
if (FIB == $exerciseInfo['question'][$currentQuestionIdent]['type']) {
$exerciseInfo['question'][$currentQuestionIdent]['response_text'] = $currentQuestionItemBody;
} else {
if (FREE_ANSWER == $exerciseInfo['question'][$currentQuestionIdent]['type']) {
$currentQuestionItemBody = trim($currentQuestionItemBody);
if (!empty($currentQuestionItemBody)) {
$exerciseInfo['question'][$currentQuestionIdent]['description'] = $currentQuestionItemBody;
}
} else {
$exerciseInfo['question'][$currentQuestionIdent]['statement'] = $currentQuestionItemBody;
}
}
break;
case 'img':
$exerciseInfo['question'][$currentQuestionIdent]['attached_file_url'] = $node->getAttribute('src');
break;
case 'order':
$orderType = $node->getAttribute('order_type');
if (!empty($orderType)) {
$exerciseInfo['order_type'] = $orderType;
}
break;
case 'feedbackInline':
if (!isset($exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['feedback'])) {
$exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['feedback'] = trim(
$node->nodeValue
);
} else {
$exerciseInfo['question'][$currentQuestionIdent]['answer'][$currentAnswerId]['feedback'] .= trim(
$node->nodeValue
);
}
break;
case 'value':
if ('correctResponse' === $node->parentNode->nodeName) {
$nodeValue = trim($node->nodeValue);
if ('single' === $cardinality) {
$exerciseInfo['question'][$currentQuestionIdent]['correct_answers'][$nodeValue] = $nodeValue;
} else {
$exerciseInfo['question'][$currentQuestionIdent]['correct_answers'][] = $nodeValue;
}
}
if ('outcomeDeclaration' === $node->parentNode->parentNode->nodeName) {
$nodeValue = trim($node->nodeValue);
if (!empty($nodeValue)) {
$exerciseInfo['question'][$currentQuestionIdent]['weighting'][0] = $nodeValue;
}
}
break;
case 'mattext':
if ('flow_mat' === $node->parentNode->parentNode->nodeName &&
('presentation_material' === $node->parentNode->parentNode->parentNode->nodeName ||
'section' === $node->parentNode->parentNode->parentNode->nodeName
)
) {
$nodeValue = trim($node->nodeValue);
if (!empty($nodeValue)) {
$exerciseInfo['description'] = $node->nodeValue;
}
}
break;
case 'prompt':
$description = trim($node->nodeValue);
$description = htmlspecialchars_decode($description);
$description = Security::remove_XSS($description);
if (!empty($description)) {
$exerciseInfo['question'][$currentQuestionIdent]['description'] = $description;
}
break;
}
}
}
/**
* Check if a given file is an IMS/QTI question bank file.
*
* @param string $filePath The absolute filepath
*
* @return bool Whether it is an IMS/QTI question bank or not
*/
function isQtiQuestionBank($data)
{
if (!empty($data)) {
$match = preg_match('/ims_qtiasiv(\d)p(\d)/', $data);
// @todo allow other types
//$match2 = preg_match('/imsqti_v(\d)p(\d)/', $data);
if ($match) {
return true;
}
}
return false;
}
/**
* Check if a given file is an IMS/QTI manifest file (listing of extra files).
*
* @param string $filePath The absolute filepath
*
* @return bool Whether it is an IMS/QTI manifest file or not
*/
function isQtiManifest($data)
{
if (!empty($data)) {
$match = preg_match('/imsccv(\d)p(\d)/', $data);
if ($match) {
return true;
}
}
return false;
}
/**
* Processes an IMS/QTI manifest file: store links to new files
* to be able to transform them into the questions text.
*
* @param string $filePath The absolute filepath
*
* @return bool
*/
function qtiProcessManifest($data)
{
$xml = simplexml_load_string($data);
$course = api_get_course_info();
$sessionId = api_get_session_id();
$exercisesSysPath = '/';
$webPath = api_get_path(WEB_CODE_PATH);
$exercisesWebPath = $webPath.'document/document.php?'.api_get_cidreq().'&action=download&id=';
$links = [
'manifest' => [],
'system' => [],
'web' => [],
];
$tableDocuments = Database::get_course_table(TABLE_DOCUMENT);
$countResources = count($xml->resources->resource->file);
for ($i = 0; $i < $countResources; $i++) {
$file = $xml->resources->resource->file[$i];
$href = '';
foreach ($file->attributes() as $key => $value) {
if ('href' == $key) {
if ('xml' != substr($value, -3, 3)) {
$href = $value;
}
}
}
if (!empty($href)) {
$links['manifest'][] = (string) $href;
$links['system'][] = $exercisesSysPath.strtolower($href);
$specialHref = Database::escape_string(preg_replace('/_/', '-', strtolower($href)));
$specialHref = preg_replace('/(-){2,8}/', '-', $specialHref);
$sql = "SELECT iid FROM $tableDocuments
WHERE
c_id = ".$course['real_id']." AND
session_id = $sessionId AND
path = '/".$specialHref."'";
$result = Database::query($sql);
$documentId = 0;
while ($row = Database::fetch_assoc($result)) {
$documentId = $row['iid'];
}
$links['web'][] = $exercisesWebPath.$documentId;
}
}
return $links;
}