prepareActivityDirectory($exportDir, 'quiz', (int) $moduleId);
$quizData = $this->getData((int) $activityId, (int) $sectionId, (int) $moduleId);
if (empty($quizData)) {
return;
}
$this->createQuizXml($quizData, $quizDir);
$this->createModuleXml($quizData, $quizDir);
$this->createGradesXml($quizData, $quizDir);
$this->createCompletionXml($quizData, $quizDir);
$this->createCommentsXml($quizData, $quizDir);
$this->createCompetenciesXml($quizData, $quizDir);
$this->createFiltersXml($quizData, $quizDir);
$this->createGradeHistoryXml($quizData, $quizDir);
$this->createInforefXml($quizData, $quizDir);
$this->createRolesXml($quizData, $quizDir);
$this->createCalendarXml($quizData, $quizDir);
}
/**
* Moodle requires a valid question behaviour.
*/
private function sanitizePreferredBehaviour($raw): string
{
$behaviour = trim((string) $raw);
$allowed = [
'deferredfeedback',
'adaptive',
'adaptivenopenalty',
'immediatefeedback',
'interactive',
'manualgraded',
'deferredcbm',
'immediatecbm',
'interactivecountback',
];
if ($behaviour === '' || !in_array($behaviour, $allowed, true)) {
return 'deferredfeedback';
}
return $behaviour;
}
/**
* Normalize an integer-like value.
*/
private function sanitizeInt($raw, int $default): int
{
if ($raw === null) {
return $default;
}
if (is_string($raw) && trim($raw) === '') {
return $default;
}
return is_numeric($raw) ? (int) $raw : $default;
}
/**
* Parse a float or return null when missing.
*/
private function parseNullableFloat($raw): ?float
{
if ($raw === null) {
return null;
}
if (is_string($raw) && trim($raw) === '') {
return null;
}
return is_numeric($raw) ? (float) $raw : null;
}
/**
* Moodle quiz navmethod accepted values.
*/
private function sanitizeNavMethod($raw): string
{
$v = trim((string) $raw);
return in_array($v, ['free', 'sequential'], true) ? $v : 'free';
}
/**
* Build a stable question category id per exported quiz occurrence.
*/
private function buildQuizQuestionCategoryId(int $moduleId): int
{
return 1000000000 + max(1, $moduleId);
}
/**
* Retrieves the quiz data.
*/
public function getData(int $quizId, int $sectionId, ?int $moduleId = null): array
{
$quizResources = $this->course->resources[RESOURCE_QUIZ] ?? [];
foreach ($quizResources as $quiz) {
if ($quiz->obj->iid == -1) {
continue;
}
if ((int) $quiz->obj->iid !== $quizId) {
continue;
}
$courseCode = $this->course->info['code'] ?? ($this->course->code ?? '');
$courseInfo = api_get_course_info($courseCode);
$courseRealId = (int) ($courseInfo['real_id'] ?? 0);
$contextid = $this->buildQuestionBankContextId($courseRealId);
$effectiveModuleId = (int) ($moduleId ?? ($quiz->obj->iid ?? 0));
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) ($quiz->obj->iid ?? 0);
}
$questionCategoryId = $this->buildQuizQuestionCategoryId($effectiveModuleId);
$name = (string) ($quiz->obj->title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_QUIZ, $quizId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$adminData = MoodleExport::getAdminUserData();
$adminId = (int) ($adminData['id'] ?? 1);
$quizFiles = [];
$questions = $this->getQuestionsForQuiz(
$quizId,
$questionCategoryId,
$contextid,
$courseInfo,
$adminId,
$quizFiles
);
$computedSumgrades = 0.0;
foreach ($questions as $q) {
$computedSumgrades += (float) ($q['maxmark'] ?? 0.0);
}
$rawSumgrades = $this->parseNullableFloat($quiz->obj->sumgrades ?? null);
$rawGrade = $this->parseNullableFloat($quiz->obj->grade ?? null);
$sumgrades = $rawSumgrades !== null ? $rawSumgrades : $computedSumgrades;
$grade = $rawGrade !== null ? $rawGrade : ($sumgrades > 0 ? $sumgrades : 0.0);
return [
'id' => (int) ($quiz->obj->iid ?? 0),
'name' => $name,
'intro' => (string) ($quiz->obj->description ?? ''),
'timeopen' => $this->sanitizeInt($quiz->obj->start_time ?? null, 0),
'timeclose' => $this->sanitizeInt($quiz->obj->end_time ?? null, 0),
'timelimit' => $this->sanitizeInt($quiz->obj->timelimit ?? null, 0),
'grademethod' => $this->sanitizeInt($quiz->obj->grademethod ?? null, 1),
'decimalpoints' => $this->sanitizeInt($quiz->obj->decimalpoints ?? null, 2),
'sumgrades' => $sumgrades,
'grade' => $grade,
'questionsperpage' => $this->sanitizeInt($quiz->obj->questionsperpage ?? null, 1),
'preferredbehaviour' => $this->sanitizePreferredBehaviour($quiz->obj->preferredbehaviour ?? null),
'shuffleanswers' => $this->sanitizeInt($quiz->obj->shuffleanswers ?? null, 1),
'navmethod' => $this->sanitizeNavMethod($quiz->obj->navmethod ?? null),
'question_category_id' => $questionCategoryId,
'questions' => $questions,
'files' => $quizFiles,
'feedbacks' => $this->getFeedbacksForQuiz($quizId),
'sectionid' => $sectionId,
'moduleid' => $effectiveModuleId,
'modulename' => 'quiz',
'contextid' => $contextid,
'courseid' => $courseRealId,
'overduehandling' => (string) ($quiz->obj->overduehandling ?? 'autosubmit'),
'graceperiod' => $this->sanitizeInt($quiz->obj->graceperiod ?? null, 0),
'canredoquestions' => $this->sanitizeInt($quiz->obj->canredoquestions ?? null, 0),
'attempts_number' => $this->sanitizeInt($quiz->obj->attempts_number ?? null, 0),
'attemptonlast' => $this->sanitizeInt($quiz->obj->attemptonlast ?? null, 0),
'questiondecimalpoints' => $this->sanitizeInt($quiz->obj->questiondecimalpoints ?? null, 2),
'reviewattempt' => $this->sanitizeInt($quiz->obj->reviewattempt ?? null, 0),
'reviewcorrectness' => $this->sanitizeInt($quiz->obj->reviewcorrectness ?? null, 0),
'reviewmarks' => $this->sanitizeInt($quiz->obj->reviewmarks ?? null, 0),
'reviewspecificfeedback' => $this->sanitizeInt($quiz->obj->reviewspecificfeedback ?? null, 0),
'reviewgeneralfeedback' => $this->sanitizeInt($quiz->obj->reviewgeneralfeedback ?? null, 0),
'reviewrightanswer' => $this->sanitizeInt($quiz->obj->reviewrightanswer ?? null, 0),
'reviewoverallfeedback' => $this->sanitizeInt($quiz->obj->reviewoverallfeedback ?? null, 0),
'timecreated' => $this->sanitizeInt($quiz->obj->insert_date ?? null, time()),
'timemodified' => $this->sanitizeInt($quiz->obj->lastedit_date ?? null, time()),
'password' => (string) ($quiz->obj->password ?? ''),
'subnet' => (string) ($quiz->obj->subnet ?? ''),
'browsersecurity' => (string) ($quiz->obj->browsersecurity ?? '-'),
'delay1' => $this->sanitizeInt($quiz->obj->delay1 ?? null, 0),
'delay2' => $this->sanitizeInt($quiz->obj->delay2 ?? null, 0),
'showuserpicture' => $this->sanitizeInt($quiz->obj->showuserpicture ?? null, 0),
'showblocks' => $this->sanitizeInt($quiz->obj->showblocks ?? null, 0),
'completionattemptsexhausted' => $this->sanitizeInt($quiz->obj->completionattemptsexhausted ?? null, 0),
'completionpass' => $this->sanitizeInt($quiz->obj->completionpass ?? null, 0),
'completionminattempts' => $this->sanitizeInt($quiz->obj->completionminattempts ?? null, 0),
'allowofflineattempts' => $this->sanitizeInt($quiz->obj->allowofflineattempts ?? null, 0),
'users' => [],
];
}
return [];
}
/**
* Exports a question in XML format.
*/
public function exportQuestion(array $question): string
{
$questionText = (string) ($question['questiontext'] ?? 'No question text');
$questionName = $this->sanitizeQuestionName($questionText, 255);
$xmlContent = ' '.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars($questionName).''.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars($questionText).''.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' '.($question['maxmark'] ?? '0').''.PHP_EOL;
$xmlContent .= ' 0.3333333'.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars((string) str_replace('_nosingle', '', $question['qtype'] ?? 'unknown')).''.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' moodle+'.time().'+QUESTIONSTAMP'.PHP_EOL;
$xmlContent .= ' moodle+'.time().'+VERSIONSTAMP'.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.time().''.PHP_EOL;
$xmlContent .= ' '.time().''.PHP_EOL;
$xmlContent .= ' 2'.PHP_EOL;
$xmlContent .= ' 2'.PHP_EOL;
switch ($question['qtype']) {
case 'multichoice':
$xmlContent .= $this->exportMultichoiceQuestion($question);
break;
case 'multichoice_nosingle':
$xmlContent .= $this->exportMultichoiceNosingleQuestion($question);
break;
case 'truefalse':
$xmlContent .= $this->exportTrueFalseQuestion($question);
break;
case 'shortanswer':
$xmlContent .= $this->exportShortAnswerQuestion($question);
break;
case 'match':
$xmlContent .= $this->exportMatchQuestion($question);
break;
}
$xmlContent .= ' '.PHP_EOL;
return $xmlContent;
}
/**
* Retrieves the questions for a specific quiz.
*/
private function getQuestionsForQuiz(
int $quizId,
int $questionCategoryId,
int $contextId,
array $courseInfo,
int $adminId,
array &$quizFiles
): array {
$questions = [];
$quizResources = $this->course->resources[RESOURCE_QUIZQUESTION] ?? [];
foreach ($quizResources as $questionId => $questionData) {
if (!in_array($questionId, $this->course->resources[RESOURCE_QUIZ][$quizId]->obj->question_ids)) {
continue;
}
$qRes = $this->extractQuestionEmbeddedFilesAndNormalizeContent(
(string) ($questionData->question ?? ''),
$contextId,
'questiontext',
(int) $questionData->source_id,
'',
$courseInfo,
$adminId
);
if (!empty($qRes['files'])) {
$quizFiles = array_merge($quizFiles, $qRes['files']);
}
$answers = $this->getAnswersForQuestion(
(int) $questionData->source_id,
$contextId,
$courseInfo,
$adminId,
$quizFiles
);
$mappedType = $this->mapQuestionType((string) $questionData->quiz_type);
if ($mappedType === 'unknown') {
continue;
}
$questions[] = [
'id' => (int) $questionData->source_id,
'questiontext' => $qRes['content'],
'qtype' => $mappedType,
'questioncategoryid' => $questionCategoryId,
'answers' => $answers,
'maxmark' => $questionData->ponderation ?? 1,
];
}
return $questions;
}
/**
* Maps the quiz type code to a descriptive string.
*/
private function mapQuestionType(string $quizType): string
{
switch ($quizType) {
case UNIQUE_ANSWER:
return 'multichoice';
case MULTIPLE_ANSWER:
return 'multichoice_nosingle';
case FILL_IN_BLANKS:
return 'match';
case FREE_ANSWER:
return 'shortanswer';
case CALCULATED_ANSWER:
return 'calculated';
case UPLOAD_ANSWER:
return 'fileupload';
default:
return 'unknown';
}
}
/**
* Retrieves the answers for a specific question ID.
*/
private function getAnswersForQuestion(
int $questionId,
int $contextId,
array $courseInfo,
int $adminId,
array &$collectedFiles
): array {
$answers = [];
$quizResources = $this->course->resources[RESOURCE_QUIZQUESTION] ?? [];
foreach ($quizResources as $questionData) {
if ((int) $questionData->source_id !== $questionId) {
continue;
}
$answerIndex = 0;
foreach ($questionData->answers as $answer) {
$answerIndex++;
$answerId = $this->buildStableQuestionAnswerId($questionId, $answerIndex);
$rawText = (string) ($answer['answer'] ?? '');
$res = $this->extractQuestionEmbeddedFilesAndNormalizeContent(
$rawText,
$contextId,
'answer',
$answerId,
'',
$courseInfo,
$adminId
);
if (!empty($res['files'])) {
$collectedFiles = array_merge($collectedFiles, $res['files']);
}
$answers[] = [
'id' => $answerId,
'text' => $res['content'],
'fraction' => (($answer['correct'] ?? '0') == '1') ? 100 : 0,
'feedback' => (string) ($answer['comment'] ?? ''),
];
}
}
return $answers;
}
/**
* Retrieves feedbacks for a specific quiz.
*/
private function getFeedbacksForQuiz(int $quizId): array
{
$feedbacks = [];
$quizResources = $this->course->resources[RESOURCE_QUIZ] ?? [];
foreach ($quizResources as $quiz) {
if ((int) $quiz->obj->iid === $quizId) {
$feedbacks[] = [
'feedbacktext' => (string) ($quiz->obj->description ?? ''),
'mingrade' => 0.00000,
'maxgrade' => $quiz->obj->grade ?? 10.00000,
];
}
}
return $feedbacks;
}
/**
* Creates the quiz.xml file.
*/
private function createQuizXml(array $quizData, string $destinationDir): void
{
$preferredBehaviour = trim((string) ($quizData['preferredbehaviour'] ?? ''));
if ($preferredBehaviour === '') {
$preferredBehaviour = 'deferredfeedback';
}
$gradeMethod = (int) ($quizData['grademethod'] ?? 1);
$decimalPoints = (int) ($quizData['decimalpoints'] ?? 2);
$questionsPerPage = (int) ($quizData['questionsperpage'] ?? 1);
$navMethod = (string) ($quizData['navmethod'] ?? 'free');
$shuffleAnswers = (int) ($quizData['shuffleanswers'] ?? 1);
$sumGrades = (float) ($quizData['sumgrades'] ?? 0.0);
$grade = (float) ($quizData['grade'] ?? 0.0);
$xmlContent = ''.PHP_EOL;
$xmlContent .= ''.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars((string) $quizData['name']).''.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars((string) $quizData['intro']).''.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' '.($quizData['timeopen'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['timeclose'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['timelimit'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['overduehandling'] ?? 'autosubmit').''.PHP_EOL;
$xmlContent .= ' '.($quizData['graceperiod'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars($preferredBehaviour).''.PHP_EOL;
$xmlContent .= ' '.($quizData['canredoquestions'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['attempts_number'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['attemptonlast'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.$gradeMethod.''.PHP_EOL;
$xmlContent .= ' '.$decimalPoints.''.PHP_EOL;
$xmlContent .= ' '.($quizData['questiondecimalpoints'] ?? -1).''.PHP_EOL;
$xmlContent .= ' '.($quizData['reviewattempt'] ?? 69888).''.PHP_EOL;
$xmlContent .= ' '.($quizData['reviewcorrectness'] ?? 4352).''.PHP_EOL;
$xmlContent .= ' '.($quizData['reviewmarks'] ?? 4352).''.PHP_EOL;
$xmlContent .= ' '.($quizData['reviewspecificfeedback'] ?? 4352).''.PHP_EOL;
$xmlContent .= ' '.($quizData['reviewgeneralfeedback'] ?? 4352).''.PHP_EOL;
$xmlContent .= ' '.($quizData['reviewrightanswer'] ?? 4352).''.PHP_EOL;
$xmlContent .= ' '.($quizData['reviewoverallfeedback'] ?? 4352).''.PHP_EOL;
$xmlContent .= ' '.$questionsPerPage.''.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars($navMethod).''.PHP_EOL;
$xmlContent .= ' '.$shuffleAnswers.''.PHP_EOL;
$xmlContent .= ' '.$sumGrades.''.PHP_EOL;
$xmlContent .= ' '.$grade.''.PHP_EOL;
$xmlContent .= ' '.($quizData['timecreated'] ?? time()).''.PHP_EOL;
$xmlContent .= ' '.($quizData['timemodified'] ?? time()).''.PHP_EOL;
$xmlContent .= ' '.(isset($quizData['password']) ? htmlspecialchars((string) $quizData['password']) : '').''.PHP_EOL;
$xmlContent .= ' '.(isset($quizData['subnet']) ? htmlspecialchars((string) $quizData['subnet']) : '').''.PHP_EOL;
$xmlContent .= ' '.(isset($quizData['browsersecurity']) ? htmlspecialchars((string) $quizData['browsersecurity']) : '-').''.PHP_EOL;
$xmlContent .= ' '.($quizData['delay1'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['delay2'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['showuserpicture'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['showblocks'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['completionattemptsexhausted'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['completionpass'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['completionminattempts'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.($quizData['allowofflineattempts'] ?? 0).''.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$slotIndex = 1;
foreach ($quizData['questions'] as $question) {
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.$slotIndex.''.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.$question['id'].''.PHP_EOL;
$xmlContent .= ' '.$question['questioncategoryid'].''.PHP_EOL;
$xmlContent .= ' $@NULL@$'.PHP_EOL;
$xmlContent .= ' '.$question['maxmark'].''.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$slotIndex++;
}
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
foreach ($quizData['feedbacks'] as $feedback) {
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars((string) $feedback['feedbacktext']).''.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' '.$feedback['mingrade'].''.PHP_EOL;
$xmlContent .= ' '.$feedback['maxgrade'].''.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
}
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL.' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL.' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL.' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ''.PHP_EOL;
$xmlFile = $destinationDir.'/quiz.xml';
if (file_put_contents($xmlFile, $xmlContent) === false) {
throw new Exception(get_lang('ErrorCreatingQuizXml'));
}
}
/**
* Exports a multiple-choice question in XML format.
*/
private function exportMultichoiceQuestion(array $question): string
{
$xmlContent = ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
foreach ($question['answers'] as $answer) {
$xmlContent .= $this->exportAnswer($answer);
}
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' Your answer is correct.'.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' Your answer is partially correct.'.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' Your answer is incorrect.'.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' abc'.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
return $xmlContent;
}
/**
* Exports a multiple-choice question with single=0 in XML format.
*/
private function exportMultichoiceNosingleQuestion(array $question): string
{
return str_replace('1', '0', $this->exportMultichoiceQuestion($question));
}
/**
* Exports a true/false question in XML format.
*/
private function exportTrueFalseQuestion(array $question): string
{
$xmlContent = ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
foreach ($question['answers'] as $answer) {
$xmlContent .= $this->exportAnswer($answer);
}
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$trueId = $question['answers'][0]['id'] ?? 0;
$falseId = $question['answers'][1]['id'] ?? 0;
$xmlContent .= ' '.$trueId.''.PHP_EOL;
$xmlContent .= ' '.$falseId.''.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
return $xmlContent;
}
/**
* Exports a short answer question in XML format.
*/
private function exportShortAnswerQuestion(array $question): string
{
$xmlContent = ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
foreach ($question['answers'] as $answer) {
$xmlContent .= $this->exportAnswer($answer);
}
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
return $xmlContent;
}
/**
* Exports a matching question in XML format.
*/
private function exportMatchQuestion(array $question): string
{
$xmlContent = ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' 1'.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars((string) ($question['correctfeedback'] ?? '')).''.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars((string) ($question['partiallycorrectfeedback'] ?? '')).''.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars((string) ($question['incorrectfeedback'] ?? '')).''.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
$res = FillBlanks::getAnswerInfo($question['answers'][0]['text']);
$words = $res['words'];
$common_words = $res['common_words'];
for ($i = 0; $i < count($common_words); $i++) {
$answer = htmlspecialchars(trim(strip_tags($common_words[$i])));
if (!empty(trim($answer))) {
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.$answer.''.PHP_EOL;
$xmlContent .= ' 0'.PHP_EOL;
$xmlContent .= ' '.htmlspecialchars(explode('|', $words[$i])[0]).''.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
}
}
$xmlContent .= ' '.PHP_EOL;
$xmlContent .= ' '.PHP_EOL;
return $xmlContent;
}
/**
* Exports an answer in XML format.
*/
private function exportAnswer(array $answer): string
{
return ' '.PHP_EOL.
' '.htmlspecialchars((string) ($answer['text'] ?? 'No answer text')).''.PHP_EOL.
' 1'.PHP_EOL.
' '.($answer['fraction'] ?? '0').''.PHP_EOL.
' '.htmlspecialchars((string) ($answer['feedback'] ?? '')).''.PHP_EOL.
' 1'.PHP_EOL.
' '.PHP_EOL;
}
private function ensureTrailingSlash(string $path): string
{
if ($path === '' || $path === '.' || $path === '/') {
return '/';
}
$path = preg_replace('#/+#', '/', $path);
return rtrim($path, '/').'/';
}
private function buildQuestionEmbeddedFileId(int $baseId, int $sequence): int
{
self::$embeddedFileGlobalSeq++;
return 1300000000 + self::$embeddedFileGlobalSeq;
}
/**
* Extract embedded /document/* images from HTML and rewrite src to @@PLUGINFILE@@.
*
* @return array{content:string, files:array>}
*/
private function extractQuestionEmbeddedFilesAndNormalizeContent(
string $html,
int $contextId,
string $fileArea,
int $itemId,
string $prefix,
array $courseInfo,
int $adminId
): array {
if ($html === '') {
return ['content' => '', 'files' => []];
}
$fileExport = new FileExport($this->course);
$files = [];
$seenDocIds = [];
$sequence = 0;
$normalizedHtml = preg_replace_callback(
'#
]+src=["\'](?[^"\']+)["\']#i',
function ($match) use (
$contextId,
$fileArea,
$itemId,
$courseInfo,
$adminId,
$fileExport,
&$files,
&$seenDocIds,
&$sequence
) {
$src = (string) ($match['url'] ?? '');
if (!preg_match('#/document(?P/[^"\']+)#', $src, $m)) {
return $match[0];
}
$docRelPath = (string) $m['path'];
$docId = \DocumentManager::get_document_id($courseInfo, $docRelPath);
if (empty($docId)) {
return $match[0];
}
$docId = (int) $docId;
if (!isset($seenDocIds[$docId])) {
$doc = \DocumentManager::get_document_data_by_id($docId, $courseInfo['code']);
if (!empty($doc) && !empty($doc['path'])) {
$docPath = (string) $doc['path'];
$rel = ltrim(str_replace('\\', '/', $docPath), '/');
$dir = dirname($rel);
$dir = ($dir === '.' ? '' : $dir.'/');
$filepath = '/'.$dir;
$filepath = $this->ensureTrailingSlash($filepath);
$filename = basename($rel);
$absolutePath = $this->course->path.'document'.$docPath;
$sequence++;
$fileId = $this->buildQuestionEmbeddedFileId($itemId, $sequence);
$files[] = [
'id' => $fileId,
'contenthash' => is_file($absolutePath) ? sha1_file($absolutePath) : hash('sha1', $filename),
'contextid' => $contextId,
'component' => 'question',
'filearea' => $fileArea,
'itemid' => $itemId,
'filepath' => $filepath,
'documentpath' => 'document'.$docPath,
'filename' => $filename,
'userid' => $adminId,
'filesize' => (int) ($doc['size'] ?? 0),
'mimetype' => $fileExport->getMimeType($docPath),
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => (string) ($doc['title'] ?? $filename),
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
$seenDocIds[$docId] = true;
}
$relHtml = ltrim($docRelPath, '/');
$pluginPath = '@@PLUGINFILE@@/'.$relHtml;
return str_replace($src, $pluginPath, $match[0]);
},
$html
);
return [
'content' => (string) $normalizedHtml,
'files' => $files,
];
}
/**
* Build a deterministic answer id for one question answer.
*/
private function buildStableQuestionAnswerId(int $questionId, int $answerIndex): int
{
return ($questionId * 1000) + $answerIndex;
}
/**
* Build a stable question-bank course context id for backup purposes.
*/
private function buildQuestionBankContextId(int $courseId): int
{
$contextId = MoodleExport::getBackupCourseContextId();
if ($contextId > 0) {
return $contextId;
}
return 700000000 + max(1, $courseId);
}
/**
* Build a clean question name from the question text.
*/
private function sanitizeQuestionName(string $raw, int $maxLen = 255): string
{
$text = trim($raw);
if ($text === '') {
return 'Question';
}
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = strip_tags($text);
$text = preg_replace('/\s+/u', ' ', $text);
$text = trim($text);
if ($text === '') {
return 'Question';
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($text, 'UTF-8') > $maxLen) {
$text = mb_substr($text, 0, $maxLen, 'UTF-8');
}
} elseif (strlen($text) > $maxLen) {
$text = substr($text, 0, $maxLen);
}
return $text;
}
}