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; } }