diff --git a/src/CoreBundle/Controller/CourseMaintenanceController.php b/src/CoreBundle/Controller/CourseMaintenanceController.php index ce8713ea196..45bd97030ab 100644 --- a/src/CoreBundle/Controller/CourseMaintenanceController.php +++ b/src/CoreBundle/Controller/CourseMaintenanceController.php @@ -172,195 +172,250 @@ public function importRestore( EntityManagerInterface $em ): JsonResponse { $this->setDebugFromRequest($req); - $this->logDebug('[importRestore] begin', ['node' => $node, 'backupId' => $backupId]); - try { - $payload = json_decode($req->getContent() ?: '{}', true); - // Keep mode consistent with GET /import/{backupId}/resources - $mode = strtolower((string) ($payload['mode'] ?? 'auto')); - - $importOption = (string) ($payload['importOption'] ?? 'full_backup'); - $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); + error_log('COURSE_DEBUG: [importRestore] begin -> ' . json_encode([ + 'node' => $node, + 'backupId' => $backupId, + ], JSON_UNESCAPED_SLASHES)); - /** @var array $selectedResources */ - $selectedResources = (array) ($payload['resources'] ?? []); + try { + // Disable profiler & SQL logger for performance + if ($this->container->has('profiler')) { + $profiler = $this->container->get('profiler'); + if ($profiler instanceof \Symfony\Component\HttpKernel\Profiler\Profiler) { + $profiler->disable(); + } + } + if ($this->container->has('doctrine')) { + $emDoctrine = $this->container->get('doctrine')->getManager(); + if ($emDoctrine && $emDoctrine->getConnection()) { + $emDoctrine->getConnection()->getConfiguration()->setSQLLogger(null); + } + } - /** @var string[] $selectedTypes */ - $selectedTypes = array_map('strval', (array) ($payload['selectedTypes'] ?? [])); + // Parse payload + $payload = json_decode($req->getContent() ?: '{}', true) ?: []; + $mode = strtolower((string) ($payload['mode'] ?? 'auto')); // 'auto' | 'dat' | 'moodle' + $importOption = (string) ($payload['importOption'] ?? 'full_backup'); // 'full_backup' | 'select_items' + $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); // 0 skip | 1 overwrite | 2 rename + $selectedResources = (array) ($payload['resources'] ?? []); // map type -> [ids] + $selectedTypes = array_map('strval', (array) ($payload['selectedTypes'] ?? [])); - $this->logDebug('[importRestore] input', [ - 'importOption' => $importOption, - 'sameFileNameOption' => $sameFileNameOption, - 'selectedTypes' => $selectedTypes, - 'hasResourcesMap' => !empty($selectedResources), - 'mode' => $mode, - ]); + error_log('COURSE_DEBUG: [importRestore] input -> ' . json_encode([ + 'mode' => $mode, + 'importOption' => $importOption, + 'sameFileNameOption' => $sameFileNameOption, + 'selectedTypes.count'=> count($selectedTypes), + 'hasResourcesMap' => !empty($selectedResources), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); - // Load with same mode to avoid switching source on POST + // Load snapshot (keep same source mode as GET /resources) $course = $this->loadLegacyCourseForAnyBackup($backupId, $mode === 'dat' ? 'chamilo' : $mode); - if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) { + if (!\is_object($course) || !\is_array($course->resources ?? null)) { return $this->json(['error' => 'Backup has no resources'], 400); } - $resourcesAll = $course->resources; - $this->logDebug('[importRestore] BEFORE filter keys', array_keys($resourcesAll)); - - // Always hydrate LP dependencies (even in full_backup). - $this->hydrateLpDependenciesFromSnapshot($course, $resourcesAll); - $this->logDebug('[importRestore] AFTER hydrate keys', array_keys((array) $course->resources)); - - // Detect source BEFORE any filtering (meta may be dropped by filters) - $importSource = $this->getImportSource($course); - $isMoodle = ('moodle' === $importSource); - $this->logDebug('[importRestore] detected import source', ['import_source' => $importSource, 'isMoodle' => $isMoodle]); - - if ('select_items' === $importOption) { - if (empty($selectedResources) && !empty($selectedTypes)) { - $selectedResources = $this->buildSelectionFromTypes($course, $selectedTypes); - } - - $hasAny = false; - foreach ($selectedResources as $ids) { - if (\is_array($ids) && !empty($ids)) { - $hasAny = true; - break; - } - } - if (!$hasAny) { + // Quick counts for logging + $counts = []; + foreach ((array) $course->resources as $k => $bag) { + if ($k === '__meta') { continue; } + $counts[$k] = \is_array($bag) ? \count($bag) : 0; + } + error_log('COURSE_DEBUG: [importRestore] snapshot.counts -> ' . json_encode($counts, JSON_UNESCAPED_SLASHES)); + + // Detect source (Moodle vs non-Moodle) + $importSource = strtolower((string) ($course->resources['__meta']['import_source'] ?? $mode)); + $isMoodle = ($importSource === 'moodle'); + error_log('COURSE_DEBUG: [importRestore] detected import source -> ' . json_encode([ + 'import_source' => $importSource, + 'isMoodle' => $isMoodle, + ], JSON_UNESCAPED_SLASHES)); + + // Build requested buckets list + if ($importOption === 'select_items') { + if (!empty($selectedResources)) { + $requested = array_keys(array_filter( + $selectedResources, + static fn ($ids) => \is_array($ids) && !empty($ids) + )); + } elseif (!empty($selectedTypes)) { + $requested = $selectedTypes; + } else { return $this->json(['error' => 'No resources selected'], 400); } - - $course = $this->filterLegacyCourseBySelection($course, $selectedResources); - if (empty($course->resources) || 0 === \count((array) $course->resources)) { - return $this->json(['error' => 'Selection produced no resources to restore'], 400); - } - } - - $this->logDebug('[importRestore] AFTER filter keys', array_keys((array) $course->resources)); - - // NON-MOODLE + } else { + // full_backup => take all snapshot keys (except __meta) + $requested = array_keys(array_filter( + (array) $course->resources, + static fn ($k) => $k !== '__meta', + ARRAY_FILTER_USE_KEY + )); + } + // Normalize for case + $requested = array_values(array_unique(array_map(static fn ($k) => strtolower((string) $k), $requested))); + error_log('COURSE_DEBUG: [importRestore] requested -> ' . json_encode($requested, JSON_UNESCAPED_SLASHES)); + + // Non-Moodle path (use CourseRestorer directly) if (!$isMoodle) { - $this->logDebug('[importRestore] non-Moodle backup -> using CourseRestorer'); - // Trace around normalization to detect bucket drops - $this->logDebug('[importRestore] BEFORE normalize', array_keys((array) $course->resources)); - - $this->normalizeBucketsForRestorer($course); - $this->logDebug('[importRestore] AFTER normalize', array_keys((array) $course->resources)); + error_log('COURSE_DEBUG: [importRestore] non-Moodle path -> CourseRestorer'); + + if ($importOption === 'select_items') { + $filtered = clone $course; + $filtered->resources = ['__meta' => $course->resources['__meta'] ?? []]; + foreach ($requested as $rk) { + if (isset($course->resources[$rk])) { + $filtered->resources[$rk] = $course->resources[$rk]; + } + } + $course = $filtered; + } $restorer = new CourseRestorer($course); - $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption)); - if (method_exists($restorer, 'setResourcesAllSnapshot')) { - $restorer->setResourcesAllSnapshot($resourcesAll); - } + // Restorer understands 0/1/2 for (skip/overwrite/rename) + $restorer->set_file_option($sameFileNameOption); if (method_exists($restorer, 'setDebug')) { - $restorer->setDebug($this->debug); + $restorer->setDebug($this->debug ?? false); } + + $t0 = microtime(true); $restorer->restore(); + $ms = (int) round((microtime(true) - $t0) * 1000); CourseArchiver::cleanBackupDir(); - $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0); - $redirectUrl = \sprintf('/course/%d/home?sid=0&gid=0', $courseId); + $dstId = (int) ($restorer->destination_course_info['real_id'] ?? 0); + error_log('COURSE_DEBUG: [importRestore] non-Moodle DONE ms=' . $ms . ' dstId=' . $dstId); return $this->json([ - 'ok' => true, - 'message' => 'Import finished', - 'redirectUrl' => $redirectUrl, + 'ok' => true, + 'message' => 'Import finished', + 'redirectUrl' => sprintf('/course/%d/home?sid=0&gid=0', $dstId ?: (int) (api_get_course_info()['real_id'] ?? 0)), ]); } - // MOODLE - $this->logDebug('[importRestore] Moodle backup -> using MoodleImport.*'); - - $backupPath = $this->resolveBackupPath($backupId); - $ci = api_get_course_info(); - $cid = (int) ($ci['real_id'] ?? 0); - $sid = 0; - - $presentBuckets = array_map('strtolower', array_keys((array) $course->resources)); - $present = static fn (string $k): bool => \in_array(strtolower($k), $presentBuckets, true); - - $wantedGroups = []; - $mark = static function (array &$dst, bool $cond, string $key): void { if ($cond) { $dst[$key] = true; } }; - - if ('full_backup' === $importOption) { - // Be tolerant with plural 'documents' - $mark($wantedGroups, $present('link') || $present('link_category'), 'links'); - $mark($wantedGroups, $present('forum') || $present('forum_category'), 'forums'); - $mark($wantedGroups, $present('document') || $present('documents'), 'documents'); - $mark($wantedGroups, $present('quiz') || $present('exercise'), 'quizzes'); - $mark($wantedGroups, $present('scorm'), 'scorm'); - } else { - $mark($wantedGroups, $present('link'), 'links'); - $mark($wantedGroups, $present('forum') || $present('forum_category'), 'forums'); - $mark($wantedGroups, $present('document') || $present('documents'), 'documents'); - $mark($wantedGroups, $present('quiz') || $present('exercise'), 'quizzes'); - $mark($wantedGroups, $present('scorm'), 'scorm'); - } - - if (empty($wantedGroups)) { - CourseArchiver::cleanBackupDir(); + // Moodle path (documents via MoodleImport, rest via CourseRestorer) + $cacheDir = (string) $this->getParameter('kernel.cache_dir'); + $backupPath = rtrim($cacheDir, '/').'/course_backups/'.$backupId; - return $this->json([ - 'ok' => true, - 'message' => 'Nothing to import for Moodle (no supported resource groups present)', - 'stats' => new stdClass(), - ]); - } + $stats = $this->restoreMoodle( + $backupPath, + $course, + $requested, + $sameFileNameOption, + $em + ); - $importer = new MoodleImport(debug: $this->debug); - $stats = []; + return $this->json([ + 'ok' => true, + 'message' => 'Moodle import finished', + 'stats' => $stats, + 'redirectUrl' => sprintf('/course/%d/home?sid=0&gid=0', (int) (api_get_course_info()['real_id'] ?? 0)), + ]); - // LINKS - if (!empty($wantedGroups['links']) && method_exists($importer, 'restoreLinks')) { - $stats['links'] = $importer->restoreLinks($backupPath, $em, $cid, $sid, $course); - } + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: [importRestore] ERROR -> ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); + return $this->json([ + 'error' => 'Restore failed: ' . $e->getMessage(), + 'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, + ], 500); + } finally { + // Defensive cleanup + try { CourseArchiver::cleanBackupDir(); } catch (\Throwable) {} + } + } - // FORUMS - if (!empty($wantedGroups['forums']) && method_exists($importer, 'restoreForums')) { - $stats['forums'] = $importer->restoreForums($backupPath, $em, $cid, $sid, $course); - } + /** + * Single helper for Moodle branch: + * - Restore documents from MBZ (needs ZIP path and MoodleImport). + * - Restore remaining buckets using CourseRestorer over the filtered snapshot. + */ + private function restoreMoodle( + string $backupPath, + object $course, + array $requested, + int $sameFileNameOption, + EntityManagerInterface $em + ): array { + $stats = []; + $ci = api_get_course_info(); + $cid = (int) ($ci['real_id'] ?? 0); + $sid = 0; + + // 1) Documents (if requested) + $wantsDocs = \in_array('document', $requested, true) || \in_array('documents', $requested, true); + if ($wantsDocs) { + $tag = substr(dechex(random_int(0, 0xFFFFFF)), 0, 6); + error_log(sprintf('MBZ[%s] RESTORE_DOCS: begin path=%s', $tag, $backupPath)); + + $importer = new MoodleImport(debug: $this->debug ?? false); + + $courseForDocs = clone $course; + $courseForDocs->resources = [ + '__meta' => $course->resources['__meta'] ?? [], + 'document' => $course->resources['document'] ?? [], + ]; - // DOCUMENTS - if (!empty($wantedGroups['documents']) && method_exists($importer, 'restoreDocuments')) { - $stats['documents'] = $importer->restoreDocuments( + $t0 = microtime(true); + if (method_exists($importer, 'restoreDocuments')) { + $res = $importer->restoreDocuments( $backupPath, $em, $cid, $sid, - $sameFileNameOption, - $course + (int) $sameFileNameOption, + $courseForDocs ); + $stats['documents'] = $res ?? ['imported' => 0]; + } else { + error_log('MBZ['.$tag.'] RESTORE_DOCS: restoreDocuments() not found on importer'); + $stats['documents'] = ['imported' => 0, 'notes' => ['restoreDocuments() missing']]; } + $ms = (int) round((microtime(true) - $t0) * 1000); + error_log(sprintf('MBZ[%s] RESTORE_DOCS: end ms=%d', $tag, $ms)); + } - // QUIZZES - if (!empty($wantedGroups['quizzes']) && method_exists($importer, 'restoreQuizzes')) { - $stats['quizzes'] = $importer->restoreQuizzes($backupPath, $em, $cid, $sid); - } + // 2) Remaining buckets via CourseRestorer (links, forums, announcements, attendance, etc.) + $restRequested = array_values(array_filter($requested, static function ($k) { + $k = strtolower((string) $k); + return $k !== 'document' && $k !== 'documents'; + })); + if (empty($restRequested)) { + return $stats; + } - // SCORM - if (!empty($wantedGroups['scorm']) && method_exists($importer, 'restoreScorm')) { - $stats['scorm'] = $importer->restoreScorm($backupPath, $em, $cid, $sid); + $filtered = clone $course; + $filtered->resources = ['__meta' => $course->resources['__meta'] ?? []]; + foreach ($restRequested as $rk) { + if (isset($course->resources[$rk])) { + $filtered->resources[$rk] = $course->resources[$rk]; } + } + // Simple dependency example: include Link_Category when links are requested + if (\in_array('link', $restRequested, true) || \in_array('links', $restRequested, true)) { + if (isset($course->resources['Link_Category'])) { + $filtered->resources['Link_Category'] = $course->resources['Link_Category']; + } + } - CourseArchiver::cleanBackupDir(); - - return $this->json([ - 'ok' => true, - 'message' => 'Moodle import finished', - 'stats' => $stats, - ]); - } catch (Throwable $e) { - $this->logDebug('[importRestore] exception', [ - 'message' => $e->getMessage(), - 'file' => $e->getFile().':'.$e->getLine(), - ]); + error_log('COURSE_DEBUG: [restoreMoodle] restorer.keys -> ' . json_encode(array_keys((array) $filtered->resources), JSON_UNESCAPED_SLASHES)); - return $this->json([ - 'error' => 'Restore failed: '.$e->getMessage(), - 'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, - ], 500); + $restorer = new CourseRestorer($filtered); + $restorer->set_file_option($sameFileNameOption); + if (method_exists($restorer, 'setDebug')) { + $restorer->setDebug($this->debug ?? false); } + + $t1 = microtime(true); + $restorer->restore(); + $ms = (int) round((microtime(true) - $t1) * 1000); + error_log('COURSE_DEBUG: [restoreMoodle] restorer DONE ms=' . $ms); + + $stats['restored_tools'] = array_values(array_filter( + array_keys((array) $filtered->resources), + static fn($k) => $k !== '__meta' + )); + + return $stats; } #[Route('/copy/options', name: 'copy_options', methods: ['GET'])] @@ -410,7 +465,7 @@ public function copyResources(int $node, Request $req): JsonResponse 'assets', 'surveys', 'survey_questions', - 'announcements', + 'announcement', 'events', 'course_descriptions', 'glossary', @@ -463,7 +518,7 @@ public function copyExecute(int $node, Request $req): JsonResponse 'assets', 'surveys', 'survey_questions', - 'announcements', + 'announcement', 'events', 'course_descriptions', 'glossary', @@ -533,7 +588,7 @@ public function recycleResources(int $node, Request $req): JsonResponse $cb = new CourseBuilder(); $cb->set_tools_to_build([ 'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', 'assets', 'surveys', - 'survey_questions', 'announcements', 'events', 'course_descriptions', 'glossary', 'wiki', + 'survey_questions', 'announcement', 'events', 'course_descriptions', 'glossary', 'wiki', 'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', ]); $course = $cb->build(0, api_get_course_id()); @@ -677,6 +732,12 @@ public function moodleExportOptions(int $node, Request $req, UserRepository $use ['value' => 'learnpaths', 'label' => 'Paths learning'], ['value' => 'tool_intro', 'label' => 'Course Introduction'], ['value' => 'course_description', 'label' => 'Course descriptions'], + ['value' => 'attendance', 'label' => 'Attendance'], + ['value' => 'announcement', 'label' => 'Announcements'], + ['value' => 'events', 'label' => 'Calendar events'], + ['value' => 'wiki', 'label' => 'Wiki'], + ['value' => 'thematic', 'label' => 'Thematic'], + ['value' => 'gradebook', 'label' => 'Gradebook'], ]; $defaults['tools'] = array_column($tools, 'value'); @@ -711,6 +772,12 @@ public function moodleExportResources(int $node, Request $req): JsonResponse 'works', 'glossary', 'tool_intro', 'course_descriptions', + 'attendance', + 'announcement', + 'events', + 'wiki', + 'thematic', + 'gradebook', ]; // Use client tools if provided; otherwise our Moodle-safe defaults @@ -719,7 +786,7 @@ public function moodleExportResources(int $node, Request $req): JsonResponse // Policy for this endpoint: // - Never show gradebook // - Always include tool_intro in the picker (harmless if empty) - $tools = array_values(array_diff($tools, ['gradebook'])); + //$tools = array_values(array_diff($tools, ['gradebook'])); if (!in_array('tool_intro', $tools, true)) { $tools[] = 'tool_intro'; } @@ -799,6 +866,12 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use 'learnpaths', 'learnpath_category', 'works', 'glossary', 'course_descriptions', + 'attendance', + 'announcement', + 'events', + 'wiki', + 'thematic', + 'gradebook', ]; $tools = $this->normalizeSelectedTools($toolsInput); @@ -810,7 +883,6 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use } // Remove unsupported tools - $tools = array_values(array_unique(array_diff($tools, ['gradebook']))); $clientSentNoTools = empty($toolsInput); $useDefault = ($scope === 'full' && $clientSentNoTools); $toolsToBuild = $useDefault ? $defaultTools : $tools; @@ -903,7 +975,7 @@ private function normalizeSelectedTools(?array $tools): array // Single list of supported tool buckets (must match CourseBuilder/exporters) $all = [ 'documents','links','quizzes','quiz_questions','surveys','survey_questions', - 'announcements','events','course_descriptions','glossary','wiki','thematic', + 'announcement','events','course_descriptions','glossary','wiki','thematic', 'attendance','works','gradebook','learnpath_category','learnpaths','tool_intro','forums', ]; @@ -1094,7 +1166,6 @@ public function cc13ExportDownload(int $node, Request $req): BinaryFileResponse| // Stream file to the browser $resp = new BinaryFileResponse($abs); $resp->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $file); - // A sensible CC mime; many LMS aceptan zip también $resp->headers->set('Content-Type', 'application/vnd.ims.ccv1p3+imscc'); return $resp; @@ -1195,7 +1266,7 @@ public function importDiagnose(int $node, string $backupId, Request $req): JsonR // Detect & decode content $probe = $this->decodeCourseInfo($raw); - // Build a tiny scan snapshot (only keys, no grafo) + // Build a tiny scan snapshot $scan = [ 'has_graph' => false, 'resources_keys' => [], @@ -1553,131 +1624,6 @@ private function readCourseInfoFromZip(string $zipPath): array ]; } - /** - * Copies the dependencies (document, link, quiz, etc.) to $course->resources - * that reference the selected LearnPaths, taking the items from the full snapshot. - * - * It doesn't break anything if something is missing or comes in a different format: it's defensive. - */ - private function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void - { - if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) { - return; - } - - $depTypes = [ - 'document', 'link', 'quiz', 'work', 'survey', - 'Forum_Category', 'forum', 'thread', 'post', - 'Exercise_Question', 'survey_question', 'Link_Category', - ]; - - $need = []; - $addNeed = function (string $type, $id) use (&$need): void { - $t = (string) $type; - $i = is_numeric($id) ? (int) $id : (string) $id; - if ('' === $i || 0 === $i) { - return; - } - $need[$t] ??= []; - $need[$t][$i] = true; - }; - - foreach ($course->resources['learnpath'] as $lpId => $lpWrap) { - $lp = \is_object($lpWrap) && isset($lpWrap->obj) ? $lpWrap->obj : $lpWrap; - - if (\is_object($lpWrap) && !empty($lpWrap->linked_resources) && \is_array($lpWrap->linked_resources)) { - foreach ($lpWrap->linked_resources as $t => $ids) { - if (!\is_array($ids)) { - continue; - } - foreach ($ids as $rid) { - $addNeed($t, $rid); - } - } - } - - $items = []; - if (\is_object($lp) && !empty($lp->items) && \is_array($lp->items)) { - $items = $lp->items; - } elseif (\is_object($lpWrap) && !empty($lpWrap->items) && \is_array($lpWrap->items)) { - $items = $lpWrap->items; - } - - foreach ($items as $it) { - $ito = \is_object($it) ? $it : (object) $it; - - if (!empty($ito->linked_resources) && \is_array($ito->linked_resources)) { - foreach ($ito->linked_resources as $t => $ids) { - if (!\is_array($ids)) { - continue; - } - foreach ($ids as $rid) { - $addNeed($t, $rid); - } - } - } - - foreach (['document_id' => 'document', 'doc_id' => 'document', 'resource_id' => null, 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) { - if (isset($ito->{$field}) && '' !== $ito->{$field} && null !== $ito->{$field}) { - $rid = is_numeric($ito->{$field}) ? (int) $ito->{$field} : (string) $ito->{$field}; - $t = $typeGuess ?: (string) ($ito->type ?? ''); - if ('' !== $t) { - $addNeed($t, $rid); - } - } - } - - if (!empty($ito->type) && isset($ito->ref)) { - $addNeed((string) $ito->type, $ito->ref); - } - } - } - - if (empty($need)) { - $core = ['document', 'link', 'quiz', 'work', 'survey', 'Forum_Category', 'forum', 'thread', 'post', 'Exercise_Question', 'survey_question', 'Link_Category']; - foreach ($core as $k) { - if (!empty($snapshot[$k]) && \is_array($snapshot[$k])) { - $course->resources[$k] ??= []; - if (0 === \count($course->resources[$k])) { - $course->resources[$k] = $snapshot[$k]; - } - } - } - $this->logDebug('[LP-deps] fallback filled from snapshot', [ - 'bags' => array_keys(array_filter($course->resources, fn ($v, $k) => \in_array($k, $core, true) && \is_array($v) && \count($v) > 0, ARRAY_FILTER_USE_BOTH)), - ]); - - return; - } - - foreach ($need as $type => $idMap) { - if (empty($snapshot[$type]) || !\is_array($snapshot[$type])) { - continue; - } - - $course->resources[$type] ??= []; - - foreach (array_keys($idMap) as $rid) { - $src = $snapshot[$type][$rid] - ?? $snapshot[$type][(string) $rid] - ?? null; - - if (!$src) { - continue; - } - - if (!isset($course->resources[$type][$rid]) && !isset($course->resources[$type][(string) $rid])) { - $course->resources[$type][$rid] = $src; - } - } - } - - $this->logDebug('[LP-deps] hydrated', [ - 'types' => array_keys($need), - 'counts' => array_map(fn ($t) => isset($course->resources[$t]) && \is_array($course->resources[$t]) ? \count($course->resources[$t]) : 0, array_keys($need)), - ]); - } - /** * Build a Vue-friendly tree from legacy Course. */ @@ -1701,6 +1647,7 @@ private function buildResourceTreeForVue(object $course): array $tree = []; + // Documents block if (!empty($resources['document']) && \is_array($resources['document'])) { $docs = $resources['document']; @@ -1921,7 +1868,7 @@ private function buildResourceTreeForVue(object $course): array // Preferred order $preferredOrder = [ 'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link', - 'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook', + 'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'events', 'tool_intro', 'gradebook', ]; usort($tree, static function ($a, $b) use ($preferredOrder) { $ia = array_search($a['type'], $preferredOrder, true); @@ -2164,39 +2111,66 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array } /** - * Normalize a raw type to a lowercase key. + * Canonicalizes a resource/bucket key used anywhere in the flow (UI type, snapshot key, etc.) + * Keep this small and stable; only map well-known aliases to the canonical snapshot keys we expect. */ - private function normalizeTypeKey(int|string $raw): string + private function normalizeTypeKey(string $key): string { - if (\is_int($raw)) { - return (string) $raw; + $k = strtolower(trim($key)); + + // Documents + if (in_array($k, ['document','documents'], true)) { + return 'document'; + } + + // Links + if (in_array($k, ['link','links'], true)) { + return 'link'; + } + if (in_array($k, ['link_category','linkcategory','link_categories'], true)) { + return 'link_category'; } - $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw)); + // Forums + if ($k === 'forum_category' || $k === 'forumcategory') { + return 'Forum_Category'; + } + if ($k === 'forums') { + return 'forum'; + } - $map = [ - 'forum_category' => 'forum_category', - 'forumtopic' => 'forum_topic', - 'forum_topic' => 'forum_topic', - 'forum_post' => 'forum_post', - 'thread' => 'forum_topic', - 'post' => 'forum_post', - 'exercise_question' => 'exercise_question', - 'surveyquestion' => 'survey_question', - 'surveyinvitation' => 'survey_invitation', - 'survey' => 'survey', - 'link_category' => 'link_category', - 'coursecopylearnpath' => 'learnpath', - 'coursecopytestcategory' => 'test_category', - 'coursedescription' => 'course_description', - 'session_course' => 'session_course', - 'gradebookbackup' => 'gradebook', - 'scormdocument' => 'scorm', - 'tool/introduction' => 'tool_intro', - 'tool_introduction' => 'tool_intro', - ]; + // Announcements / News + if (in_array($k, ['announcement','announcements','news'], true)) { + return 'announcement'; + } + + // Attendance + if (in_array($k, ['attendance','attendances'], true)) { + return 'attendance'; + } + + // Course descriptions + if (in_array($k, ['course_description','course_descriptions','description','descriptions'], true)) { + return 'course_descriptions'; + } + + // Events / Calendar + if (in_array($k, ['event','events','calendar','calendar_event','calendar_events'], true)) { + return 'events'; + } - return $map[$s] ?? $s; + // Learnpaths + if ($k === 'learnpaths') { + return 'learnpath'; + } + + // Quizzes + if (in_array($k, ['quiz','quizzes'], true)) { + return 'quiz'; + } + + // Default: return as-is + return $k; } /** @@ -2230,11 +2204,12 @@ private function getSkipTypeKeys(): array private function getDefaultTypeTitles(): array { return [ - 'announcement' => 'Announcements', + 'announcement' => 'announcement', 'document' => 'Documents', 'glossary' => 'Glossaries', 'calendar_event' => 'Calendar events', 'event' => 'Calendar events', + 'events' => 'Calendar events', 'link' => 'Links', 'course_description' => 'Course descriptions', 'learnpath' => 'Parcours', @@ -2263,6 +2238,11 @@ private function getDefaultTypeTitles(): array */ private function isSelectableItem(string $type, object $obj): bool { + if ($type === 'announcement') { + // Require at least a non-empty title + return isset($obj->title) && trim((string)$obj->title) !== ''; + } + if ('document' === $type) { return true; } @@ -2275,6 +2255,10 @@ private function isSelectableItem(string $type, object $obj): bool */ private function resolveItemLabel(string $type, object $obj, int $fallbackId): string { + if ($type === 'announcement') { + return (string)($obj->title ?? ("Announcement #".$obj->iid)); + } + $entity = $this->objectEntity($obj); foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) { @@ -2286,31 +2270,30 @@ private function resolveItemLabel(string $type, object $obj, int $fallbackId): s if (isset($obj->params) && \is_array($obj->params)) { foreach (['title', 'name', 'subject', 'display', 'description'] as $k) { if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) { - return (string) $obj->params[$k]; + return $obj->params[$k]; } } } switch ($type) { + case 'events': + $title = trim((string)($entity->title ?? $entity->name ?? $entity->subject ?? '')); + if ($title !== '') { return $title; } + + return '#'.$fallbackId; case 'document': - // 1) ruta cruda tal como viene del backup/DB $raw = (string) ($entity->path ?? $obj->path ?? ''); if ('' !== $raw) { - // 2) normalizar a ruta relativa y quitar prefijo "document/" si viniera en el path del backup $rel = ltrim($raw, '/'); $rel = preg_replace('~^document/?~', '', $rel); - - // 3) carpeta ⇒ que termine con "/" $fileType = (string) ($entity->file_type ?? $obj->file_type ?? ''); if ('folder' === $fileType) { $rel = rtrim($rel, '/').'/'; } - // 4) si la ruta quedó vacía, usa basename como último recurso return '' !== $rel ? $rel : basename($raw); } - // fallback: título o nombre de archivo if (!empty($obj->title)) { return (string) $obj->title; } @@ -2484,15 +2467,18 @@ private function buildExtra(string $type, object $obj): array : []; break; + case 'events': + $entity = $this->objectEntity($obj); + $extra['start'] = (string)($entity->start ?? $entity->start_date ?? $entity->timestart ?? ''); + $extra['end'] = (string)($entity->end ?? $entity->end_date ?? $entity->timeend ?? ''); + $extra['all_day'] = (string)($entity->all_day ?? $entity->allday ?? ''); + $extra['location'] = (string)($entity->location ?? ''); + break; } return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v)); } - // -------------------------------------------------------------------------------- - // Selection filtering (used by partial restore) - // -------------------------------------------------------------------------------- - /** * Get first existing key from candidates. */ @@ -3175,7 +3161,7 @@ private function filterCourseResources(object $course, array $selected): void 'surveys' => RESOURCE_SURVEY, 'survey' => RESOURCE_SURVEY, 'survey_questions' => RESOURCE_SURVEYQUESTION, - 'announcements' => RESOURCE_ANNOUNCEMENT, + 'announcement' => RESOURCE_ANNOUNCEMENT, 'events' => RESOURCE_EVENT, 'course_description' => RESOURCE_COURSEDESCRIPTION, 'glossary' => RESOURCE_GLOSSARY, @@ -3640,6 +3626,13 @@ 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('attendance')) { $want[] = 'attendance'; } + if ($has('announcement')) { $want[] = 'announcement'; } + if ($has('calendar_event')) { $want[] = 'events'; } + if ($has('wiki')) { $want[] = 'wiki'; } + if ($has('thematic')) { $want[] = 'thematic'; } + if ($has('gradebook')) { $want[] = 'gradebook'; } + if ($has('course_descriptions') || $has('course_description')) { $tools[] = 'course_descriptions'; } // Dedup @@ -3778,31 +3771,108 @@ private function loadMoodleCourseOrFail(string $absPath): object } /** - * Recursively sanitize an unserialized PHP graph: - * - Objects are cast to arrays, keys like "\0Class\0prop" become "prop" - * - Returns arrays/stdClass with only public-like keys + * Clone a course snapshot keeping only the allowed buckets (preserving original-case keys). + * This is a shallow clone of the course object with a filtered 'resources' array. */ - private function sanitizePhpGraph(mixed $value): mixed + private function cloneCourseWithBuckets(object $course, array $allowed): object { - if (\is_array($value)) { - $out = []; - foreach ($value as $k => $v) { - $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k; - $out[$ck] = $this->sanitizePhpGraph($v); + $clone = clone $course; + + if (!isset($course->resources) || !\is_array($course->resources)) { + $clone->resources = []; + return $clone; + } + + // Build a lookup based on the original-case keys present in $allowed + $allowedLookup = array_flip($allowed); + + // Intersect by original-case keys + $clone->resources = array_intersect_key($course->resources, $allowedLookup); + + return $clone; + } + + /** + * Generic runner for a bucket group: checks if it was requested, extracts present snapshot keys, + * appends legacy constant keys (if defined), and delegates to MoodleImport::restoreSelectedBuckets(). + * + * Returns: + * - array stats when executed, + * - ['imported'=>0,'notes'=>['No ... buckets']] when requested but none present, + * - null when not requested at all. + */ + private function runBucketRestore( + MoodleImport $importer, + array $requestedNormalized, + array $requestAliases, + array $snapshotAliases, + array $legacyConstNames, + object $course, + string $backupPath, + EntityManagerInterface $em, + int $cid, + int $sid, + int $sameFileNameOption, + string $statKey + ): ?array { + // Normalize request aliases and check if intersect + $norm = static fn(string $k): string => strtolower((string) $k); + $reqSet = array_map($norm, $requestAliases); + + if (count(array_intersect($requestedNormalized, $reqSet)) === 0) { + // Not requested -> skip quietly + return null; + } + + // Gather snapshot-present keys + $resources = (array) ($course->resources ?? []); + $present = array_keys($resources); + $presentNorm = array_map($norm, $present); + + $snapWanted = array_map($norm, $snapshotAliases); + $allowed = []; + foreach ($present as $idx => $origKey) { + if (in_array($presentNorm[$idx], $snapWanted, true)) { + $allowed[] = $origKey; // keep original key casing as in snapshot } - return $out; } - if (\is_object($value)) { - $arr = (array) $value; - $clean = []; - foreach ($arr as $k => $v) { - $ck = \is_string($k) ? (string) preg_replace('/^\0.*\0/', '', $k) : $k; - $clean[$ck] = $this->sanitizePhpGraph($v); + // Add legacy constant-based keys if defined + foreach ($legacyConstNames as $c) { + if (\defined($c)) { + $allowed[] = (string) \constant($c); } - return (object) $clean; } - return $value; + // Deduplicate + sanitize + $allowed = array_values(array_unique(array_filter($allowed, static fn($v) => \is_string($v) && $v !== ''))); + + // Quick clone of the course with only these buckets + $courseForThis = $this->cloneCourseWithBuckets($course, $allowed); + + if (empty((array) ($courseForThis->resources ?? []))) { + $this->logDebug("[runBucketRestore] {$statKey} skipped (no matching buckets present)", [ + 'requested' => $requestAliases, + 'snapshot_aliases' => $snapshotAliases, + 'legacy' => $legacyConstNames, + 'available' => array_keys((array) $course->resources), + ]); + return ['imported' => 0, 'notes' => ["No {$statKey} buckets"]]; + } + + if (\method_exists($importer, 'attachContext')) { + // Optional internal context + $importer->attachContext($backupPath, $em, $cid, $sid, $sameFileNameOption); + } + + // Delegate with stable signature + return $importer->restoreSelectedBuckets( + $backupPath, + $em, + $cid, + $sid, + $allowed, + $courseForThis + ); } } diff --git a/src/CourseBundle/Component/CourseCopy/Course.php b/src/CourseBundle/Component/CourseCopy/Course.php index 5e84d947f74..bddec8295da 100644 --- a/src/CourseBundle/Component/CourseCopy/Course.php +++ b/src/CourseBundle/Component/CourseCopy/Course.php @@ -125,13 +125,18 @@ public function get_sample_text(): string case RESOURCE_EVENT: case RESOURCE_THEMATIC: case RESOURCE_WIKI: - $title = $resource->title; - $description = $resource->content; + $title = $this->getStrProp($resource, 'title'); + $description = $this->getStrProp($resource, 'content'); break; case RESOURCE_DOCUMENT: - $title = $resource->title; - $description = $resource->comment; + // Some old exports may miss "comment" and only carry "description/summary/intro" + $title = $this->getStrProp($resource, 'title'); + $description = + $this->getStrProp($resource, 'comment') + ?: $this->getStrProp($resource, 'description') + ?: $this->getStrProp($resource, 'summary') + ?: $this->getStrProp($resource, 'intro'); break; case RESOURCE_FORUM: @@ -141,60 +146,60 @@ public function get_sample_text(): string case RESOURCE_QUIZ: case RESOURCE_TEST_CATEGORY: case RESOURCE_WORK: - $title = $resource->title; - $description = $resource->description; + $title = $this->getStrProp($resource, 'title'); + $description = $this->getStrProp($resource, 'description'); break; case RESOURCE_FORUMPOST: - $title = $resource->title; - $description = $resource->text; + $title = $this->getStrProp($resource, 'title'); + $description = $this->getStrProp($resource, 'text'); break; case RESOURCE_SCORM: case RESOURCE_FORUMTOPIC: - $title = $resource->title; + $title = $this->getStrProp($resource, 'title'); break; case RESOURCE_GLOSSARY: case RESOURCE_LEARNPATH: - $title = $resource->name; - $description = $resource->description; + $title = $this->getStrProp($resource, 'name'); + $description = $this->getStrProp($resource, 'description'); break; case RESOURCE_LEARNPATH_CATEGORY: - $title = $resource->name; + $title = $this->getStrProp($resource, 'name'); break; case RESOURCE_QUIZQUESTION: - $title = $resource->question; - $description = $resource->description; + $title = $this->getStrProp($resource, 'question'); + $description = $this->getStrProp($resource, 'description'); break; case RESOURCE_SURVEY: - $title = $resource->title; - $description = $resource->subtitle; + $title = $this->getStrProp($resource, 'title'); + $description = $this->getStrProp($resource, 'subtitle'); break; case RESOURCE_SURVEYQUESTION: - $title = $resource->survey_question; - $description = $resource->survey_question_comment; + $title = $this->getStrProp($resource, 'survey_question'); + $description = $this->getStrProp($resource, 'survey_question_comment'); break; case RESOURCE_TOOL_INTRO: - $description = $resource->intro_text; + $description = $this->getStrProp($resource, 'intro_text'); break; case RESOURCE_ATTENDANCE: - $title = $resource->params['name']; - $description = $resource->params['description']; + $title = isset($resource->params['name']) && \is_string($resource->params['name']) ? $resource->params['name'] : ''; + $description = isset($resource->params['description']) && \is_string($resource->params['description']) ? $resource->params['description'] : ''; break; default: break; } - $title = api_html_to_text($title); - $description = api_html_to_text($description); + $title = $this->toTextOrEmpty($title); + $description = $this->toTextOrEmpty($description); if ($title !== '') { $sample_text .= $title . "\n"; @@ -225,25 +230,32 @@ public function to_system_encoding(): void switch ($type) { case RESOURCE_ANNOUNCEMENT: case RESOURCE_EVENT: - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - $resource->content = api_to_system_encoding($resource->content, $this->encoding); + // Defensive: only convert if present and string + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'content'); break; case RESOURCE_DOCUMENT: - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - $resource->comment = api_to_system_encoding($resource->comment, $this->encoding); + // Defensive normalization: backfill "comment" if missing + if (!property_exists($resource, 'comment') || !\is_string($resource->comment)) { + $fallback = + $this->getStrProp($resource, 'description') + ?: $this->getStrProp($resource, 'summary') + ?: $this->getStrProp($resource, 'intro') + ?: ''; + $resource->comment = $fallback; + } + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'comment'); // may be empty (but now always defined) break; case RESOURCE_FORUM: case RESOURCE_QUIZ: case RESOURCE_FORUMCATEGORY: - if (isset($resource->title)) { - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - } - if (isset($resource->description)) { - $resource->description = api_to_system_encoding($resource->description, $this->encoding); - } - if (isset($resource->obj)) { + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'description'); + if (isset($resource->obj) && \is_object($resource->obj)) { + // Encode nested forum fields safely foreach (['cat_title', 'cat_comment', 'title', 'description'] as $f) { if (isset($resource->obj->{$f}) && \is_string($resource->obj->{$f})) { $resource->obj->{$f} = api_to_system_encoding($resource->obj->{$f}, $this->encoding); @@ -255,96 +267,91 @@ public function to_system_encoding(): void case RESOURCE_LINK: case RESOURCE_LINKCATEGORY: case RESOURCE_TEST_CATEGORY: - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - $resource->description = api_to_system_encoding($resource->description, $this->encoding); + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'description'); break; case RESOURCE_FORUMPOST: - if (isset($resource->title)) { - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - } - if (isset($resource->text)) { - $resource->text = api_to_system_encoding($resource->text, $this->encoding); - } - if (isset($resource->poster_name)) { - $resource->poster_name = api_to_system_encoding($resource->poster_name, $this->encoding); - } + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'text'); + $this->encodeIfSet($resource, 'poster_name'); break; case RESOURCE_FORUMTOPIC: - if (isset($resource->title)) { - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - } - if (isset($resource->topic_poster_name)) { - $resource->topic_poster_name = api_to_system_encoding($resource->topic_poster_name, $this->encoding); - } - if (isset($resource->title_qualify)) { - $resource->title_qualify = api_to_system_encoding($resource->title_qualify, $this->encoding); - } + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'topic_poster_name'); + $this->encodeIfSet($resource, 'title_qualify'); break; case RESOURCE_GLOSSARY: - $resource->name = api_to_system_encoding($resource->name, $this->encoding); - $resource->description = api_to_system_encoding($resource->description, $this->encoding); + $this->encodeIfSet($resource, 'name'); + $this->encodeIfSet($resource, 'description'); break; case RESOURCE_LEARNPATH: - $resource->name = api_to_system_encoding($resource->name, $this->encoding); - $resource->description = api_to_system_encoding($resource->description, $this->encoding); - $resource->content_maker = api_to_system_encoding($resource->content_maker, $this->encoding); - $resource->content_license = api_to_system_encoding($resource->content_license, $this->encoding); + $this->encodeIfSet($resource, 'name'); + $this->encodeIfSet($resource, 'description'); + $this->encodeIfSet($resource, 'content_maker'); + $this->encodeIfSet($resource, 'content_license'); break; case RESOURCE_QUIZQUESTION: - $resource->question = api_to_system_encoding($resource->question, $this->encoding); - $resource->description = api_to_system_encoding($resource->description, $this->encoding); - if (\is_array($resource->answers) && \count($resource->answers) > 0) { + $this->encodeIfSet($resource, 'question'); + $this->encodeIfSet($resource, 'description'); + if (isset($resource->answers) && \is_array($resource->answers) && \count($resource->answers) > 0) { foreach ($resource->answers as &$answer) { - $answer['answer'] = api_to_system_encoding($answer['answer'], $this->encoding); - $answer['comment'] = api_to_system_encoding($answer['comment'], $this->encoding); + // Answers array may be sparse; be defensive + if (isset($answer['answer']) && \is_string($answer['answer'])) { + $answer['answer'] = api_to_system_encoding($answer['answer'], $this->encoding); + } + if (isset($answer['comment']) && \is_string($answer['comment'])) { + $answer['comment'] = api_to_system_encoding($answer['comment'], $this->encoding); + } } } break; case RESOURCE_SCORM: - $resource->title = api_to_system_encoding($resource->title, $this->encoding); + $this->encodeIfSet($resource, 'title'); break; case RESOURCE_SURVEY: - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - $resource->subtitle = api_to_system_encoding($resource->subtitle, $this->encoding); - $resource->author = api_to_system_encoding($resource->author, $this->encoding); - $resource->intro = api_to_system_encoding($resource->intro, $this->encoding); - $resource->surveythanks = api_to_system_encoding($resource->surveythanks, $this->encoding); + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'subtitle'); + $this->encodeIfSet($resource, 'author'); + $this->encodeIfSet($resource, 'intro'); + $this->encodeIfSet($resource, 'surveythanks'); break; case RESOURCE_SURVEYQUESTION: - $resource->survey_question = api_to_system_encoding($resource->survey_question, $this->encoding); - $resource->survey_question_comment = api_to_system_encoding($resource->survey_question_comment, $this->encoding); + $this->encodeIfSet($resource, 'survey_question'); + $this->encodeIfSet($resource, 'survey_question_comment'); break; case RESOURCE_TOOL_INTRO: - $resource->intro_text = api_to_system_encoding($resource->intro_text, $this->encoding); + $this->encodeIfSet($resource, 'intro_text'); break; case RESOURCE_WIKI: - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - $resource->content = api_to_system_encoding($resource->content, $this->encoding); - $resource->reflink = api_to_system_encoding($resource->reflink, $this->encoding); + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'content'); + $this->encodeIfSet($resource, 'reflink'); break; case RESOURCE_WORK: - $resource->url = api_to_system_encoding($resource->url, $this->encoding); - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - $resource->description = api_to_system_encoding($resource->description, $this->encoding); + $this->encodeIfSet($resource, 'url'); + $this->encodeIfSet($resource, 'title'); + $this->encodeIfSet($resource, 'description'); break; default: + // No string fields to encode or unsupported resource type break; } } } + // Update current encoding after conversion $this->encoding = api_get_system_encoding(); } @@ -390,4 +397,32 @@ public static function unserialize($course): Course /** @var Course $unserialized */ return $unserialized; } + + /** + * Safely returns a string property from a dynamic object-like resource. + * Returns '' if missing or not a string. + */ + private function getStrProp(object $obj, string $prop): string + { + return (property_exists($obj, $prop) && \is_string($obj->$prop)) ? $obj->$prop : ''; + } + + /** + * Encode obj->$prop in-place if it exists and is a non-empty string. + * Keeps behavior no-op for absent properties (defensive). + */ + private function encodeIfSet(object $obj, string $prop): void + { + if (property_exists($obj, $prop) && \is_string($obj->$prop) && $obj->$prop !== '') { + $obj->$prop = api_to_system_encoding($obj->$prop, $this->encoding); + } + } + + /** + * Converts HTML-ish input to plain text if string, else returns ''. + */ + private function toTextOrEmpty($value): string + { + return (\is_string($value) && $value !== '') ? api_html_to_text($value) : ''; + } } diff --git a/src/CourseBundle/Component/CourseCopy/CourseBuilder.php b/src/CourseBundle/Component/CourseCopy/CourseBuilder.php index 8de9c1fef9b..e1bd920fd21 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseBuilder.php +++ b/src/CourseBundle/Component/CourseCopy/CourseBuilder.php @@ -309,14 +309,6 @@ public function set_tools_specific_id_list(array $array): void $this->specific_id_list = $array; } - /** - * Get legacy Course container. - */ - public function get_course(): Course - { - return $this->course; - } - /** * Build the course (documents already repo-based; other tools preserved). * @@ -1628,14 +1620,6 @@ public function build_quizzes( return array_keys($neededQuestionIds); } - /** - * Safe count helper for mixed values. - */ - private function safeCount(mixed $v): int - { - return (\is_array($v) || $v instanceof Countable) ? \count($v) : 0; - } - /** * Export Quiz Questions (answers and options promoted). * @@ -1761,6 +1745,14 @@ private function exportQuestionsWithAnswers(object $legacyCourse, array $questio } } + /** + * Safe count helper for mixed values. + */ + private function safeCount(mixed $v): int + { + return (\is_array($v) || $v instanceof Countable) ? \count($v) : 0; + } + /** * Export Link category as legacy item. */ diff --git a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php index 9291f6546e2..86633e7ab0b 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php +++ b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php @@ -4,8 +4,6 @@ /* For licensing terms, see /license.txt */ -/* For licensing terms, see /license.txt */ - namespace Chamilo\CourseBundle\Component\CourseCopy; use AllowDynamicProperties; @@ -184,13 +182,15 @@ class CourseRestorer */ public function __construct($course) { - // Read env constant/course hint if present - if (\defined('COURSE_RESTORER_DEBUG')) { - $this->debug = (bool) \constant('COURSE_RESTORER_DEBUG'); + $this->course = $course ?: (object)[]; + + $code = (string) ($this->course->code ?? ''); + if ($code === '') { + $code = api_get_course_id(); + $this->course->code = $code; } - $this->course = $course; - $courseInfo = api_get_course_info($this->course->code); + $courseInfo = $code !== '' ? api_get_course_info($code) : api_get_course_info(); $this->course_origin_id = !empty($courseInfo) ? $courseInfo['real_id'] : null; $this->file_option = FILE_RENAME; diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/AnnouncementsForumExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/AnnouncementsForumExport.php new file mode 100644 index 00000000000..46b50f161a3 --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/AnnouncementsForumExport.php @@ -0,0 +1,288 @@ + 0 ? $moduleId : self::DEFAULT_MODULE_ID; + $forumDir = $this->prepareActivityDirectory($exportDir, 'forum', $moduleId); + + // Build forum payload from announcements + $forumData = $this->getDataFromAnnouncements($moduleId, $sectionId); + + // Primary XMLs + $this->createForumXml($forumData, $forumDir); + $this->createModuleXml($forumData, $forumDir); + $this->createInforefXml($forumData, $forumDir); + + // Optional skeletons (keeps structure consistent) + $this->createFiltersXml($forumData, $forumDir); + $this->createGradesXml($forumData, $forumDir); + $this->createGradeHistoryXml($forumData, $forumDir); + $this->createCompletionXml($forumData, $forumDir); + $this->createCommentsXml($forumData, $forumDir); + $this->createCompetenciesXml($forumData, $forumDir); + $this->createRolesXml($forumData, $forumDir); + $this->createCalendarXml($forumData, $forumDir); + } + + /** Build forum data (1 discussion per announcement). */ + private function getDataFromAnnouncements(int $moduleId, int $sectionId): array + { + $anns = $this->collectAnnouncements(); + + // Use export admin user; fallback to 2 (typical Moodle admin id) + $adminData = MoodleExport::getAdminUserData(); + $adminId = (int) ($adminData['id'] ?? 2); + if ($adminId <= 0) { + $adminId = 2; + } + + $threads = []; + $postId = 1; + $discId = 1; + + foreach ($anns as $a) { + $created = (int) ($a['created_ts'] ?? time()); + $subject = (string) ($a['subject'] ?? 'Announcement'); + $message = (string) ($a['message'] ?? ''); + + // One discussion per announcement, one post inside (by admin export user) + $threads[] = [ + 'id' => $discId, + 'title' => $subject, + 'userid' => $adminId, + 'timemodified' => $created, + 'usermodified' => $adminId, + 'firstpost' => $postId, + 'posts' => [[ + 'id' => $postId, + 'parent' => 0, + 'userid' => $adminId, + 'created' => $created, + 'modified' => $created, + 'mailed' => 0, + 'subject' => $subject, + // Keep rich HTML safely + 'message' => $message, + ]], + ]; + + $postId++; + $discId++; + } + + return [ + // Identity & placement + 'id' => $moduleId, + 'moduleid' => $moduleId, + 'modulename' => 'forum', + 'contextid' => (int) ($this->course->info['real_id'] ?? 0), + 'sectionid' => $sectionId, + 'sectionnumber' => 1, + + // News forum config + 'name' => 'Announcements', + 'description' => '', + 'type' => 'news', + 'forcesubscribe' => 1, + + // Timing + 'timecreated' => time(), + 'timemodified' => time(), + + // Content + 'threads' => $threads, + + // Refs → drives users.xml + userinfo=1 + 'users' => [$adminId], + 'files' => [], + ]; + } + + /** Same shape as ForumExport but type=news and CDATA for HTML messages. */ + private function createForumXml(array $data, string $forumDir): void + { + $introCdata = ''; + + $xml = ''.PHP_EOL; + $xml .= ''.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.htmlspecialchars((string) ($data['type'] ?? 'news')).''.PHP_EOL; + $xml .= ' '.htmlspecialchars((string) $data['name']).''.PHP_EOL; + $xml .= ' '.$introCdata.''.PHP_EOL; + $xml .= ' 1'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 100'.PHP_EOL; + $xml .= ' 512000'.PHP_EOL; + $xml .= ' 9'.PHP_EOL; + $xml .= ' '.(int) ($data['forcesubscribe'] ?? 1).''.PHP_EOL; + $xml .= ' 1'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' '.$data['timemodified'].''.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + + $xml .= ' '.PHP_EOL; + foreach ($data['threads'] as $thread) { + $xml .= ' '.PHP_EOL; + $xml .= ' '.htmlspecialchars((string) $thread['title']).''.PHP_EOL; + $xml .= ' '.(int) $thread['firstpost'].''.PHP_EOL; + $xml .= ' '.$thread['userid'].''.PHP_EOL; + $xml .= ' -1'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' '.$thread['timemodified'].''.PHP_EOL; + $xml .= ' '.$thread['usermodified'].''.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + + $xml .= ' '.PHP_EOL; + foreach ($thread['posts'] as $post) { + $xml .= ' '.PHP_EOL; + $xml .= ' '.(int) $post['parent'].''.PHP_EOL; + $xml .= ' '.$post['userid'].''.PHP_EOL; + $xml .= ' '.$post['created'].''.PHP_EOL; + $xml .= ' '.$post['modified'].''.PHP_EOL; + $xml .= ' '.(int) $post['mailed'].''.PHP_EOL; + $xml .= ' '.htmlspecialchars((string) $post['subject']).''.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' 1'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; + } + $xml .= ' '.PHP_EOL; + + $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.$thread['userid'].''.PHP_EOL; + $xml .= ' '.$thread['timemodified'].''.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; + + $xml .= ' '.PHP_EOL; + } + $xml .= ' '.PHP_EOL; + + $xml .= ' '.PHP_EOL; + $xml .= ''; + + $this->createXmlFile('forum', $xml, $forumDir); + } + + /** + * Collect announcements from CourseBuilder bag. + * + * Supports multiple bucket names and shapes defensively: + * - resources[RESOURCE_ANNOUNCEMENT] or resources['announcements'] or ['announcement'] + * - items wrapped as {obj: …} or direct objects/arrays + */ + private function collectAnnouncements(): array + { + $res = \is_array($this->course->resources ?? null) ? $this->course->resources : []; + + $bag = + ($res[\defined('RESOURCE_ANNOUNCEMENT') ? RESOURCE_ANNOUNCEMENT : 'announcements'] ?? null) + ?? ($res['announcements'] ?? null) + ?? ($res['announcement'] ?? null) + ?? []; + + $out = []; + foreach ((array) $bag as $maybe) { + $o = $this->unwrap($maybe); + if (!$o) { continue; } + + $title = $this->firstNonEmpty($o, ['title','name','subject'], 'Announcement'); + $html = $this->firstNonEmpty($o, ['content','message','description','text','body'], ''); + if ($html === '') { continue; } + + $ts = $this->firstTimestamp($o, ['created','ctime','date','add_date','time']); + $out[] = ['subject' => $title, 'message' => $html, 'created_ts' => $ts]; + } + + return $out; + } + + private function unwrap(mixed $maybe): ?object + { + if (\is_object($maybe)) { + return (isset($maybe->obj) && \is_object($maybe->obj)) ? $maybe->obj : $maybe; + } + if (\is_array($maybe)) { + return (object) $maybe; + } + return null; + } + + private function firstNonEmpty(object $o, array $keys, string $fallback = ''): string + { + foreach ($keys as $k) { + if (!empty($o->{$k}) && \is_string($o->{$k})) { + $v = trim((string) $o->{$k}); + if ($v !== '') { return $v; } + } + } + return $fallback; + } + + private function firstTimestamp(object $o, array $keys): int + { + foreach ($keys as $k) { + if (isset($o->{$k})) { + $v = $o->{$k}; + if (\is_numeric($v)) { return (int) $v; } + if (\is_string($v)) { + $t = strtotime($v); + if (false !== $t) { return (int) $t; } + } + } + } + return time(); + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/AttendanceMetaExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/AttendanceMetaExport.php new file mode 100644 index 00000000000..28238e526b0 --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/AttendanceMetaExport.php @@ -0,0 +1,236 @@ +findAttendanceById($activityId); + if (null === $attendance) { + // Nothing to export; keep a trace for debug + @error_log('[AttendanceMetaExport] Skipping: attendance not found id='.$activityId); + return; + } + + // Build payload from legacy object assembled by CourseBuilder + $payload = $this->buildPayloadFromLegacy($attendance, $moduleId, $sectionId); + + // Ensure base dir exists: {exportDir}/chamilo/attendance + $base = rtrim($exportDir, '/').'/chamilo/attendance'; + if (!is_dir($base)) { + @mkdir($base, (int) octdec('0775'), true); + } + + // Write JSON: chamilo/attendance/attendance_{moduleId}.json + $jsonFile = $base.'/attendance_'.$moduleId.'.json'; + file_put_contents( + $jsonFile, + json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) + ); + + // Append entry to chamilo/manifest.json + $this->appendToManifest($exportDir, [ + 'kind' => 'attendance', + 'moduleid' => $moduleId, + 'sectionid' => $sectionId, + 'title' => (string) ($payload['name'] ?? 'Attendance'), + 'path' => 'chamilo/attendance/attendance_'.$moduleId.'.json', + ]); + + @error_log('[AttendanceMetaExport] Exported attendance moduleid='.$moduleId.' sectionid='.$sectionId); + } + + /** + * Find an Attendance legacy object from the CourseBuilder bag. + * + * Accepts multiple buckets and wrappers defensively: + * - resources[RESOURCE_ATTENDANCE] or resources['attendance'] + * - each item may be {$obj: …} or the object itself. + */ + private function findAttendanceById(int $iid): ?object + { + $bag = $this->course->resources[\defined('RESOURCE_ATTENDANCE') ? RESOURCE_ATTENDANCE : 'attendance'] + ?? $this->course->resources['attendance'] + ?? []; + + if (!\is_array($bag)) { + return null; + } + + foreach ($bag as $maybe) { + if (!\is_object($maybe)) { + continue; + } + $obj = (isset($maybe->obj) && \is_object($maybe->obj)) ? $maybe->obj : $maybe; + + // Accept id, iid or source_id (defensive) + $candidates = [ + (int) ($obj->id ?? 0), + (int) ($obj->iid ?? 0), + (int) ($obj->source_id ?? 0), + ]; + if (\in_array($iid, $candidates, true)) { + return $obj; + } + } + + return null; + } + + /** + * Build a robust JSON payload from the legacy Attendance object. + * Tries several field names to be resilient to legacy structures. + */ + private function buildPayloadFromLegacy(object $att, int $moduleId, int $sectionId): array + { + $name = $this->firstNonEmptyString($att, ['title','name'], 'Attendance'); + $intro = $this->firstNonEmptyString($att, ['description','intro','introtext'], ''); + $active = (int) ($att->active ?? 1); + + $qualTitle = $this->firstNonEmptyString($att, ['attendance_qualify_title','grade_title'], ''); + $qualMax = (int) ($att->attendance_qualify_max ?? $att->grade_max ?? 0); + $weight = (float) ($att->attendance_weight ?? 0.0); + $locked = (int) ($att->locked ?? 0); + + $calendars = $this->extractCalendars($att); + + return [ + 'type' => 'attendance', + 'moduleid' => $moduleId, + 'sectionid' => $sectionId, + 'name' => $name, + 'intro' => $intro, + 'active' => $active, + 'qualify' => [ + 'title' => $qualTitle, + 'max' => $qualMax, + 'weight'=> $weight, + ], + 'locked' => $locked, + 'calendars' => $calendars, + '_exportedAt' => date('c'), + ]; + } + + /** Extract calendars list from different possible shapes. */ + private function extractCalendars(object $att): array + { + // Try common property names first + $lists = [ + $att->calendars ?? null, + $att->attendance_calendar?? null, + $att->calendar ?? null, + ]; + + // Try getter methods as fallback + foreach (['getCalendars','get_calendar','get_attendance_calendars'] as $m) { + if (\is_callable([$att, $m])) { + $lists[] = $att->{$m}(); + } + } + + // Flatten items to a normalized array + $out = []; + foreach ($lists as $maybeList) { + if (!$maybeList) { + continue; + } + foreach ((array) $maybeList as $c) { + if (!\is_array($c) && !\is_object($c)) { + continue; + } + $id = (int) ($c['id'] ?? $c['iid'] ?? $c->id ?? $c->iid ?? 0); + $aid = (int) ($c['attendance_id'] ?? $c->attendance_id ?? 0); + $dt = (string) ($c['date_time'] ?? $c->date_time ?? $c['datetime'] ?? $c->datetime ?? ''); + $done = (bool) ($c['done_attendance'] ?? $c->done_attendance ?? false); + $blocked= (bool) ($c['blocked'] ?? $c->blocked ?? false); + $dur = $c['duration'] ?? $c->duration ?? null; + $dur = (null !== $dur) ? (int) $dur : null; + + $out[$id] = [ + 'id' => $id, + 'attendance_id' => $aid, + 'date_time' => $dt, + 'done_attendance'=> $done, + 'blocked' => $blocked, + 'duration' => $dur, + ]; + } + } + + // Preserve stable order + ksort($out); + + return array_values($out); + } + + /** Helper: pick first non-empty string field from object. */ + private function firstNonEmptyString(object $o, array $keys, string $fallback = ''): string + { + foreach ($keys as $k) { + if (!empty($o->{$k}) && \is_string($o->{$k})) { + $v = trim((string) $o->{$k}); + if ($v !== '') { + return $v; + } + } + } + return $fallback; + } + + /** Append a record into chamilo/manifest.json (create if missing). */ + private function appendToManifest(string $exportDir, array $record): void + { + $dir = rtrim($exportDir, '/').'/chamilo'; + if (!is_dir($dir)) { + @mkdir($dir, (int) octdec('0775'), true); + } + + $manifestFile = $dir.'/manifest.json'; + $manifest = [ + 'version' => 1, + 'exporter' => 'C2-MoodleExport', + 'generatedAt' => date('c'), + 'items' => [], + ]; + + if (is_file($manifestFile)) { + $decoded = json_decode((string) file_get_contents($manifestFile), true); + if (\is_array($decoded)) { + $manifest = array_replace_recursive($manifest, $decoded); + } + if (!isset($manifest['items']) || !\is_array($manifest['items'])) { + $manifest['items'] = []; + } + } + + $manifest['items'][] = $record; + + file_put_contents( + $manifestFile, + json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) + ); + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/CourseCalendarExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/CourseCalendarExport.php new file mode 100644 index 00000000000..79eb8b0a90e --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/CourseCalendarExport.php @@ -0,0 +1,149 @@ +course = $course; + } + + /** Export course-level calendar events into course/events.xml */ + public function export(string $exportDir): int + { + $events = $this->collectEvents(); + if (empty($events)) { + @error_log('[CourseCalendarExport] No events found; skipping course/events.xml'); + return 0; + } + + $courseDir = rtrim($exportDir, '/') . '/course'; + if (!is_dir($courseDir)) { + @mkdir($courseDir, 0775, true); + } + + $xml = $this->buildEventsXml($events); + file_put_contents($courseDir . '/events.xml', $xml); + @error_log('[CourseCalendarExport] Wrote '.count($events).' events to course/events.xml'); + + return count($events); + } + + /** Collect events from legacy course resources (best-effort). */ + private function collectEvents(): array + { + $res = \is_array($this->course->resources ?? null) ? $this->course->resources : []; + $bag = ($res[\defined('RESOURCE_EVENT') ? RESOURCE_EVENT : 'events'] ?? null) + ?? ($res['events'] ?? null) + ?? ($res['event'] ?? null) + ?? ($res['agenda'] ?? null) + ?? []; + + $out = []; + foreach ((array) $bag as $maybe) { + $o = $this->unwrap($maybe); + if (!$o) continue; + + $title = $this->firstNonEmpty($o, ['title','name','subject'], 'Event'); + $desc = $this->firstNonEmpty($o, ['content','description','text','body'], ''); + $ts = $this->firstTimestamp($o, ['start','start_date','from','begin','date']); + $te = $this->firstTimestampOrNull($o, ['end','end_date','to','due']); + $dur = ($te !== null && $te > $ts) ? ($te - $ts) : 0; + + $out[] = [ + 'name' => $title, + 'description' => $desc, + 'format' => 1, + // Let Moodle bind on restore: + 'courseid' => '$@NULL@$', // important + 'groupid' => 0, + 'userid' => 0, // restoring without users + 'repeatid' => 0, + 'eventtype' => 'course', + 'timestart' => $ts, + 'timeduration' => $dur, + 'visible' => 1, + 'timemodified' => $ts, + 'timesort' => $ts, + ]; + } + + // Sort by timestart for determinism + usort($out, static fn($a,$b) => [$a['timestart'],$a['name']] <=> [$b['timestart'],$b['name']]); + return $out; + } + + /** Build the minimal, Moodle-compatible events.xml */ + private function buildEventsXml(array $events): string + { + $eol = PHP_EOL; + $xml = ''.$eol; + $xml .= ''.$eol; + + foreach ($events as $e) { + $xml .= ' '.$eol; + $xml .= ' '.htmlspecialchars($e['name'], ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8').''.$eol; + $xml .= ' '.$eol; + $xml .= ' '.(int)$e['format'].''.$eol; + $xml .= ' '.$e['courseid'].''.$eol; // $@NULL@$ + $xml .= ' '.(int)$e['groupid'].''.$eol; + $xml .= ' '.(int)$e['userid'].''.$eol; + $xml .= ' '.(int)$e['repeatid'].''.$eol; + $xml .= ' '.htmlspecialchars($e['eventtype'], ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8').''.$eol; + $xml .= ' '.(int)$e['timestart'].''.$eol; + $xml .= ' '.(int)$e['timeduration'].''.$eol; + $xml .= ' '.(int)$e['visible'].''.$eol; + $xml .= ' '.(int)$e['timemodified'].''.$eol; + $xml .= ' '.(int)$e['timesort'].''.$eol; + $xml .= ' '.$eol; + } + + $xml .= ''.$eol; + return $xml; + } + + private function unwrap(mixed $x): ?object + { + if (\is_object($x)) return isset($x->obj) && \is_object($x->obj) ? $x->obj : $x; + if (\is_array($x)) return (object) $x; + return null; + } + + private function firstNonEmpty(object $o, array $keys, string $fallback=''): string + { + foreach ($keys as $k) { + if (!empty($o->{$k}) && \is_string($o->{$k})) { + $v = trim((string)$o->{$k}); + if ($v !== '') return $v; + } + } + return $fallback; + } + + private function firstTimestamp(object $o, array $keys): int + { + foreach ($keys as $k) { + if (!isset($o->{$k})) continue; + $v = $o->{$k}; + if (\is_numeric($v)) return (int)$v; + if (\is_string($v)) { $t = strtotime($v); if ($t !== false) return (int)$t; } + } + return time(); + } + + private function firstTimestampOrNull(object $o, array $keys): ?int + { + foreach ($keys as $k) { + if (!isset($o->{$k})) continue; + $v = $o->{$k}; + if (\is_numeric($v)) return (int)$v; + if (\is_string($v)) { $t = strtotime($v); if ($t !== false) return (int)$t; } + } + return null; + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/GradebookMetaExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/GradebookMetaExport.php new file mode 100644 index 00000000000..2beb9372c6e --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/GradebookMetaExport.php @@ -0,0 +1,325 @@ +findGradebookBackup($activityId); + if ($backup === null) { + @error_log('[GradebookMetaExport] Skip: gradebook backup not found for activityId=' . $activityId); + return; + } + + $payload = $this->buildPayloadFromBackup($backup, $moduleId, $sectionId); + + // Ensure base dir exists: {exportDir}/chamilo/gradebook + $base = rtrim($exportDir, '/') . '/chamilo/gradebook'; + if (!\is_dir($base)) { + @mkdir($base, (int)\octdec('0775'), true); + } + + // Write JSON: chamilo/gradebook/gradebook_{moduleId}.json + $jsonFile = $base . '/gradebook_' . $moduleId . '.json'; + @file_put_contents( + $jsonFile, + \json_encode($payload, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT) + ); + + // Append entry to chamilo/manifest.json + $this->appendToManifest($exportDir, [ + 'kind' => 'gradebook', + 'moduleid' => $moduleId, + 'sectionid' => $sectionId, + 'title' => 'Gradebook', + 'path' => 'chamilo/gradebook/gradebook_' . $moduleId . '.json', + ]); + + @error_log('[GradebookMetaExport] Exported gradebook meta moduleId=' . $moduleId . ' sectionId=' . $sectionId); + } + + /** + * Locate the GradeBookBackup wrapper from the CourseBuilder bag. + * Robust rules: + * - Accept constant or string keys ("gradebook", "Gradebook"). + * - If there is only ONE entry, return its first value without assuming index 0. + * - Otherwise, loosely match by "source_id" or "id" against $iid (string tolerant). + * - Finally, return the first object that exposes a "categories" array. + */ + private function findGradebookBackup(int $iid): ?object + { + $resources = \is_array($this->course->resources ?? null) ? $this->course->resources : []; + + // Resolve "gradebook" bag defensively + $bag = + ($resources[\defined('RESOURCE_GRADEBOOK') ? \constant('RESOURCE_GRADEBOOK') : 'gradebook'] ?? null) + ?? ($resources['gradebook'] ?? null) + ?? ($resources['Gradebook'] ?? null) + ?? []; + + if (!\is_array($bag) || empty($bag)) { + return null; + } + + // Fast path: single element but do not assume index 0 + if (\count($bag) === 1) { + $first = \reset($bag); // returns first value regardless of key + return \is_object($first) ? $first : null; + } + + // Try to match loosely by id/source_id (string/numeric tolerant) + foreach ($bag as $maybe) { + if (!\is_object($maybe)) { + continue; + } + $sid = null; + + // Many wrappers expose 'source_id' + if (isset($maybe->source_id)) { + $sid = (string) $maybe->source_id; + } elseif (isset($maybe->id)) { + // Some wrappers store an 'id' field on the object + $sid = (string) $maybe->id; + } + + if ($sid !== null && $sid !== '') { + if ($sid === (string) $iid) { + return $maybe; + } + } + } + + // Fallback: pick first object that has a categories array (GradeBookBackup shape) + foreach ($bag as $maybe) { + if (\is_object($maybe) && isset($maybe->categories) && \is_array($maybe->categories)) { + return $maybe; + } + } + + return null; + } + + /** + * Build JSON payload from GradeBookBackup wrapper. + * The wrapper already contains the serialized array produced by the builder. + * We pass it through, applying minimal normalization. + * + * Additionally, we compute a best-effort list of "assessed_refs" pointing to + * referenced activities (e.g., quiz/assign ids) if such hints are present in the categories. + */ + private function buildPayloadFromBackup(object $backup, int $moduleId, int $sectionId): array + { + $categories = $this->readCategories($backup); + $assessed = $this->computeAssessedRefsFromCategories($categories); + + return [ + 'type' => 'gradebook', + 'moduleid' => $moduleId, + 'sectionid' => $sectionId, + 'title' => 'Gradebook', + 'categories' => $categories, // structure as produced by serializeGradebookCategory() + 'assessed_refs' => $assessed, // best-effort references for Chamilo re-import + '_exportedAt' => \date('c'), + ]; + } + + /** + * Read and normalize categories from the wrapper. + * Accepts arrays, Traversables and shallow objects of arrays. + */ + private function readCategories(object $backup): array + { + // Direct property first + if (isset($backup->categories)) { + return $this->deepArray($backup->categories); + } + + // Common getters + foreach (['getCategories', 'get_categories'] as $m) { + if (\is_callable([$backup, $m])) { + try { + $v = $backup->{$m}(); + return $this->deepArray($v); + } catch (\Throwable) { + // ignore and continue + } + } + } + + // Nothing found + return []; + } + + /** + * Convert input into a JSON-safe array recursively. + * - Arrays are copied deeply + * - Traversables become arrays + * - StdClass/DTOs with public props are cast to (array) and normalized + * Note: we intentionally DO NOT traverse Doctrine entities here; the builder already serialized them. + */ + private function deepArray(mixed $value): array + { + if (\is_array($value)) { + $out = []; + foreach ($value as $k => $v) { + // inner values may be arrays or scalars; recurse only for arrays/objects/traversables + if (\is_array($v) || $v instanceof \Traversable || \is_object($v)) { + $out[$k] = $this->deepArray($v); + } else { + $out[$k] = $v; + } + } + return $out; + } + + if ($value instanceof \Traversable) { + return $this->deepArray(\iterator_to_array($value)); + } + + if (\is_object($value)) { + // Cast public properties, then normalize + return $this->deepArray((array) $value); + } + + // If a scalar reaches here at the top-level, normalize to array + return [$value]; + } + + /** + * Attempt to derive a minimal set of references to assessed activities + * from the categories structure. This is *best-effort* and will only + * collect what is already serialized by the builder. + * + * Output example: + * [ + * {"type":"quiz","id":123}, + * {"type":"assign","id":45} + * ] + */ + private function computeAssessedRefsFromCategories(array $categories): array + { + $out = []; + $seen = []; + + $push = static function (string $type, int $id) use (&$out, &$seen): void { + if ($id <= 0 || $type === '') { + return; + } + $key = $type . ':' . $id; + if (isset($seen[$key])) { + return; + } + $seen[$key] = true; + $out[] = ['type' => $type, 'id' => $id]; + }; + + $walk = function ($node) use (&$walk, $push): void { + if (\is_array($node)) { + // Heuristic: look for common keys that the builder might have serialized + $typeKeys = ['item_type', 'resource_type', 'tool', 'type', 'modulename']; + $idKeys = ['item_id', 'resource_id', 'source_id', 'ref_id', 'id', 'iid', 'moduleid']; + + $type = ''; + foreach ($typeKeys as $k) { + if (isset($node[$k]) && \is_string($node[$k]) && $node[$k] !== '') { + $type = \strtolower(\trim((string) $node[$k])); + break; + } + } + + $id = 0; + foreach ($idKeys as $k) { + if (isset($node[$k]) && \is_numeric($node[$k])) { + $id = (int) $node[$k]; + break; + } + } + + // Allow a few known aliases + $aliases = [ + 'exercise' => 'quiz', + 'work' => 'assign', + ]; + if (isset($aliases[$type])) { + $type = $aliases[$type]; + } + + // Only record reasonable pairs (e.g., quiz/assign/wiki/resource/url) + if ($type !== '' && $id > 0) { + $push($type, $id); + } + + // Recurse into children/columns/items if present + foreach ($node as $v) { + if (\is_array($v)) { + $walk($v); + } + } + } + }; + + $walk($categories); + + return $out; + } + + /** + * Append record to chamilo/manifest.json (create if missing). + */ + private function appendToManifest(string $exportDir, array $record): void + { + $dir = rtrim($exportDir, '/') . '/chamilo'; + if (!\is_dir($dir)) { + @mkdir($dir, (int)\octdec('0775'), true); + } + + $manifestFile = $dir . '/manifest.json'; + $manifest = [ + 'version' => 1, + 'exporter' => 'C2-MoodleExport', + 'generatedAt' => \date('c'), + 'items' => [], + ]; + + if (\is_file($manifestFile)) { + $decoded = \json_decode((string) \file_get_contents($manifestFile), true); + if (\is_array($decoded)) { + // Merge with defaults but preserve existing 'items' + $manifest = \array_replace_recursive($manifest, $decoded); + } + if (!isset($manifest['items']) || !\is_array($manifest['items'])) { + $manifest['items'] = []; + } + } + + $manifest['items'][] = $record; + + @file_put_contents( + $manifestFile, + \json_encode($manifest, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT) + ); + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/LabelExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/LabelExport.php new file mode 100644 index 00000000000..e81d484e97a --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/LabelExport.php @@ -0,0 +1,281 @@ +prepareActivityDirectory($exportDir, 'label', $moduleId); + + // Resolve payload + $data = $this->getData($activityId, $sectionId); + if (null === $data) { + // Nothing to export + return; + } + + // Write primary XMLs + $this->createLabelXml($data, $labelDir); + $this->createModuleXml($data, $labelDir); + $this->createInforefXml($data, $labelDir); + + // Optional, but keeps structure consistent with other exporters + $this->createFiltersXml($data, $labelDir); + $this->createGradesXml($data, $labelDir); + $this->createGradeHistoryXml($data, $labelDir); + $this->createCompletionXml($data, $labelDir); + $this->createCommentsXml($data, $labelDir); + $this->createCompetenciesXml($data, $labelDir); + $this->createRolesXml($data, $labelDir); + $this->createCalendarXml($data, $labelDir); + + } + + /** + * Build label payload from legacy "course_description" bucket. + */ + public function getData(int $labelId, int $sectionId): ?array + { + // Accept both constant and plain string, defensively + $bag = + $this->course->resources[\defined('RESOURCE_COURSEDESCRIPTION') ? RESOURCE_COURSEDESCRIPTION : 'course_description'] + ?? $this->course->resources['course_description'] + ?? []; + + if (empty($bag) || !\is_array($bag)) { + return null; + } + + $wrap = $bag[$labelId] ?? null; + if (!$wrap || !\is_object($wrap)) { + return null; + } + + // Unwrap ->obj if present + $desc = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap; + + $title = $this->resolveTitle($desc); + $introRaw = (string) ($desc->content ?? ''); + $intro = $this->normalizeContent($introRaw); + + // Collect files referenced by intro (so inforef can point to them) + $files = $this->collectIntroFiles($introRaw, (string) ($this->course->code ?? '')); + + // Build the minimal dataset required by ActivityExport::createModuleXml() + return [ + 'id' => (int) ($desc->source_id ?? $labelId), + 'moduleid' => (int) ($desc->source_id ?? $labelId), + 'modulename' => 'label', + 'sectionid' => $sectionId, + // Use section number = section id; falls back to 0 (General) if not in LP + 'sectionnumber' => $sectionId, + 'name' => $title, + 'intro' => $intro, + 'introformat' => 1, + 'timemodified' => time(), + 'users' => [], + 'files' => $files, + ]; + } + + /** + * Title resolver with fallback by description_type. + */ + private function resolveTitle(object $desc): string + { + $t = trim((string) ($desc->title ?? '')); + if ('' !== $t) { + return $t; + } + $map = [1 => 'Descripción', 2 => 'Objetivos', 3 => 'Temas']; + return $map[(int) ($desc->description_type ?? 0)] ?? 'Descripción'; + } + + /** + * Normalize HTML: rewrite /document/... to @@PLUGINFILE@@/, including srcset, style url(...), etc. + */ + private function normalizeContent(string $html): string + { + if ('' === $html) { + return $html; + } + + // Handle srcset + $html = (string) preg_replace_callback( + '~\bsrcset\s*=\s*([\'"])(.*?)\1~is', + function (array $m): string { + $q = $m[1]; $val = $m[2]; + $parts = array_map('trim', explode(',', $val)); + foreach ($parts as &$p) { + if ($p === '') { continue; } + $tokens = preg_split('/\s+/', $p, -1, PREG_SPLIT_NO_EMPTY); + if (!$tokens) { continue; } + $url = $tokens[0]; + $new = $this->rewriteDocUrl($url); + if ($new !== $url) { + $tokens[0] = $new; + $p = implode(' ', $tokens); + } + } + return 'srcset='.$q.implode(', ', $parts).$q; + }, + $html + ); + + // Generic attributes + $html = (string) preg_replace_callback( + '~\b(src|href|poster|data)\s*=\s*([\'"])([^\'"]+)\2~i', + fn(array $m) => $m[1].'='.$m[2].$this->rewriteDocUrl($m[3]).$m[2], + $html + ); + + // Inline CSS + $html = (string) preg_replace_callback( + '~\bstyle\s*=\s*([\'"])(.*?)\1~is', + function (array $m): string { + $q = $m[1]; $style = $m[2]; + $style = (string) preg_replace_callback( + '~url\((["\']?)([^)\'"]+)\1\)~i', + fn(array $mm) => 'url('.$mm[1].$this->rewriteDocUrl($mm[2]).$mm[1].')', + $style + ); + return 'style='.$q.$style.$q; + }, + $html + ); + + // )~is', + function (array $m): string { + $open = $m[1]; $css = $m[2]; $close = $m[3]; + $css = (string) preg_replace_callback( + '~url\((["\']?)([^)\'"]+)\1\)~i', + fn(array $mm) => 'url('.$mm[1].$this->rewriteDocUrl($mm[2]).$mm[1].')', + $css + ); + return $open.$css.$close; + }, + $html + ); + + return $html; + } + + /** + * Rewrite /document/... (or /courses//document/...) to @@PLUGINFILE@@/. + */ + private function rewriteDocUrl(string $url): string + { + if ($url === '' || str_contains($url, '@@PLUGINFILE@@')) { + return $url; + } + if (preg_match('#/(?:courses/[^/]+/)?document(/[^?\'" )]+)#i', $url, $m)) { + return '@@PLUGINFILE@@/'.basename($m[1]); + } + return $url; + } + + /** + * Collect referenced intro files for files.xml (component=mod_label, filearea=intro). + * + * @return array> + */ + private function collectIntroFiles(string $introHtml, string $courseCode): array + { + if ($introHtml === '') { + return []; + } + + $files = []; + $contextid = (int) ($this->course->info['real_id'] ?? 0); + $adminId = MoodleExport::getAdminUserData()['id'] ?? ($this->getAdminUserData()['id'] ?? 0); + + $resources = DocumentManager::get_resources_from_source_html($introHtml); + $courseInfo = api_get_course_info($courseCode); + + foreach ($resources as [$src]) { + if (preg_match('#/document(/[^"\']+)#', $src, $matches)) { + $path = $matches[1]; + $docId = DocumentManager::get_document_id($courseInfo, $path); + if (!$docId) { + continue; + } + $document = DocumentManager::get_document_data_by_id($docId, $courseCode); + if (!$document) { + continue; + } + + $contenthash = hash('sha1', basename($document['path'])); + $mimetype = (new FileExport($this->course))->getMimeType($document['path']); + + $files[] = [ + 'id' => (int) $document['id'], + 'contenthash' => $contenthash, + 'contextid' => $contextid, + 'component' => 'mod_label', + 'filearea' => 'intro', + 'itemid' => 0, + 'filepath' => '/', + 'documentpath'=> 'document'.$document['path'], + 'filename' => basename($document['path']), + 'userid' => $adminId, + 'filesize' => (int) $document['size'], + 'mimetype' => $mimetype, + 'status' => 0, + 'timecreated' => time() - 3600, + 'timemodified'=> time(), + 'source' => (string) $document['title'], + 'author' => 'Unknown', + 'license' => 'allrightsreserved', + ]; + } + } + + return $files; + } + + /** + * Write label.xml for the activity. + */ + private function createLabelXml(array $data, string $dir): void + { + $xml = ''.PHP_EOL; + $xml .= ''.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ''; + + $this->createXmlFile('label', $xml, $dir); + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/LearnpathMetaExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/LearnpathMetaExport.php new file mode 100644 index 00000000000..952c2cbdbd3 --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/LearnpathMetaExport.php @@ -0,0 +1,203 @@ +ran) { + return; // Already exported; keep it idempotent. + } + $this->ran = true; + $this->exportAll($exportDir); + } + + /** + * Export categories, an index for all present learnpaths, and one folder per LP with raw JSON. + * Returns the number of learnpaths exported. + */ + public function exportAll(string $exportDir): int + { + $baseDir = rtrim($exportDir, '/').'/chamilo/learnpath'; + $this->ensureDir($baseDir); + + // Resolve resources bag defensively + $res = is_array($this->course->resources ?? null) ? $this->course->resources : []; + + // ---- Categories (optional but recommended) ---- + $catBag = + ($res[\defined('RESOURCE_LEARNPATH_CATEGORY') ? RESOURCE_LEARNPATH_CATEGORY : 'learnpath_category'] ?? null) + ?? ($res['learnpath_category'] ?? []) + ; + + $categories = []; + if (is_array($catBag)) { + foreach ($catBag as $cid => $cwrap) { + $cobj = $this->unwrapIfObject($cwrap); + $carr = $this->toArray($cobj); + // Normalize minimal shape (id, title) if present + $categories[] = [ + 'id' => (int) ($carr['id'] ?? $cid), + 'title' => (string) ($carr['title'] ?? ($carr['name'] ?? '')), + 'raw' => $carr, // keep full raw payload as well + ]; + } + } + $this->writeJson($baseDir.'/categories.json', ['categories' => $categories]); + + // Build a map id→title for quick lookup + $catTitle = []; + foreach ($categories as $c) { + $catTitle[(int) $c['id']] = (string) $c['title']; + } + + // ---- Learnpaths ---- + $lpBag = + ($res[\defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : 'learnpath'] ?? null) + ?? ($res['learnpath'] ?? []) + ; + + if (!is_array($lpBag) || empty($lpBag)) { + @error_log('[LearnpathMetaExport] No learnpaths present in resources; skipping.'); + // still return 0 after writing (possibly empty) categories.json + $this->exportScormIndexIfAny($res, $baseDir); + $this->writeJson($baseDir.'/index.json', ['learnpaths' => []]); + return 0; + } + + $index = []; + $count = 0; + + foreach ($lpBag as $lpId => $lpWrap) { + $lpObj = $this->unwrapIfObject($lpWrap); // stdClass payload from builder + $lpArr = $this->toArray($lpObj); // full raw payload + + $lpDir = $baseDir.'/lp_'.((int) $lpArr['id'] ?: (int) $lpId); + $this->ensureDir($lpDir); + + // Resolve category label if possible + $cid = (int) ($lpArr['category_id'] ?? 0); + $lpArr['_context'] = [ + 'lp_id' => (int) ($lpArr['id'] ?? $lpId), + 'lp_type' => (int) ($lpArr['lp_type'] ?? 0), // 1=LP,2=SCORM,3=AICC + 'category_id' => $cid, + 'category_name' => $catTitle[$cid] ?? null, + ]; + + // Persist learnpath.json (complete raw payload + _context) + $this->writeJson($lpDir.'/learnpath.json', ['learnpath' => $lpArr]); + + // Persist items.json as a separate, ordered list (if provided) + $items = []; + if (isset($lpArr['items']) && is_array($lpArr['items'])) { + $items = $lpArr['items']; + // Stable sort by display_order if present, otherwise keep builder order + usort($items, static function (array $a, array $b): int { + return (int) ($a['display_order'] ?? 0) <=> (int) ($b['display_order'] ?? 0); + }); + } + $this->writeJson($lpDir.'/items.json', ['items' => $items]); + + // Add to index + $index[] = [ + 'id' => (int) ($lpArr['id'] ?? $lpId), + 'title' => (string) ($lpArr['title'] ?? ''), + 'lp_type' => (int) ($lpArr['lp_type'] ?? 0), + 'category_id' => $cid, + 'category_name' => $catTitle[$cid] ?? null, + 'dir' => 'lp_'.((int) $lpArr['id'] ?: (int) $lpId), + ]; + + $count++; + } + + // Persist learnpaths index + $this->writeJson($baseDir.'/index.json', ['learnpaths' => $index]); + + // Optional SCORM index (if present in resources) + $this->exportScormIndexIfAny($res, $baseDir); + + @error_log('[LearnpathMetaExport] Exported learnpaths='.$count.' categories='.count($categories)); + return $count; + } + + /** If builder added "scorm" bag, also dump a simple index for reference. */ + private function exportScormIndexIfAny(array $res, string $baseDir): void + { + $scormBag = $res['scorm'] ?? null; + if (!is_array($scormBag) || empty($scormBag)) { + return; + } + $out = []; + foreach ($scormBag as $sid => $swrap) { + $sobj = $this->unwrapIfObject($swrap); + $sarr = $this->toArray($sobj); + $out[] = [ + 'id' => (int) ($sarr['id'] ?? $sid), + 'name' => (string) ($sarr['name'] ?? ''), + 'path' => (string) ($sarr['path'] ?? ''), + 'raw' => $sarr, + ]; + } + $this->writeJson($baseDir.'/scorm_index.json', ['scorm' => $out]); + } + + /** Ensure directory exists (recursive). */ + private function ensureDir(string $dir): void + { + if (!is_dir($dir) && !@mkdir($dir, api_get_permissions_for_new_directories(), true)) { + @error_log('[LearnpathMetaExport] ERROR mkdir failed: '.$dir); + } + } + + /** Write pretty JSON with utf8/slashes preserved. */ + private function writeJson(string $file, array $data): void + { + $json = json_encode( + $data, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + if (false === @file_put_contents($file, (string) $json)) { + @error_log('[LearnpathMetaExport] ERROR writing file: '.$file); + } + } + + /** Unwrap builder wrappers (->obj) into the raw stdClass payload. */ + private function unwrapIfObject($wrap) + { + if (\is_object($wrap) && isset($wrap->obj) && \is_object($wrap->obj)) { + return $wrap->obj; + } + return $wrap; + } + + /** Deep convert stdClass/objects to arrays. */ + private function toArray($value) + { + if (\is_array($value)) { + return array_map([$this, 'toArray'], $value); + } + if (\is_object($value)) { + return array_map([$this, 'toArray'], get_object_vars($value)); + } + return $value; + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/QuizMetaExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/QuizMetaExport.php new file mode 100644 index 00000000000..32b50974f06 --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/QuizMetaExport.php @@ -0,0 +1,171 @@ +course->resources for the selected quiz. + */ +class QuizMetaExport extends ActivityExport +{ + /** + * Export JSON files for the given quiz: + * chamilo/quiz/quiz_{moduleId}/quiz.json + * chamilo/quiz/quiz_{moduleId}/questions.json + * chamilo/quiz/quiz_{moduleId}/answers.json + */ + public function export(int $activityId, string $exportDir, int $moduleId, int $sectionId): void + { + // Build destination folder + $baseDir = rtrim($exportDir, '/').'/chamilo/quiz/quiz_'.$moduleId; + $this->ensureDir($baseDir); + + // Resolve quiz bag (accept constant or string key) + $quizBag = + $this->course->resources[\defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz'] ?? + $this->course->resources['quiz'] ?? []; + + if (empty($quizBag[$activityId])) { + @error_log('[QuizMetaExport] WARN quiz not found in resources: id='.$activityId); + return; + } + + // Unwrap quiz wrapper → raw payload already prepared by build_quizzes() + $quizWrap = $quizBag[$activityId]; + $quizObj = $this->unwrap($quizWrap); // stdClass payload + $quizArr = $this->toArray($quizObj); // array payload for JSON + + // Keep minimal context references (section/module) for traceability + $quizArr['_context'] = [ + 'module_id' => (int) $moduleId, + 'section_id' => (int) $sectionId, + ]; + + // Persist quiz.json + $this->writeJson($baseDir.'/quiz.json', ['quiz' => $quizArr]); + + // Collect questions for this quiz (preserve order if available) + $questionIds = []; + $orders = []; + + if (isset($quizArr['question_ids']) && \is_array($quizArr['question_ids'])) { + $questionIds = array_map('intval', $quizArr['question_ids']); + } + if (isset($quizArr['question_orders']) && \is_array($quizArr['question_orders'])) { + $orders = array_map('intval', $quizArr['question_orders']); + } + + $qBag = + $this->course->resources[\defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : 'Exercise_Question'] + ?? $this->course->resources['Exercise_Question'] + ?? []; + + // Build ordered questions array (raw, with their nested answers) + $questions = []; + $answersFlat = []; + $orderMap = []; + + // If we have question_orders aligned with question_ids, build an order map + if (!empty($questionIds) && !empty($orders) && \count($questionIds) === \count($orders)) { + foreach ($questionIds as $idx => $qid) { + $orderMap[(int) $qid] = (int) $orders[$idx]; + } + } + + foreach ($questionIds as $qid) { + if (!isset($qBag[$qid])) { + continue; + } + $qWrap = $qBag[$qid]; + $qObj = $this->unwrap($qWrap); // stdClass payload from build_quiz_questions() + $qArr = $this->toArray($qObj); + + // Attach quiz reference + $qArr['_links']['quiz_id'] = (int) $activityId; + + // Optional: attach explicit question_id for clarity + $qArr['id'] = $qArr['id'] ?? (int) ($qWrap->source_id ?? $qid); + + // Flatten answers to a standalone list (still keep them nested in question) + $answers = []; + if (isset($qArr['answers']) && \is_array($qArr['answers'])) { + foreach ($qArr['answers'] as $ans) { + $answers[] = $ans; + + $answersFlat[] = [ + 'quiz_id' => (int) $activityId, + 'question_id' => (int) $qArr['id'], + // Persist raw answer data verbatim + 'data' => $ans, + ]; + } + } + + // Preserve original order if available; fallback to question "position" + $qArr['_order'] = $orderMap[$qid] ?? (int) ($qArr['position'] ?? 0); + $questions[] = $qArr; + } + + // Sort questions by _order asc (stable) + usort($questions, static function (array $a, array $b): int { + return ($a['_order'] ?? 0) <=> ($b['_order'] ?? 0); + }); + + // Persist questions.json (full raw) + $this->writeJson($baseDir.'/questions.json', ['questions' => $questions]); + + // Persist answers.json (flat list) + $this->writeJson($baseDir.'/answers.json', ['answers' => $answersFlat]); + } + + /** Ensure directory exists (recursive). */ + private function ensureDir(string $dir): void + { + if (!is_dir($dir) && !@mkdir($dir, api_get_permissions_for_new_directories(), true)) { + @error_log('[QuizMetaExport] ERROR mkdir failed: '.$dir); + } + } + + /** Write pretty JSON with utf8/slashes preserved. */ + private function writeJson(string $file, array $data): void + { + $json = json_encode( + $data, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + if (false === @file_put_contents($file, (string) $json)) { + @error_log('[QuizMetaExport] ERROR writing file: '.$file); + } + } + + /** + * Unwraps a legacy wrapper produced by mkLegacyItem() into its raw stdClass payload. + * We prefer ->obj if present; otherwise return the object itself. + */ + private function unwrap(object $wrap): object + { + // Some wrappers keep original payload at ->obj + if (isset($wrap->obj) && \is_object($wrap->obj)) { + return $wrap->obj; + } + return $wrap; + } + + /** Deep convert stdClass/objects to arrays. */ + private function toArray($value) + { + if (\is_array($value)) { + return array_map([$this, 'toArray'], $value); + } + if (\is_object($value)) { + // Convert to array and recurse + return array_map([$this, 'toArray'], get_object_vars($value)); + } + return $value; + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/ThematicMetaExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/ThematicMetaExport.php new file mode 100644 index 00000000000..de231cf43ee --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/ThematicMetaExport.php @@ -0,0 +1,409 @@ +params: ['id','title','content','active'] + * - $thematic->thematic_advance_list: list of advances (array of arrays) + * - $thematic->thematic_plan_list: list of plans (array of arrays) + * + * Also supports the "legacy/domain" shape as a fallback. + */ +class ThematicMetaExport extends ActivityExport +{ + public function export(int $activityId, string $exportDir, int $moduleId, int $sectionId): void + { + $thematic = $this->findThematicById($activityId); + if ($thematic === null) { + @error_log('[ThematicMetaExport] Skipping: thematic not found id=' . $activityId); + return; + } + + $payload = $this->buildPayloadFromLegacy($thematic, $moduleId, $sectionId); + + $base = rtrim($exportDir, '/') . '/chamilo/thematic'; + if (!is_dir($base)) { + @mkdir($base, (int)octdec('0775'), true); + } + + $jsonFile = $base . '/thematic_' . $moduleId . '.json'; + @file_put_contents( + $jsonFile, + json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) + ); + + $this->appendToManifest($exportDir, [ + 'kind' => 'thematic', + 'moduleid' => $moduleId, + 'sectionid' => $sectionId, + 'title' => (string)($payload['title'] ?? 'Thematic'), + 'path' => 'chamilo/thematic/thematic_' . $moduleId . '.json', + ]); + + @error_log('[ThematicMetaExport] Exported thematic moduleid=' . $moduleId . ' sectionid=' . $sectionId); + } + + /** + * Find thematic by iid across both shapes: + * - CourseBuilder wrapper: params['id'] + * - Legacy object: id/iid/source_id + */ + private function findThematicById(int $iid): ?object + { + $bag = $this->course->resources[\defined('RESOURCE_THEMATIC') ? RESOURCE_THEMATIC : 'thematic'] + ?? $this->course->resources['thematic'] + ?? []; + + if (!\is_array($bag)) { + return null; + } + + foreach ($bag as $maybe) { + if (!\is_object($maybe)) { + continue; + } + + // Direct match on wrapper params['id'] + $params = $this->readParams($maybe); + $pid = (int)($params['id'] ?? 0); + if ($pid === $iid) { + return $maybe; + } + + // Fallback to object/id/iid/source_id + $obj = (isset($maybe->obj) && \is_object($maybe->obj)) ? $maybe->obj : $maybe; + $candidates = [ + (int)($obj->id ?? 0), + (int)($obj->iid ?? 0), + (int)($obj->source_id ?? 0), + ]; + if (\in_array($iid, $candidates, true)) { + return $obj; + } + } + + return null; + } + + /** + * Build payload from the CourseBuilder wrapper first; fallback to legacy getters/props. + */ + private function buildPayloadFromLegacy(object $t, int $moduleId, int $sectionId): array + { + // ---- PRIMARY: CourseBuilder wrapper shape ---- + $params = $this->readParams($t); + $title = (string)($params['title'] ?? $this->readFirst($t, ['title','name'], 'Thematic')); + $content = (string)($params['content'] ?? $this->readFirst($t, ['content','summary','description','intro'], '')); + $active = (int)((isset($params['active']) ? (int)$params['active'] : ($t->active ?? 1))); + + // Lists from wrapper keys + $advanceList = $this->readList($t, [ + 'thematic_advance_list', // exact key from your dump + 'thematic_advances', + 'advances', + ]); + + $planList = $this->readList($t, [ + 'thematic_plan_list', // exact key from your dump + 'thematic_plans', + 'plans', + ]); + + // Normalize collections + $advances = $this->normalizeAdvances($advanceList); + $plans = $this->normalizePlans($planList); + + // Derive optional semantics (objective/outcomes) from plans if available + [$objective, $outcomes] = $this->deriveObjectiveAndOutcomes($plans); + + // Collect cross-links (documents/assign/quiz/forums/URLs) from texts + $links = array_values($this->uniqueByHash(array_merge( + $this->collectLinksFromText($content), + ...array_map(fn($a) => $this->collectLinksFromText((string)($a['content'] ?? '')), $advances), + ...array_map(fn($p) => $this->collectLinksFromText((string)($p['description'] ?? '')), $plans) + ))); + + return [ + 'type' => 'thematic', + 'moduleid' => $moduleId, + 'sectionid' => $sectionId, + 'title' => $title, + 'content' => $content, + 'active' => $active, + 'objective' => $objective, + 'outcomes' => $outcomes, + 'advances' => $advances, + 'plans' => $plans, + 'links' => $links, + '_exportedAt' => date('c'), + ]; + } + + /** Read $obj->params as array if present (wrapper shape). */ + private function readParams(object $obj): array + { + // Direct property + if (isset($obj->params) && \is_array($obj->params)) { + return $obj->params; + } + // Common getters + foreach (['getParams','get_params'] as $m) { + if (\is_callable([$obj, $m])) { + try { + $v = $obj->{$m}(); + if (\is_array($v)) { + return $v; + } + } catch (\Throwable) { /* ignore */ } + } + } + return []; + } + + /** Read a list from any of the given property names or simple getters. */ + private function readList(object $obj, array $propNames): array + { + foreach ($propNames as $k) { + if (isset($obj->{$k})) { + $v = $obj->{$k}; + if (\is_array($v)) { + return $v; + } + if ($v instanceof \Traversable) { + return iterator_to_array($v); + } + } + $getter = 'get' . str_replace(' ', '', ucwords(str_replace(['_','-'], ' ', $k))); + if (\is_callable([$obj, $getter])) { + try { + $v = $obj->{$getter}(); + if (\is_array($v)) { + return $v; + } + if ($v instanceof \Traversable) { + return iterator_to_array($v); + } + } catch (\Throwable) { /* ignore */ } + } + } + return []; + } + + /** Fallback string reader from object props; returns $fallback when empty. */ + private function readFirst(object $o, array $propNames, string $fallback = ''): string + { + foreach ($propNames as $k) { + if (isset($o->{$k}) && \is_string($o->{$k})) { + $v = trim($o->{$k}); + if ($v !== '') { + return $v; + } + } + } + return $fallback; + } + + /** Normalize advances array (array-of-arrays OR array-of-objects). */ + private function normalizeAdvances(array $list): array + { + $out = []; + foreach ($list as $it) { + if (!\is_array($it) && !\is_object($it)) { + continue; + } + $a = (array)$it; // array cast works for stdClass and most DTOs + $id = (int)($a['id'] ?? ($a['iid'] ?? 0)); + $themid = (int)($a['thematic_id'] ?? 0); + $content = (string)($a['content'] ?? ''); + $start = (string)($a['start_date']?? ''); + $duration = (int)($a['duration'] ?? 0); + $done = (bool)($a['done_advance']?? false); + $attid = (int)($a['attendance_id']?? 0); + $roomId = (int)($a['room_id'] ?? 0); + + $out[] = [ + 'id' => $id, + 'thematic_id' => $themid, + 'content' => $content, + 'start_date' => $start, + 'start_iso8601' => $this->toIso($start), + 'duration' => $duration, + 'done_advance' => $done, + 'attendance_id' => $attid, + 'room_id' => $roomId, + ]; + } + + usort($out, function ($a, $b) { + if (($a['id'] ?? 0) !== ($b['id'] ?? 0)) { + return ($a['id'] ?? 0) <=> ($b['id'] ?? 0); + } + return strcmp((string)($a['start_date'] ?? ''), (string)($b['start_date'] ?? '')); + }); + + return $out; + } + + /** Normalize plans array (array-of-arrays OR array-of-objects). */ + private function normalizePlans(array $list): array + { + $out = []; + foreach ($list as $it) { + if (!\is_array($it) && !\is_object($it)) { + continue; + } + $p = (array)$it; + $id = (int)($p['id'] ?? ($p['iid'] ?? 0)); + $themid = (int)($p['thematic_id'] ?? 0); + $title = (string)($p['title'] ?? ''); + $desc = (string)($p['description'] ?? ''); + $dtype = (int)($p['description_type']?? 0); + + $out[] = [ + 'id' => $id, + 'thematic_id' => $themid, + 'title' => $title, + 'description' => $this->normalizePlanText($desc), + 'description_type' => $dtype, + ]; + } + + usort($out, fn($a, $b) => ($a['id'] ?? 0) <=> ($b['id'] ?? 0)); + return $out; + } + + /** Very light HTML/whitespace normalization. */ + private function normalizePlanText(string $s): string + { + $s = preg_replace('/[ \t]+/', ' ', (string)$s); + return trim($s ?? ''); + } + + /** Derive objective/outcomes from plans per description_type codes (1=objective, 2=outcomes). */ + private function deriveObjectiveAndOutcomes(array $plans): array + { + $objective = ''; + $outcomes = []; + + foreach ($plans as $p) { + $type = (int)($p['description_type'] ?? 0); + $ttl = trim((string)($p['title'] ?? '')); + $txt = trim((string)($p['description'] ?? '')); + + if ($type === 1 && $objective === '') { + $objective = $ttl !== '' ? $ttl : $txt; + } elseif ($type === 2) { + $outcomes[] = $ttl !== '' ? $ttl : $txt; + } + } + + // Clean and deduplicate outcomes + $outcomes = array_values(array_unique(array_filter($outcomes, fn($x) => $x !== ''))); + + return [$objective, $outcomes]; + } + + /** Collect cheap cross-links from HTML/text. */ + private function collectLinksFromText(string $html): array + { + $found = []; + $patterns = [ + ['type' => 'document', 're' => '/(?:document\/|doc:)(\d+)/i'], + ['type' => 'quiz', 're' => '/(?:quiz\/|quiz:)(\d+)/i'], + ['type' => 'assign', 're' => '/(?:assign\/|assign:)(\d+)/i'], + ['type' => 'forum', 're' => '/(?:forum\/|forum:)(\d+)/i'], + ['type' => 'url', 're' => '/https?:\/\/[\w\-\.\:]+[^\s"<>\']*/i'], + ]; + + foreach ($patterns as $p) { + if ($p['type'] === 'url') { + if (preg_match_all($p['re'], (string)$html, $m)) { + foreach ($m[0] as $u) { + $found[] = ['type' => 'url', 'href' => (string)$u]; + } + } + } else { + if (preg_match_all($p['re'], (string)$html, $m)) { + foreach ($m[1] as $id) { + $id = (int)$id; + if ($id > 0) { + $found[] = ['type' => $p['type'], 'id' => $id]; + } + } + } + } + } + + return $found; + } + + /** Convert 'Y-m-d H:i:s' or 'Y-m-d' to ISO 8601 if possible. */ + private function toIso(string $s): ?string + { + $s = trim($s); + if ($s === '') { + return null; + } + $ts = strtotime($s); + return $ts ? date('c', $ts) : null; + } + + /** De-duplicate link arrays by hashing. */ + private function uniqueByHash(array $items): array + { + $seen = []; + $out = []; + foreach ($items as $it) { + $key = md5(json_encode($it)); + if (!isset($seen[$key])) { + $seen[$key] = true; + $out[] = $it; + } + } + return $out; + } + + /** Append to chamilo/manifest.json (create if missing). */ + private function appendToManifest(string $exportDir, array $record): void + { + $dir = rtrim($exportDir, '/') . '/chamilo'; + if (!is_dir($dir)) { + @mkdir($dir, (int)octdec('0775'), true); + } + + $manifestFile = $dir . '/manifest.json'; + $manifest = [ + 'version' => 1, + 'exporter' => 'C2-MoodleExport', + 'generatedAt' => date('c'), + 'items' => [], + ]; + + if (is_file($manifestFile)) { + $decoded = json_decode((string)file_get_contents($manifestFile), true); + if (\is_array($decoded)) { + $manifest = array_replace_recursive($manifest, $decoded); + } + if (!isset($manifest['items']) || !\is_array($manifest['items'])) { + $manifest['items'] = []; + } + } + + $manifest['items'][] = $record; + + @file_put_contents( + $manifestFile, + json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) + ); + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Activities/WikiExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/WikiExport.php new file mode 100644 index 00000000000..844b0b6b7fb --- /dev/null +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Activities/WikiExport.php @@ -0,0 +1,309 @@ + one Moodle wiki activity (single subwiki + single page + version #1). + * - Keeps the same auxiliary XMLs consistency as LabelExport (module.xml, inforef.xml, etc.). + */ +final class WikiExport extends ActivityExport +{ + /** + * Export a single Wiki activity. + * + * @param int $activityId Source page identifier (we try pageId, iid, or array key) + * @param string $exportDir Root temp export directory + * @param int $moduleId Module id used in directory name (usually = $activityId) + * @param int $sectionId Resolved course section (0 = General) + */ + public function export(int $activityId, string $exportDir, int $moduleId, int $sectionId): void + { + $wikiDir = $this->prepareActivityDirectory($exportDir, 'wiki', $moduleId); + + $data = $this->getData($activityId, $sectionId); + if (null === $data) { + // Nothing to export + return; + } + + // Primary XMLs + $this->createWikiXml($data, $wikiDir); // activities/wiki_{id}/wiki.xml + $this->createModuleXml($data, $wikiDir); // activities/wiki_{id}/module.xml + $this->createInforefXml($data, $wikiDir); // activities/wiki_{id}/inforef.xml + + // Optional auxiliaries to keep structure consistent with other exporters + $this->createFiltersXml($data, $wikiDir); + $this->createGradesXml($data, $wikiDir); + $this->createGradeHistoryXml($data, $wikiDir); + $this->createCompletionXml($data, $wikiDir); + $this->createCommentsXml($data, $wikiDir); + $this->createCompetenciesXml($data, $wikiDir); + $this->createRolesXml($data, $wikiDir); + $this->createCalendarXml($data, $wikiDir); + } + + /** + * Build wiki payload from legacy "wiki" bucket (CWiki). + * + * Returns a structure with: + * - id, moduleid, modulename='wiki', sectionid, sectionnumber + * - name (title), intro (empty), introformat=1 (HTML) + * - wikimode ('collaborative'), defaultformat ('html'), forceformat=1 + * - firstpagetitle, timecreated, timemodified + * - pages[]: one page with versions[0] + */ + public function getData(int $activityId, int $sectionId): ?array + { + $bag = + $this->course->resources[\defined('RESOURCE_WIKI') ? RESOURCE_WIKI : 'wiki'] + ?? $this->course->resources['wiki'] + ?? []; + + if (empty($bag) || !\is_array($bag)) { + return null; + } + + $pages = []; + $users = []; + $firstTitle = null; + + foreach ($bag as $key => $wrap) { + if (!\is_object($wrap)) { continue; } + $p = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap; + + $pid = (int)($p->pageId ?? $p->iid ?? $key ?? 0); + if ($pid <= 0) { continue; } + + $title = trim((string)($p->title ?? 'Wiki page '.$pid)); + if ($title === '') { $title = 'Wiki page '.$pid; } + $rawHtml = (string)($p->content ?? ''); + $content = $this->normalizeContent($rawHtml); + + $userId = (int)($p->userId ?? 0); + $created = $this->toTimestamp((string)($p->dtime ?? ''), time()); + $modified = $created; + + $pages[] = [ + 'id' => $pid, + 'title' => $title, + 'content' => $content, + 'contentformat' => 'html', + 'version' => 1, + 'timecreated' => $created, + 'timemodified' => $modified, + 'userid' => $userId, + ]; + + if ($userId > 0) { $users[$userId] = true; } + if (null === $firstTitle) { $firstTitle = $title; } + } + + if (empty($pages)) { + return null; + } + + return [ + 'id' => $activityId, + 'moduleid' => $activityId, + 'modulename' => 'wiki', + 'sectionid' => $sectionId, + 'sectionnumber' => $sectionId, + 'name' => 'Wiki', + 'intro' => '', + 'introformat' => 1, + 'timemodified' => max(array_column($pages, 'timemodified')), + 'editbegin' => 0, + 'editend' => 0, + + 'wikimode' => 'collaborative', + 'defaultformat' => 'html', + 'forceformat' => 1, + 'firstpagetitle' => $firstTitle ?? 'Home', + 'timecreated' => min(array_column($pages, 'timecreated')), + 'timemodified2' => max(array_column($pages, 'timemodified')), + + 'pages' => $pages, + 'userids' => array_keys($users), + ]; + } + + /** + * Write activities/wiki_{id}/wiki.xml + * NOTE: We ensure non-null and present at level + * to satisfy Moodle restore expectations (mdl_wiki_pages.userid and cachedcontent NOT NULL). + */ + private function createWikiXml(array $d, string $dir): void + { + $admin = MoodleExport::getAdminUserData(); + $adminId = (int)($admin['id'] ?? 2); + if ($adminId <= 0) { $adminId = 2; } + + $xml = ''.PHP_EOL; + $xml .= ''.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.$this->h($d['name']).''.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.(int)($d['introformat'] ?? 1).''.PHP_EOL; + $xml .= ' '.$this->h((string)$d['wikimode']).''.PHP_EOL; + $xml .= ' '.$this->h((string)$d['defaultformat']).''.PHP_EOL; + $xml .= ' '.(int)$d['forceformat'].''.PHP_EOL; + $xml .= ' '.$this->h((string)$d['firstpagetitle']).''.PHP_EOL; + $xml .= ' '.(int)$d['timecreated'].''.PHP_EOL; + $xml .= ' '.(int)$d['timemodified2'].''.PHP_EOL; + $xml .= ' '.(int)($d['editbegin'] ?? 0).''.PHP_EOL; + $xml .= ' '.(int)($d['editend'] ?? 0).''.PHP_EOL; + + // single subwiki (no groups/users) + $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + $xml .= ' 0'.PHP_EOL; + + // pages + $xml .= ' '.PHP_EOL; + foreach ($d['pages'] as $i => $p) { + $pid = (int)$p['id']; + $pageUserId = (int)($p['userid'] ?? 0); + if ($pageUserId <= 0) { $pageUserId = $adminId; } // fallback user id + + // Ensure non-empty cachedcontent; Moodle expects NOT NULL. + $pageHtml = trim((string)($p['content'] ?? '')); + if ($pageHtml === '') { $pageHtml = '

'; } + + $xml .= ' '.PHP_EOL; + $xml .= ' '.$this->h((string)$p['title']).''.PHP_EOL; + $xml .= ' '.$pageUserId.''.PHP_EOL; // <-- new: page-level userid + $xml .= ' '.PHP_EOL; // <-- not NULL + $xml .= ' '.(int)$p['timecreated'].''.PHP_EOL; + $xml .= ' '.(int)$p['timemodified'].''.PHP_EOL; + $xml .= ' '.$pid.''.PHP_EOL; + + // one version + $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.$this->h((string)$p['contentformat']).''.PHP_EOL; + $xml .= ' '.(int)$p['version'].''.PHP_EOL; + $xml .= ' '.(int)$p['timecreated'].''.PHP_EOL; + $xml .= ' '.$pageUserId.''.PHP_EOL; + $xml .= ' '.PHP_EOL; + $xml .= ' '.PHP_EOL; + + $xml .= ' '.PHP_EOL; + } + $xml .= '
'.PHP_EOL; + + $xml .= '
'.PHP_EOL; + $xml .= '
'.PHP_EOL; + + $xml .= '
'.PHP_EOL; + $xml .= '
'; + + $this->createXmlFile('wiki', $xml, $dir); + } + + /** Normalize HTML like LabelExport: rewrite /document/... to @@PLUGINFILE@@/. */ + private function normalizeContent(string $html): string + { + if ($html === '') { + return $html; + } + + // srcset + $html = (string)preg_replace_callback( + '~\bsrcset\s*=\s*([\'"])(.*?)\1~is', + function (array $m): string { + $q = $m[1]; $val = $m[2]; + $parts = array_map('trim', explode(',', $val)); + foreach ($parts as &$p) { + if ($p === '') { continue; } + $tokens = preg_split('/\s+/', $p, -1, PREG_SPLIT_NO_EMPTY); + if (!$tokens) { continue; } + $url = $tokens[0]; + $new = $this->rewriteDocUrl($url); + if ($new !== $url) { + $tokens[0] = $new; + $p = implode(' ', $tokens); + } + } + return 'srcset='.$q.implode(', ', $parts).$q; + }, + $html + ); + + // generic attributes + $html = (string)preg_replace_callback( + '~\b(src|href|poster|data)\s*=\s*([\'"])([^\'"]+)\2~i', + fn(array $m) => $m[1].'='.$m[2].$this->rewriteDocUrl($m[3]).$m[2], + $html + ); + + // inline CSS + $html = (string)preg_replace_callback( + '~\bstyle\s*=\s*([\'"])(.*?)\1~is', + function (array $m): string { + $q = $m[1]; $style = $m[2]; + $style = (string)preg_replace_callback( + '~url\((["\']?)([^)\'"]+)\1\)~i', + fn(array $mm) => 'url('.$mm[1].$this->rewriteDocUrl($mm[2]).$mm[1].')', + $style + ); + return 'style='.$q.$style.$q; + }, + $html + ); + + // )~is', + function (array $m): string { + $open = $m[1]; $css = $m[2]; $close = $m[3]; + $css = (string)preg_replace_callback( + '~url\((["\']?)([^)\'"]+)\1\)~i', + fn(array $mm) => 'url('.$mm[1].$this->rewriteDocUrl($mm[2]).$mm[1].')', + $css + ); + return $open.$css.$close; + }, + $html + ); + + return $html; + } + + /** Replace Chamilo /document URLs by @@PLUGINFILE@@/basename */ + private function rewriteDocUrl(string $url): string + { + if ($url === '' || str_contains($url, '@@PLUGINFILE@@')) { + return $url; + } + if (preg_match('#/(?:courses/[^/]+/)?document(/[^?\'" )]+)#i', $url, $m)) { + return '@@PLUGINFILE@@/'.basename($m[1]); + } + return $url; + } + + private function toTimestamp(string $value, int $fallback): int + { + if ($value === '') { return $fallback; } + if (\is_numeric($value)) { return (int)$value; } + $t = strtotime($value); + return false !== $t ? (int)$t : $fallback; + } + + private function h(string $s): string + { + return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Builder/CourseExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Builder/CourseExport.php index 5d836e97733..030455fdb8f 100644 --- a/src/CourseBundle/Component/CourseCopy/Moodle/Builder/CourseExport.php +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Builder/CourseExport.php @@ -7,9 +7,12 @@ namespace Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\QuizExport; +use Chamilo\CourseBundle\Component\CourseCopy\Resources\CalendarEvent; use Exception; use const PHP_EOL; +use const ENT_QUOTES; +use const ENT_SUBSTITUTE; /** * Writes the course-level directory and XMLs inside the export root. @@ -232,28 +235,79 @@ private function createRolesXml(array $rolesData, string $destinationDir): void } /** - * Creates the calendar.xml file. + * Always writes course/calendar.xml (Moodle expects it). + * Priority: + * 1) Events pushed by the builder into $this->course->resources (truth source). + * 2) Fallback to $calendarData (legacy optional). + * 3) Minimal stub if none. * * @param array> $calendarData */ private function createCalendarXml(array $calendarData, string $destinationDir): void { - $xmlContent = ''.PHP_EOL; - $xmlContent .= ''.PHP_EOL; - foreach ($calendarData as $event) { - $eventName = (string) ($event['name'] ?? 'Event'); - $timestart = (int) ($event['timestart'] ?? time()); - $duration = (int) ($event['duration'] ?? 3600); - - $xmlContent .= ' '.PHP_EOL; - $xmlContent .= ' '.htmlspecialchars($eventName).''.PHP_EOL; - $xmlContent .= ' '.$timestart.''.PHP_EOL; - $xmlContent .= ' '.$duration.''.PHP_EOL; - $xmlContent .= ' '.PHP_EOL; + $builderEvents = $this->collectCalendarEvents(); + + if (!empty($builderEvents)) { + $xml = '' . PHP_EOL; + $xml .= '' . PHP_EOL; + + foreach ($builderEvents as $ev) { + // Builder fields: iid, title, content, startDate, endDate, firstPath, firstName, firstSize, firstComment, allDay + $title = (string) ($ev->title ?? 'Event'); + $content = (string) ($ev->content ?? ''); + $start = $this->toTimestamp((string)($ev->startDate ?? ''), time()); + $end = $this->toTimestamp((string)($ev->endDate ?? ''), 0); + $allday = (int) ($ev->allDay ?? 0); + + $duration = 0; + if ($end > 0 && $end > $start) { + $duration = $end - $start; + } + + // Keep a Moodle-restore-friendly minimal shape + $xml .= " " . PHP_EOL; + $xml .= ' '.htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'' . PHP_EOL; + $xml .= ' ' . PHP_EOL; + $xml .= ' 1' . PHP_EOL; // HTML + $xml .= ' course' . PHP_EOL; + $xml .= ' '.$start.'' . PHP_EOL; + $xml .= ' '.$duration.'' . PHP_EOL; + $xml .= ' 1' . PHP_EOL; + $xml .= ' '.$allday.'' . PHP_EOL; + $xml .= ' 0' . PHP_EOL; + $xml .= ' $@NULL@$' . PHP_EOL; + $xml .= " " . PHP_EOL; + } + + $xml .= '' . PHP_EOL; + file_put_contents($destinationDir . '/calendar.xml', $xml); + return; } - $xmlContent .= ''; - file_put_contents($destinationDir.'/calendar.xml', $xmlContent); + if (!empty($calendarData)) { + $xml = '' . PHP_EOL; + $xml .= '' . PHP_EOL; + + foreach ($calendarData as $e) { + $name = htmlspecialchars((string)($e['name'] ?? 'Event'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $timestart = (int)($e['timestart'] ?? time()); + $duration = (int)($e['duration'] ?? 0); + + $xml .= " " . PHP_EOL; + $xml .= " {$name}" . PHP_EOL; + $xml .= " {$timestart}" . PHP_EOL; + $xml .= " {$duration}" . PHP_EOL; + $xml .= " " . PHP_EOL; + } + + $xml .= '' . PHP_EOL; + file_put_contents($destinationDir . '/calendar.xml', $xml); + return; + } + + $xml = '' . PHP_EOL; + $xml .= '' . PHP_EOL; + file_put_contents($destinationDir . '/calendar.xml', $xml); } /** @@ -360,4 +414,55 @@ private function createFiltersXml(array $filtersData, string $destinationDir): v file_put_contents($destinationDir.'/filters.xml', $xmlContent); } + + /** + * Gather CalendarEvent objects pushed by the builder into $this->course->resources. + * We DO NOT invent types; we rely on the builder's CalendarEvent class in the same namespace. + * + * @return CalendarEvent[] // from Builder namespace + */ + private function collectCalendarEvents(): array + { + $out = []; + $resources = $this->course->resources ?? null; + + if (!\is_array($resources)) { + return $out; + } + + // Prefer a dedicated 'calendar' bucket if present; otherwise, scan all buckets. + if (isset($resources['calendar']) && \is_array($resources['calendar'])) { + foreach ($resources['calendar'] as $item) { + if ($item instanceof CalendarEvent) { + $out[] = $item; + } + } + return $out; + } + + foreach ($resources as $bucket) { + if (\is_array($bucket)) { + foreach ($bucket as $item) { + if ($item instanceof CalendarEvent) { + $out[] = $item; + } + } + } elseif ($bucket instanceof CalendarEvent) { + $out[] = $bucket; + } + } + + return $out; + } + + /** + * Convert a date-string ('Y-m-d H:i:s') or numeric to timestamp with fallback. + */ + private function toTimestamp(string $value, int $fallback): int + { + if ($value === '') { return $fallback; } + if (\is_numeric($value)) { return (int) $value; } + $t = \strtotime($value); + return false !== $t ? (int) $t : $fallback; + } } diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleExport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleExport.php index c33e3723d41..64692b85b42 100644 --- a/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleExport.php +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleExport.php @@ -8,15 +8,23 @@ use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ActivityExport; +use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\AnnouncementsForumExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\AssignExport; +use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\AttendanceMetaExport; +use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\CourseCalendarExport; 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\GradebookMetaExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\LabelExport; +use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\LearnpathMetaExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\PageExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\QuizExport; +use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\QuizMetaExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ResourceExport; +use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\ThematicMetaExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\UrlExport; +use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Activities\WikiExport; use Exception; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -48,6 +56,15 @@ class MoodleExport protected static array $activityUserinfo = []; + /** Synthetic module id for the News forum generated from announcements */ + private const ANNOUNCEMENTS_MODULE_ID = 48000001; + + /** Synthetic module id for Gradebook (Chamilo-only metadata) */ + private const GRADEBOOK_MODULE_ID = 48000002; + + private bool $wikiAdded = false; + private const WIKI_MODULE_ID = 48000003; + /** * Constructor to initialize the course object. * @@ -99,7 +116,7 @@ public function export(string $courseId, string $exportDir, int $version) throw new Exception(get_lang('Course not found')); } - // 1) Create Moodle backup skeleton (backup.xml + dirs) + // Create Moodle backup skeleton (backup.xml + dirs) $this->createMoodleBackupXml($tempDir, $version); @error_log('[MoodleExport::export] moodle_backup.xml generated'); @@ -140,9 +157,16 @@ public function export(string $courseId, string $exportDir, int $version) // Sections export (topics/weeks descriptors) $this->exportSections($tempDir); - @error_log('[MoodleExport::export] sections/ exported'); + $this->exportCourseCalendar($tempDir); + $this->exportAnnouncementsForum($activities, $tempDir); $this->exportLabelActivities($activities, $tempDir); + $this->exportAttendanceActivities($activities, $tempDir); + $this->exportThematicActivities($activities, $tempDir); + $this->exportWikiActivities($activities, $tempDir); + $this->exportGradebookActivities($activities, $tempDir); + $this->exportQuizMetaActivities($activities, $tempDir); + $this->exportLearnpathMeta($tempDir); // Root XMLs (course/activities indexes) $this->exportRootXmlFiles($tempDir); @@ -171,18 +195,25 @@ public function exportQuestionsXml(array $questionsData, string $exportDir): voi $categoryHashes = []; foreach ($questionsData as $quiz) { - $categoryId = $quiz['questions'][0]['questioncategoryid'] ?? '1'; + // Skip empty sets defensively + if (empty($quiz['questions']) || !\is_array($quiz['questions'])) { + continue; + } + + $first = $quiz['questions'][0] ?? []; + $categoryId = $first['questioncategoryid'] ?? '1'; + $hash = md5($categoryId.($quiz['name'] ?? '')); if (isset($categoryHashes[$hash])) { continue; } $categoryHashes[$hash] = true; $xmlContent .= ' '.PHP_EOL; - $xmlContent .= ' Default for '.htmlspecialchars((string) $quiz['name'] ?? 'Unknown').''.PHP_EOL; + $xmlContent .= ' Default for '.htmlspecialchars((string) ($quiz['name'] ?? 'Unknown')).''.PHP_EOL; $xmlContent .= ' '.($quiz['contextid'] ?? '0').''.PHP_EOL; $xmlContent .= ' 70'.PHP_EOL; $xmlContent .= ' '.($quiz['moduleid'] ?? '0').''.PHP_EOL; - $xmlContent .= ' The default category for questions shared in context "'.htmlspecialchars($quiz['name'] ?? 'Unknown').'".'.PHP_EOL; + $xmlContent .= ' The default category for questions shared in context "'.htmlspecialchars((string) ($quiz['name'] ?? 'Unknown')).'".'.PHP_EOL; $xmlContent .= ' 0'.PHP_EOL; $xmlContent .= ' moodle+'.time().'+CATEGORYSTAMP'.PHP_EOL; $xmlContent .= ' 0'.PHP_EOL; @@ -273,6 +304,37 @@ public static function flagActivityUserinfo(string $modname, int $moduleId, bool self::$activityUserinfo[$modname][$moduleId] = $hasUserinfo; } + /** + * Robustly checks if a resource type matches a constant or any string aliases. + * This prevents "undefined constant" notices and supports mixed key styles. + * + * @param mixed $resourceType Numeric constant or string like 'quiz', 'document', etc. + * @param string $constName Constant name, e.g. 'RESOURCE_QUIZ' + * @param array $aliases String aliases accepted for this type + */ + private function isType($resourceType, string $constName, array $aliases = []): bool + { + // Match numeric constant exactly when defined + if (\defined($constName)) { + $constVal = \constant($constName); + if ($resourceType === $constVal) { + return true; + } + } + + // Match by string aliases (case-insensitive) if resourceType is a string + if (\is_string($resourceType)) { + $rt = mb_strtolower($resourceType); + foreach ($aliases as $a) { + if ($rt === mb_strtolower($a)) { + return true; + } + } + } + + return false; + } + /** * Pulls dependent resources that LP items reference (only when LP bag exists). * Defensive: if no learnpath bag is present (e.g., exporting only documents), @@ -347,7 +409,7 @@ private function exportRootXmlFiles(string $exportDir): void $activities = $this->getActivities(); $questionsData = []; foreach ($activities as $activity) { - if ('quiz' === $activity['modulename']) { + if ('quiz' === ($activity['modulename'] ?? '')) { $quizExport = new QuizExport($this->course); $quizData = $quizExport->getData($activity['id'], $activity['sectionid']); $questionsData[] = $quizData; @@ -445,11 +507,11 @@ private function createMoodleBackupXml(string $destinationDir, int $version): vo } } - // Append only "label" activities discovered by getActivities(), with dedupe + // Append label/forum/wiki from getActivities() that are not already listed by sections foreach ($this->getActivities() as $a) { $modname = (string) ($a['modulename'] ?? ''); - if ($modname !== 'label') { - continue; // keep minimal: only labels are missing in backup XML + if (!\in_array($modname, ['label','forum','wiki'], true)) { + continue; } $moduleid = (int) ($a['moduleid'] ?? 0); @@ -467,7 +529,7 @@ private function createMoodleBackupXml(string $destinationDir, int $version): vo $activitiesFlat[] = [ 'moduleid' => $moduleid, 'sectionid' => (int) ($a['sectionid'] ?? 0), - 'modulename'=> 'label', + 'modulename'=> $modname, 'title' => (string) ($a['title'] ?? ''), ]; } @@ -579,6 +641,7 @@ private function getActivities(): array $activities = []; $glossaryAdded = false; + $wikiAdded = false; // Build a "documents" bucket (root-level files/folders) $docBucket = []; @@ -615,37 +678,47 @@ private function getActivities(): array $id = 0; // Quiz - if (RESOURCE_QUIZ === $resourceType && ($resource->obj->iid ?? 0) > 0) { - $exportClass = QuizExport::class; - $moduleName = 'quiz'; - $id = (int) $resource->obj->iid; - $title = (string) $resource->obj->title; + if ($this->isType($resourceType, 'RESOURCE_QUIZ', ['quiz'])) { + if (($resource->obj->iid ?? 0) > 0) { + $exportClass = QuizExport::class; + $moduleName = 'quiz'; + $id = (int) $resource->obj->iid; + $title = (string) $resource->obj->title; + } } // URL - if (RESOURCE_LINK === $resourceType && ($resource->source_id ?? 0) > 0) { - $exportClass = UrlExport::class; - $moduleName = 'url'; - $id = (int) $resource->source_id; - $title = (string) ($resource->title ?? ''); + if ($this->isType($resourceType, 'RESOURCE_LINK', ['link'])) { + if (($resource->source_id ?? 0) > 0) { + $exportClass = UrlExport::class; + $moduleName = 'url'; + $id = (int) $resource->source_id; + $title = (string) ($resource->title ?? ''); + } } // Glossary (only once) - elseif (RESOURCE_GLOSSARY === $resourceType && ($resource->glossary_id ?? 0) > 0 && !$glossaryAdded) { - $exportClass = GlossaryExport::class; - $moduleName = 'glossary'; - $id = 1; - $title = get_lang('Glossary'); - $glossaryAdded = true; + elseif ($this->isType($resourceType, 'RESOURCE_GLOSSARY', ['glossary'])) { + if (($resource->glossary_id ?? 0) > 0 && !$glossaryAdded) { + $exportClass = GlossaryExport::class; + $moduleName = 'glossary'; + $id = 1; + $title = get_lang('Glossary'); + $glossaryAdded = true; + self::flagActivityUserinfo('glossary', $id, true); + } } // Forum - elseif (RESOURCE_FORUM === $resourceType && ($resource->source_id ?? 0) > 0) { - $exportClass = ForumExport::class; - $moduleName = 'forum'; - $id = (int) ($resource->obj->iid ?? 0); - $title = (string) ($resource->obj->forum_title ?? ''); + elseif ($this->isType($resourceType, 'RESOURCE_FORUM', ['forum'])) { + if (($resource->source_id ?? 0) > 0) { + $exportClass = ForumExport::class; + $moduleName = 'forum'; + $id = (int) ($resource->obj->iid ?? 0); + $title = (string) ($resource->obj->forum_title ?? ''); + self::flagActivityUserinfo('forum', $id, true); + } } // Documents (as Page or Resource) - elseif (RESOURCE_DOCUMENT === $resourceType && ($resource->source_id ?? 0) > 0) { + elseif ($this->isType($resourceType, 'RESOURCE_DOCUMENT', ['document'])) { $resPath = (string) ($resource->path ?? ''); $resTitle = (string) ($resource->title ?? ''); $fileType = (string) ($resource->file_type ?? ''); @@ -675,8 +748,8 @@ private function getActivities(): array } } } - // *** Tool Intro -> treat "course_homepage" as a Page activity (id=0) *** - elseif (RESOURCE_TOOL_INTRO === $resourceType) { + // Tool Intro -> treat "course_homepage" as a Page activity (id=0) + elseif ($this->isType($resourceType, 'RESOURCE_TOOL_INTRO', ['tool_intro'])) { // IMPORTANT: do not check source_id; the real key is obj->id $objId = (string) ($resource->obj->id ?? ''); if ($objId === 'course_homepage') { @@ -688,25 +761,121 @@ private function getActivities(): array } } // Assignments - elseif (RESOURCE_WORK === $resourceType && ($resource->source_id ?? 0) > 0) { - $exportClass = AssignExport::class; - $moduleName = 'assign'; - $id = (int) $resource->source_id; - $title = (string) ($resource->params['title'] ?? ''); + elseif ($this->isType($resourceType, 'RESOURCE_WORK', ['work', 'assign'])) { + if (($resource->source_id ?? 0) > 0) { + $exportClass = AssignExport::class; + $moduleName = 'assign'; + $id = (int) $resource->source_id; + $title = (string) ($resource->params['title'] ?? ''); + } } // Surveys -> Feedback - elseif (RESOURCE_SURVEY === $resourceType && ($resource->source_id ?? 0) > 0) { - $exportClass = FeedbackExport::class; - $moduleName = 'feedback'; - $id = (int) $resource->source_id; - $title = (string) ($resource->params['title'] ?? ''); + elseif ($this->isType($resourceType, 'RESOURCE_SURVEY', ['survey', 'feedback'])) { + if (($resource->source_id ?? 0) > 0) { + $exportClass = FeedbackExport::class; + $moduleName = 'feedback'; + $id = (int) $resource->source_id; + $title = (string) ($resource->params['title'] ?? ''); + } + } + // Course descriptions → Label + elseif ($this->isType($resourceType, 'RESOURCE_COURSEDESCRIPTION', ['coursedescription', 'course_description'])) { + if (($resource->source_id ?? 0) > 0) { + $exportClass = LabelExport::class; + $moduleName = 'label'; + $id = (int) $resource->source_id; + $title = (string) ($resource->title ?? ''); + } + } + // Attendance (store as Chamilo-only metadata; NOT a Moodle activity) + elseif ($this->isType($resourceType, 'RESOURCE_ATTENDANCE', ['attendance'])) { + // Resolve legacy id (iid) from possible fields + $id = 0; + if (isset($resource->obj->iid) && \is_numeric($resource->obj->iid)) { + $id = (int) $resource->obj->iid; + } elseif (isset($resource->source_id) && \is_numeric($resource->source_id)) { + $id = (int) $resource->source_id; + } elseif (isset($resource->obj->id) && \is_numeric($resource->obj->id)) { + $id = (int) $resource->obj->id; + } + + // Resolve title or fallback + $title = ''; + foreach (['title','name'] as $k) { + if (!empty($resource->obj->{$k}) && \is_string($resource->obj->{$k})) { $title = trim((string)$resource->obj->{$k}); break; } + if (!empty($resource->{$k}) && \is_string($resource->{$k})) { $title = trim((string)$resource->{$k}); break; } + } + if ($title === '') { $title = 'Attendance'; } + + // Section: best-effort (0 when unknown). We avoid calling any Attendance exporter here. + $sectionId = 0; + + // IMPORTANT: + // We register it with a pseudo module "attendance" for our own export step, + // but we do NOT emit a Moodle activity nor include it in moodle_backup.xml. + $activities[] = [ + 'id' => $id, + 'sectionid' => $sectionId, + 'modulename' => 'attendance', + 'moduleid' => $id, + 'title' => $title, + '__from' => 'attendance', + ]; + + @error_log('[MoodleExport::getActivities] ADD (Chamilo-only) attendance moduleid='.$id.' sectionid='.$sectionId.' title="'.str_replace(["\n","\r"],' ',$title).'"'); + // do NOT set $exportClass → keeps getActivities() side-effect free for Moodle + } + // Thematic (Chamilo-only metadata) + elseif ($this->isType($resourceType, 'RESOURCE_THEMATIC', ['thematic'])) { + $id = (int) ($resource->obj->iid ?? $resource->source_id ?? $resource->obj->id ?? 0); + if ($id > 0) { + $title = ''; + foreach (['title','name'] as $k) { + if (!empty($resource->obj->{$k})) { $title = trim((string) $resource->obj->{$k}); break; } + if (!empty($resource->{$k})) { $title = trim((string) $resource->{$k}); break; } + } + if ($title === '') $title = 'Thematic'; + + $activities[] = [ + 'id' => $id, + 'sectionid' => 0, // or the real topic if you track it + 'modulename' => 'thematic', // Chamilo-only meta + 'moduleid' => $id, + 'title' => $title, + '__from' => 'thematic', + ]; + @error_log('[MoodleExport::getActivities] ADD (Chamilo-only) thematic id='.$id); + } } - // 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 ?? ''); + // Wiki (only once) + elseif ($this->isType($resourceType, 'RESOURCE_WIKI', ['wiki'])) { + if (!$wikiAdded) { + $exportClass = WikiExport::class; + $moduleName = 'wiki'; + $id = self::WIKI_MODULE_ID; + $title = get_lang('Wiki') ?: 'Wiki'; + $wikiAdded = true; + + self::flagActivityUserinfo('wiki', $id, true); + } else { + continue; + } + } + // Gradebook (Chamilo-only; exports chamilo/gradebook/*.json; NOT a Moodle activity) + elseif ($this->isType($resourceType, 'RESOURCE_GRADEBOOK', ['gradebook'])) { + // One snapshot per course/session; treat as a single meta activity. + $id = 1; // local activity id (opaque; not used by Moodle) + $title = 'Gradebook'; + + $activities[] = [ + 'id' => $id, + 'sectionid' => 0, // place in "General" topic (informational only) + 'modulename' => 'gradebook', + 'moduleid' => self::GRADEBOOK_MODULE_ID, + 'title' => $title, + '__from' => 'gradebook', + ]; + @error_log('[MoodleExport::getActivities] ADD (Chamilo-only) gradebook moduleid=' . self::GRADEBOOK_MODULE_ID); } // Emit activity if resolved @@ -726,6 +895,32 @@ private function getActivities(): array } } + // ---- Append synthetic News forum from Chamilo announcements (if any) ---- + try { + $res = \is_array($this->course->resources ?? null) ? $this->course->resources : []; + $annBag = + ($res[\defined('RESOURCE_ANNOUNCEMENT') ? RESOURCE_ANNOUNCEMENT : 'announcements'] ?? null) + ?? ($res['announcements'] ?? null) + ?? ($res['announcement'] ?? null) + ?? []; + + if (!empty($annBag) && !$this->hasAnnouncementsLikeForum($activities)) { + $activities[] = [ + 'id' => 1, // local activity id for our synthetic forum + 'sectionid' => 0, // place in "General" topic + 'modulename' => 'forum', + 'moduleid' => self::ANNOUNCEMENTS_MODULE_ID, + 'title' => get_lang('Announcements'), + '__from' => 'announcements', + ]; + // Forum contains posts, mark userinfo = true + self::flagActivityUserinfo('forum', self::ANNOUNCEMENTS_MODULE_ID, true); + @error_log('[MoodleExport::getActivities] Added synthetic News forum (announcements) moduleid='.self::ANNOUNCEMENTS_MODULE_ID); + } + } catch (\Throwable $e) { + @error_log('[MoodleExport::getActivities][WARN] announcements detection: '.$e->getMessage()); + } + @error_log('[MoodleExport::getActivities] Done. total='.count($activities)); return $activities; } @@ -902,9 +1097,116 @@ private function recursiveDelete(string $dir): void rmdir($dir); } + /** + * Export Gradebook metadata into chamilo/gradebook/*.json (no Moodle module). + * Keeps getActivities() side-effect free and avoids adding to moodle_backup.xml. + */ + private function exportGradebookActivities(array $activities, string $exportDir): void + { + $count = 0; + foreach ($activities as $a) { + if (($a['modulename'] ?? '') !== 'gradebook') { + continue; + } + $activityId = (int) ($a['id'] ?? 0); // local/opaque; not strictly needed + $moduleId = (int) ($a['moduleid'] ?? self::GRADEBOOK_MODULE_ID); + $sectionId = (int) ($a['sectionid'] ?? 0); + + try { + $meta = new GradebookMetaExport($this->course); + $meta->export($activityId, $exportDir, $moduleId, $sectionId); + + // No userinfo here (change if you later add per-user grades) + self::flagActivityUserinfo('gradebook', $moduleId, false); + $count++; + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportGradebookActivities][ERROR] '.$e->getMessage()); + } + } + + @error_log('[MoodleExport::exportGradebookActivities] exported=' . $count); + } + + /** + * Export raw learnpath metadata (categories + each LP with items) as JSON sidecars. + * This does not affect Moodle XML; it complements the backup with Chamilo-native data. + */ + private function exportLearnpathMeta(string $exportDir): void + { + try { + $meta = new LearnpathMetaExport($this->course); + $count = $meta->exportAll($exportDir); + @error_log('[MoodleExport::exportLearnpathMeta] exported learnpaths='.$count); + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportLearnpathMeta][ERROR] '.$e->getMessage()); + } + } + + /** + * Export quiz raw JSON sidecars (quiz.json, questions.json, answers.json) + * for every selected quiz activity. This does not affect Moodle XML export. + */ + private function exportQuizMetaActivities(array $activities, string $exportDir): void + { + $count = 0; + foreach ($activities as $a) { + if (($a['modulename'] ?? '') !== 'quiz') { + continue; + } + $activityId = (int) ($a['id'] ?? 0); + $moduleId = (int) ($a['moduleid'] ?? 0); + $sectionId = (int) ($a['sectionid'] ?? 0); + if ($activityId <= 0 || $moduleId <= 0) { + continue; + } + + try { + $meta = new QuizMetaExport($this->course); + $meta->export($activityId, $exportDir, $moduleId, $sectionId); + $count++; + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportQuizMetaActivities][ERROR] '.$e->getMessage()); + } + } + @error_log('[MoodleExport::exportQuizMetaActivities] exported='.$count); + } + + /** + * Export Attendance metadata into chamilo/attendance/*.json (no Moodle module). + * Keeps getActivities() side-effect free and avoids adding to moodle_backup.xml. + */ + private function exportAttendanceActivities(array $activities, string $exportDir): void + { + $count = 0; + foreach ($activities as $a) { + if (($a['modulename'] ?? '') !== 'attendance') { + continue; + } + $activityId = (int) ($a['id'] ?? 0); + $moduleId = (int) ($a['moduleid'] ?? 0); + $sectionId = (int) ($a['sectionid'] ?? 0); + if ($activityId <= 0 || $moduleId <= 0) { + continue; + } + + try { + $meta = new AttendanceMetaExport($this->course); + $meta->export($activityId, $exportDir, $moduleId, $sectionId); + + // No userinfo here (change to true if you later include per-user marks) + self::flagActivityUserinfo('attendance', $moduleId, false); + $count++; + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportAttendanceActivities][ERROR] '.$e->getMessage()); + } + } + + @error_log('[MoodleExport::exportAttendanceActivities] exported='. $count); + } + /** * Export Label activities into activities/label_{id}/label.xml - * Keeps getActivities() side-effect free. + * Only for real "label" items (course descriptions). */ private function exportLabelActivities(array $activities, string $exportDir): void { @@ -912,15 +1214,13 @@ private function exportLabelActivities(array $activities, string $exportDir): vo if (($a['modulename'] ?? '') !== 'label') { continue; } - try { - $label = new LabelExport($this->course); - $activityId= (int) $a['id']; - $moduleId = (int) $a['moduleid']; - $sectionId = (int) $a['sectionid']; + $activityId = (int) ($a['id'] ?? 0); + $moduleId = (int) ($a['moduleid'] ?? 0); + $sectionId = (int) ($a['sectionid'] ?? 0); - // Correct argument order: (activityId, exportDir, moduleId, sectionId) + try { + $label = new LabelExport($this->course); $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()); @@ -928,6 +1228,98 @@ private function exportLabelActivities(array $activities, string $exportDir): vo } } + private function exportThematicActivities(array $activities, string $exportDir): void + { + $count = 0; + foreach ($activities as $a) { + if (($a['modulename'] ?? '') !== 'thematic') continue; + + $activityId = (int) ($a['id'] ?? 0); + $moduleId = (int) ($a['moduleid'] ?? 0); + $sectionId = (int) ($a['sectionid'] ?? 0); + if ($activityId <= 0 || $moduleId <= 0) continue; + + try { + $meta = new ThematicMetaExport($this->course); + $meta->export($activityId, $exportDir, $moduleId, $sectionId); + + // no userinfo for meta-only artifacts + self::flagActivityUserinfo('thematic', $moduleId, false); + + $count++; + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportThematicActivities][ERROR] '.$e->getMessage()); + } + } + @error_log('[MoodleExport::exportThematicActivities] exported='.$count); + } + + private function exportWikiActivities(array $activities, string $exportDir): void + { + foreach ($activities as $a) { + if (($a['modulename'] ?? '') !== 'wiki') { + continue; + } + $activityId = (int)($a['id'] ?? 0); + $moduleId = (int)($a['moduleid'] ?? 0); + $sectionId = (int)($a['sectionid'] ?? 0); + if ($activityId <= 0 || $moduleId <= 0) { + continue; + } + try { + $exp = new WikiExport($this->course); + $exp->export($activityId, $exportDir, $moduleId, $sectionId); + @error_log('[MoodleExport::exportWikiActivities] exported wiki moduleid='.$moduleId.' sectionid='.$sectionId); + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportWikiActivities][ERROR] '.$e->getMessage()); + } + } + } + + /** + * Export synthetic News forum built from Chamilo announcements. + */ + private function exportAnnouncementsForum(array $activities, string $exportDir): void + { + foreach ($activities as $a) { + if (($a['modulename'] ?? '') !== 'forum') { + continue; + } + if (($a['__from'] ?? '') !== 'announcements') { + continue; // only our synthetic forum + } + + $activityId = (int) ($a['id'] ?? 0); + $moduleId = (int) ($a['moduleid'] ?? 0); + $sectionId = (int) ($a['sectionid'] ?? 0); + + try { + $exp = new AnnouncementsForumExport($this->course); + $exp->export($activityId, $exportDir, $moduleId, $sectionId); + @error_log('[MoodleExport::exportAnnouncementsForum] exported forum moduleid='.$moduleId.' sectionid='.$sectionId); + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportAnnouncementsForum][ERROR] '.$e->getMessage()); + } + } + } + + /** + * Export course-level calendar events to course/calendarevents.xml + * (This is NOT an activity; it belongs to the course folder.) + */ + private function exportCourseCalendar(string $exportDir): void + { + try { + $cal = new CourseCalendarExport($this->course); + $count = $cal->export($exportDir); + + // Root backup settings already include "calendarevents" = 1 in createMoodleBackupXml(). + @error_log('[MoodleExport::exportCourseCalendar] exported events='.$count); + } catch (\Throwable $e) { + @error_log('[MoodleExport::exportCourseCalendar][ERROR] '.$e->getMessage()); + } + } + private function exportBadgesXml(string $exportDir): void { $xmlContent = ''.PHP_EOL; @@ -1128,4 +1520,21 @@ private function exportBackupSettings(array $sections, array $activities): array return $settings; } + + /** Returns true if an existing forum already looks like "Announcements/News". */ + private function hasAnnouncementsLikeForum(array $activities): bool + { + foreach ($activities as $a) { + if (($a['modulename'] ?? '') !== 'forum') { + continue; + } + $t = mb_strtolower((string) ($a['title'] ?? '')); + foreach (['announcements','news'] as $kw) { + if ($t === $kw || str_contains($t, $kw)) { + return true; // looks like an announcements/news forum already + } + } + } + return false; + } } diff --git a/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleImport.php b/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleImport.php index 09ca87ba307..033e1314678 100644 --- a/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleImport.php +++ b/src/CourseBundle/Component/CourseCopy/Moodle/Builder/MoodleImport.php @@ -39,309 +39,740 @@ */ class MoodleImport { - public function __construct( - private bool $debug = false - ) {} + private ?string $ctxArchivePath = null; + private ?EntityManagerInterface $ctxEm = null; + private int $ctxCourseRealId = 0; + private int $ctxSessionId = 0; + private ?int $ctxSameFileNameOption = null; + + public function __construct(private bool $debug = false) {} + + public function attachContext( + string $archivePath, + EntityManagerInterface $em, + int $courseRealId, + int $sessionId = 0, + ?int $sameFileNameOption = null + ): self { + $this->ctxArchivePath = $archivePath; + $this->ctxEm = $em; + $this->ctxCourseRealId = $courseRealId; + $this->ctxSessionId = $sessionId; + $this->ctxSameFileNameOption = $sameFileNameOption; + + return $this; + } /** * Builds a Course ready for CourseRestorer::restore(). */ public function buildLegacyCourseFromMoodleArchive(string $archivePath): object { - // Extract Moodle backup in a temp working directory + $rid = \function_exists('random_bytes') ? substr(bin2hex(random_bytes(3)), 0, 6) : substr(sha1((string) mt_rand()), 0, 6); + if ($this->debug) { error_log("MBZ[$rid] START buildLegacyCourseFromMoodleArchive archivePath={$archivePath}"); } + + // 1) Extract archive to a temp working directory [$workDir] = $this->extractToTemp($archivePath); + if ($this->debug) { error_log("MBZ[$rid] extracted workDir={$workDir}"); } $mbx = $workDir.'/moodle_backup.xml'; if (!is_file($mbx)) { + if ($this->debug) { error_log("MBZ[$rid] ERROR moodle_backup.xml missing at {$mbx}"); } throw new RuntimeException('Not a Moodle backup (moodle_backup.xml missing)'); } - // Optional files.xml (used for documents/resources restore) + // Optional: files.xml for documents/resources $fx = $workDir.'/files.xml'; - $fileIndex = is_file($fx) ? $this->buildFileIndex($fx, $workDir) : ['byId' => [], 'byHash' => []]; + $hasFilesXml = is_file($fx); + $fileIndex = $hasFilesXml ? $this->buildFileIndex($fx, $workDir) : ['byId' => [], 'byHash' => []]; + if ($this->debug) { + $byId = isset($fileIndex['byId']) ? count((array) $fileIndex['byId']) : 0; + $byHash= isset($fileIndex['byHash']) ? count((array) $fileIndex['byHash']) : 0; + error_log("MBZ[$rid] indexes moodle_backup.xml=1 files.xml=".($hasFilesXml?1:0)." fileIndex.byId={$byId} fileIndex.byHash={$byHash}"); + } - // Read backup structure (sections + activities) + // 2) Load main XMLs $mbDoc = $this->loadXml($mbx); $mb = new DOMXPath($mbDoc); + // Detect meta sidecars early to drive import policy + $hasQuizMeta = $this->hasQuizMeta($workDir); + $hasLpMeta = $this->hasLearnpathMeta($workDir); + if ($this->debug) { error_log("MBZ[$rid] meta_flags hasQuizMeta=".($hasQuizMeta?1:0)." hasLpMeta=".($hasLpMeta?1:0)); } + + $skippedQuizXml = 0; // stats + + // Optional course.xml (course meta, summary) + $courseXmlPath = $workDir.'/course/course.xml'; + $courseMeta = $this->readCourseMeta($courseXmlPath); // NEW: safe, tolerant + if ($this->debug) { + $cm = array_intersect_key((array)$courseMeta, array_flip(['fullname','shortname','idnumber','format'])); + error_log("MBZ[$rid] course_meta ".json_encode($cm, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } + + // 3) Read sections & pre-build LP map (one LP per section) $sections = $this->readSections($mb); + if ($this->debug) { error_log("MBZ[$rid] sections.count=".count((array)$sections)); } $lpMap = $this->sectionsToLearnpaths($sections); - // Initialize resource buckets (legacy snapshot shape) + // 4) Init resource buckets (legacy snapshot shape) $resources = [ - 'document' => [], - 'Forum_Category' => [], - 'forum' => [], - 'link' => [], - // 'Link_Category' / 'learnpath' / 'scorm' will be created on demand + 'document' => [], + 'Forum_Category' => [], + 'forum' => [], + 'Link_Category' => [], + 'link' => [], + 'learnpath' => [], + 'learnpath_category' => [], + 'scorm_documents' => [], + 'scorm' => [], + 'announcement' => [], + 'course_descriptions' => [], + 'tool_intro' => [], + 'events' => [], + 'quizzes' => [], + 'quiz_question' => [], + 'surveys' => [], + 'works' => [], + 'glossary' => [], + 'wiki' => [], + 'gradebook' => [], + 'assets' => [], + 'attendance' => [], ]; - // Ensure document folder structure + // 5) Ensure a default Forum Category (fallback) + $defaultForumCatId = 1; + $resources['Forum_Category'][$defaultForumCatId] = $this->mkLegacyItem('Forum_Category', $defaultForumCatId, [ + 'id' => $defaultForumCatId, + 'cat_title' => 'General', + 'cat_comment' => '', + 'title' => 'General', + 'description' => '', + ]); + if ($this->debug) { error_log("MBZ[$rid] forum.default_category id={$defaultForumCatId}"); } + + // 6) Ensure document working dirs $this->ensureDir($workDir.'/document'); $this->ensureDir($workDir.'/document/moodle_pages'); - // Root folder as a legacy "document" entry (folder) - $docFolderId = $this->nextId($resources['document']); - $resources['document'][$docFolderId] = $this->mkLegacyItem( - 'document', - $docFolderId, - [ + // Root folder example (kept for consistency; optional) + if (empty($resources['document'])) { + $docFolderId = $this->nextId($resources['document']); + $resources['document'][$docFolderId] = $this->mkLegacyItem('document', $docFolderId, [ 'file_type' => 'folder', - 'path' => '/document/moodle_pages', - 'title' => 'moodle_pages', - ] - ); + 'path' => '/document/moodle_pages', + 'title' => 'moodle_pages', + ]); + if ($this->debug) { error_log("MBZ[$rid] document.root_folder id={$docFolderId} path=/document/moodle_pages"); } + } - // Default forum category (used as fallback) - $defaultForumCatId = 1; - $resources['Forum_Category'][$defaultForumCatId] = $this->mkLegacyItem( - 'Forum_Category', - $defaultForumCatId, - [ - 'id' => $defaultForumCatId, - 'cat_title' => 'General', - 'cat_comment' => '', - ] - ); + // 7) Iterate activities and fill buckets + $activityNodes = $mb->query('//activity'); + $activityCount = $activityNodes?->length ?? 0; + if ($this->debug) { error_log("MBZ[$rid] activities.count total={$activityCount}"); } + $i = 0; // contador interno (punto en property names no permitido, usar otra var) + $i = 0; - // Iterate Moodle activities - foreach ($mb->query('//activity') as $node) { + foreach ($activityNodes as $node) { /** @var DOMElement $node */ - $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? ''); - $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); - $sectionId = (int) ($node->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0); + $i++; + $modName = (string) ($node->getElementsByTagName('modulename')->item(0)?->nodeValue ?? ''); + $dir = (string) ($node->getElementsByTagName('directory')->item(0)?->nodeValue ?? ''); + $sectionId = (int) ($node->getElementsByTagName('sectionid')->item(0)?->nodeValue ?? 0); + + if ($this->debug) { error_log("MBZ[$rid] activity #{$i} mod={$modName} dir={$dir} section={$sectionId}"); } + + // Locate module xml path $moduleXml = ('' !== $modName && '' !== $dir) ? $workDir.'/'.$dir.'/'.$modName.'.xml' : null; + if (!$moduleXml || !is_file($moduleXml)) { + // Some modules use different file names (resource, folder...) – handled separately + if ($this->debug) { error_log("MBZ[$rid] activity #{$i} skip={$modName} reason=module_xml_not_found"); } + } - if ($this->debug) { - error_log("MOODLE_IMPORT: activity={$modName} dir={$dir} section={$sectionId}"); + // --- Early mapping: Moodle forum(type=news) -> Chamilo announcements (skip normal forum path) + if ($moduleXml && is_file($moduleXml) && strtolower((string)$modName) === 'forum') { + if ($this->hasChamiloAnnouncementMeta($workDir)) { + if ($this->debug) { error_log("MBZ[$rid] forum early-map: announcements meta present -> keep non-news, skip news"); } + } else { + $forumInfo = $this->readForumHeader($moduleXml); + if ($this->isNewsForum($forumInfo)) { + if ($this->debug) { error_log("MBZ[$rid] forum NEWS detected -> mapping to announcements"); } + $anns = $this->readAnnouncementsFromForum($moduleXml, $workDir); + + if (empty($anns)) { + if ($this->debug) { error_log("MBZ[$rid] forum NEWS no-discussions fallback=module intro"); } + $f = $this->readForumModule($moduleXml); + $fallbackTitle = (string)($f['name'] ?? 'announcement'); + $fallbackHtml = (string)($f['description'] ?? ''); + $fallbackTime = (int)($f['timemodified'] ?? $f['timecreated'] ?? time()); + if ($fallbackHtml !== '') { + $anns[] = [ + 'title' => $fallbackTitle, + 'html' => $this->wrapHtmlIfNeeded($fallbackHtml, $fallbackTitle), + 'date' => date('Y-m-d H:i:s', $fallbackTime), + 'attachments' => [], + ]; + } + } + + foreach ($anns as $a) { + $iid = $this->nextId($resources['announcement']); + $payload = [ + 'title' => (string) $a['title'], + 'content' => (string) $this->wrapHtmlIfNeeded($a['html'], (string)$a['title']), + 'date' => (string) $a['date'], + 'display_order' => 0, + 'email_sent' => 0, + 'attachment_path' => (string) ($a['first_path'] ?? ''), + 'attachment_filename' => (string) ($a['first_name'] ?? ''), + 'attachment_size' => (int) ($a['first_size'] ?? 0), + 'attachment_comment' => '', + 'attachments' => (array) ($a['attachments'] ?? []), + ]; + $resources['announcement'][$iid] = $this->mkLegacyItem('announcement', $iid, $payload, ['attachments']); + } + if ($this->debug) { error_log("MBZ[$rid] forum NEWS mapped announcements.count=".count($anns)." -> skip forum case"); } + continue; // Skip normal forum case + } + } } switch ($modName) { - case 'label': - case 'page': - if (!$moduleXml || !is_file($moduleXml)) { + case 'label': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + $data = $this->readHtmlModule($moduleXml, 'label'); + $title = (string) ($data['name'] ?? 'Label'); + $html = (string) $this->wrapHtmlIfNeeded( + $this->rewritePluginfileBasic((string) ($data['content'] ?? ''), 'label'), + $title + ); + $descId = $this->nextId($resources['course_descriptions']); + $resources['course_descriptions'][$descId] = $this->mkLegacyItem('course_descriptions', $descId, [ + 'title' => $title, + 'content' => $html, + 'description_type' => 0, + 'source_id' => $descId, + ]); + if ($this->debug) { error_log("MBZ[$rid] label -> course_descriptions id={$descId} title=".json_encode($title)); } + break; + } + + case 'page': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + $isHomepage = $this->looksLikeCourseHomepage($dir, $moduleXml); + + if ($isHomepage) { + $raw = $this->readPageContent($moduleXml); + $html = (string) $this->wrapHtmlIfNeeded( + $this->rewritePluginfileBasic($raw, 'page'), + get_lang('Introduction') + ); + if (!isset($resources['tool_intro']['course_homepage'])) { + $resources['tool_intro']['course_homepage'] = $this->mkLegacyItem('tool_intro', 0, [ + 'id' => 'course_homepage', + 'intro_text' => $html, + ]); + if ($this->debug) { error_log("MBZ[$rid] page HOMEPAGE -> tool_intro[course_homepage] set"); } + } else { + if ($this->debug) { error_log("MBZ[$rid] page HOMEPAGE -> tool_intro[course_homepage] exists, skip overwrite"); } + } break; } - $data = $this->readHtmlModule($moduleXml, $modName); - // Dump HTML content into /document/moodle_pages + $data = $this->readHtmlModule($moduleXml, $modName); $docId = $this->nextId($resources['document']); - $slug = $data['slug'] ?: ('page_'.$docId); - $rel = 'document/moodle_pages/'.$slug.'.html'; - $abs = $workDir.'/'.$rel; + $slug = $data['slug'] ?: ('page_'.$docId); + $rel = 'document/moodle_pages/'.$slug.'.html'; + $abs = $workDir.'/'.$rel; + $this->ensureDir(\dirname($abs)); $html = $this->wrapHtmlIfNeeded($data['content'] ?? '', $data['name'] ?? ucfirst($modName)); file_put_contents($abs, $html); - // Legacy document entry (file) - $resources['document'][$docId] = $this->mkLegacyItem( - 'document', - $docId, - [ - 'file_type' => 'file', - 'path' => '/'.$rel, - 'title' => (string) ($data['name'] ?? ucfirst($modName)), - 'size' => @filesize($abs) ?: 0, - 'comment' => '', - ] - ); + $resources['document'][$docId] = $this->mkLegacyItem('document', $docId, [ + 'file_type' => 'file', + 'path' => '/'.$rel, + 'title' => (string) ($data['name'] ?? ucfirst($modName)), + 'size' => @filesize($abs) ?: 0, + 'comment' => '', + ]); + if ($this->debug) { error_log("MBZ[$rid] page -> document id={$docId} path=/{$rel} title=".json_encode($resources['document'][$docId]->title)); } - // Add to LP if section map exists if ($sectionId > 0 && isset($lpMap[$sectionId])) { $lpMap[$sectionId]['items'][] = [ 'item_type' => 'document', - 'ref' => $docId, - 'title' => $data['name'] ?? ucfirst($modName), + 'ref' => $docId, + 'title' => $data['name'] ?? ucfirst($modName), ]; + if ($this->debug) { error_log("MBZ[$rid] page -> LP section={$sectionId} add document ref={$docId}"); } } - break; + } - // Forums (+categories from intro hints) - case 'forum': - if (!$moduleXml || !is_file($moduleXml)) { + case 'forum': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + + // If there is Chamilo meta for announcements, prefer it and let non-news forums pass through + if ($this->hasChamiloAnnouncementMeta($workDir)) { + if ($this->debug) { error_log('MOODLE_IMPORT: announcements meta present → keep forum import for non-news'); } + } + + // 1) Read forum type header + $forumInfo = $this->readForumHeader($moduleXml); // ['type' => 'news'|'general'|..., 'name' => ..., ...] + if ($this->debug) { + error_log('MOODLE_IMPORT: forum header peek -> ' . json_encode([ + 'name' => $forumInfo['name'] ?? null, + 'type' => $forumInfo['type'] ?? null, + ])); + } + + // 2) If it's a "news" forum and meta wasn't used → import as announcements (with fallback) + if (!$this->hasChamiloAnnouncementMeta($workDir) && $this->isNewsForum($forumInfo)) { + $anns = $this->readAnnouncementsFromForum($moduleXml, $workDir); + if ($this->debug) { + error_log('MOODLE_IMPORT: news-forum detected, announcements extracted=' . count($anns)); + } + + if (empty($anns)) { + if ($this->debug) { error_log('MOODLE_IMPORT: announcements empty -> intro fallback (in switch)'); } + $f = $this->readForumModule($moduleXml); + $fallbackTitle = (string)($f['name'] ?? 'announcement'); + $fallbackHtml = (string)($f['description'] ?? ''); + $fallbackTime = (int)($f['timemodified'] ?? $f['timecreated'] ?? time()); + if ($fallbackHtml !== '') { + $anns[] = [ + 'title' => $fallbackTitle, + 'html' => $this->wrapHtmlIfNeeded($fallbackHtml, $fallbackTitle), + 'date' => date('Y-m-d H:i:s', $fallbackTime), + 'attachments' => [], + ]; + } + } + + foreach ($anns as $a) { + $iid = $this->nextId($resources['announcement']); + $payload = [ + 'title' => (string) $a['title'], + 'content' => (string) $this->wrapHtmlIfNeeded($a['html'], (string)$a['title']), + 'date' => (string) $a['date'], + 'display_order' => 0, + 'email_sent' => 0, + 'attachment_path' => (string) ($a['first_path'] ?? ''), + 'attachment_filename' => (string) ($a['first_name'] ?? ''), + 'attachment_size' => (int) ($a['first_size'] ?? 0), + 'attachment_comment' => '', + 'attachments' => (array) ($a['attachments'] ?? []), + ]; + $resources['announcement'][$iid] = $this->mkLegacyItem('announcement', $iid, $payload, ['attachments']); + } + + // Do NOT also import as forum break; } - $f = $this->readForumModule($moduleXml); - $resources['forum'] ??= []; - $resources['Forum_Category'] ??= []; + // 3) Normal forum path (general, Q&A, etc.) + $f = $this->readForumModule($moduleXml); - $catId = (int) ($f['category_id'] ?? 0); + $catId = (int) ($f['category_id'] ?? 0); $catTitle = (string) ($f['category_title'] ?? ''); - - // Create Forum_Category if Moodle intro provided hints if ($catId > 0 && !isset($resources['Forum_Category'][$catId])) { - $resources['Forum_Category'][$catId] = $this->mkLegacyItem( - 'Forum_Category', - $catId, - [ - 'id' => $catId, - 'cat_title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), - 'cat_comment' => '', - 'title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), - 'description' => '', - ] - ); + $resources['Forum_Category'][$catId] = $this->mkLegacyItem('Forum_Category', $catId, [ + 'id' => $catId, + 'cat_title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), + 'cat_comment' => '', + 'title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), + 'description' => '', + ]); + if ($this->debug) { error_log("MBZ[$rid] forum -> created Forum_Category id={$catId} title=".json_encode($catTitle)); } } - - // Forum entry pointing to detected category or fallback $dstCatId = $catId > 0 ? $catId : $defaultForumCatId; + $fid = $this->nextId($resources['forum']); - $resources['forum'][$fid] = $this->mkLegacyItem( - 'forum', - $fid, - [ - 'id' => $fid, - 'forum_title' => (string) ($f['name'] ?? 'Forum'), - 'forum_comment' => (string) ($f['description'] ?? ''), - 'forum_category' => $dstCatId, - 'default_view' => 'flat', - ] - ); + $resources['forum'][$fid] = $this->mkLegacyItem('forum', $fid, [ + 'id' => $fid, + 'forum_title' => (string) ($f['name'] ?? 'Forum'), + 'forum_comment' => (string) ($f['description'] ?? ''), + 'forum_category' => $dstCatId, + 'default_view' => 'flat', + ]); + if ($this->debug) { error_log("MBZ[$rid] forum -> forum id={$fid} category={$dstCatId} title=".json_encode($resources['forum'][$fid]->forum_title)); } - // Add to LP if section map exists if ($sectionId > 0 && isset($lpMap[$sectionId])) { $lpMap[$sectionId]['items'][] = [ 'item_type' => 'forum', - 'ref' => $fid, - 'title' => $f['name'] ?? 'Forum', + 'ref' => $fid, + 'title' => $f['name'] ?? 'Forum', ]; + if ($this->debug) { error_log("MBZ[$rid] forum -> LP section={$sectionId} add forum ref={$fid}"); } } - break; + } - // URL => link (+ Link_Category from intro hints) - case 'url': - if (!$moduleXml || !is_file($moduleXml)) { - break; - } + case 'url': { + if (!$moduleXml || !is_file($moduleXml)) { break; } $u = $this->readUrlModule($moduleXml); - $urlVal = trim((string) ($u['url'] ?? '')); - if ('' === $urlVal) { - break; - } - - $resources['link'] ??= []; - $resources['Link_Category'] ??= []; + if ('' === $urlVal) { if ($this->debug) { error_log("MBZ[$rid] url -> empty url, skip"); } break; } - $catId = (int) ($u['category_id'] ?? 0); + $catId = (int) ($u['category_id'] ?? 0); $catTitle = (string) ($u['category_title'] ?? ''); if ($catId > 0 && !isset($resources['Link_Category'][$catId])) { - $resources['Link_Category'][$catId] = $this->mkLegacyItem( - 'Link_Category', - $catId, - [ - 'id' => $catId, - 'title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), - 'description' => '', - 'category_title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), - ] - ); + $resources['Link_Category'][$catId] = $this->mkLegacyItem('Link_Category', $catId, [ + 'id' => $catId, + 'title' => ('' !== $catTitle ? $catTitle : ('Category '.$catId)), + 'description' => '', + ]); + if ($this->debug) { error_log("MBZ[$rid] url -> created Link_Category id={$catId} title=".json_encode($catTitle)); } } - $lid = $this->nextId($resources['link']); + $lid = $this->nextId($resources['link']); $linkTitle = ($u['name'] ?? '') !== '' ? (string) $u['name'] : $urlVal; - $resources['link'][$lid] = $this->mkLegacyItem( - 'link', - $lid, - [ - 'id' => $lid, - 'title' => $linkTitle, - 'description' => '', - 'url' => $urlVal, - 'target' => '', - 'category_id' => $catId, - 'on_homepage' => false, - ] - ); + $resources['link'][$lid] = $this->mkLegacyItem('link', $lid, [ + 'id' => $lid, + 'title' => $linkTitle, + 'description' => '', + 'url' => $urlVal, + 'target' => '', + 'category_id' => $catId, + 'on_homepage' => false, + ]); + if ($this->debug) { error_log("MBZ[$rid] url -> link id={$lid} url=".json_encode($urlVal)); } + if ($sectionId > 0 && isset($lpMap[$sectionId])) { + $lpMap[$sectionId]['items'][] = [ + 'item_type' => 'link', + 'ref' => $lid, + 'title' => $linkTitle, + ]; + if ($this->debug) { error_log("MBZ[$rid] url -> LP section={$sectionId} add link ref={$lid}"); } + } break; + } - // SCORM - case 'scorm': - if (!$moduleXml || !is_file($moduleXml)) { - break; - } - $scorm = $this->readScormModule($moduleXml); - $resources['scorm'] ??= []; - - $sid = $this->nextId($resources['scorm']); - $resources['scorm'][$sid] = $this->mkLegacyItem( - 'scorm', - $sid, - [ - 'id' => $sid, - 'title' => (string) ($scorm['name'] ?? 'SCORM package'), - ] - ); + case 'scorm': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + $sc = $this->readScormModule($moduleXml); + $sid = $this->nextId($resources['scorm_documents']); + + $resources['scorm_documents'][$sid] = $this->mkLegacyItem('scorm_documents', $sid, [ + 'id' => $sid, + 'title' => (string) ($sc['name'] ?? 'SCORM package'), + ]); + $resources['scorm'][$sid] = $resources['scorm_documents'][$sid]; + if ($this->debug) { error_log("MBZ[$rid] scorm -> scorm_documents id={$sid}"); } if ($sectionId > 0 && isset($lpMap[$sectionId])) { $lpMap[$sectionId]['items'][] = [ 'item_type' => 'scorm', - 'ref' => $sid, - 'title' => $scorm['name'] ?? 'SCORM package', + 'ref' => $sid, + 'title' => $sc['name'] ?? 'SCORM package', ]; + if ($this->debug) { error_log("MBZ[$rid] scorm -> LP section={$sectionId} add scorm ref={$sid}"); } + } + break; + } + + case 'quiz': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + if ($hasQuizMeta) { + $peekTitle = $this->peekQuizTitle($moduleXml); + if ($sectionId > 0 && isset($lpMap[$sectionId])) { + $lpMap[$sectionId]['items'][] = [ + 'item_type' => 'quiz', + 'ref' => null, + 'title' => $peekTitle ?? 'Quiz', + ]; + if ($this->debug) { error_log("MBZ[$rid] quiz(meta) -> LP section={$sectionId} add quiz (ref=null) title=".json_encode($peekTitle ?? 'Quiz')); } + } + $skippedQuizXml++; + if ($this->debug) { error_log("MBZ[$rid] quiz(meta) skipping heavy XML (skipped={$skippedQuizXml})"); } + break; } + [$quiz, $questions] = $this->readQuizModule($workDir, $dir, $moduleXml); + if (!empty($quiz)) { + $qid = $this->nextId($resources['quizzes']); + $resources['quizzes'][$qid] = $this->mkLegacyItem('quizzes', $qid, $quiz); + if ($this->debug) { error_log("MBZ[$rid] quiz -> quizzes id={$qid} title=".json_encode($quiz['name'] ?? 'Quiz')); } + if ($sectionId > 0 && isset($lpMap[$sectionId])) { + $lpMap[$sectionId]['items'][] = [ + 'item_type' => 'quiz', + 'ref' => $qid, + 'title' => $quiz['name'] ?? 'Quiz', + ]; + if ($this->debug) { error_log("MBZ[$rid] quiz -> LP section={$sectionId} add quiz ref={$qid}"); } + } + foreach ($questions as $q) { + $qqid = $this->nextId($resources['quiz_question']); + $resources['quiz_question'][$qqid] = $this->mkLegacyItem('quiz_question', $qqid, $q); + } + if ($this->debug) { error_log("MBZ[$rid] quiz -> quiz_question added=".count($questions)); } + } break; + } - default: - if ($this->debug) { - error_log("MOODLE_IMPORT: unhandled module {$modName}"); + case 'survey': + case 'feedback': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + $s = $this->readSurveyModule($moduleXml, $modName); + if (!empty($s)) { + $sid = $this->nextId($resources['surveys']); + $resources['surveys'][$sid] = $this->mkLegacyItem('surveys', $sid, $s); + if ($this->debug) { error_log("MBZ[$rid] {$modName} -> surveys id={$sid}"); } + if ($sectionId > 0 && isset($lpMap[$sectionId])) { + $lpMap[$sectionId]['items'][] = [ + 'item_type' => 'survey', + 'ref' => $sid, + 'title' => $s['name'] ?? ucfirst($modName), + ]; + if ($this->debug) { error_log("MBZ[$rid] {$modName} -> LP section={$sectionId} add survey ref={$sid}"); } + } + } + break; + } + + case 'assign': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + $w = $this->readAssignModule($moduleXml); + if (!empty($w)) { + $wid = $this->nextId($resources['works']); + $resources['works'][$wid] = $this->mkLegacyItem('works', $wid, $w); + if ($this->debug) { error_log("MBZ[$rid] assign -> works id={$wid}"); } + if ($sectionId > 0 && isset($lpMap[$sectionId])) { + $lpMap[$sectionId]['items'][] = [ + 'item_type' => 'works', + 'ref' => $wid, + 'title' => $w['name'] ?? 'Assignment', + ]; + if ($this->debug) { error_log("MBZ[$rid] assign -> LP section={$sectionId} add works ref={$wid}"); } + } } + break; + } + + case 'glossary': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + $g = $this->readGlossaryModule($moduleXml); + $added = 0; + foreach ((array) ($g['entries'] ?? []) as $term) { + $title = (string) ($term['concept'] ?? ''); + if ($title === '') { continue; } + $descHtml = $this->wrapHtmlIfNeeded((string) ($term['definition'] ?? ''), $title); + $gid = $this->nextId($resources['glossary']); + $resources['glossary'][$gid] = $this->mkLegacyItem('glossary', $gid, [ + 'id' => $gid, + 'title' => $title, + 'description' => $descHtml, + 'approved' => (int) ($term['approved'] ?? 1), + 'aliases' => (array) ($term['aliases'] ?? []), + 'userid' => (int) ($term['userid'] ?? 0), + 'timecreated' => (int) ($term['timecreated'] ?? 0), + 'timemodified'=> (int) ($term['timemodified'] ?? 0), + ]); + $added++; + } + if ($this->debug) { error_log("MBZ[$rid] glossary -> entries added={$added}"); } + break; + } + + case 'wiki': { + if (!$moduleXml || !is_file($moduleXml)) { break; } + [$meta, $pages] = $this->readWikiModuleFull($moduleXml); + $added = 0; + if (!empty($pages)) { + foreach ($pages as $p) { + $payload = [ + 'pageId' => (int) $p['id'], + 'reflink' => (string) ($p['reflink'] ?? $this->slugify((string)$p['title'])), + 'title' => (string) $p['title'], + 'content' => (string) $this->wrapHtmlIfNeeded($this->rewritePluginfileBasic((string)($p['content'] ?? ''), 'wiki'), (string)$p['title']), + 'userId' => (int) ($p['userid'] ?? 0), + 'groupId' => 0, + 'dtime' => date('Y-m-d H:i:s', (int) ($p['timemodified'] ?? time())), + 'progress'=> '', + 'version' => (int) ($p['version'] ?? 1), + 'source_id' => (int) $p['id'], + 'source_moduleid' => (int) ($meta['moduleid'] ?? 0), + 'source_sectionid'=> (int) ($meta['sectionid'] ?? 0), + ]; + $wkid = $this->nextId($resources['wiki']); + $resources['wiki'][$wkid] = $this->mkLegacyItem('wiki', $wkid, $payload); + $added++; + } + } + if ($this->debug) { error_log("MBZ[$rid] wiki -> pages added={$added}"); } + break; + } + default: + if ($this->debug) { error_log("MBZ[$rid] unhandled module {$modName}"); } break; } + + if ($this->debug && ($i % 10 === 0)) { + $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); + error_log("MBZ[$rid] progress.counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } } - // Read Documents and Resource files using files.xml + activities/resource + // 8) Documents from resource + folder + inlined pluginfile $this->readDocuments($workDir, $mb, $fileIndex, $resources, $lpMap); + if ($this->debug) { + $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); + error_log("MBZ[$rid] after.readDocuments counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } + + // 8.1) Import quizzes from meta when present (skipping XML ensured above) + if ($hasQuizMeta) { + $this->tryImportQuizMeta($workDir, $resources); + if ($this->debug) { + $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); + error_log("MBZ[$rid] after.quizMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } + } + + // 8.2) Prefer LP meta; otherwise fallback to sections + $lpFromMeta = false; + if ($hasLpMeta) { + $lpFromMeta = $this->tryImportLearnpathMeta($workDir, $resources); + if ($this->debug) { + error_log("MBZ[$rid] lpFromMeta=".($lpFromMeta?1:0)); + $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); + error_log("MBZ[$rid] after.lpMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } + } + + // 8.3) Thematic meta (authoritative) + $this->tryImportThematicMeta($workDir, $resources); + if ($this->debug) { + $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); + error_log("MBZ[$rid] after.thematicMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } + + // 8.4) Attendance meta (authoritative) + $this->tryImportAttendanceMeta($workDir, $resources); + if ($this->debug) { + $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); + error_log("MBZ[$rid] after.attendanceMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } + + // 8.5) Gradebook meta (authoritative) + $this->tryImportGradebookMeta($workDir, $resources); + if ($this->debug) { + $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); + error_log("MBZ[$rid] after.gradebookMeta counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } + + // 9) Build learnpaths from sections (fallback only if no meta) + if (!$lpFromMeta && !empty($lpMap)) { + $this->backfillLpRefsFromResources($lpMap, $resources, [ + \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz', + 'quizzes', + 'document', + 'forum', + 'link', + 'scorm', + ]); - // Build learnpaths (one per section) with linked resources map - if (!empty($lpMap)) { - $resources['learnpath'] ??= []; foreach ($lpMap as $sid => $lp) { + if (empty($resources['learnpath_category'])) { + $catId = $this->nextId($resources['learnpath_category']); + $resources['learnpath_category'][$catId] = $this->mkLegacyItem('learnpath_category', $catId, [ + 'id' => $catId, + 'name' => 'Sections', + 'title' => 'Sections', + ]); + if ($this->debug) { error_log("MBZ[$rid] lp.category created id={$catId}"); } + } + $linked = $this->collectLinkedFromLpItems($lp['items']); + $lid = $this->nextId($resources['learnpath']); - $lid = $this->nextId($resources['learnpath']); $resources['learnpath'][$lid] = $this->mkLegacyItem( 'learnpath', $lid, [ - 'id' => $lid, + 'id' => $lid, 'name' => (string) $lp['title'], + 'lp_type' => 'section', + 'category_id' => array_key_first($resources['learnpath_category']), ], - ['items', 'linked_resources'] + ['items','linked_resources'] ); $resources['learnpath'][$lid]->items = array_map( static fn (array $i) => [ 'item_type' => (string) $i['item_type'], - 'title' => (string) $i['title'], - 'path' => '', - 'ref' => $i['ref'] ?? null, + 'title' => (string) $i['title'], + 'path' => '', + 'ref' => $i['ref'] ?? null, ], $lp['items'] ); $resources['learnpath'][$lid]->linked_resources = $linked; + + if ($this->debug) { error_log("MBZ[$rid] lp.created id={$lid} name=".json_encode($resources['learnpath'][$lid]->name)); } } } - // Compose Course snapshot + // 10) Course descriptions / tool intro from course meta (safe fallbacks) + if (!empty($courseMeta['summary'])) { + $cdId = $this->nextId($resources['course_descriptions']); + $resources['course_descriptions'][$cdId] = $this->mkLegacyItem('course_descriptions', $cdId, [ + 'title' => 'Course summary', + 'description' => (string) $courseMeta['summary'], + 'type' => 'summary', + ]); + $tiId = $this->nextId($resources['tool_intro']); + $resources['tool_intro'][$tiId] = $this->mkLegacyItem('tool_intro', $tiId, [ + 'tool' => 'Course home', + 'title' => 'Introduction', + 'content' => (string) $courseMeta['summary'], + ]); + if ($this->debug) { error_log("MBZ[$rid] course_meta -> added summary to course_descriptions id={$cdId} and tool_intro id={$tiId}"); } + } + + // 11) Events (course-level calendar) — optional + $events = $this->readCourseEvents($workDir); + foreach ($events as $e) { + $eid = $this->nextId($resources['events']); + $resources['events'][$eid] = $this->mkLegacyItem('events', $eid, $e); + } + if ($this->debug) { error_log("MBZ[$rid] events.added count=".count($events)); } + + $resources = $this->canonicalizeResourceBags($resources); + if ($this->debug) { + $counts = array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources); + error_log("MBZ[$rid] final.counts ".json_encode($counts, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + } + + // 12) Compose Course snapshot $course = new Course(); - $course->resources = $resources; + $course->resources = $resources; $course->backup_path = $workDir; - // Meta: keep a stable place (Course::$meta) and optionally mirror into resources['__meta'] - $course->meta = [ + // 13) Meta: "metaexport" + derived moodle meta + $meta = [ 'import_source' => 'moodle', - 'generated_at' => date('c'), + 'generated_at' => date('c'), + 'moodle' => [ + 'fullname' => (string) ($courseMeta['fullname'] ?? ''), + 'shortname' => (string) ($courseMeta['shortname'] ?? ''), + 'idnumber' => (string) ($courseMeta['idnumber'] ?? ''), + 'startdate' => (int) ($courseMeta['startdate'] ?? 0), + 'enddate' => (int) ($courseMeta['enddate'] ?? 0), + 'format' => (string) ($courseMeta['format'] ?? ''), + ], ]; - $course->resources['__meta'] = $course->meta; // if you prefer not to iterate over this, skip it in your loops - // Basic course info (optional) + // Merge metaexport JSON if present (export_meta.json | meta_export.json) + $meta = $this->mergeMetaExportIfPresent($workDir, $meta); // NEW + + $course->meta = $meta; + $course->resources['__meta'] = $meta; + + // 14) Optional course basic info $ci = \function_exists('api_get_course_info') ? (api_get_course_info() ?: []) : []; if (property_exists($course, 'code')) { $course->code = (string) ($ci['code'] ?? ''); @@ -357,18 +788,20 @@ public function buildLegacyCourseFromMoodleArchive(string $archivePath): object if ($this->debug) { error_log('MOODLE_IMPORT: resources='.json_encode( - array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources), - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES - )); + array_map(static fn ($b) => \is_array($b) ? \count($b) : 0, $resources), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + )); error_log('MOODLE_IMPORT: backup_path='.$course->backup_path); if (property_exists($course, 'code') && property_exists($course, 'encoding')) { error_log('MOODLE_IMPORT: course_code='.$course->code.' encoding='.$course->encoding); } + error_log("MBZ[$rid] DONE buildLegacyCourseFromMoodleArchive"); } return $course; } + private function extractToTemp(string $archivePath): array { $base = rtrim(sys_get_temp_dir(), '/').'/moodle_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)); @@ -1963,9 +2396,6 @@ private function normalizeSlash(string $p): string return rtrim($p, '/').'/'; } - /** - * Igual que en CourseBuilder: crea la “caja” legacy (obj, type, source_id, destination_id, etc.). - */ private function mkLegacyItem(string $type, int $sourceId, array|object $obj, array $arrayKeysToPromote = []): stdClass { $o = new stdClass(); @@ -2045,4 +2475,1807 @@ private function normalizePluginfileContent(string $html): string return $html; } + + private function readCourseMeta(string $courseXmlPath): array + { + if (!is_file($courseXmlPath)) { + return []; + } + $doc = $this->loadXml($courseXmlPath); + $xp = new DOMXPath($doc); + + $get = static function (string $q) use ($xp) { + $n = $xp->query($q)->item(0); + + return $n ? (string) $n->nodeValue : ''; + }; + + // Moodle course.xml typical nodes + $fullname = $get('//course/fullname'); + $shortname = $get('//course/shortname'); + $idnumber = $get('//course/idnumber'); + $summary = $get('//course/summary'); + $format = $get('//course/format'); + + $startdate = (int) ($get('//course/startdate') ?: 0); + $enddate = (int) ($get('//course/enddate') ?: 0); + + return [ + 'fullname' => $fullname, + 'shortname' => $shortname, + 'idnumber' => $idnumber, + 'summary' => $summary, + 'format' => $format, + 'startdate' => $startdate, + 'enddate' => $enddate, + ]; + } + + private function mergeMetaExportIfPresent(string $workDir, array $meta): array + { + $candidates = ['meta_export.json', 'export_meta.json', 'meta.json']; + foreach ($candidates as $fn) { + $p = rtrim($workDir,'/').'/'.$fn; + if (is_file($p)) { + $raw = @file_get_contents($p); + if (false !== $raw && '' !== $raw) { + $j = json_decode($raw, true); + if (\is_array($j)) { + // shallow merge under 'metaexport' + $meta['metaexport'] = $j; + } + } + break; + } + } + + return $meta; + } + + private function readQuizModule(string $workDir, string $dir, string $quizXmlPath): array + { + $doc = $this->loadXml($quizXmlPath); + $xp = new DOMXPath($doc); + + $name = (string) ($xp->query('//quiz/name')->item(0)?->nodeValue ?? 'Quiz'); + $intro = (string) ($xp->query('//quiz/intro')->item(0)?->nodeValue ?? ''); + $timeopen = (int) ($xp->query('//quiz/timeopen')->item(0)?->nodeValue ?? 0); + $timeclose = (int) ($xp->query('//quiz/timeclose')->item(0)?->nodeValue ?? 0); + $timelimit = (int) ($xp->query('//quiz/timelimit')->item(0)?->nodeValue ?? 0); + + $quiz = [ + 'name' => $name, + 'description' => $intro, + 'timeopen' => $timeopen, + 'timeclose' => $timeclose, + 'timelimit' => $timelimit, + 'attempts' => (int) ($xp->query('//quiz/attempts')->item(0)?->nodeValue ?? 0), + 'shuffle' => (int) ($xp->query('//quiz/shufflequestions')->item(0)?->nodeValue ?? 0), + ]; + + // Question bank usually sits at $dir/questions.xml (varies by Moodle version) + $qxml = $workDir.'/'.$dir.'/questions.xml'; + $questions = []; + if (is_file($qxml)) { + $qDoc = $this->loadXml($qxml); + $qx = new DOMXPath($qDoc); + + foreach ($qx->query('//question') as $qn) { + /** @var DOMElement $qn */ + $qtype = strtolower((string) $qn->getAttribute('type')); + $qname = (string) ($qn->getElementsByTagName('name')->item(0)?->getElementsByTagName('text')->item(0)?->nodeValue ?? ''); + $qtext = (string) ($qn->getElementsByTagName('questiontext')->item(0)?->getElementsByTagName('text')->item(0)?->nodeValue ?? ''); + + $q = [ + 'type' => $qtype ?: 'description', + 'name' => $qname ?: 'Question', + 'questiontext' => $qtext, + 'answers' => [], + 'defaultgrade' => (float) ($qn->getElementsByTagName('defaultgrade')->item(0)?->nodeValue ?? 1.0), + 'single' => null, + 'correct' => [], + ]; + + if ('multichoice' === $qtype) { + $single = (int) ($qn->getElementsByTagName('single')->item(0)?->nodeValue ?? 1); + $q['single'] = $single; + + foreach ($qn->getElementsByTagName('answer') as $an) { + /** @var DOMElement $an */ + $t = (string) ($an->getElementsByTagName('text')->item(0)?->nodeValue ?? ''); + $f = (float) ($an->getAttribute('fraction') ?: 0); + $q['answers'][] = ['text' => $t, 'fraction' => $f]; + if ($f > 0) { + $q['correct'][] = $t; + } + } + } elseif ('truefalse' === $qtype) { + foreach ($qn->getElementsByTagName('answer') as $an) { + $t = (string) ($an->getElementsByTagName('text')->item(0)?->nodeValue ?? ''); + $f = (float) ($an->getAttribute('fraction') ?: 0); + $q['answers'][] = ['text' => $t, 'fraction' => $f]; + if ($f > 0) { + $q['correct'][] = $t; + } + } + } // else: keep minimal info + + $questions[] = $q; + } + } + + return [$quiz, $questions]; + } + + private function readAssignModule(string $xmlPath): array + { + $doc = $this->loadXml($xmlPath); + $xp = new DOMXPath($doc); + + $name = (string) ($xp->query('//assign/name')->item(0)?->nodeValue ?? 'Assignment'); + $intro = (string) ($xp->query('//assign/intro')->item(0)?->nodeValue ?? ''); + $duedate = (int) ($xp->query('//assign/duedate')->item(0)?->nodeValue ?? 0); + $allowsub = (int) ($xp->query('//assign/teamsubmission')->item(0)?->nodeValue ?? 0); + + return [ + 'name' => $name, + 'description' => $intro, + 'deadline' => $duedate, + 'group' => $allowsub, + ]; + } + + private function readSurveyModule(string $xmlPath, string $type): array + { + $doc = $this->loadXml($xmlPath); + $xp = new DOMXPath($doc); + + $name = (string) ($xp->query("//{$type}/name")->item(0)?->nodeValue ?? ucfirst($type)); + $intro = (string) ($xp->query("//{$type}/intro")->item(0)?->nodeValue ?? ''); + + return [ + 'name' => $name, + 'subtitle' => '', + 'intro' => $intro, + 'thanks' => '', + 'survey_type' => $type, + ]; + } + + private function readGlossaryModule(string $xmlPath): array + { + $doc = $this->loadXml($xmlPath); + $xp = new DOMXPath($doc); + + $name = (string) ($xp->query('//glossary/name')->item(0)?->nodeValue ?? 'Glossary'); + $intro = (string) ($xp->query('//glossary/intro')->item(0)?->nodeValue ?? ''); + + $entries = []; + foreach ($xp->query('//glossary/entries/entry') as $eNode) { + /** @var DOMElement $eNode */ + $entryId = (int) $eNode->getAttribute('id'); + $concept = trim((string) ($xp->evaluate('string(concept)', $eNode) ?? '')); + $definition = (string) ($xp->evaluate('string(definition)', $eNode) ?? ''); + $approved = (int) $xp->evaluate('number(approved)', $eNode); + $userId = (int) $xp->evaluate('number(userid)', $eNode); + $created = (int) $xp->evaluate('number(timecreated)', $eNode); + $modified = (int) $xp->evaluate('number(timemodified)', $eNode); + + // Collect aliases + $aliases = []; + foreach ($xp->query('aliases/alias/alias_text', $eNode) as $aNode) { + $aliases[] = (string) $aNode->nodeValue; + } + + $entries[] = [ + 'id' => $entryId, + 'concept' => $concept, + 'definition' => $definition, // keep HTML; resolver for @@PLUGINFILE@@ can run later + 'approved' => $approved ?: 1, + 'userid' => $userId, + 'timecreated' => $created, + 'timemodified'=> $modified, + 'aliases' => $aliases, + ]; + } + + return [ + 'name' => $name, + 'description' => $intro, + 'entries' => $entries, + ]; + } + + /** + * Read course-level events from /course/calendar.xml (as written by CourseExport). + * Returns legacy-shaped payloads for the 'events' bag. + * + * @return array> + */ + private function readCourseEvents(string $workDir): array + { + $path = rtrim($workDir, '/').'/course/calendar.xml'; + if (!is_file($path)) { + // No calendar file -> no events + return []; + } + + // Load XML safely + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->preserveWhiteSpace = false; + $doc->formatOutput = false; + + $prev = libxml_use_internal_errors(true); + $ok = @$doc->load($path); + libxml_clear_errors(); + libxml_use_internal_errors($prev); + + if (!$ok) { + // Corrupted calendar.xml -> ignore + return []; + } + + $xp = new \DOMXPath($doc); + $evNodes = $xp->query('/calendar/event'); + if (!$evNodes || $evNodes->length === 0) { + return []; + } + + $out = []; + /** @var \DOMElement $ev */ + foreach ($evNodes as $ev) { + $get = static function (\DOMElement $ctx, string $tag): ?string { + $n = $ctx->getElementsByTagName($tag)->item(0); + return $n ? (string) $n->nodeValue : null; // preserves CDATA inner content + }; + + // Fields per CourseExport::createCalendarXml() + $name = trim((string) ($get($ev, 'name') ?? '')); + $desc = (string) ($get($ev, 'description') ?? ''); + $timestartV = (string) ($get($ev, 'timestart') ?? ''); + $durationV = (string) ($get($ev, 'duration') ?? ''); + $alldayV = (string) ($get($ev, 'allday') ?? ''); + $visibleV = (string) ($get($ev, 'visible') ?? '1'); + $eventtype = (string) ($get($ev, 'eventtype') ?? 'course'); + $uuid = (string) ($get($ev, 'uuid') ?? ''); + + // Tolerant parsing: accept numeric or date-string (older/other writers) + $toTs = static function (string $v, int $fallback = 0): int { + if ($v === '') { return $fallback; } + if (is_numeric($v)) { return (int) $v; } + $t = @\strtotime($v); + return $t !== false ? (int) $t : $fallback; + }; + + $timestart = $toTs($timestartV, time()); + $duration = max(0, (int) $durationV); + $allday = (int) $alldayV ? 1 : 0; + $visible = (int) $visibleV ? 1 : 0; + + // Legacy-friendly payload (used by mkLegacyItem('events', ...)) + $payload = [ + 'name' => $name !== '' ? $name : 'Event', + 'description' => $desc, // HTML allowed (comes from CDATA) + 'timestart' => $timestart, // Unix timestamp + 'duration' => $duration, // seconds + 'allday' => $allday, // 0/1 + 'visible' => $visible, // 0/1 + 'eventtype' => $eventtype, // 'course' by default + 'uuid' => $uuid, // $@NULL@$ or value + ]; + + $out[] = $payload; + } + + return $out; + } + + /** + * Restore selected buckets using the generic CourseRestorer to persist them. + * This lets us reuse the legacy snapshot you already build. + */ + private function restoreWithRestorer( + string $archivePath, + EntityManagerInterface $em, + int $courseRealId, + int $sessionId, + array $allowedBuckets, // ej: ['quizzes','quiz_question'] + ?object $courseArg = null + ): array { + $legacy = $courseArg ?: $this->buildLegacyCourseFromMoodleArchive($archivePath); + $legacy->resources = isset($legacy->resources) && \is_array($legacy->resources) + ? $this->canonicalizeResourceBags($legacy->resources) + : []; + + $expanded = $this->expandBucketAliases($allowedBuckets); + $legacy->resources = array_intersect_key( + (array) $legacy->resources, + array_flip($expanded) + ); + + $total = 0; + foreach ($legacy->resources as $v) { + $total += \is_array($v) ? \count($v) : 0; + } + if ($total === 0) { + return ['imported' => 0, 'notes' => ['No resources to restore for '.implode(',', $allowedBuckets)]]; + } + + $restorerClass = '\\Chamilo\\CourseBundle\\Component\\CourseCopy\\CourseRestorer'; + if (!\class_exists($restorerClass)) { + return ['imported' => 0, 'notes' => ['CourseRestorer not available']]; + } + $restorer = new $restorerClass($em, $courseRealId, $sessionId); + + if (property_exists($restorer, 'course')) { + $restorer->course = $legacy; + } elseif (\method_exists($restorer, 'setCourse')) { + $restorer->setCourse($legacy); + } + + $destCode = ''; + $courseEntity = \function_exists('api_get_course_entity') ? api_get_course_entity($courseRealId) : null; + if ($courseEntity && \method_exists($courseEntity, 'getCode')) { + $destCode = (string) $courseEntity->getCode(); + } else { + $ci = \function_exists('api_get_course_info_by_id') ? api_get_course_info_by_id($courseRealId) : null; + if (\is_array($ci) && !empty($ci['code'])) { + $destCode = (string) $ci['code']; + } + } + + if (\method_exists($restorer, 'restore')) { + $restorer->restore($destCode, $sessionId, false, false); + } else { + return ['imported' => 0, 'notes' => ['No supported restore() method in CourseRestorer']]; + } + + return ['imported' => $total, 'notes' => ['Restored: '.implode(',', $expanded)]]; + } + + private function expandBucketAliases(array $buckets): array + { + $map = [ + 'quizzes' => ['quizzes', 'quiz', \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : 'quiz'], + 'quiz' => ['quiz', 'quizzes', \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : 'quiz'], + 'quiz_question' => ['quiz_question', 'Exercise_Question', \defined('RESOURCE_QUIZQUESTION') ? (string) RESOURCE_QUIZQUESTION : 'quiz_question'], + 'scorm' => ['scorm', 'scorm_documents'], + 'scorm_documents' => ['scorm_documents', 'scorm'], + 'document' => ['document', 'Document'], + 'forum' => ['forum'], + 'Forum_Category' => ['Forum_Category'], + 'link' => ['link'], + 'Link_Category' => ['Link_Category'], + 'learnpath' => ['learnpath'], + 'learnpath_category' => ['learnpath_category'], + 'thematic' => ['thematic', \defined('RESOURCE_THEMATIC') ? (string) RESOURCE_THEMATIC : 'thematic'], + 'attendance' => ['attendance', \defined('RESOURCE_ATTENDANCE') ? (string) RESOURCE_ATTENDANCE : 'attendance'], + 'gradebook' => ['gradebook', 'Gradebook', \defined('RESOURCE_GRADEBOOK') ? (string) RESOURCE_GRADEBOOK : 'gradebook'], + 'announcement' => array_values(array_unique(array_filter([ + 'announcement', + 'news', + \defined('RESOURCE_ANNOUNCEMENT') ? (string) RESOURCE_ANNOUNCEMENT : null, + ]))), + 'news' => ['news', 'announcement'], + ]; + + $out = []; + foreach ($buckets as $b) { + $b = (string) $b; + $out = array_merge($out, $map[$b] ?? [$b]); + } + + return array_values(array_unique($out)); + } + + /** + * Convenience: restore a set of specific buckets using the generic CourseRestorer. + * Keeps all logic inside MoodleImport (no new classes). + * + * @param string[] $buckets + * @return array{imported:int,notes:array} + */ + public function restoreSelectedBuckets( + string $archivePath, + EntityManagerInterface $em, + int $courseRealId, + int $sessionId = 0, + array $buckets = [], + ?object $courseArg = null + ): array { + if (empty($buckets)) { + return ['imported' => 0, 'notes' => ['No buckets requested']]; + } + + // Delegate to the generic restorer while filtering the snapshot to the requested buckets. + return $this->restoreWithRestorer( + $archivePath, + $em, + $courseRealId, + $sessionId, + $buckets, + $courseArg + ); + } + + /** Quizzes (+ question bank minimal snapshot) */ + public function restoreQuizzes( + string $archivePath, + EntityManagerInterface $em, + int $courseRealId, + int $sessionId = 0, + ?object $courseArg = null + ): array { + return $this->restoreSelectedBuckets( + $archivePath, $em, $courseRealId, $sessionId, + ['quizzes', 'quiz_question'], + $courseArg + ); + } + + /** SCORM packages */ + public function restoreScorm( + string $archivePath, + EntityManagerInterface $em, + int $courseRealId, + int $sessionId = 0, + ?object $courseArg = null + ): array { + // Keep both keys for maximum compatibility with CourseRestorer implementations + return $this->restoreSelectedBuckets( + $archivePath, $em, $courseRealId, $sessionId, + ['scorm_documents', 'scorm'], + $courseArg + ); + } + + /** + * Preferred Learnpath importer using Chamilo sidecar JSON under chamilo/learnpath. + * Returns true if LPs (and categories) were imported from meta. + */ + private function tryImportLearnpathMeta(string $workDir, array &$resources): bool + { + $base = rtrim($workDir, '/').'/chamilo/learnpath'; + $indexFile = $base.'/index.json'; + if (!is_file($indexFile)) { + return false; // No meta present -> fallback to sections + } + + $index = $this->readJsonFile($indexFile); + $cats = $this->readJsonFile($base.'/categories.json'); + + // 1) Ensure learnpath_category from meta (idempotent) + $existingCatIds = array_map('intval', array_keys((array) ($resources['learnpath_category'] ?? []))); + foreach ((array) ($cats['categories'] ?? []) as $c) { + $cid = (int) ($c['id'] ?? 0); + $title = (string) ($c['title'] ?? ''); + if ($cid <= 0) { continue; } + if (!\in_array($cid, $existingCatIds, true)) { + // Preserve category id from meta to simplify mapping + $resources['learnpath_category'][$cid] = $this->mkLegacyItem('learnpath_category', $cid, [ + 'id' => $cid, + 'name' => $title, + 'title' => $title, + ]); + $existingCatIds[] = $cid; + } + } + + // 2) Build search indexes to resolve item "ref" into our freshly-built resource IDs + $idx = $this->buildResourceIndexes($resources); + + // 3) Import learnpaths + $imported = 0; + foreach ((array) ($index['learnpaths'] ?? []) as $row) { + $dir = (string) ($row['dir'] ?? ''); + if ($dir === '') { continue; } + + $lpJson = $this->readJsonFile($base.'/'.$dir.'/learnpath.json'); + $itemsJson= $this->readJsonFile($base.'/'.$dir.'/items.json'); + + $lpRaw = (array) ($lpJson['learnpath'] ?? []); + // Defensive normalization + $lpTitle = (string) ($lpRaw['title'] ?? $lpRaw['name'] ?? ($row['title'] ?? 'Lesson')); + $lpType = (int) ($lpRaw['lp_type'] ?? $row['lp_type'] ?? 1); + $catId = (int) ($lpRaw['category_id'] ?? $row['category_id'] ?? 0); + + // Allocate a fresh ID (avoid collisions) but keep source id in meta + $lid = $this->nextId($resources['learnpath']); + $payload = [ + 'id' => $lid, + 'lp_type' => $lpType, // 1=LP, 2=SCORM, 3=AICC + 'title' => $lpTitle, + 'path' => (string) ($lpRaw['path'] ?? ''), + 'ref' => (string) ($lpRaw['ref'] ?? ''), + 'description' => (string) ($lpRaw['description'] ?? ''), + 'content_local' => (string) ($lpRaw['content_local'] ?? ''), + 'default_encoding' => (string) ($lpRaw['default_encoding'] ?? ''), + 'default_view_mod' => (string) ($lpRaw['default_view_mod'] ?? ''), + 'prevent_reinit' => (bool) ($lpRaw['prevent_reinit'] ?? false), + 'force_commit' => (bool) ($lpRaw['force_commit'] ?? false), + 'content_maker' => (string) ($lpRaw['content_maker'] ?? ''), + 'display_order' => (int) ($lpRaw['display_order'] ?? 0), + 'js_lib' => (string) ($lpRaw['js_lib'] ?? ''), + 'content_license' => (string) ($lpRaw['content_license'] ?? ''), + 'debug' => (bool) ($lpRaw['debug'] ?? false), + 'visibility' => (string) ($lpRaw['visibility'] ?? '1'), + 'author' => (string) ($lpRaw['author'] ?? ''), + 'use_max_score' => (int) ($lpRaw['use_max_score'] ?? 0), + 'autolaunch' => (int) ($lpRaw['autolaunch'] ?? 0), + 'created_on' => (string) ($lpRaw['created_on'] ?? ''), + 'modified_on' => (string) ($lpRaw['modified_on'] ?? ''), + 'published_on' => (string) ($lpRaw['published_on'] ?? ''), + 'expired_on' => (string) ($lpRaw['expired_on'] ?? ''), + 'session_id' => 0, + 'category_id' => $catId > 0 ? $catId : (array_key_first($resources['learnpath_category']) ?? 0), + '_src' => [ + 'lp_id' => (int) ($lpRaw['id'] ?? ($row['id'] ?? 0)), + ], + ]; + + // Create wrapper with extended props (items, linked_resources) + $resources['learnpath'][$lid] = $this->mkLegacyItem('learnpath', $lid, $payload, ['items','linked_resources']); + + // Items: stable-order by display_order if present + $rawItems = (array) ($itemsJson['items'] ?? []); + usort($rawItems, static fn(array $a, array $b) => + (int)($a['display_order'] ?? 0) <=> (int)($b['display_order'] ?? 0)); + + $items = []; + foreach ($rawItems as $it) { + $mappedRef = $this->mapLpItemRef($it, $idx, $resources); + $items[] = [ + 'id' => (int) ($it['id'] ?? 0), + 'item_type' => (string)($it['item_type'] ?? ''), + 'ref' => $mappedRef, + 'title' => (string)($it['title'] ?? ''), + 'name' => (string)($it['name'] ?? $lpTitle), + 'description' => (string)($it['description'] ?? ''), + 'path' => (string)($it['path'] ?? ''), + 'min_score' => (float) ($it['min_score'] ?? 0), + 'max_score' => isset($it['max_score']) ? (float) $it['max_score'] : null, + 'mastery_score' => isset($it['mastery_score']) ? (float) $it['mastery_score'] : null, + 'parent_item_id' => (int) ($it['parent_item_id'] ?? 0), + 'previous_item_id'=> isset($it['previous_item_id']) ? (int) $it['previous_item_id'] : null, + 'next_item_id' => isset($it['next_item_id']) ? (int) $it['next_item_id'] : null, + 'display_order' => (int) ($it['display_order'] ?? 0), + 'prerequisite' => (string)($it['prerequisite'] ?? ''), + 'parameters' => (string)($it['parameters'] ?? ''), + 'launch_data' => (string)($it['launch_data'] ?? ''), + 'audio' => (string)($it['audio'] ?? ''), + '_src' => [ + 'ref' => $it['ref'] ?? null, + 'path' => $it['path'] ?? null, + ], + ]; + } + + $resources['learnpath'][$lid]->items = $items; + $resources['learnpath'][$lid]->linked_resources = $this->collectLinkedFromLpItems($items); + + $imported++; + } + + if ($this->debug) { + @error_log("MOODLE_IMPORT: LPs from meta imported={$imported}"); + } + return $imported > 0; + } + + /** Read JSON file safely; return [] on error. */ + private function readJsonFile(string $file): array + { + $raw = @file_get_contents($file); + if ($raw === false) { return []; } + $data = json_decode($raw, true); + return \is_array($data) ? $data : []; + } + + /** + * Build look-up indexes over the freshly collected resources to resolve LP item refs. + * We index by multiple keys to increase match odds (path, title, url, etc.) + */ + private function buildResourceIndexes(array $resources): array + { + $idx = [ + 'documentByPath' => [], + 'documentByTitle' => [], + 'linkByUrl' => [], + 'forumByTitle' => [], + 'quizByTitle' => [], + 'workByTitle' => [], + 'scormByTitle' => [], + ]; + + foreach ((array) ($resources['document'] ?? []) as $id => $doc) { + $arr = \is_object($doc) ? get_object_vars($doc) : (array) $doc; + $p = (string) ($arr['path'] ?? ''); + $t = (string) ($arr['title'] ?? ''); + if ($p !== '') { $idx['documentByPath'][$p] = (int) $id; } + if ($t !== '') { $idx['documentByTitle'][mb_strtolower($t)][] = (int) $id; } + } + foreach ((array) ($resources['link'] ?? []) as $id => $lnk) { + $arr = \is_object($lnk) ? get_object_vars($lnk) : (array) $lnk; + $u = (string) ($arr['url'] ?? ''); + if ($u !== '') { $idx['linkByUrl'][$u] = (int) $id; } + } + foreach ((array) ($resources['forum'] ?? []) as $id => $f) { + $arr = \is_object($f) ? get_object_vars($f) : (array) $f; + $t = (string) ($arr['forum_title'] ?? $arr['title'] ?? ''); + if ($t !== '') { $idx['forumByTitle'][mb_strtolower($t)][] = (int) $id; } + } + foreach ((array) ($resources['quizzes'] ?? []) as $id => $q) { + $arr = \is_object($q) ? get_object_vars($q) : (array) $q; + $t = (string) ($arr['name'] ?? $arr['title'] ?? ''); + if ($t !== '') { $idx['quizByTitle'][mb_strtolower($t)][] = (int) $id; } + } + foreach ((array) ($resources['works'] ?? []) as $id => $w) { + $arr = \is_object($w) ? get_object_vars($w) : (array) $w; + $t = (string) ($arr['name'] ?? $arr['title'] ?? ''); + if ($t !== '') { $idx['workByTitle'][mb_strtolower($t)][] = (int) $id; } + } + foreach ((array) ($resources['scorm'] ?? $resources['scorm_documents'] ?? []) as $id => $s) { + $arr = \is_object($s) ? get_object_vars($s) : (array) $s; + $t = (string) ($arr['title'] ?? $arr['name'] ?? ''); + if ($t !== '') { $idx['scormByTitle'][mb_strtolower($t)][] = (int) $id; } + } + + return $idx; + } + + /** + * Resolve LP item "ref" from meta (which refers to the source system) into a local resource id. + * Strategy: + * 1) For documents: match by path (strong), or by title (weak). + * 2) For links: match by url. + * 3) For forum/quizzes/works/scorm: match by title. + * If not resolvable, return null and keep original _src in item for later diagnostics. + */ + private function mapLpItemRef(array $item, array $idx, array $resources): ?int + { + $type = (string) ($item['item_type'] ?? ''); + $srcRef = $item['ref'] ?? null; + $path = (string) ($item['path'] ?? ''); + $title = mb_strtolower((string) ($item['title'] ?? '')); + + switch ($type) { + case 'document': + if ($path !== '' && isset($idx['documentByPath'][$path])) { + return $idx['documentByPath'][$path]; + } + if ($title !== '' && !empty($idx['documentByTitle'][$title])) { + // If multiple, pick the first; could be improved with size/hash if available + return $idx['documentByTitle'][$title][0]; + } + return null; + + case 'link': + if (isset($idx['linkByUrl'][$srcRef])) { + return $idx['linkByUrl'][$srcRef]; + } + // Sometimes meta keeps URL in "path" + if ($path !== '' && isset($idx['linkByUrl'][$path])) { + return $idx['linkByUrl'][$path]; + } + return null; + + case 'forum': + if ($title !== '' && !empty($idx['forumByTitle'][$title])) { + return $idx['forumByTitle'][$title][0]; + } + return null; + + case 'quiz': + case 'quizzes': + if ($title !== '' && !empty($idx['quizByTitle'][$title])) { + return $idx['quizByTitle'][$title][0]; + } + return null; + + case 'works': + if ($title !== '' && !empty($idx['workByTitle'][$title])) { + return $idx['workByTitle'][$title][0]; + } + return null; + + case 'scorm': + if ($title !== '' && !empty($idx['scormByTitle'][$title])) { + return $idx['scormByTitle'][$title][0]; + } + return null; + + default: + return null; + } + } + + /** + " Import quizzes from QuizMetaExport sidecars under chamilo/quiz/quiz_. + * Builds 'quiz' and 'quiz_question' (and their constant-key aliases if defined). + * Returns true if at least one quiz was imported. + */ + private function tryImportQuizMeta(string $workDir, array &$resources): bool + { + $base = rtrim($workDir, '/').'/chamilo/quiz'; + if (!is_dir($base)) { + return false; + } + + // Resolve resource keys (support both constant and string bags) + $quizKey = \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz'; + // Chamilo snapshot also uses sometimes 'quizzes' — we fill both for compatibility + $quizCompatKey = 'quizzes'; + + $qqKey = \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : 'Exercise_Question'; + $qqCompatKey = 'quiz_question'; + + $imported = 0; + + // Iterate all quiz_* folders + $dh = @opendir($base); + if (!$dh) { + return false; + } + + while (false !== ($entry = readdir($dh))) { + if ($entry === '.' || $entry === '..') { + continue; + } + $dir = $base.'/'.$entry; + if (!is_dir($dir) || strpos($entry, 'quiz_') !== 0) { + continue; + } + + $quizJsonFile = $dir.'/quiz.json'; + $questionsFile = $dir.'/questions.json'; + $answersFile = $dir.'/answers.json'; // optional (flat; we prefer nested) + + $quizWrap = $this->readJsonFile($quizJsonFile); + $qList = $this->readJsonFile($questionsFile); + if (empty($quizWrap) || empty($qList)) { + // Nothing to import for this folder + if ($this->debug) { + @error_log("MOODLE_IMPORT: Quiz meta missing or incomplete in {$entry}"); + } + continue; + } + + $quizArr = (array) ($quizWrap['quiz'] ?? []); + $questions = (array) ($qList['questions'] ?? []); + + // ---- Resolve or allocate quiz local id + $title = (string) ($quizArr['title'] ?? $quizArr['name'] ?? 'Quiz'); + $qidLocal = $this->findExistingQuizIdByTitle($resources, $title, [$quizKey, $quizCompatKey]); + if (!$qidLocal) { + $qidLocal = $this->nextId($resources[$quizKey] ?? []); + } + + // Ensure bags exist + if (!isset($resources[$quizKey])) { $resources[$quizKey] = []; } + if (!isset($resources[$quizCompatKey])){ $resources[$quizCompatKey] = []; } + if (!isset($resources[$qqKey])) { $resources[$qqKey] = []; } + if (!isset($resources[$qqCompatKey])) { $resources[$qqCompatKey] = []; } + + // ---- Build local question id map (src → local) + $srcToLocalQ = []; + // If meta provides question_ids, we keep order from 'question_orders' when present. + $srcIds = array_map('intval', (array) ($quizArr['question_ids'] ?? [])); + $srcOrder = array_map('intval', (array) ($quizArr['question_orders'] ?? [])); + + // First pass: assign local ids to all questions we are about to import + foreach ($questions as $qArr) { + // Prefer explicit id added by exporter; otherwise try _links.quiz_id or fallback 0 + $srcQid = (int) ($qArr['id'] ?? 0); + if ($srcQid <= 0) { + // Try to infer from position in the list (not ideal, but keeps import going) + $srcQid = $this->nextId($srcToLocalQ); // synthetic progressive id + } + $srcToLocalQ[$srcQid] = $this->nextId($resources[$qqKey]); + } + + // ---- Rebuild quiz payload (builder-compatible) + $payload = [ + 'title' => (string) ($quizArr['title'] ?? ''), + 'description' => (string) ($quizArr['description'] ?? ''), + 'type' => (int) ($quizArr['type'] ?? 0), + 'random' => (int) ($quizArr['random'] ?? 0), + 'random_answers' => (bool) ($quizArr['random_answers'] ?? false), + 'results_disabled' => (int) ($quizArr['results_disabled'] ?? 0), + 'max_attempt' => (int) ($quizArr['max_attempt'] ?? 0), + 'feedback_type' => (int) ($quizArr['feedback_type'] ?? 0), + 'expired_time' => (int) ($quizArr['expired_time'] ?? 0), + 'review_answers' => (int) ($quizArr['review_answers'] ?? 0), + 'random_by_category' => (int) ($quizArr['random_by_category'] ?? 0), + 'text_when_finished' => (string) ($quizArr['text_when_finished'] ?? ''), + 'text_when_finished_failure' => (string) ($quizArr['text_when_finished_failure'] ?? ''), + 'display_category_name' => (int) ($quizArr['display_category_name'] ?? 0), + 'save_correct_answers' => (int) ($quizArr['save_correct_answers'] ?? 0), + 'propagate_neg' => (int) ($quizArr['propagate_neg'] ?? 0), + 'hide_question_title' => (bool) ($quizArr['hide_question_title'] ?? false), + 'hide_question_number' => (int) ($quizArr['hide_question_number'] ?? 0), + 'question_selection_type'=> (int) ($quizArr['question_selection_type'] ?? 0), + 'access_condition' => (string) ($quizArr['access_condition'] ?? ''), + 'pass_percentage' => $quizArr['pass_percentage'] ?? null, + 'start_time' => (string) ($quizArr['start_time'] ?? ''), + 'end_time' => (string) ($quizArr['end_time'] ?? ''), + // We will remap IDs to locals below: + 'question_ids' => [], + 'question_orders' => [], + ]; + + // Fill question_ids and orders using the local id map + $localIds = []; + $localOrder = []; + + // If we received aligned srcIds/srcOrder, keep that order; otherwise, use question '_order' + if (!empty($srcIds) && !empty($srcOrder) && \count($srcIds) === \count($srcOrder)) { + foreach ($srcIds as $i => $srcQid) { + if (isset($srcToLocalQ[$srcQid])) { + $localIds[] = $srcToLocalQ[$srcQid]; + $localOrder[] = (int) $srcOrder[$i]; + } + } + } else { + // Build order from questions array (_order) if present; otherwise keep list order + usort($questions, static fn(array $a, array $b) => + (int)($a['_order'] ?? 0) <=> (int)($b['_order'] ?? 0)); + foreach ($questions as $qArr) { + $srcQid = (int) ($qArr['id'] ?? 0); + if (isset($srcToLocalQ[$srcQid])) { + $localIds[] = $srcToLocalQ[$srcQid]; + $localOrder[] = (int) ($qArr['_order'] ?? 0); + } + } + } + + $payload['question_ids'] = $localIds; + $payload['question_orders'] = $localOrder; + + // Store quiz in both bags (constant/string and compat) + $resources[$quizKey][$qidLocal] = + $this->mkLegacyItem($quizKey, $qidLocal, $payload, ['question_ids', 'question_orders']); + $resources[$quizCompatKey][$qidLocal] = $resources[$quizKey][$qidLocal]; + + // ---- Import questions (with nested answers), mapping to local ids + foreach ($questions as $qArr) { + $srcQid = (int) ($qArr['id'] ?? 0); + $qid = $srcToLocalQ[$srcQid] ?? $this->nextId($resources[$qqKey]); + + $qPayload = [ + 'question' => (string) ($qArr['question'] ?? ''), + 'description' => (string) ($qArr['description'] ?? ''), + 'ponderation' => (float) ($qArr['ponderation'] ?? 0), + 'position' => (int) ($qArr['position'] ?? 0), + 'type' => (int) ($qArr['type'] ?? ($qArr['quiz_type'] ?? 0)), + 'quiz_type' => (int) ($qArr['quiz_type'] ?? ($qArr['type'] ?? 0)), + 'picture' => (string) ($qArr['picture'] ?? ''), + 'level' => (int) ($qArr['level'] ?? 0), + 'extra' => (string) ($qArr['extra'] ?? ''), + 'feedback' => (string) ($qArr['feedback'] ?? ''), + 'question_code' => (string) ($qArr['question_code'] ?? ''), + 'mandatory' => (int) ($qArr['mandatory'] ?? 0), + 'duration' => $qArr['duration'] ?? null, + 'parent_media_id' => $qArr['parent_media_id'] ?? null, + 'answers' => [], + ]; + + // Answers: prefer nested in questions.json; fallback to answers.json (flat) + $ansList = []; + if (isset($qArr['answers']) && \is_array($qArr['answers'])) { + $ansList = $qArr['answers']; + } else { + // Try to reconstruct from flat answers.json + $ansFlat = $this->readJsonFile($answersFile); + foreach ((array) ($ansFlat['answers'] ?? []) as $row) { + if ((int) ($row['question_id'] ?? -1) === $srcQid && isset($row['data'])) { + $ansList[] = $row['data']; + } + } + } + + $pos = 1; + foreach ($ansList as $a) { + $qPayload['answers'][] = [ + 'id' => (int) ($a['id'] ?? $this->nextId($qPayload['answers'])), + 'answer' => (string) ($a['answer'] ?? ''), + 'comment' => (string) ($a['comment'] ?? ''), + 'ponderation' => (float) ($a['ponderation'] ?? 0), + 'position' => (int) ($a['position'] ?? $pos), + 'hotspot_coordinates' => $a['hotspot_coordinates'] ?? null, + 'hotspot_type' => $a['hotspot_type'] ?? null, + 'correct' => $a['correct'] ?? null, + ]; + $pos++; + } + + // Optional: MATF options (as in builder) + if (isset($qArr['question_options']) && \is_array($qArr['question_options'])) { + $qPayload['question_options'] = array_map(static fn ($o) => [ + 'id' => (int) ($o['id'] ?? 0), + 'name' => (string) ($o['name'] ?? ''), + 'position' => (int) ($o['position'] ?? 0), + ], $qArr['question_options']); + } + + $resources[$qqKey][$qid] = + $this->mkLegacyItem($qqKey, $qid, $qPayload, ['answers', 'question_options']); + $resources[$qqCompatKey][$qid] = $resources[$qqKey][$qid]; + } + + $imported++; + } + + closedir($dh); + + if ($this->debug) { + @error_log("MOODLE_IMPORT: Quizzes from meta imported={$imported}"); + } + return $imported > 0; + } + + /** Find an existing quiz by title (case-insensitive) in any of the provided bags. */ + private function findExistingQuizIdByTitle(array $resources, string $title, array $bags): ?int + { + $needle = mb_strtolower(trim($title)); + foreach ($bags as $bag) { + foreach ((array) ($resources[$bag] ?? []) as $id => $q) { + $arr = \is_object($q) ? get_object_vars($q) : (array) $q; + $t = (string) ($arr['title'] ?? $arr['name'] ?? ''); + if ($needle !== '' && mb_strtolower($t) === $needle) { + return (int) $id; + } + } + } + return null; + } + + /** Quick probe: do we have at least one chamilo/quiz/quiz_quiz.json + questions.json ? */ + private function hasQuizMeta(string $workDir): bool + { + $base = rtrim($workDir, '/').'/chamilo/quiz'; + if (!is_dir($base)) return false; + if (!$dh = @opendir($base)) return false; + while (false !== ($e = readdir($dh))) { + if ($e === '.' || $e === '..') continue; + $dir = $base.'/'.$e; + if (is_dir($dir) && str_starts_with($e, 'quiz_') + && is_file($dir.'/quiz.json') && is_file($dir.'/questions.json')) { + closedir($dh); + return true; + } + } + closedir($dh); + return false; + } + + /** Quick probe: typical LearnpathMetaExport artifacts */ + private function hasLearnpathMeta(string $workDir): bool + { + $base = rtrim($workDir, '/').'/chamilo/learnpath'; + return is_dir($base) && (is_file($base.'/index.json') || is_file($base.'/categories.json')); + } + + /** Cheap reader: obtain from module xml without building resources. */ + private function peekQuizTitle(string $moduleXml): ?string + { + try { + $doc = $this->loadXml($moduleXml); + $xp = new DOMXPath($doc); + $name = $xp->query('//quiz/name')->item(0)?->nodeValue ?? null; + return $name ? (string) $name : null; + } catch (\Throwable) { + return null; + } + } + + /** + * For LP fallback items with missing 'ref', try to resolve by title against resources bags. + * Matching is case-insensitive and checks both 'title' and 'name' fields in resources. + */ + private function backfillLpRefsFromResources(array &$lpMap, array $resources, array $bags): void + { + // Build lookup: item_type => [lower(title) => id] + $lookups = []; + + foreach ($bags as $bag) { + foreach ((array) ($resources[$bag] ?? []) as $id => $wrap) { + $obj = \is_object($wrap) ? $wrap : (object) $wrap; + // candidate fields + $title = ''; + if (isset($obj->title) && \is_string($obj->title)) { + $title = $obj->title; + } elseif (isset($obj->name) && \is_string($obj->name)) { + $title = $obj->name; + } elseif (isset($obj->obj) && \is_object($obj->obj)) { + $title = (string) ($obj->obj->title ?? $obj->obj->name ?? ''); + } + if ($title === '') continue; + + $key = mb_strtolower($title); + $typeKey = $this->normalizeItemTypeKey($bag); // e.g. 'quiz' for ['quiz','quizzes'] + if (!isset($lookups[$typeKey])) $lookups[$typeKey] = []; + $lookups[$typeKey][$key] = (int) $id; + } + } + + // Walk all LP items and fill 'ref' when empty + foreach ($lpMap as &$lp) { + foreach ($lp['items'] as &$it) { + if (!empty($it['ref'])) continue; + $type = $this->normalizeItemTypeKey((string) ($it['item_type'] ?? '')); + $t = mb_strtolower((string) ($it['title'] ?? '')); + if ($t !== '' && isset($lookups[$type][$t])) { + $it['ref'] = $lookups[$type][$t]; + } + } + unset($it); + } + unset($lp); + } + + /** Normalize various bag/item_type labels into a stable key used in lookup. */ + private function normalizeItemTypeKey(string $s): string + { + $s = strtolower($s); + return match ($s) { + 'quizzes', 'quiz', \defined('RESOURCE_QUIZ') ? strtolower((string) RESOURCE_QUIZ) : 'quiz' => 'quiz', + 'document', 'documents' => 'document', + 'forum', 'forums' => 'forum', + 'link', 'links' => 'link', + 'scorm', 'scorm_documents' => 'scorm', + 'coursedescription' => 'course_description', + 'course_descriptions' => 'course_description', + default => $s, + }; + } + + /** Merge bag aliases into canonical keys to avoid duplicate groups. */ + private function canonicalizeResourceBags(array $res): array + { + // Canonical keys (fall back to strings if constants not defined) + $QUIZ_KEY = \defined('RESOURCE_QUIZ') ? RESOURCE_QUIZ : 'quiz'; + $QQ_KEY = \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : 'quiz_question'; + + // ---- Quizzes ---- + $mergedQuiz = []; + foreach (['quizzes', 'quiz', 'Exercise', $QUIZ_KEY] as $k) { + if (!empty($res[$k]) && \is_array($res[$k])) { + foreach ($res[$k] as $id => $item) { + $mergedQuiz[(int)$id] = $item; + } + } + unset($res[$k]); + } + $res[$QUIZ_KEY] = $mergedQuiz; + + // ---- Quiz Questions ---- + $mergedQQ = []; + foreach (['quiz_question', 'Exercise_Question', $QQ_KEY] as $k) { + if (!empty($res[$k]) && \is_array($res[$k])) { + foreach ($res[$k] as $id => $item) { + $mergedQQ[(int)$id] = $item; + } + } + unset($res[$k]); + } + $res[$QQ_KEY] = $mergedQQ; + + $THEM_KEY = \defined('RESOURCE_THEMATIC') ? RESOURCE_THEMATIC : 'thematic'; + $mergedThematic = []; + foreach (['thematic', $THEM_KEY] as $k) { + if (!empty($res[$k]) && \is_array($res[$k])) { + foreach ($res[$k] as $id => $item) { + $mergedThematic[(int)$id] = $item; + } + } + unset($res[$k]); + } + if (!empty($mergedThematic)) { + $res[$THEM_KEY] = $mergedThematic; + } + + $ATT_KEY = \defined('RESOURCE_ATTENDANCE') ? RESOURCE_ATTENDANCE : 'attendance'; + $merged = []; + foreach (['attendance', $ATT_KEY] as $k) { + if (!empty($res[$k]) && \is_array($res[$k])) { + foreach ($res[$k] as $id => $it) { $merged[(int)$id] = $it; } + } + unset($res[$k]); + } + if ($merged) { $res[$ATT_KEY] = $merged; } + + $GB_KEY = \defined('RESOURCE_GRADEBOOK') ? RESOURCE_GRADEBOOK : 'gradebook'; + $merged = []; + foreach (['gradebook', 'Gradebook', $GB_KEY] as $k) { + if (!empty($res[$k]) && \is_array($res[$k])) { + foreach ($res[$k] as $id => $it) { $merged[(int)$id] = $it; } + } + unset($res[$k]); + } + if ($merged) { $res[$GB_KEY] = $merged; } + + return $res; + } + + /** + * Import Thematic sidecars written by ThematicMetaExport. + * Authoritative: if present, replaces any pre-filled bag. + * + * @return bool true if at least one thematic was imported + */ + private function tryImportThematicMeta(string $workDir, array &$resources): bool + { + $THEM_KEY = \defined('RESOURCE_THEMATIC') ? RESOURCE_THEMATIC : 'thematic'; + + $base = rtrim($workDir, '/').'/chamilo/thematic'; + if (!is_dir($base)) { + return false; + } + + // 1) Discover files (prefer manifest, fallback to glob) + $files = []; + $manifest = @json_decode((string)@file_get_contents(rtrim($workDir, '/').'/chamilo/manifest.json'), true); + if (\is_array($manifest['items'] ?? null)) { + foreach ($manifest['items'] as $it) { + if (($it['kind'] ?? '') === 'thematic' && !empty($it['path'])) { + $path = rtrim($workDir, '/').'/'.ltrim((string)$it['path'], '/'); + if (is_file($path)) { + $files[] = $path; + } + } + } + } + if (empty($files)) { + foreach ((array)@glob($base.'/thematic_*.json') as $f) { + if (is_file($f)) { + $files[] = $f; + } + } + } + if (empty($files)) { + return false; + } + + // Authoritative: reset bag to avoid duplicates + $resources[$THEM_KEY] = []; + + $imported = 0; + foreach ($files as $f) { + $payload = @json_decode((string)@file_get_contents($f), true); + if (!\is_array($payload)) { + continue; + } + + // Exporter shape: { "type":"thematic", ..., "title","content","active","advances":[...], "plans":[...] } + $title = (string)($payload['title'] ?? 'Thematic'); + $content = (string)($payload['content'] ?? ''); + $active = (int) ($payload['active'] ?? 1); + + // Prefer explicit id inside nested shapes if present + $iid = (int)($payload['id'] ?? 0); + if ($iid <= 0) { + // Derive from filename thematic_{moduleId}.json or moduleid + $iid = (int)($payload['moduleid'] ?? 0); + if ($iid <= 0 && preg_match('/thematic_(\d+)\.json$/', (string)$f, $m)) { + $iid = (int)$m[1]; + } + if ($iid <= 0) { + $iid = $this->nextId($resources[$THEM_KEY] ?? []); + } + } + + // Normalize lists + $advances = []; + foreach ((array)($payload['advances'] ?? []) as $a) { + $a = (array)$a; + $advances[] = [ + 'id' => (int) ($a['id'] ?? ($a['iid'] ?? 0)), + 'thematic_id' => (int) ($a['thematic_id'] ?? $iid), + 'content' => (string)($a['content'] ?? ''), + 'start_date' => (string)($a['start_date'] ?? ''), + 'duration' => (int) ($a['duration'] ?? 0), + 'done_advance' => (bool) ($a['done_advance'] ?? false), + 'attendance_id' => (int) ($a['attendance_id'] ?? 0), + 'room_id' => (int) ($a['room_id'] ?? 0), + ]; + } + + $plans = []; + foreach ((array)($payload['plans'] ?? []) as $p) { + $p = (array)$p; + $plans[] = [ + 'id' => (int) ($p['id'] ?? ($p['iid'] ?? 0)), + 'thematic_id' => (int) ($p['thematic_id'] ?? $iid), + 'title' => (string)($p['title'] ?? ''), + 'description' => (string)($p['description'] ?? ''), + 'description_type' => (int) ($p['description_type'] ?? 0), + ]; + } + + // mkLegacyItem wrapper with explicit list fields + $item = $this->mkLegacyItem($THEM_KEY, $iid, [ + 'id' => $iid, + 'title' => $title, + 'content' => $content, + 'active' => $active, + ], ['thematic_advance_list','thematic_plan_list']); + + // Attach lists on the wrapper (builder-friendly) + $item->thematic_advance_list = $advances; + $item->thematic_plan_list = $plans; + + $resources[$THEM_KEY][$iid] = $item; + $imported++; + } + + if ($this->debug) { + @error_log('MOODLE_IMPORT: Thematic meta imported='.$imported); + } + + return $imported > 0; + } + + /** + * Import Attendance sidecars written by AttendanceMetaExport. + * Authoritative: if present, replaces any pre-filled bag to avoid duplicates. + * + * @return bool true if at least one attendance was imported + */ + private function tryImportAttendanceMeta(string $workDir, array &$resources): bool + { + $ATT_KEY = \defined('RESOURCE_ATTENDANCE') ? RESOURCE_ATTENDANCE : 'attendance'; + $base = rtrim($workDir, '/').'/chamilo/attendance'; + if (!is_dir($base)) { + return false; + } + + // 1) Discover files via manifest (preferred), fallback to glob + $files = []; + $manifestFile = rtrim($workDir, '/').'/chamilo/manifest.json'; + $manifest = @json_decode((string)@file_get_contents($manifestFile), true); + if (\is_array($manifest['items'] ?? null)) { + foreach ($manifest['items'] as $it) { + if (($it['kind'] ?? '') === 'attendance' && !empty($it['path'])) { + $path = rtrim($workDir, '/').'/'.ltrim((string)$it['path'], '/'); + if (is_file($path)) { + $files[] = $path; + } + } + } + } + if (empty($files)) { + foreach ((array)@glob($base.'/attendance_*.json') as $f) { + if (is_file($f)) { + $files[] = $f; + } + } + } + if (empty($files)) { + return false; + } + + // Authoritative: clear bag to avoid duplicates + $resources[$ATT_KEY] = []; + + $imported = 0; + foreach ($files as $f) { + $payload = @json_decode((string)@file_get_contents($f), true); + if (!\is_array($payload)) { + continue; + } + + // ---- Map top-level fields (robust against naming variants) + $iid = (int)($payload['id'] ?? 0); + if ($iid <= 0) { + $iid = (int)($payload['moduleid'] ?? 0); + } + if ($iid <= 0 && preg_match('/attendance_(\d+)\.json$/', (string)$f, $m)) { + $iid = (int)$m[1]; + } + if ($iid <= 0) { + $iid = $this->nextId($resources[$ATT_KEY] ?? []); + } + + $title = (string)($payload['title'] ?? $payload['name'] ?? 'Attendance'); + $desc = (string)($payload['description'] ?? $payload['intro'] ?? ''); + $active= (int) ($payload['active'] ?? 1); + $locked= (int) ($payload['locked'] ?? 0); + + // Qualify block may be nested or flattened + $qual = \is_array($payload['qualify'] ?? null) ? $payload['qualify'] : []; + $qualTitle = (string)($qual['title'] ?? $payload['attendance_qualify_title'] ?? ''); + $qualMax = (int) ($qual['max'] ?? $payload['attendance_qualify_max'] ?? 0); + $weight = (float) ($qual['weight']?? $payload['attendance_weight'] ?? 0.0); + + // ---- Normalize calendars + $calIn = (array)($payload['calendars'] ?? []); + $cals = []; + foreach ($calIn as $c) { + if (!\is_array($c) && !\is_object($c)) { + continue; + } + $c = (array)$c; + $cid = (int)($c['id'] ?? $c['iid'] ?? 0); + $aid = (int)($c['attendance_id'] ?? $iid); + $dt = (string)($c['date_time'] ?? $c['datetime'] ?? ''); + $done = (bool) ($c['done_attendance'] ?? false); + $block = (bool) ($c['blocked'] ?? false); + $dur = $c['duration'] ?? null; + $dur = (null !== $dur) ? (int)$dur : null; + + $cals[] = [ + 'id' => $cid > 0 ? $cid : $this->nextId($cals), + 'attendance_id' => $aid, + 'date_time' => $dt, + 'done_attendance' => $done, + 'blocked' => $block, + 'duration' => $dur, + ]; + } + + // ---- Wrap as legacy item compatible with builder + $item = $this->mkLegacyItem( + $ATT_KEY, + $iid, + [ + 'id' => $iid, + 'title' => $title, + 'name' => $title, // keep alias + 'description' => $desc, + 'active' => $active, + 'attendance_qualify_title' => $qualTitle, + 'attendance_qualify_max' => $qualMax, + 'attendance_weight' => $weight, + 'locked' => $locked, + ], + ['attendance_calendar'] // list fields that will be appended below + ); + + // Attach calendars collection on the wrapper + $item->attendance_calendar = $cals; + + $resources[$ATT_KEY][$iid] = $item; + $imported++; + } + + if ($this->debug) { + @error_log('MOODLE_IMPORT: Attendance meta imported='.$imported); + } + + return $imported > 0; + } + + /** + * Import Gradebook sidecars written by GradebookMetaExport. + * Authoritative: if present, replaces any pre-filled bag to avoid duplicates. + * + * @return bool true if at least one gradebook was imported + */ + private function tryImportGradebookMeta(string $workDir, array &$resources): bool + { + $GB_KEY = \defined('RESOURCE_GRADEBOOK') ? RESOURCE_GRADEBOOK : 'gradebook'; + $base = rtrim($workDir, '/').'/chamilo/gradebook'; + if (!is_dir($base)) { + return false; + } + + // 1) Discover files via manifest (preferred), fallback to glob + $files = []; + $manifestFile = rtrim($workDir, '/').'/chamilo/manifest.json'; + $manifest = @json_decode((string)@file_get_contents($manifestFile), true); + if (\is_array($manifest['items'] ?? null)) { + foreach ($manifest['items'] as $it) { + if (($it['kind'] ?? '') === 'gradebook' && !empty($it['path'])) { + $path = rtrim($workDir, '/').'/'.ltrim((string)$it['path'], '/'); + if (is_file($path)) { + $files[] = $path; + } + } + } + } + if (empty($files)) { + foreach ((array)@glob($base.'/gradebook_*.json') as $f) { + if (is_file($f)) { + $files[] = $f; + } + } + } + if (empty($files)) { + return false; + } + + // Authoritative: clear bag to avoid duplicates + $resources[$GB_KEY] = []; + + $imported = 0; + foreach ($files as $f) { + $payload = @json_decode((string)@file_get_contents($f), true); + if (!\is_array($payload)) { + continue; + } + + // Categories are already serialized by the builder; pass them through. + $categories = \is_array($payload['categories'] ?? null) ? $payload['categories'] : []; + + // Determine a stable id (not really used by Moodle, but kept for parity) + $iid = (int)($payload['id'] ?? 0); + if ($iid <= 0) { $iid = (int)($payload['moduleid'] ?? 0); } + if ($iid <= 0 && preg_match('/gradebook_(\d+)\.json$/', (string)$f, $m)) { + $iid = (int)$m[1]; + } + if ($iid <= 0) { $iid = 1; } + + // Build a minimal legacy-like object compatible with GradebookMetaExport::findGradebookBackup() + $gb = (object)[ + // matches what the exporter looks for + 'categories' => $categories, + + // helpful hints + 'id' => $iid, + 'source_id' => $iid, + 'title' => (string)($payload['title'] ?? 'Gradebook'), + ]; + + // Store in canonical bag + $resources[$GB_KEY][$iid] = $gb; + $imported++; + } + + if ($this->debug) { + @error_log('MOODLE_IMPORT: Gradebook meta imported='.$imported); + } + + return $imported > 0; + } + + /** + * Read activities/wiki_{moduleId}/wiki.xml and return: + * - meta: module-level info (name, moduleid, sectionid) + * - pages: array of pages with id,title,content,contentformat,version,userid,timecreated,timemodified + */ + private function readWikiModuleFull(string $xmlPath): array + { + $doc = $this->loadXml($xmlPath); + $xp = new DOMXPath($doc); + + // Module meta + $activity = $xp->query('/activity')->item(0); + $moduleId = (int) ($activity?->getAttribute('moduleid') ?? 0); + + $nameNode = $xp->query('//wiki/name')->item(0); + $name = (string) ($nameNode?->nodeValue ?? 'Wiki'); + + // Some exports put sectionid on ; default 0 + $sectionId = (int) ($xp->query('/activity')->item(0)?->getAttribute('contextid') ?? 0); + + $pages = []; + foreach ($xp->query('//wiki/subwikis/subwiki/pages/page') as $node) { + /** @var DOMElement $node */ + $pid = (int) ($node->getAttribute('id') ?: 0); + $title = (string) ($node->getElementsByTagName('title')->item(0)?->nodeValue ?? ('Wiki page '.$pid)); + $uid = (int) ($node->getElementsByTagName('userid')->item(0)?->nodeValue ?? 0); + + $timeCreated = (int) ($node->getElementsByTagName('timecreated')->item(0)?->nodeValue ?? time()); + $timeModified = (int) ($node->getElementsByTagName('timemodified')->item(0)?->nodeValue ?? $timeCreated); + + // Prefer cachedcontent; fallback to the last //content + $cached = $node->getElementsByTagName('cachedcontent')->item(0)?->nodeValue ?? ''; + $content = (string) $cached; + $version = 1; + + $versionsEl = $node->getElementsByTagName('versions')->item(0); + if ($versionsEl instanceof DOMElement) { + $versNodes = $versionsEl->getElementsByTagName('version'); + if ($versNodes->length > 0) { + $last = $versNodes->item($versNodes->length - 1); + $vHtml = $last?->getElementsByTagName('content')->item(0)?->nodeValue ?? ''; + $vNum = (int) ($last?->getElementsByTagName('version')->item(0)?->nodeValue ?? 1); + if (trim((string)$vHtml) !== '') { + $content = (string) $vHtml; + } + if ($vNum > 0) { + $version = $vNum; + } + } + } + + $pages[] = [ + 'id' => $pid, + 'title' => $title, + 'content' => $content, + 'contentformat' => 'html', + 'version' => $version, + 'timecreated' => $timeCreated, + 'timemodified' => $timeModified, + 'userid' => $uid, + 'reflink' => $this->slugify($title), + ]; + } + + // Stable order + usort($pages, fn(array $a, array $b) => $a['id'] <=> $b['id']); + + return [ + [ + 'moduleid' => $moduleId, + 'sectionid' => $sectionId, + 'name' => $name, + ], + $pages, + ]; + } + + private function rewritePluginfileBasic(string $html, string $context): string + { + if ($html === '' || !str_contains($html, '@@PLUGINFILE@@')) { + return $html; + } + + // src/href/poster/data + $html = (string)preg_replace( + '~\b(src|href|poster|data)\s*=\s*([\'"])@@PLUGINFILE@@/([^\'"]+)\2~i', + '$1=$2/document/moodle_pages/$3$2', + $html + ); + + // url(...) in inline styles + $html = (string)preg_replace( + '~url\((["\']?)@@PLUGINFILE@@/([^)\'"]+)\1\)~i', + 'url($1/document/moodle_pages/$2$1)', + $html + ); + + return $html; + } + + /** Check if chamilo manifest has any 'announcement' kind to avoid duplicates */ + private function hasChamiloAnnouncementMeta(string $exportRoot): bool + { + $mf = rtrim($exportRoot, '/').'/chamilo/manifest.json'; + if (!is_file($mf)) { return false; } + $data = json_decode((string)file_get_contents($mf), true); + if (!is_array($data) || empty($data['items'])) { return false; } + foreach ((array)$data['items'] as $it) { + $k = strtolower((string)($it['kind'] ?? '')); + if ($k === 'announcement' || $k === 'announcement') { + return true; + } + } + return false; + } + + /** Read minimal forum header (type, name) */ + private function readForumHeader(string $moduleXml): array + { + $doc = $this->loadXml($moduleXml); + $xp = new \DOMXPath($doc); + $type = (string) ($xp->query('//forum/type')->item(0)?->nodeValue ?? ''); + $name = (string) ($xp->query('//forum/name')->item(0)?->nodeValue ?? ''); + return ['type' => $type, 'name' => $name]; + } + + /** + * Parse forum.xml (news) → array of announcements: + * [ + * [ + * 'title' => string, + * 'html' => string, + * 'date' => 'Y-m-d H:i:s', + * 'attachments' => [ {path, filename, size, comment, asset_relpath}... ], + * 'first_path' => string, + * 'first_name' => string, + * 'first_size' => int, + * ], ... + * ] + */ + private function readAnnouncementsFromForum(string $moduleXml, string $exportRoot): array + { + $doc = $this->loadXml($moduleXml); + $xp = new \DOMXPath($doc); + + $anns = []; + // One discussion = one announcement; firstpost = main message + foreach ($xp->query('//forum/discussions/discussion') as $d) { + /** @var \DOMElement $d */ + $title = (string) ($d->getElementsByTagName('name')->item(0)?->nodeValue ?? 'Announcement'); + $firstPostId = (int) ($d->getElementsByTagName('firstpost')->item(0)?->nodeValue ?? 0); + $created = (int) ($d->getElementsByTagName('timemodified')->item(0)?->nodeValue ?? time()); + + // find post by id + $postNode = null; + foreach ($d->getElementsByTagName('post') as $p) { + /** @var \DOMElement $p */ + $pid = (int) $p->getAttribute('id'); + if ($pid === $firstPostId || ($firstPostId === 0 && !$postNode)) { + $postNode = $p; + if ($pid === $firstPostId) { break; } + } + } + if (!$postNode) { continue; } + + $subject = (string) ($postNode->getElementsByTagName('subject')->item(0)?->nodeValue ?? $title); + $message = (string) ($postNode->getElementsByTagName('message')->item(0)?->nodeValue ?? ''); + $createdPost = (int) ($postNode->getElementsByTagName('created')->item(0)?->nodeValue ?? $created); + + // Normalize HTML and rewrite @@PLUGINFILE@@ + $html = $this->rewritePluginfileForAnnouncements($message, $exportRoot, (int)$postNode->getAttribute('id')); + + // Attachments from files.xml (component=mod_forum, filearea=post, itemid=postId) + $postId = (int) $postNode->getAttribute('id'); + $attachments = $this->extractForumPostAttachments($exportRoot, $postId); + + // First attachment info (builder-style) + $first = $attachments[0] ?? null; + $anns[] = [ + 'title' => $subject !== '' ? $subject : $title, + 'html' => $html, + 'date' => date('Y-m-d H:i:s', $createdPost ?: $created), + 'attachments' => $attachments, + 'first_path' => (string) ($first['path'] ?? ''), + 'first_name' => (string) ($first['filename'] ?? ''), + 'first_size' => (int) ($first['size'] ?? 0), + ]; + } + + return $anns; + } + + /** + * Rewrite @@PLUGINFILE@@ URLs in forum messages to point to /document/announcements/{postId}/. + * The physical copy is handled by extractForumPostAttachments(). + */ + private function rewritePluginfileForAnnouncements(string $html, string $exportRoot, int $postId): string + { + if ($html === '' || !str_contains($html, '@@PLUGINFILE@@')) { return $html; } + + $targetBase = '/document/announcements/'.$postId.'/'; + + // src/href/poster/data + $html = (string)preg_replace( + '~\b(src|href|poster|data)\s*=\s*([\'"])@@PLUGINFILE@@/([^\'"]+)\2~i', + '$1=$2'.$targetBase.'$3$2', + $html + ); + + // url(...) in inline styles + $html = (string)preg_replace( + '~url\((["\']?)@@PLUGINFILE@@/([^)\'"]+)\1\)~i', + 'url($1'.$targetBase.'$2$1)', + $html + ); + + return $html; + } + + /** + * Copy attachments for a forum post from files.xml store to /document/announcements/{postId}/ + * and return normalized descriptors for the announcement payload. + */ + private function extractForumPostAttachments(string $exportRoot, int $postId): array + { + $fx = rtrim($exportRoot, '/').'/files.xml'; + if (!is_file($fx)) { return []; } + + $doc = $this->loadXml($fx); + $xp = new \DOMXPath($doc); + + // files/file with these conditions + $q = sprintf("//files/file[component='mod_forum' and filearea='post' and itemid='%d']", $postId); + $list = $xp->query($q); + + if ($list->length === 0) { return []; } + + $destBase = rtrim($exportRoot, '/').'/document/announcements/'.$postId; + $this->ensureDir($destBase); + + $out = []; + foreach ($list as $f) { + /** @var \DOMElement $f */ + $filename = (string) ($f->getElementsByTagName('filename')->item(0)?->nodeValue ?? ''); + if ($filename === '' || $filename === '.') { continue; } // skip directories + + $contenthash = (string) ($f->getElementsByTagName('contenthash')->item(0)?->nodeValue ?? ''); + $filesize = (int) ($f->getElementsByTagName('filesize')->item(0)?->nodeValue ?? 0); + + // Moodle file path inside backup: files/aa/bb/ + $src = rtrim($exportRoot, '/').'/files/'.substr($contenthash, 0, 2).'/'.substr($contenthash, 2, 2).'/'.$contenthash; + $dst = $destBase.'/'.$filename; + + if (is_file($src)) { + @copy($src, $dst); + } else { + // keep record even if missing; size may still be useful + if ($this->debug) { error_log("MOODLE_IMPORT: forum post attachment missing file=$src"); } + } + + $rel = 'document/announcements/'.$postId.'/'.$filename; // relative inside backup root + $out[] = [ + 'path' => $rel, // builder sets 'attachment_path' from first item + 'filename' => $filename, + 'size' => $filesize, + 'comment' => '', + 'asset_relpath' => $rel, // mirrors builder's asset_relpath semantics + ]; + } + + return $out; + } + + private function isNewsForum(array $forumInfo): bool + { + $type = strtolower((string)($forumInfo['type'] ?? '')); + if ($type === 'news') { + return true; + } + $name = strtolower((string)($forumInfo['name'] ?? '')); + $intro = strtolower((string)($forumInfo['description'] ?? $forumInfo['intro'] ?? '')); + + // Common names across locales + $nameHints = ['announcement']; + foreach ($nameHints as $h) { + if ($name !== '' && str_contains($name, $h)) { + return true; + } + } + + return false; + } + + /** + * Detect the special "course homepage" Page exported as activities/page_0. + * Heuristics: + * - directory ends with 'activities/page_0' + * - or + * - or page name equals 'Introduction' (soft signal) + */ + private function looksLikeCourseHomepage(string $dir, string $moduleXml): bool + { + if (preg_match('~/activities/page_0/?$~', $dir)) { + return true; + } + + try { + $doc = $this->loadXml($moduleXml); + $xp = new DOMXPath($doc); + + $idAttr = $xp->query('/activity/@id')->item(0)?->nodeValue ?? null; + $moduleIdAttr = $xp->query('/activity/@moduleid')->item(0)?->nodeValue ?? null; + $modNameAttr = $xp->query('/activity/@modulename')->item(0)?->nodeValue ?? null; + $nameNode = $xp->query('//page/name')->item(0)?->nodeValue ?? ''; + + $id = is_numeric($idAttr) ? (int) $idAttr : null; + $moduleId = is_numeric($moduleIdAttr) ? (int) $moduleIdAttr : null; + $modName = is_string($modNameAttr) ? strtolower($modNameAttr) : ''; + + if ($id === 0 && $moduleId === 0 && $modName === 'page') { + return true; + } + if (trim($nameNode) === 'Introduction') { + // Soft hint: do not exclusively rely on this, but keep as fallback + return true; + } + } catch (\Throwable $e) { + // Be tolerant: if parsing fails, just return false + } + + return false; + } + + /** + * Read ... as decoded HTML. + */ + private function readPageContent(string $moduleXml): string + { + $doc = $this->loadXml($moduleXml); + $xp = new DOMXPath($doc); + + $node = $xp->query('//page/content')->item(0); + if (!$node) { + return ''; + } + + // PageExport wrote content with htmlspecialchars; decode back to HTML. + return html_entity_decode($node->nodeValue ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + }