diff --git a/src/CoreBundle/Controller/CourseMaintenanceController.php b/src/CoreBundle/Controller/CourseMaintenanceController.php index a91113941a5..be25b6edfc1 100644 --- a/src/CoreBundle/Controller/CourseMaintenanceController.php +++ b/src/CoreBundle/Controller/CourseMaintenanceController.php @@ -676,6 +676,7 @@ public function moodleExportOptions(int $node, Request $req, UserRepository $use ['value' => 'glossary', 'label' => 'Glossary'], ['value' => 'learnpaths', 'label' => 'Paths learning'], ['value' => 'tool_intro', 'label' => 'Course Introduction'], + ['value' => 'course_description', 'label' => 'Course descriptions'], ]; $defaults['tools'] = array_column($tools, 'value'); @@ -709,6 +710,7 @@ public function moodleExportResources(int $node, Request $req): JsonResponse 'learnpaths', 'learnpath_category', 'works', 'glossary', 'tool_intro', + 'course_descriptions', ]; // Use client tools if provided; otherwise our Moodle-safe defaults @@ -774,14 +776,14 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use { $this->setDebugFromRequest($req); - $p = json_decode($req->getContent() ?: '{}', true); - $moodleVersion = (string) ($p['moodleVersion'] ?? '4'); - $scope = (string) ($p['scope'] ?? 'full'); - $adminId = (int) ($p['adminId'] ?? 0); - $adminLogin = trim((string) ($p['adminLogin'] ?? '')); - $adminEmail = trim((string) ($p['adminEmail'] ?? '')); - $selected = (array) ($p['resources'] ?? []); - $toolsInput = (array) ($p['tools'] ?? []); + $p = json_decode($req->getContent() ?: '{}', true) ?: []; + $moodleVersion = (string) ($p['moodleVersion'] ?? '4'); // "3" | "4" + $scope = (string) ($p['scope'] ?? 'full'); // "full" | "selected" + $adminId = (int) ($p['adminId'] ?? 0); + $adminLogin = trim((string) ($p['adminLogin'] ?? '')); + $adminEmail = trim((string) ($p['adminEmail'] ?? '')); + $selected = is_array($p['resources'] ?? null) ? (array) $p['resources'] : []; + $toolsInput = is_array($p['tools'] ?? null) ? (array) $p['tools'] : []; if (!\in_array($moodleVersion, ['3', '4'], true)) { return $this->json(['error' => 'Unsupported Moodle version'], 400); @@ -790,7 +792,15 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use return $this->json(['error' => 'No resources selected'], 400); } - // Normalize tools from client (adds implied deps) + $defaultTools = [ + 'documents', 'links', 'forums', + 'quizzes', 'quiz_questions', + 'surveys', 'survey_questions', + 'learnpaths', 'learnpath_category', + 'works', 'glossary', + 'course_descriptions', + ]; + $tools = $this->normalizeSelectedTools($toolsInput); // If scope=selected, merge inferred tools from selection @@ -799,64 +809,86 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use $tools = $this->normalizeSelectedTools(array_merge($tools, $inferred)); } + // Remove unsupported tools $tools = array_values(array_unique(array_diff($tools, ['gradebook']))); - if (!in_array('tool_intro', $tools, true)) { - $tools[] = 'tool_intro'; + $clientSentNoTools = empty($toolsInput); + $useDefault = ($scope === 'full' && $clientSentNoTools); + $toolsToBuild = $useDefault ? $defaultTools : $tools; + + // Ensure "tool_intro" is present (append only if missing) + if (!in_array('tool_intro', $toolsToBuild, true)) { + $toolsToBuild[] = 'tool_intro'; } - if ($adminId <= 0 || '' === $adminLogin || '' === $adminEmail) { - $adm = $users->getDefaultAdminForExport(); - $adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1); - $adminLogin = '' !== $adminLogin ? $adminLogin : (string) ($adm['username'] ?? 'admin'); - $adminEmail = '' !== $adminEmail ? $adminEmail : (string) ($adm['email'] ?? 'admin@example.com'); + // Final dedupe/normalize + $toolsToBuild = array_values(array_unique($toolsToBuild)); + + $this->logDebug('[moodleExportExecute] course tools to build (final)', $toolsToBuild); + + if ($adminId <= 0 || $adminLogin === '' || $adminEmail === '') { + $adm = $users->getDefaultAdminForExport(); + $adminId = $adminId > 0 ? $adminId : (int) ($adm['id'] ?? 1); + $adminLogin = $adminLogin !== '' ? $adminLogin : (string) ($adm['username'] ?? 'admin'); + $adminEmail = $adminEmail !== '' ? $adminEmail : (string) ($adm['email'] ?? 'admin@example.com'); } - $this->logDebug('[moodleExportExecute] tools for CourseBuilder', $tools); + $courseId = api_get_course_id(); + if (empty($courseId)) { + return $this->json(['error' => 'No active course context'], 400); + } - // Build legacy Course from CURRENT course $cb = new CourseBuilder(); - $cb->set_tools_to_build(!empty($tools) ? $tools : [ - // Fallback should mirror the Moodle-safe list used in the picker - 'documents', 'links', 'forums', - 'quizzes', 'quiz_questions', - 'surveys', 'survey_questions', - 'learnpaths', 'learnpath_category', - 'works', 'glossary', - 'tool_intro', - ]); - $course = $cb->build(0, api_get_course_id()); + $cb->set_tools_to_build($toolsToBuild); + $course = $cb->build(0, $courseId); - // IMPORTANT: when scope === 'selected', use the same robust selection filter as copy-course if ('selected' === $scope) { - // This method trims buckets to only selected items and pulls needed deps (LP/quiz/survey) $course = $this->filterLegacyCourseBySelection($course, $selected); - // Safety guard: fail if nothing remains after filtering if (empty($course->resources) || !\is_array($course->resources)) { return $this->json(['error' => 'Selection produced no resources to export'], 400); } } try { - // Pass selection flag to exporter so it does NOT re-hydrate from a complete snapshot. - $selectionMode = ('selected' === $scope); + // === Export to Moodle MBZ === + $selectionMode = ($scope === 'selected'); $exporter = new MoodleExport($course, $selectionMode); $exporter->setAdminUserData($adminId, $adminLogin, $adminEmail); - $courseId = api_get_course_id(); - $exportDir = 'moodle_export_'.date('Ymd_His'); - $versionNum = ('3' === $moodleVersion) ? 3 : 4; + $exportDir = 'moodle_export_' . date('Ymd_His'); + $versionNum = ($moodleVersion === '3') ? 3 : 4; + + $this->logDebug('[moodleExportExecute] starting exporter', [ + 'courseId' => $courseId, + 'exportDir' => $exportDir, + 'versionNum' => $versionNum, + 'selection' => $selectionMode, + 'scope' => $scope, + ]); $mbzPath = $exporter->export($courseId, $exportDir, $versionNum); + if (!\is_string($mbzPath) || $mbzPath === '' || !is_file($mbzPath)) { + return $this->json(['error' => 'Moodle export failed: artifact not found'], 500); + } + + // Build download response $resp = new BinaryFileResponse($mbzPath); $resp->setContentDisposition( ResponseHeaderBag::DISPOSITION_ATTACHMENT, basename($mbzPath) ); + $resp->headers->set('X-Moodle-Version', (string) $versionNum); + $resp->headers->set('X-Export-Scope', $scope); + $resp->headers->set('X-Selection-Mode', $selectionMode ? '1' : '0'); return $resp; - } catch (Throwable $e) { - return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500); + } catch (\Throwable $e) { + $this->logDebug('[moodleExportExecute] exception', [ + 'message' => $e->getMessage(), + 'code' => (int) $e->getCode(), + ]); + + return $this->json(['error' => 'Moodle export failed: ' . $e->getMessage()], 500); } } @@ -3145,7 +3177,7 @@ private function filterCourseResources(object $course, array $selected): void 'survey_questions' => RESOURCE_SURVEYQUESTION, 'announcements' => RESOURCE_ANNOUNCEMENT, 'events' => RESOURCE_EVENT, - 'course_descriptions' => RESOURCE_COURSEDESCRIPTION, + 'course_description' => RESOURCE_COURSEDESCRIPTION, 'glossary' => RESOURCE_GLOSSARY, 'wiki' => RESOURCE_WIKI, 'thematic' => RESOURCE_THEMATIC, @@ -3608,6 +3640,7 @@ private function inferToolsFromSelection(array $selected): array if ($has('work')) { $want[] = 'works'; } if ($has('glossary')) { $want[] = 'glossary'; } if ($has('tool_intro')) { $want[] = 'tool_intro'; } + if ($has('course_descriptions') || $has('course_description')) { $tools[] = 'course_descriptions'; } // Dedup return array_values(array_unique(array_filter($want))); diff --git a/src/CourseBundle/Component/CourseCopy/CourseRecycler.php b/src/CourseBundle/Component/CourseCopy/CourseRecycler.php index 49ae223d12f..9eda31860a9 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseRecycler.php +++ b/src/CourseBundle/Component/CourseCopy/CourseRecycler.php @@ -108,8 +108,22 @@ private function recycleGeneric( bool $autoClean = false, bool $scormCleanup = false ): void { + $repo = $this->em->getRepository($entityClass); + $hasHardDelete = method_exists($repo, 'hardDelete'); + if ($isFull) { - $this->deleteAllOfTypeForCourse($entityClass); + $resources = $this->fetchResourcesForCourse($entityClass, null); + if ($resources) { + $this->hardDeleteMany($entityClass, $resources); + + // Physical delete fallback for documents if repo lacks hardDelete() + if ($deleteFiles && !$hasHardDelete && CDocument::class === $entityClass) { + foreach ($resources as $res) { + $this->physicallyDeleteDocumentFiles($res); + } + } + } + if ($autoClean) { $this->autoCleanIfSupported($entityClass); } @@ -129,7 +143,16 @@ private function recycleGeneric( return; } - $this->deleteSelectedOfTypeForCourse($entityClass, $ids); + $resources = $this->fetchResourcesForCourse($entityClass, $ids); + if ($resources) { + $this->hardDeleteMany($entityClass, $resources); + + if ($deleteFiles && !$hasHardDelete && CDocument::class === $entityClass) { + foreach ($resources as $res) { + $this->physicallyDeleteDocumentFiles($res); + } + } + } if ($autoClean) { $this->autoCleanIfSupported($entityClass); @@ -196,7 +219,16 @@ private function fetchResourcesForCourse(string $entityClass, ?array $ids = null if (method_exists($repo, 'getResourcesByCourseIgnoreVisibility')) { $qb = $repo->getResourcesByCourseIgnoreVisibility($this->courseRef()); if ($ids && \count($ids) > 0) { - $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids); + // Try iid first; if the entity has no iid, fall back to id + $meta = $this->em->getClassMetadata($entityClass); + $hasIid = $meta->hasField('iid'); + + if ($hasIid) { + $qb->andWhere('resource.iid IN (:ids)'); + } else { + $qb->andWhere('resource.id IN (:ids)'); + } + $qb->setParameter('ids', $ids); } return $qb->getQuery()->getResult(); @@ -213,7 +245,9 @@ private function fetchResourcesForCourse(string $entityClass, ?array $ids = null ; if ($ids && \count($ids) > 0) { - $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids); + $meta = $this->em->getClassMetadata($entityClass); + $field = $meta->hasField('iid') ? 'resource.iid' : 'resource.id'; + $qb->andWhere("$field IN (:ids)")->setParameter('ids', $ids); } return $qb->getQuery()->getResult(); @@ -269,9 +303,11 @@ private function hardDeleteMany(string $entityClass, array $resources): void } } - if ($usedFallback) { - $this->em->flush(); - } + // Always flush once at the end of the batch to materialize changes + $this->em->flush(); + + // Optional: clear EM to reduce memory in huge batches + // $this->em->clear(); } /** @@ -388,6 +424,28 @@ private function unplugCertificateDocsForCourse(): void } } + /** @param CDocument $doc */ + private function physicallyDeleteDocumentFiles(AbstractResource $doc): void + { + // This generic example traverses node->resourceFiles and removes them from disk. + $node = $doc->getResourceNode(); + if (!method_exists($node, 'getResourceFiles')) { + return; + } + + foreach ($node->getResourceFiles() as $rf) { + // Example: if you have an absolute path getter or storage key + if (method_exists($rf, 'getAbsolutePath')) { + $path = (string) $rf->getAbsolutePath(); + if ($path && file_exists($path)) { + @unlink($path); + } + } + // If you use a storage service, call it here instead of unlink() + // $this->storage->delete($rf->getStorageKey()); + } + } + /** * SCORM directory cleanup for ALL LPs (hook your storage service here if needed). */ diff --git a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php index db20fe3f2c8..9291f6546e2 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php +++ b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php @@ -517,6 +517,65 @@ public function restore_documents($session_id = 0, $respect_base_content = false error_log('[RESTORE:HTMLURL] '.$msg.(empty($ctx) ? '' : ' '.json_encode($ctx))); }; + // Helper: returns the logical path from source CDocument (starts with "/") + $getLogicalPathFromSource = function ($sourceId) use ($docRepo): string { + $doc = $docRepo->find((int) $sourceId); + if ($doc && method_exists($doc, 'getPath')) { + $p = (string) $doc->getPath(); + return $p !== '' && $p[0] === '/' ? $p : '/'.$p; + } + return ''; + }; + + // Reserved top-level containers that must not leak into destination when copying + $reservedTopFolders = ['certificates', 'learnpaths']; + + // Normalize any incoming "rel" to avoid internal reserved prefixes leaking into the destination tree. + $normalizeRel = function (string $rel) use ($copyMode): string { + // Always ensure a single leading slash + $rel = '/'.ltrim($rel, '/'); + + // Collapse any repeated /document/ prefixes (e.g., /document/document/…) + while (preg_match('#^/document/#i', $rel)) { + $rel = preg_replace('#^/document/#i', '/', $rel, 1); + } + + // Flatten "/certificates/{portal}/{course}/..." → "/..." + if (preg_match('#^/certificates/[^/]+/[^/]+(?:/(.*))?$#i', $rel, $m)) { + $rest = $m[1] ?? ''; + return $rest === '' ? '/' : '/'.ltrim($rest, '/'); + } + // Fallback: strip generic "certificates/" container if it still shows up + if (preg_match('#^/certificates/(.*)$#i', $rel, $m)) { + return '/'.ltrim($m[1], '/'); + } + + // Flatten "/{host}/{course}/..." → "/..." only in COPY mode + if ($copyMode && preg_match('#^/([^/]+)/([^/]+)(?:/(.*))?$#', $rel, $m)) { + $host = $m[1]; + $course = $m[2]; + $rest = $m[3] ?? ''; + + $hostLooksLikeHostname = ($host === 'localhost') || str_contains($host, '.'); + $courseLooksLikeCode = (bool) preg_match('/^[A-Za-z0-9_\-]{3,}$/', $course); + + if ($hostLooksLikeHostname && $courseLooksLikeCode) { + return $rest === '' ? '/' : '/'.ltrim($rest, '/'); + } + } + + // Optionally flatten learnpath containers only in COPY mode + if ($copyMode && preg_match('#^/(?:learnpaths?|lp)/[^/]+/(.*)$#i', $rel, $m)) { + return '/'.ltrim($m[1], '/'); + } + if ($copyMode && preg_match('#^/(?:learnpaths?|lp)/(.*)$#i', $rel, $m)) { + return '/'.ltrim($m[1], '/'); + } + + // Nothing to normalize + return $rel; + }; + // Ensure a folder chain exists under Documents (skipping "document" as root) $ensureFolder = function (string $relPath) use ($docRepo, $courseEntity, $courseInfo, $session_id, $DBG) { $rel = '/'.ltrim($relPath, '/'); @@ -614,12 +673,35 @@ public function restore_documents($session_id = 0, $respect_base_content = false continue; } - // Strip leading "document/" - $rel = '/'.ltrim(substr($item->path, 8), '/'); + // Build destination folder path: + // - In copy mode prefer the logical path from the source document (stable), + // otherwise strip leading "document/" from archive path. + if ($copyMode && !empty($item->source_id)) { + $rel = $getLogicalPathFromSource($item->source_id); + if ($rel === '') { + $rel = '/'.ltrim(substr($item->path, 8), '/'); + } + } else { + // Strip leading "document/" + $rel = '/'.ltrim(substr($item->path, 8), '/'); + } + + $origRelX = $rel; + $rel = $normalizeRel($rel); + if ($rel !== $origRelX) { + $DBG('normalizeRel:folder', ['from' => $origRelX, 'to' => $rel]); + } + if ($rel === '/') { continue; } + // Avoid creating internal system folders at root in copy mode + $firstSeg = explode('/', trim($rel, '/'))[0] ?? ''; + if ($copyMode && in_array($firstSeg, $reservedTopFolders, true)) { + continue; + } + $parts = array_values(array_filter(explode('/', $rel))); $accum = ''; $parentId = 0; @@ -781,10 +863,35 @@ public function restore_documents($session_id = 0, $respect_base_content = false } } - $isHtml = $isHtmlFile($srcPath, $rawTitle); - $rel = '/'.ltrim(substr($item->path, 8), '/'); // remove "document" prefix + $isHtml = $isHtmlFile($srcPath, $rawTitle); + + // Build destination file path: + // - In copy mode base on the logical source path + // - Otherwise, strip "document/" from archive path + if ($copyMode && !empty($item->source_id)) { + $rel = $getLogicalPathFromSource($item->source_id); + if ($rel === '') { + $rel = '/'.ltrim(substr($item->path, 8), '/'); // fallback + } + } else { + $rel = '/'.ltrim(substr($item->path, 8), '/'); // remove "document" prefix + } + + $origRelF = $rel; + $rel = $normalizeRel($rel); // <- critical: flatten internal containers in copy mode + if ($rel !== $origRelF) { + $DBG('normalizeRel:file', ['from' => $origRelF, 'to' => $rel]); + } + + // If it still comes from a reserved top-level folder, flatten to the basename (safety) + $firstSeg = explode('/', trim($rel, '/'))[0] ?? ''; + if ($copyMode && in_array($firstSeg, $reservedTopFolders, true)) { + $rel = '/'.basename($rel); + } + $parentRel = rtrim(\dirname($rel), '/'); + // Avoid re-copying already mapped non-HTML assets (images, binaries) if we already created them if (!empty($item->destination_id) && !$isHtml) { $maybeExisting = $docRepo->find((int) $item->destination_id); if ($maybeExisting) { @@ -5528,9 +5635,9 @@ private function dlog(string $message, array $context = []): void if (!empty($context)) { try { $ctx = ' '.json_encode( - $context, - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR - ); + $context, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR + ); } catch (Throwable $e) { $ctx = ' [context_json_failed: '.$e->getMessage().']'; } diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/FeedbackExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/FeedbackExport.php index 353f24b7207..06a15dd4f58 100644 --- a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/FeedbackExport.php +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/FeedbackExport.php @@ -57,46 +57,113 @@ public function export($activityId, $exportDir, $moduleId, $sectionId): void */ public function getData(int $surveyId, int $sectionId): array { - // TODO: Replace this with your own provider if different: - // e.g. $adminId = $this->adminProvider->getAdminUserId(); $adminData = MoodleExport::getAdminUserData(); $adminId = (int) $adminData['id']; $survey = $this->course->resources['survey'][$surveyId] ?? null; $questions = []; - foreach ($this->course->resources['survey_question'] ?? [] as $question) { - if ((int) ($question->survey_id ?? 0) === $surveyId) { - $questions[] = [ - 'id' => (int) $question->id, - 'text' => (string) $question->survey_question, - 'type' => (string) $question->survey_question_type, - 'options' => array_map( - static fn ($answer) => $answer['option_text'], - (array) ($question->answers ?? []) - ), - 'position' => (int) ($question->sort ?? 0), - 'label' => '', // Keep empty unless you map labels explicitly - ]; + foreach ($this->course->resources['survey_question'] ?? [] as $q) { + if ((int) ($q->survey_id ?? $q->obj->survey_id ?? 0) !== $surveyId) { + continue; } + + $qo = (isset($q->obj) && is_object($q->obj)) ? $q->obj : $q; + + $rawType = (string)($qo->type ?? $qo->survey_question_type ?? ''); + $normalizedType = $this->normalizeChamiloType($rawType); + + $options = $this->normalizeOptions($qo, $normalizedType); + + $text = (string)($qo->survey_question ?? $q->survey_question ?? 'Question'); + $text = trim($text); + + $questions[] = [ + 'id' => (int) ($qo->id ?? $q->id ?? 0), + 'text' => $text, + 'type' => $normalizedType, + 'options' => $options, + 'position' => (int) ($qo->sort ?? $q->sort ?? 0), + 'label' => '', + ]; } return [ - 'id' => $surveyId, - 'moduleid' => $surveyId, - 'modulename' => 'feedback', - 'contextid' => (int) $this->course->info['real_id'], - 'sectionid' => $sectionId, + 'id' => $surveyId, + 'moduleid' => $surveyId, + 'modulename' => 'feedback', + 'type' => 'mod', + 'contextid' => (int) $this->course->info['real_id'], + 'sectionid' => $sectionId, 'sectionnumber' => 0, - 'name' => (string) ($survey->title ?? ('Survey '.$surveyId)), - 'intro' => (string) ($survey->intro ?? ''), - 'timemodified' => time(), - 'questions' => $questions, - 'users' => [$adminId], - 'files' => [], + 'name' => (string) ($survey->title ?? ('Survey '.$surveyId)), + 'intro' => (string) ($survey->intro ?? ''), + 'timemodified' => time(), + 'questions' => $questions, + 'users' => [$adminId], + 'files' => [], ]; } + /** Converts types used multiple times in Chamilo to the 6–7 we export to Moodle. */ + private function normalizeChamiloType(string $t): string + { + $t = strtolower(trim($t)); + return match ($t) { + 'yes_no', 'yesno' => 'yesno', + 'multiple_single', 'single', 'radio', + 'multiplechoice' => 'multiplechoice', + 'multiple_multiple', 'multiple', 'checks', + 'multipleresponse' => 'multipleresponse', + 'multiple_dropdown', 'dropdown', 'select' => 'dropdown', + 'multiplechoiceother' => 'multiplechoiceother', + 'open_short', 'short', 'textfield' => 'textfield', + 'open_long', 'open', 'textarea', 'comment'=> 'open', + 'pagebreak' => 'pagebreak', + 'numeric', 'number', 'score' => 'numeric', + 'percentage' => 'percentage', + default => 'open', + }; + } + + /** Extract options without breaking if the shape changes (array of objects, string “A|B”, etc.) */ + private function normalizeOptions(object $qo, string $normalizedType): array + { + if ($normalizedType === 'yesno') { + if (!empty($qo->answers)) { + $out = []; + foreach ($qo->answers as $a) { + $txt = is_array($a) ? ($a['option_text'] ?? '') : (is_object($a) ? ($a->option_text ?? '') : (string)$a); + $txt = trim((string)$txt); + if ($txt !== '') { $out[] = $txt; } + } + if ($out) { return $out; } + } + return ['Yes','No']; + } + + if (!empty($qo->answers) && is_iterable($qo->answers)) { + $out = []; + foreach ($qo->answers as $a) { + $txt = is_array($a) ? ($a['option_text'] ?? '') : (is_object($a) ? ($a->option_text ?? '') : (string)$a); + $txt = trim((string)$txt); + if ($txt !== '') { $out[] = $txt; } + } + if ($out) { return $out; } + } + + if (!empty($qo->options) && is_string($qo->options)) { + $out = array_values(array_filter(array_map('trim', explode('|', $qo->options)), 'strlen')); + if ($out) { return $out; } + } + + if ($normalizedType === 'percentage') { + return range(1, 100); + } + + return []; + } + /** * Build feedback.xml (the core activity file for Moodle backup). */ @@ -133,24 +200,24 @@ private function createFeedbackXml(array $surveyData, string $feedbackDir): void $this->createXmlFile('feedback', $xml, $feedbackDir); } - /** - * Render a single question in Moodle Feedback XML format. - */ - private function createQuestionXml(array $question): string + private function createQuestionXml(array $q): string { - $name = htmlspecialchars(strip_tags((string) ($question['text'] ?? '')), ENT_XML1 | ENT_QUOTES, 'UTF-8'); - $label = htmlspecialchars(strip_tags((string) ($question['label'] ?? '')), ENT_XML1 | ENT_QUOTES, 'UTF-8'); - $presentation = $this->getPresentation($question); - $hasValue = (($question['type'] ?? '') === 'pagebreak') ? '0' : '1'; - $pos = (int) ($question['position'] ?? 0); - $id = (int) ($question['id'] ?? 0); - $typ = $this->mapQuestionType((string) ($question['type'] ?? '')); - - $xml = ' '.PHP_EOL; + $name = htmlspecialchars(strip_tags((string) ($q['text'] ?? '')), ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $label = htmlspecialchars(strip_tags((string) ($q['label'] ?? '')), ENT_XML1 | ENT_QUOTES, 'UTF-8'); + + $typ = $this->mapQuestionType((string) ($q['type'] ?? '')); + $pres = $this->getPresentation($q); + + $hasValue = in_array($typ, ['multichoice','textarea','textfield','numeric'], true) ? '1' : '0'; + + $pos = (int) ($q['position'] ?? 0); + $id = (int) ($q['id'] ?? 0); + + $xml = ' '.PHP_EOL; $xml .= ' '.PHP_EOL; $xml .= ' '.$name.''.PHP_EOL; $xml .= ' '.PHP_EOL; - $xml .= ' '.$presentation.''.PHP_EOL; + $xml .= ' '.$pres.''.PHP_EOL; $xml .= ' '.$typ.''.PHP_EOL; $xml .= ' '.$hasValue.''.PHP_EOL; $xml .= ' '.$pos.''.PHP_EOL; @@ -163,42 +230,37 @@ private function createQuestionXml(array $question): string return $xml; } - /** - * Encode presentation string depending on question type. - */ - private function getPresentation(array $question): string + private function getPresentation(array $q): string { - $type = (string) ($question['type'] ?? ''); - $opts = array_map('strip_tags', (array) ($question['options'] ?? [])); - $opts = array_map( - static fn ($o) => htmlspecialchars((string) $o, ENT_XML1 | ENT_QUOTES, 'UTF-8'), - $opts - ); - - // Moodle feedback encodes the widget type as a single char: - // r = radio, c = checkbox, d = dropdown, textareas use "|" + $type = (string)($q['type'] ?? ''); + $opts = array_map(static fn($o) => htmlspecialchars((string)trim($o), ENT_XML1 | ENT_QUOTES, 'UTF-8'), (array)($q['options'] ?? [])); + $joined = implode('|', $opts); + return match ($type) { - 'yesno', 'multiplechoice', 'multiplechoiceother' => 'r>>>>>'.implode(PHP_EOL.'|', $opts), - 'multipleresponse' => 'c>>>>>'.implode(PHP_EOL.'|', $opts), - 'dropdown' => 'd>>>>>'.implode(PHP_EOL.'|', $opts), - 'open' => '30|5', // textarea: cols|rows - default => '', + 'yesno', 'multiplechoice' => 'r>>>>>'.$joined, + 'multiplechoiceother' => 'r>>>>>'.$joined, + 'multipleresponse' => 'c>>>>>'.$joined, + 'dropdown' => 'd>>>>>'.$joined, + 'percentage' => 'r>>>>>'.$joined, + 'textfield' => '30', + 'open' => '30|5', + 'numeric' => '', + default => '', }; } - /** - * Map Chamilo survey question types to Moodle feedback types. - */ - private function mapQuestionType(string $chamiloType): string + private function mapQuestionType(string $chType): string { - return [ - 'yesno' => 'multichoice', - 'multiplechoice' => 'multichoice', - 'multipleresponse' => 'multichoice', - 'dropdown' => 'multichoice', - 'multiplechoiceother' => 'multichoice', - 'open' => 'textarea', - 'pagebreak' => 'pagebreak', - ][$chamiloType] ?? 'unknown'; + return match ($chType) { + 'yesno', 'multiplechoice', 'multiplechoiceother', 'percentage' => 'multichoice', + 'multipleresponse' => 'multichoice', + 'dropdown' => 'multichoice', + 'textfield' => 'textfield', + 'open', 'comment' => 'textarea', + 'numeric', 'score' => 'numeric', + 'pagebreak' => 'pagebreak', + 'label' => 'label', + default => 'textarea', + }; } } diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/GlossaryExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/GlossaryExport.php index 2e620ba4265..1a9fc5aa11d 100644 --- a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/GlossaryExport.php +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/GlossaryExport.php @@ -25,62 +25,128 @@ class GlossaryExport extends ActivityExport */ public function export($activityId, $exportDir, $moduleId, $sectionId): void { - // Prepare destination directory for the activity $glossaryDir = $this->prepareActivityDirectory($exportDir, 'glossary', (int) $moduleId); - // Collect data - $glossaryData = $this->getData((int) $activityId, (int) $sectionId); + $glossaryData = $this->getData((int) $activityId, (int) $sectionId, (int) $moduleId); - // Generate XML files for the glossary $this->createGlossaryXml($glossaryData, $glossaryDir); $this->createModuleXml($glossaryData, $glossaryDir); $this->createGradesXml($glossaryData, $glossaryDir); $this->createGradeHistoryXml($glossaryData, $glossaryDir); - $this->createInforefXml($glossaryData, $glossaryDir); // relies on 'users' and 'files' keys + $this->createInforefXml($glossaryData, $glossaryDir); $this->createRolesXml($glossaryData, $glossaryDir); $this->createCalendarXml($glossaryData, $glossaryDir); $this->createCommentsXml($glossaryData, $glossaryDir); $this->createCompetenciesXml($glossaryData, $glossaryDir); $this->createFiltersXml($glossaryData, $glossaryDir); + + MoodleExport::flagActivityUserinfo('glossary', (int) $moduleId, true); } /** - * Gather all terms from the course and group them under a single glossary activity. + * Gather all terms and build a single glossary activity dataset. */ - public function getData(int $glossaryId, int $sectionId): array + public function getData(int $glossaryId, int $sectionId, ?int $moduleId = null): array { + if ($moduleId === null) { + $moduleId = $glossaryId; + } + $adminData = MoodleExport::getAdminUserData(); - $adminId = (int) ($adminData['id'] ?? 0); + $adminId = (int) ($adminData['id'] ?? 0); + + $res = \is_array($this->course->resources ?? null) ? $this->course->resources : []; + $bags = []; + if (\defined('RESOURCE_GLOSSARY') && !empty($res[RESOURCE_GLOSSARY]) && \is_array($res[RESOURCE_GLOSSARY])) { + $bags[] = $res[RESOURCE_GLOSSARY]; + } + foreach (['glossary', 'glossary_definition', 'glossary_terms'] as $k) { + if (!empty($res[$k]) && \is_array($res[$k])) { + $bags[] = $res[$k]; + } + } + + $entries = []; + $seen = []; + $nextId = 1; + $userIds = []; + + $norm = static function (string $s): string { + $s = trim($s); + $s = mb_strtolower($s, 'UTF-8'); + return $s; + }; + + foreach ($bags as $bag) { + foreach ($bag as $g) { + $o = (\is_object($g) && isset($g->obj) && \is_object($g->obj)) ? $g->obj : $g; + if (!\is_object($o)) { + continue; + } + + $concept = ''; + foreach (['name', 'term', 'title'] as $k) { + if (!empty($o->{$k}) && \is_string($o->{$k})) { + $concept = trim((string) $o->{$k}); + break; + } + } + if ($concept === '') { + continue; + } + + $key = $norm($concept); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $definition = ''; + foreach (['description','definition','comment','text'] as $k) { + if (isset($o->{$k}) && \is_string($o->{$k})) { + $definition = (string) $o->{$k}; + break; + } + } + + $aliases = []; + $lc = mb_strtolower($concept, 'UTF-8'); + if ($lc !== $concept) { + $aliases[] = $lc; + } - $entries = []; - if (!empty($this->course->resources['glossary'])) { - foreach ($this->course->resources['glossary'] as $g) { $entries[] = [ - 'id' => (int) ($g->glossary_id ?? 0), - 'userid' => $adminId, - 'concept' => (string) ($g->name ?? ''), - 'definition' => (string) ($g->description ?? ''), - 'timecreated' => time(), + 'id' => $nextId++, + 'userid' => $adminId, + 'concept' => $concept, + 'definition' => $definition, + 'timecreated' => time(), 'timemodified' => time(), + 'aliases' => $aliases, ]; } } + if ($adminId > 0) { + $userIds[$adminId] = true; + } + return [ - 'id' => $glossaryId, - 'moduleid' => $glossaryId, - 'modulename' => 'glossary', - 'contextid' => (int) ($this->course->info['real_id'] ?? 0), - 'name' => get_lang('Glossary'), - 'description' => '', - 'timecreated' => time(), - 'timemodified' => time(), - 'sectionid' => $sectionId, - 'sectionnumber' => 0, - 'userid' => $adminId, - 'entries' => $entries, - 'users' => [$adminId], - 'files' => [], // no file refs for glossary entries (plain text) + 'id' => $glossaryId, + 'moduleid' => (int) $moduleId, + 'modulename' => 'glossary', + 'contextid' => (int) ($this->course->info['real_id'] ?? 0), + 'name' => get_lang('Glossary'), + 'description' => '', + 'timecreated' => time(), + 'timemodified' => time(), + 'sectionid' => $sectionId, + 'sectionnumber' => 0, + 'userid' => $adminId, + 'entries' => $entries, + 'users' => array_map('intval', array_keys($userIds)), + 'files' => [], + 'include_userinfo' => true, ]; } @@ -89,11 +155,19 @@ public function getData(int $glossaryId, int $sectionId): array */ private function createGlossaryXml(array $glossaryData, string $glossaryDir): void { - $xml = ''.PHP_EOL; + $esc = static function (?string $html): string { + return htmlspecialchars((string) $html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + }; + + $introHtml = $glossaryData['description'] !== '' + ? $glossaryData['description'] + : '

'.get_lang('Glossary').'

'; + + $xml = ''.PHP_EOL; $xml .= ''.PHP_EOL; $xml .= ' '.PHP_EOL; - $xml .= ' '.htmlspecialchars((string) $glossaryData['name']).''.PHP_EOL; - $xml .= ' '.PHP_EOL; + $xml .= ' '.$esc((string) $glossaryData['name']).''.PHP_EOL; + $xml .= ' '.$esc($introHtml).''.PHP_EOL; $xml .= ' 1'.PHP_EOL; $xml .= ' 0'.PHP_EOL; $xml .= ' dictionary'.PHP_EOL; @@ -123,8 +197,8 @@ private function createGlossaryXml(array $glossaryData, string $glossaryDir): vo foreach ($glossaryData['entries'] as $entry) { $xml .= ' '.PHP_EOL; $xml .= ' '.$entry['userid'].''.PHP_EOL; - $xml .= ' '.htmlspecialchars((string) $entry['concept']).''.PHP_EOL; - $xml .= ' '.PHP_EOL; + $xml .= ' '.$esc((string) $entry['concept']).''.PHP_EOL; + $xml .= ' '.$esc((string) $entry['definition']).''.PHP_EOL; $xml .= ' 1'.PHP_EOL; $xml .= ' 0'.PHP_EOL; $xml .= ' '.PHP_EOL; @@ -132,12 +206,22 @@ private function createGlossaryXml(array $glossaryData, string $glossaryDir): vo $xml .= ' '.$entry['timemodified'].''.PHP_EOL; $xml .= ' 1'.PHP_EOL; $xml .= ' 0'.PHP_EOL; - $xml .= ' 0'.PHP_EOL; + $xml .= ' 1'.PHP_EOL; $xml .= ' 0'.PHP_EOL; $xml .= ' 0'.PHP_EOL; $xml .= ' 1'.PHP_EOL; - $xml .= ' '.PHP_EOL; - $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; + $aliasId = 1; + if (!empty($entry['aliases']) && \is_array($entry['aliases'])) { + foreach ($entry['aliases'] as $a) { + $xml .= ' '.PHP_EOL; + $xml .= ' '.$esc((string) $a).''.PHP_EOL; + $xml .= ' '.PHP_EOL; + $aliasId++; + } + } + $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; $xml .= ' '.PHP_EOL; } $xml .= ' '.PHP_EOL; diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleExport.php index 89d64991a2e..27cfefeb054 100644 --- a/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleExport.php +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleExport.php @@ -12,6 +12,7 @@ use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\FeedbackExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ForumExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\GlossaryExport; +use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\LabelExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\PageExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\QuizExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ResourceExport; @@ -45,6 +46,8 @@ class MoodleExport */ private bool $selectionMode = false; + protected static array $activityUserinfo = []; + /** * Constructor to initialize the course object. * @@ -100,8 +103,6 @@ public function export(string $courseId, string $exportDir, int $version) $this->createMoodleBackupXml($tempDir, $version); @error_log('[MoodleExport::export] moodle_backup.xml generated'); - // 2) <<< INSERT HERE >>> Enqueue URL activities before collecting all activities - // We build URL activities from the "link" bucket and push them into the pipeline. // This must happen BEFORE calling getActivities() so they are included. if (method_exists($this, 'enqueueUrlActivities')) { @error_log('[MoodleExport::export] Enqueuing URL activities …'); @@ -111,16 +112,16 @@ public function export(string $courseId, string $exportDir, int $version) @error_log('[MoodleExport::export][WARN] enqueueUrlActivities() not found; skipping URL activities'); } - // 3) Gather activities (now includes URLs) + // Gather activities (now includes URLs) $activities = $this->getActivities(); @error_log('[MoodleExport::export] Activities count='.count($activities)); - // 4) Export course structure (sections + activities metadata) + // Export course structure (sections + activities metadata) $courseExport = new CourseExport($this->course, $activities); $courseExport->exportCourse($tempDir); @error_log('[MoodleExport::export] course/ exported'); - // 5) Page export (collect extra files from HTML pages) + // Page export (collect extra files from HTML pages) $pageExport = new PageExport($this->course); $pageFiles = []; $pageData = $pageExport->getData(0, 1); @@ -129,7 +130,7 @@ public function export(string $courseId, string $exportDir, int $version) } @error_log('[MoodleExport::export] pageFiles from PageExport='.count($pageFiles)); - // 6) Files export (documents, attachments, + pages’ files) + // Files export (documents, attachments, + pages’ files) $fileExport = new FileExport($this->course); $filesData = $fileExport->getFilesData(); @error_log('[MoodleExport::export] getFilesData='.count($filesData['files'] ?? [])); @@ -137,19 +138,21 @@ public function export(string $courseId, string $exportDir, int $version) @error_log('[MoodleExport::export] merged files='.count($filesData['files'] ?? [])); $fileExport->exportFiles($filesData, $tempDir); - // 7) Sections export (topics/weeks descriptors) + // Sections export (topics/weeks descriptors) $this->exportSections($tempDir); @error_log('[MoodleExport::export] sections/ exported'); - // 8) Root XMLs (course/activities indexes) + $this->exportLabelActivities($activities, $tempDir); + + // Root XMLs (course/activities indexes) $this->exportRootXmlFiles($tempDir); @error_log('[MoodleExport::export] root XMLs exported'); - // 9) Create .mbz archive + // Create .mbz archive $exportedFile = $this->createMbzFile($tempDir); @error_log('[MoodleExport::export] mbz created at '.$exportedFile); - // 10) Cleanup temp dir + // Cleanup temp dir $this->cleanupTempDir($tempDir); @error_log('[MoodleExport::export] tempDir removed '.$tempDir); @@ -265,6 +268,11 @@ public static function getAdminUserData(): array return self::$adminUserData; } + public static function flagActivityUserinfo(string $modname, int $moduleId, bool $hasUserinfo): void + { + self::$activityUserinfo[$modname][$moduleId] = $hasUserinfo; + } + /** * Pulls dependent resources that LP items reference (only when LP bag exists). * Defensive: if no learnpath bag is present (e.g., exporting only documents), @@ -437,15 +445,50 @@ private function createMoodleBackupXml(string $destinationDir, int $version): vo } } + // Append only "label" activities discovered by getActivities(), with dedupe + foreach ($this->getActivities() as $a) { + $modname = (string) ($a['modulename'] ?? ''); + if ($modname !== 'label') { + continue; // keep minimal: only labels are missing in backup XML + } + + $moduleid = (int) ($a['moduleid'] ?? 0); + if ($moduleid <= 0) { + continue; + } + + $key = $modname.':'.$moduleid; + if (isset($seenActs[$key])) { + continue; // already present via sections, skip to avoid duplicates + } + $seenActs[$key] = true; + + // Ensure we propagate title and section for the backup XML + $activitiesFlat[] = [ + 'moduleid' => $moduleid, + 'sectionid' => (int) ($a['sectionid'] ?? 0), + 'modulename'=> 'label', + 'title' => (string) ($a['title'] ?? ''), + ]; + } + if (!empty($activitiesFlat)) { $xmlContent .= ' '.PHP_EOL; foreach ($activitiesFlat as $activity) { + $modname = (string) $activity['modulename']; + $moduleid = (int) $activity['moduleid']; + $sectionid= (int) $activity['sectionid']; + $title = (string) $activity['title']; + + $hasUserinfo = self::$activityUserinfo[$modname][$moduleid] ?? false; + $xmlContent .= ' '.PHP_EOL; - $xmlContent .= ' '.$activity['moduleid'].''.PHP_EOL; - $xmlContent .= ' '.$activity['sectionid'].''.PHP_EOL; - $xmlContent .= ' '.htmlspecialchars((string) $activity['modulename']).''.PHP_EOL; - $xmlContent .= ' '.htmlspecialchars((string) $activity['title']).''.PHP_EOL; - $xmlContent .= ' activities/'.$activity['modulename'].'_'.$activity['moduleid'].''.PHP_EOL; + $xmlContent .= ' '.$moduleid.''.PHP_EOL; + $xmlContent .= ' '.$sectionid.''.PHP_EOL; + $xmlContent .= ' '.htmlspecialchars($modname).''.PHP_EOL; + $xmlContent .= ' '.htmlspecialchars($title).''.PHP_EOL; + $xmlContent .= ' activities/'.$modname.'_'.$moduleid.''.PHP_EOL; + $xmlContent .= ' '.($hasUserinfo ? '1' : '0').''.PHP_EOL; $xmlContent .= ' '.PHP_EOL; } $xmlContent .= ' '.PHP_EOL; @@ -530,7 +573,6 @@ private function getSections(): array return $sections; } - // src/.../MoodleExport.php private function getActivities(): array { @error_log('[MoodleExport::getActivities] Start'); @@ -659,6 +701,13 @@ private function getActivities(): array $id = (int) $resource->source_id; $title = (string) ($resource->params['title'] ?? ''); } + // Course descriptions + elseif (RESOURCE_COURSEDESCRIPTION === $resourceType && ($resource->source_id ?? 0) > 0) { + $exportClass = LabelExport::class; + $moduleName = 'label'; + $id = (int) $resource->source_id; + $title = (string) ($resource->title ?? ''); + } // Emit activity if resolved if ($exportClass && $moduleName) { @@ -853,6 +902,32 @@ private function recursiveDelete(string $dir): void rmdir($dir); } + /** + * Export Label activities into activities/label_{id}/label.xml + * Keeps getActivities() side-effect free. + */ + private function exportLabelActivities(array $activities, string $exportDir): void + { + foreach ($activities as $a) { + if (($a['modulename'] ?? '') !== 'label') { + continue; + } + try { + $label = new LabelExport($this->course); + $activityId= (int) $a['id']; + $moduleId = (int) $a['moduleid']; + $sectionId = (int) $a['sectionid']; + + // Correct argument order: (activityId, exportDir, moduleId, sectionId) + $label->export($activityId, $exportDir, $moduleId, $sectionId); + + @error_log('[MoodleExport::exportLabelActivities] exported label moduleid='.$moduleId.' sectionid='.$sectionId); + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportLabelActivities][ERROR] '.$e->getMessage()); + } + } + } + private function exportBadgesXml(string $exportDir): void { $xmlContent = ''.PHP_EOL; @@ -1042,11 +1117,12 @@ private function exportBackupSettings(array $sections, array $activities): array 'name' => $activity['modulename'].'_'.$activity['moduleid'].'_included', 'value' => '1', ]; + $value = (self::$activityUserinfo[$activity['modulename']][$activity['moduleid']] ?? false) ? '1' : '0'; $settings[] = [ 'level' => 'activity', 'activity' => $activity['modulename'].'_'.$activity['moduleid'], 'name' => $activity['modulename'].'_'.$activity['moduleid'].'_userinfo', - 'value' => '1', + 'value' => $value, ]; }