diff --git a/src/CoreBundle/Controller/AbstractCourseMaintenanceController.php b/src/CoreBundle/Controller/AbstractCourseMaintenanceController.php new file mode 100644 index 00000000000..24d7932d6c2 --- /dev/null +++ b/src/CoreBundle/Controller/AbstractCourseMaintenanceController.php @@ -0,0 +1,2081 @@ +headers->get('X-Debug'); + if (null !== $hdr && '' !== trim($hdr)) { + $this->debug = $this->toBool($hdr, $this->debug); + + return; + } + + // Then query param + $q = $req->query->get('debug'); + if (null !== $q && '' !== trim((string) $q)) { + $this->debug = $this->toBool((string) $q, $this->debug); + } + } + + protected function logDebug(string $message, array $context = []): void + { + if (!$this->debug) { + return; + } + + $ctx = $context; + // Avoid dumping huge objects + foreach ($ctx as $k => $v) { + if (\is_object($v)) { + $ctx[$k] = ['_object' => get_class($v)]; + } + if (\is_array($v) && \count($v) > 2000) { + $ctx[$k] = ['_array_count' => \count($v)]; + } + } + + $payload = $ctx ? ' '.json_encode($ctx, JSON_PARTIAL_OUTPUT_ON_ERROR) : ''; + error_log('[CourseMaintenance] '.$message.$payload); + } + + protected static function getPhpUploadLimitBytes(): int + { + $toBytes = static function (?string $v): int { + $v = trim((string) $v); + if ('' === $v) { + return 0; + } + if (is_numeric($v)) { + return (int) $v; + } + + $unit = strtolower(substr($v, -1)); + $num = (int) substr($v, 0, -1); + + return match ($unit) { + 'g' => $num * 1024 * 1024 * 1024, + 'm' => $num * 1024 * 1024, + 'k' => $num * 1024, + default => (int) $v, + }; + }; + + $u1 = $toBytes(ini_get('upload_max_filesize') ?: null); + $u2 = $toBytes(ini_get('post_max_size') ?: null); + + if ($u1 <= 0 && $u2 <= 0) { + return 0; + } + if ($u1 <= 0) { + return $u2; + } + if ($u2 <= 0) { + return $u1; + } + + return min($u1, $u2); + } + + private function toBool(string $raw, bool $default = false): bool + { + $v = strtolower(trim($raw)); + if (\in_array($v, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (\in_array($v, ['0', 'false', 'no', 'off'], true)) { + return false; + } + + return $default; + } + + // ----------------------------------------------------------------------------- + // Tools normalization helpers + // ----------------------------------------------------------------------------- + + /** + * Normalize tool list to supported ones and add implied dependencies. + * + * @param array|null $tools + * + * @return string[] + */ + protected 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', + 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', 'tool_intro', 'forums', + ]; + + // Implied dependencies + $deps = [ + 'quizzes' => ['quiz_questions'], + 'surveys' => ['survey_questions'], + 'learnpaths' => ['learnpath_category'], + ]; + + $sel = \is_array($tools) ? array_values(array_intersect($tools, $all)) : []; + + foreach ($sel as $t) { + foreach ($deps[$t] ?? [] as $d) { + $sel[] = $d; + } + } + + // Unique and preserve a sane order based on $all + $sel = array_values(array_unique($sel)); + usort($sel, static function ($a, $b) use ($all) { + return array_search($a, $all, true) <=> array_search($b, $all, true); + }); + + return $sel; + } + + /** + * Infer tools from a selection map (type => [id => true]). + * + * @param array $selected + * + * @return string[] + */ + protected function inferToolsFromSelection(array $selected): array + { + $types = array_map('strval', array_keys($selected)); + + $map = [ + 'document' => 'documents', + 'documents' => 'documents', + 'link' => 'links', + 'links' => 'links', + 'forum' => 'forums', + 'forums' => 'forums', + 'quiz' => 'quizzes', + 'quizzes' => 'quizzes', + 'exercise' => 'quizzes', + 'survey' => 'surveys', + 'surveys' => 'surveys', + 'learnpath' => 'learnpaths', + 'learnpaths' => 'learnpaths', + 'work' => 'works', + 'works' => 'works', + 'glossary' => 'glossary', + 'tool_intro' => 'tool_intro', + 'course_description' => 'course_descriptions', + 'course_descriptions' => 'course_descriptions', + ]; + + $out = []; + foreach ($types as $t) { + $k = strtolower((string) $t); + if (isset($map[$k])) { + $out[] = $map[$k]; + } + } + + return array_values(array_unique($out)); + } + + /** + * Build selection map from selected types (select all items in those types). + * + * @param string[] $selectedTypes + * + * @return array> + */ + protected function buildSelectionFromTypes(object $course, array $selectedTypes): array + { + $out = []; + + $res = (isset($course->resources) && \is_array($course->resources)) ? $course->resources : []; + $wanted = array_fill_keys(array_map('strtolower', array_map('strval', $selectedTypes)), true); + + foreach ($res as $rawType => $items) { + if (!\is_array($items) || empty($items)) { + continue; + } + + $typeKey = $this->normalizeTypeKey($rawType); + + if (!isset($wanted[strtolower($typeKey)])) { + continue; + } + + $out[$typeKey] = []; + foreach ($items as $id => $_obj) { + if (\is_int($id) || is_numeric($id)) { + $out[$typeKey][(string) (int) $id] = true; + } else { + $idStr = trim((string) $id); + if ('' !== $idStr) { + $out[$typeKey][$idStr] = true; + } + } + } + } + + return $out; + } + + // ----------------------------------------------------------------------------- + // Diagnose helpers (course_info.dat reader + tolerant decode) + // ----------------------------------------------------------------------------- + + /** + * Replace any __PHP_Incomplete_Class instances with stdClass (deep). + * Also traverses arrays and objects (diagnostics-only). + */ + protected function deincomplete(mixed $v): mixed + { + if ($v instanceof __PHP_Incomplete_Class) { + $o = new stdClass(); + foreach (get_object_vars($v) as $k => $vv) { + $o->{$k} = $this->deincomplete($vv); + } + + return $o; + } + if (\is_array($v)) { + foreach ($v as $k => $vv) { + $v[$k] = $this->deincomplete($vv); + } + + return $v; + } + if (\is_object($v)) { + foreach (get_object_vars($v) as $k => $vv) { + $v->{$k} = $this->deincomplete($vv); + } + + return $v; + } + + return $v; + } + + /** + * Return [ok, name, index, size, data] for the first matching entry of course_info.dat (case-insensitive). + * Also tries common subpaths, e.g., "course/course_info.dat". + */ + protected function readCourseInfoFromZip(string $zipPath): array + { + $candidates = [ + 'course_info.dat', + 'course/course_info.dat', + 'backup/course_info.dat', + ]; + + $zip = new ZipArchive(); + if (true !== ($err = $zip->open($zipPath))) { + return ['ok' => false, 'error' => 'Failed to open ZIP (ZipArchive::open error '.$err.')']; + } + + // First: direct scan (case-insensitive base name) + $foundIdx = null; + $foundName = null; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $st = $zip->statIndex($i); + if (!$st || !isset($st['name'])) { + continue; + } + $name = (string) $st['name']; + $base = strtolower(basename($name)); + if ('course_info.dat' === $base) { + $foundIdx = $i; + $foundName = $name; + + break; + } + } + + // Try specific candidate paths if direct scan failed + if (null === $foundIdx) { + foreach ($candidates as $cand) { + $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE); + if (false !== $idx) { + $foundIdx = $idx; + $foundName = $zip->getNameIndex($idx); + + break; + } + } + } + + if (null === $foundIdx) { + $list = []; + $limit = min($zip->numFiles, 200); + for ($i = 0; $i < $limit; $i++) { + $n = $zip->getNameIndex($i); + if (false !== $n) { + $list[] = $n; + } + } + $zip->close(); + + return [ + 'ok' => false, + 'error' => 'course_info.dat not found in archive', + 'zip_list_sample' => $list, + 'num_files' => $zip->numFiles, + ]; + } + + $stat = $zip->statIndex($foundIdx); + $size = (int) ($stat['size'] ?? 0); + $fp = $zip->getStream((string) $foundName); + if (!$fp) { + $zip->close(); + + return ['ok' => false, 'error' => 'Failed to open stream for course_info.dat (getStream)']; + } + + $data = stream_get_contents($fp); + fclose($fp); + $zip->close(); + + if (!\is_string($data)) { + return ['ok' => false, 'error' => 'Failed to read course_info.dat contents']; + } + + return [ + 'ok' => true, + 'name' => $foundName, + 'index' => $foundIdx, + 'size' => $size, + 'data' => $data, + ]; + } + + /** + * Try to detect and decode course_info.dat content. + * Hardened: preprocess typed-prop numeric strings and register legacy aliases + * before attempting unserialize. Falls back to relaxed mode to avoid typed + * property crashes during diagnosis. + */ + protected function decodeCourseInfo(string $raw): array + { + $r = [ + 'encoding' => 'raw', + 'decoded_len' => \strlen($raw), + 'magic_hex' => bin2hex(substr($raw, 0, 8)), + 'magic_ascii' => preg_replace('/[^\x20-\x7E]/', '.', substr($raw, 0, 16)), + 'steps' => [], + 'decoded' => null, + 'is_serialized' => false, + 'is_json' => false, + 'json_preview' => null, + ]; + + $isJson = static function (string $s): bool { + $t = ltrim($s); + + return '' !== $t && ('{' === $t[0] || '[' === $t[0]); + }; + + // Centralized tolerant unserialize with typed-props preprocessing + $tryUnserializeTolerant = function (string $s, string $label) use (&$r) { + $ok = false; + $val = null; + $err = null; + $relaxed = false; + + // Ensure legacy aliases and coerce numeric strings before unserialize + try { + CourseArchiver::ensureLegacyAliases(); + } catch (Throwable) { + // ignore + } + + try { + $s = CourseArchiver::preprocessSerializedPayloadForTypedProps($s); + } catch (Throwable) { + // ignore + } + + // Strict mode + set_error_handler(static function (): void {}); + + try { + $val = @unserialize($s, ['allowed_classes' => true]); + $ok = (false !== $val) || ('b:0;' === trim($s)); + } catch (Throwable $e) { + $err = $e->getMessage(); + $ok = false; + } finally { + restore_error_handler(); + } + $r['steps'][] = ['action' => "unserialize[$label][strict]", 'ok' => $ok, 'error' => $err]; + + // Relaxed fallback (no class instantiation) + deincomplete to stdClass + if (!$ok) { + $err2 = null; + set_error_handler(static function (): void {}); + + try { + $tmp = @unserialize($s, ['allowed_classes' => false]); + if (false !== $tmp || 'b:0;' === trim($s)) { + $val = $this->deincomplete($tmp); + $ok = true; + $relaxed = true; + $err = null; + } + } catch (Throwable $e2) { + $err2 = $e2->getMessage(); + } finally { + restore_error_handler(); + } + $r['steps'][] = ['action' => "unserialize[$label][relaxed]", 'ok' => $ok, 'error' => $err2]; + } + + if ($ok) { + $r['is_serialized'] = true; + $r['decoded'] = null; // keep payload minimal + $r['used_relaxed'] = $relaxed; + + return $val; + } + + return null; + }; + + // 0) JSON as-is? + if ($isJson($raw)) { + $r['encoding'] = 'json'; + $r['is_json'] = true; + $r['json_preview'] = json_decode($raw, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); + + return $r; + } + + // Direct PHP serialize (strict then relaxed, after preprocessing) + if (($u = $tryUnserializeTolerant($raw, 'raw')) !== null) { + $r['encoding'] = 'php-serialize'; + + return $r + ['value' => $u]; + } + + // GZIP + if (0 === strncmp($raw, "\x1F\x8B", 2)) { + $dec = @gzdecode($raw); + $r['steps'][] = ['action' => 'gzdecode', 'ok' => false !== $dec]; + if (false !== $dec) { + if ($isJson($dec)) { + $r['encoding'] = 'gzip+json'; + $r['is_json'] = true; + $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); + + return $r; + } + if (($u = $tryUnserializeTolerant($dec, 'gzip')) !== null) { + $r['encoding'] = 'gzip+php-serialize'; + + return $r + ['value' => $u]; + } + } + } + + // ZLIB/DEFLATE + $z2 = substr($raw, 0, 2); + if ("\x78\x9C" === $z2 || "\x78\xDA" === $z2) { + $dec = @gzuncompress($raw); + $r['steps'][] = ['action' => 'gzuncompress', 'ok' => false !== $dec]; + if (false !== $dec) { + if ($isJson($dec)) { + $r['encoding'] = 'zlib+json'; + $r['is_json'] = true; + $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); + + return $r; + } + if (($u = $tryUnserializeTolerant($dec, 'zlib')) !== null) { + $r['encoding'] = 'zlib+php-serialize'; + + return $r + ['value' => $u]; + } + } + + $dec2 = @gzinflate($raw); + $r['steps'][] = ['action' => 'gzinflate', 'ok' => false !== $dec2]; + if (false !== $dec2) { + if ($isJson($dec2)) { + $r['encoding'] = 'deflate+json'; + $r['is_json'] = true; + $r['json_preview'] = json_decode($dec2, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); + + return $r; + } + if (($u = $tryUnserializeTolerant($dec2, 'deflate')) !== null) { + $r['encoding'] = 'deflate+php-serialize'; + + return $r + ['value' => $u]; + } + } + } + + // BASE64 + if (preg_match('~^[A-Za-z0-9+/=\r\n]+$~', $raw)) { + $dec = base64_decode($raw, true); + $r['steps'][] = ['action' => 'base64_decode', 'ok' => false !== $dec]; + if (false !== $dec) { + if ($isJson($dec)) { + $r['encoding'] = 'base64(json)'; + $r['is_json'] = true; + $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); + + return $r; + } + if (($u = $tryUnserializeTolerant($dec, 'base64')) !== null) { + $r['encoding'] = 'base64(php-serialize)'; + + return $r + ['value' => $u]; + } + + // base64 + gzip nested + if (0 === strncmp($dec, "\x1F\x8B", 2)) { + $dec2 = @gzdecode($dec); + $r['steps'][] = ['action' => 'base64+gzdecode', 'ok' => false !== $dec2]; + if (false !== $dec2 && ($u = $tryUnserializeTolerant($dec2, 'base64+gzip')) !== null) { + $r['encoding'] = 'base64(gzip+php-serialize)'; + + return $r + ['value' => $u]; + } + } + } + } + + // Nested ZIP? + if (0 === strncmp($raw, "PK\x03\x04", 4)) { + $r['encoding'] = 'nested-zip'; + } + + return $r; + } + + // ----------------------------------------------------------------------------- + // Resource tree builder (Vue-friendly) + selection filtering + // ----------------------------------------------------------------------------- + + /** + * Copies the dependencies (document, link, quiz, etc.) to $course->resources + * that reference the selected LearnPaths, taking the items from the full snapshot. + * + * Defensive by design: it won't break if something is missing or in a different format. + * + * @param array $snapshot + */ + protected function hydrateLpDependenciesFromSnapshot(object $course, array $snapshot): void + { + if (empty($course->resources['learnpath']) || !\is_array($course->resources['learnpath'])) { + return; + } + + $need = []; + $addNeed = function (string $type, $id) use (&$need): void { + $t = (string) $type; + $i = is_numeric($id) ? (int) $id : (string) $id; + if ('' === (string) $i || 0 === (int) $i) { + return; + } + $need[$t] ??= []; + $need[$t][$i] = true; + }; + + foreach ($course->resources['learnpath'] as $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((string) $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((string) $t, $rid); + } + } + } + + foreach (['document_id' => 'document', 'doc_id' => 'document', 'link_id' => 'link', 'quiz_id' => 'quiz', 'work_id' => 'work'] as $field => $typeGuess) { + if (isset($ito->{$field}) && '' !== (string) $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)) { + 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. + */ + protected function buildResourceTreeForVue(object $course): array + { + if ($this->debug) { + $this->logDebug('[buildResourceTreeForVue] start'); + } + + $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources) + ? $course->resources + : []; + + $legacyTitles = []; + if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) { + /** @var array $legacyTitles */ + $legacyTitles = CourseSelectForm::getResourceTitleList(); + } + $fallbackTitles = $this->getDefaultTypeTitles(); + $skipTypes = $this->getSkipTypeKeys(); + + $tree = []; + + // Documents: build nested folder tree (children) + if (!empty($resources['document']) && \is_array($resources['document'])) { + $docs = $resources['document']; + + $normalize = function (string $rawPath, string $title, string $filetype): string { + $p = trim($rawPath, '/'); + $p = (string) preg_replace('~^(?:document/)+~i', '', $p); + $parts = array_values(array_filter(explode('/', $p), 'strlen')); + + // Host segment + if (!empty($parts) && ('localhost' === $parts[0] || str_contains($parts[0], '.'))) { + array_shift($parts); + } + // Course code segment + if (!empty($parts) && preg_match('~^[A-Z0-9_-]{6,}$~', $parts[0])) { + array_shift($parts); + } + + $clean = implode('/', $parts); + if ('' === $clean && 'folder' !== $filetype) { + $clean = $title; + } + if ('folder' === $filetype) { + $clean = rtrim($clean, '/').'/'; + } + + return $clean; + }; + + $folderIdByPath = []; + foreach ($docs as $obj) { + if (!\is_object($obj)) { + continue; + } + $ft = (string) ($obj->filetype ?? $obj->file_type ?? ''); + if ('folder' !== $ft) { + continue; + } + $rel = $normalize((string) $obj->path, (string) $obj->title, $ft); + $key = rtrim($rel, '/'); + if ('' !== $key) { + $folderIdByPath[strtolower($key)] = (int) $obj->source_id; + } + } + + $docRoot = []; + $findChild = static function (array &$children, string $label): ?int { + foreach ($children as $i => $n) { + if ((string) ($n['label'] ?? '') === $label) { + return $i; + } + } + + return null; + }; + + foreach ($docs as $obj) { + if (!\is_object($obj)) { + continue; + } + + $title = (string) $obj->title; + $filetype = (string) ($obj->filetype ?? $obj->file_type ?? ''); + $rel = $normalize((string) $obj->path, $title, $filetype); + $parts = array_values(array_filter(explode('/', trim($rel, '/')), 'strlen')); + + $cursor = &$docRoot; + $soFar = ''; + $total = \count($parts); + + for ($i = 0; $i < $total; $i++) { + $seg = $parts[$i]; + $isLast = ($i === $total - 1); + $isFolder = (!$isLast) || ('folder' === $filetype); + + $soFar = ltrim($soFar.'/'.$seg, '/'); + $label = $seg.($isFolder ? '/' : ''); + + $idx = $findChild($cursor, $label); + if (null === $idx) { + if ($isFolder) { + $folderId = $folderIdByPath[strtolower($soFar)] ?? null; + $node = [ + 'id' => $folderId ?? ('dir:'.$soFar), + 'label' => $label, + 'selectable' => true, + 'children' => [], + ]; + } else { + $node = [ + 'id' => (int) $obj->source_id, + 'label' => $label, + 'selectable' => true, + ]; + } + $cursor[] = $node; + $idx = \count($cursor) - 1; + } + + if ($isFolder) { + if (!isset($cursor[$idx]['children']) || !\is_array($cursor[$idx]['children'])) { + $cursor[$idx]['children'] = []; + } + $cursor = &$cursor[$idx]['children']; + } + } + } + + $sortTree = null; + $sortTree = function (array &$nodes) use (&$sortTree): void { + usort($nodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); + foreach ($nodes as &$n) { + if (isset($n['children']) && \is_array($n['children'])) { + $sortTree($n['children']); + } + } + }; + $sortTree($docRoot); + + $tree[] = [ + 'type' => 'document', + 'title' => $legacyTitles['document'] ?? ($fallbackTitles['document'] ?? 'Documents'), + 'children' => $docRoot, + ]; + + $skipTypes['document'] = true; + } + + // Forums block + $hasForumData = + (!empty($resources['forum']) || !empty($resources['Forum'])) + || (!empty($resources['forum_category']) || !empty($resources['Forum_Category'])) + || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic'])) + || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post'])); + + if ($hasForumData) { + $tree[] = $this->buildForumTreeForVue( + $course, + $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums') + ); + $skipTypes['forum'] = true; + $skipTypes['forum_category'] = true; + $skipTypes['forum_topic'] = true; + $skipTypes['forum_post'] = true; + $skipTypes['thread'] = true; + $skipTypes['post'] = true; + } + + // Links block (Category → Link) + $hasLinkData = + (!empty($resources['link']) || !empty($resources['Link'])) + || (!empty($resources['link_category']) || !empty($resources['Link_Category'])); + + if ($hasLinkData) { + $tree[] = $this->buildLinkTreeForVue( + $course, + $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links') + ); + $skipTypes['link'] = true; + $skipTypes['link_category'] = true; + } + + // Generic buckets + foreach ($resources as $rawType => $items) { + if (!\is_array($items) || empty($items)) { + continue; + } + $typeKey = $this->normalizeTypeKey($rawType); + if (isset($skipTypes[$typeKey])) { + continue; + } + + $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey)); + $group = [ + 'type' => $typeKey, + 'title' => (string) $groupTitle, + 'items' => [], + ]; + + if ('gradebook' === $typeKey) { + $group['items'][] = [ + 'id' => 'all', + 'label' => 'Gradebook (all)', + 'extra' => new stdClass(), + 'selectable' => true, + ]; + $tree[] = $group; + + continue; + } + + foreach ($items as $id => $obj) { + if (!\is_object($obj)) { + continue; + } + + $idKey = is_numeric($id) ? (int) $id : (string) $id; + if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) { + continue; + } + + if (!$this->isSelectableItem($typeKey, $obj)) { + continue; + } + + $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0); + + if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) { + $label = $idKey; + } + + $extra = $this->buildExtra($typeKey, $obj); + + $group['items'][] = [ + 'id' => $idKey, + 'label' => $label, + 'extra' => $extra ?: new stdClass(), + 'selectable' => true, + ]; + } + + if (!empty($group['items'])) { + usort( + $group['items'], + static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']) + ); + $tree[] = $group; + } + } + + // Preferred order + $preferredOrder = [ + 'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link', + 'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook', + ]; + usort($tree, static function ($a, $b) use ($preferredOrder) { + $ia = array_search($a['type'], $preferredOrder, true); + $ib = array_search($b['type'], $preferredOrder, true); + if (false !== $ia && false !== $ib) { + return $ia <=> $ib; + } + if (false !== $ia) { + return -1; + } + if (false !== $ib) { + return 1; + } + + return strcasecmp((string) $a['title'], (string) $b['title']); + }); + + if ($this->debug) { + $this->logDebug( + '[buildResourceTreeForVue] end groups', + array_map( + fn ($g) => [ + 'type' => $g['type'], + 'items' => \count($g['items'] ?? []), + 'children' => \count($g['children'] ?? []), + ], + $tree + ) + ); + } + + return $tree; + } + + /** + * Build forum tree (Category → Forum → Topic) for the UI. + * Uses only "items" (no "children") and sets UI hints (has_children, item_count). + */ + protected function buildForumTreeForVue(object $course, string $groupTitle): array + { + $this->logDebug('[buildForumTreeForVue] start'); + + $res = \is_array($course->resources ?? null) ? $course->resources : []; + + // Buckets (defensive: accept legacy casings / aliases) + $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? []; + $forumRaw = $res['forum'] ?? $res['Forum'] ?? []; + $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []); + $postRaw = $res['forum_post'] ?? $res['Forum_Post'] ?? ($res['post'] ?? []); + + $this->logDebug('[buildForumTreeForVue] raw counts', [ + 'categories' => \is_array($catRaw) ? \count($catRaw) : 0, + 'forums' => \is_array($forumRaw) ? \count($forumRaw) : 0, + 'topics' => \is_array($topicRaw) ? \count($topicRaw) : 0, + 'posts' => \is_array($postRaw) ? \count($postRaw) : 0, + ]); + + // Quick classifiers (defensive) + $isForum = function (object $o): bool { + $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o; + if (isset($e->forum_title) && \is_string($e->forum_title)) { + return true; + } + if (isset($e->default_view) || isset($e->allow_anonymous)) { + return true; + } + if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) { + return true; + } + + return false; + }; + $isTopic = function (object $o) use ($isForum): bool { + $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o; + if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) { + return true; + } + if (isset($e->forum_id) && !isset($e->forum_title)) { + return true; + } + if ($isForum($o)) { + return false; + } + + return false; + }; + $getForumCategoryId = function (object $forum): int { + $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum; + $cid = (int) ($e->forum_category ?? 0); + if ($cid <= 0) { + $cid = (int) ($e->forum_category_id ?? 0); + } + if ($cid <= 0) { + $cid = (int) ($e->category_id ?? 0); + } + + return $cid; + }; + + // Categories + $cats = []; + foreach ($catRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !\is_object($obj)) { + continue; + } + $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id); + $cats[$id] = [ + 'id' => $id, + 'type' => 'forum_category', + 'label' => ('' !== $label ? $label : 'Category #'.$id).'/', + 'selectable' => true, + 'items' => [], + 'has_children' => false, + 'item_count' => 0, + 'extra' => ['filetype' => 'folder'], + ]; + } + + // Virtual "Uncategorized" + $uncatKey = -9999; + $cats[$uncatKey] ??= [ + 'id' => $uncatKey, + 'type' => 'forum_category', + 'label' => 'Uncategorized/', + 'selectable' => true, + 'items' => [], + '_virtual' => true, + 'has_children' => false, + 'item_count' => 0, + 'extra' => ['filetype' => 'folder'], + ]; + + // Forums + $forums = []; + foreach ($forumRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !\is_object($obj)) { + continue; + } + if (!$isForum($obj)) { + $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]); + + continue; + } + $forums[$id] = $this->objectEntity($obj); + } + + // Topics (+ post counts) + $topics = []; + $postCountByTopic = []; + foreach ($topicRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !\is_object($obj)) { + continue; + } + if (!$isTopic($obj)) { + continue; + } + $topics[$id] = $this->objectEntity($obj); + } + foreach ($postRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !\is_object($obj)) { + continue; + } + $e = $this->objectEntity($obj); + $tid = (int) ($e->thread_id ?? 0); + if ($tid > 0) { + $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1; + } + } + + // Attach topics to forums and forums to categories + foreach ($forums as $fid => $f) { + $catId = $getForumCategoryId($f); + if (!isset($cats[$catId])) { + $catId = $uncatKey; + } + + $forumNode = [ + 'id' => $fid, + 'type' => 'forum', + 'label' => $this->resolveItemLabel('forum', $f, $fid), + 'extra' => $this->buildExtra('forum', $f) ?: new stdClass(), + 'selectable' => true, + 'items' => [], + // UI hints + 'has_children' => false, + 'item_count' => 0, + 'ui_depth' => 2, + ]; + + foreach ($topics as $tid => $t) { + if ((int) ($t->forum_id ?? 0) !== $fid) { + continue; + } + + $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? ''); + $date = (string) ($t->thread_date ?? ''); + $nPosts = (int) ($postCountByTopic[$tid] ?? 0); + + $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid); + + $meta = []; + if ('' !== $author) { + $meta[] = $author; + } + if ('' !== $date) { + $meta[] = $date; + } + if ($meta) { + $topicLabel .= ' ('.implode(', ', $meta).')'; + } + if ($nPosts > 0) { + $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's'); + } + + $forumNode['items'][] = [ + 'id' => $tid, + 'type' => 'forum_topic', + 'label' => $topicLabel, + 'extra' => new stdClass(), + 'selectable' => true, + 'ui_depth' => 3, + 'item_count' => 0, + ]; + } + + if (!empty($forumNode['items'])) { + usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); + $forumNode['has_children'] = true; + $forumNode['item_count'] = \count($forumNode['items']); + } + + $cats[$catId]['items'][] = $forumNode; + } + + // Remove empty virtual category; sort forums inside each category and finalize hints + $catNodes = array_values(array_filter($cats, static function ($c) { + if (!empty($c['_virtual']) && empty($c['items'])) { + return false; + } + + return true; + })); + + foreach ($catNodes as &$cat) { + if (!empty($cat['items'])) { + usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); + } + $cat['has_children'] = !empty($cat['items']); + $cat['item_count'] = \count($cat['items'] ?? []); + } + unset($cat); + + $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]); + + return [ + 'type' => 'forum', + 'title' => $groupTitle, + 'items' => $catNodes, + ]; + } + + /** + * Build link tree (Category → Link) for the UI. + */ + protected function buildLinkTreeForVue(object $course, string $groupTitle): array + { + $res = \is_array($course->resources ?? null) ? $course->resources : []; + + $catRaw = $res['link_category'] ?? $res['Link_Category'] ?? []; + $linkRaw = $res['link'] ?? $res['Link'] ?? []; + + $cats = []; + foreach ($catRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !\is_object($obj)) { + continue; + } + $label = $this->resolveItemLabel('link_category', $this->objectEntity($obj), $id); + $cats[$id] = [ + 'id' => $id, + 'type' => 'link_category', + 'label' => ('' !== $label ? $label : 'Category #'.$id).'/', + 'selectable' => true, + 'items' => [], + 'has_children' => false, + 'item_count' => 0, + 'extra' => ['filetype' => 'folder'], + ]; + } + + // Virtual "Uncategorized" + $uncatKey = -9999; + $cats[$uncatKey] ??= [ + 'id' => $uncatKey, + 'type' => 'link_category', + 'label' => 'Uncategorized/', + 'selectable' => true, + 'items' => [], + '_virtual' => true, + 'has_children' => false, + 'item_count' => 0, + 'extra' => ['filetype' => 'folder'], + ]; + + foreach ($linkRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !\is_object($obj)) { + continue; + } + + $e = $this->objectEntity($obj); + $catId = (int) ($e->category_id ?? 0); + if (!isset($cats[$catId])) { + $catId = $uncatKey; + } + + $cats[$catId]['items'][] = [ + 'id' => $id, + 'type' => 'link', + 'label' => $this->resolveItemLabel('link', $e, $id), + 'extra' => $this->buildExtra('link', $e) ?: new stdClass(), + 'selectable' => true, + ]; + } + + // Drop empty virtual and finalize hints + $catNodes = array_values(array_filter($cats, static function ($c) { + if (!empty($c['_virtual']) && empty($c['items'])) { + return false; + } + + return true; + })); + + foreach ($catNodes as &$cat) { + if (!empty($cat['items'])) { + usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); + } + $cat['has_children'] = !empty($cat['items']); + $cat['item_count'] = \count($cat['items'] ?? []); + } + unset($cat); + + return [ + 'type' => 'link', + 'title' => $groupTitle, + 'items' => $catNodes, + ]; + } + + /** + * Normalize a raw type to a lowercase key. + */ + protected function normalizeTypeKey(int|string $raw): string + { + if (\is_int($raw)) { + return (string) $raw; + } + + $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw)); + + $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', + ]; + + return $map[$s] ?? $s; + } + + /** + * Keys to skip as top-level groups in UI. + * + * @return array + */ + protected function getSkipTypeKeys(): array + { + return [ + 'forum_category' => true, + 'forum_topic' => true, + 'forum_post' => true, + 'thread' => true, + 'post' => true, + 'exercise_question' => true, + 'survey_question' => true, + 'survey_invitation' => true, + 'session_course' => true, + 'scorm' => true, + 'asset' => true, + 'link_category' => true, + ]; + } + + /** + * Default labels for groups. + * + * @return array + */ + protected function getDefaultTypeTitles(): array + { + return [ + 'announcement' => 'Announcements', + 'document' => 'Documents', + 'glossary' => 'Glossaries', + 'calendar_event' => 'Calendar events', + 'event' => 'Calendar events', + 'link' => 'Links', + 'course_description' => 'Course descriptions', + 'learnpath' => 'Learning paths', + 'learnpath_category' => 'Learning path categories', + 'forum' => 'Forums', + 'forum_category' => 'Forum categories', + 'quiz' => 'Quizzes', + 'test_category' => 'Test categories', + 'wiki' => 'Wikis', + 'thematic' => 'Thematics', + 'attendance' => 'Attendances', + 'work' => 'Works', + 'session_course' => 'Session courses', + 'gradebook' => 'Gradebook', + 'scorm' => 'SCORM packages', + 'survey' => 'Surveys', + 'survey_question' => 'Survey questions', + 'survey_invitation' => 'Survey invitations', + 'asset' => 'Assets', + 'tool_intro' => 'Tool introductions', + ]; + } + + protected function isSelectableItem(string $type, object $obj): bool + { + // Keep permissive; selection rules can be tightened later if needed. + return true; + } + + /** + * Resolve label for an item with fallbacks. + */ + protected function resolveItemLabel(string $type, object $obj, int $fallbackId): string + { + $entity = $this->objectEntity($obj); + + foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) { + if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) { + return trim((string) $entity->{$k}); + } + } + + 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]; + } + } + } + + switch ($type) { + case 'document': + // 1) raw path as stored in backup/DB + $raw = (string) ($entity->path ?? $obj->path ?? ''); + if ('' !== $raw) { + // 2) normalize to relative and strip optional "document/" prefix + $rel = ltrim($raw, '/'); + $rel = preg_replace('~^document/?~', '', (string) $rel); + + // 3) folder => enforce trailing slash + $fileType = (string) ($entity->file_type ?? $obj->file_type ?? ''); + if ('folder' === $fileType) { + $rel = rtrim((string) $rel, '/').'/'; + } + + // 4) last resort: basename + return '' !== (string) $rel ? (string) $rel : basename($raw); + } + + // fallback: title or filename + if (!empty($obj->title)) { + return (string) $obj->title; + } + + break; + + case 'course_description': + if (!empty($obj->title)) { + return (string) $obj->title; + } + $t = (int) ($obj->description_type ?? 0); + $names = [ + 1 => 'Description', + 2 => 'Objectives', + 3 => 'Topics', + 4 => 'Methodology', + 5 => 'Course material', + 6 => 'Resources', + 7 => 'Assessment', + 8 => 'Custom', + ]; + + return $names[$t] ?? ('#'.$fallbackId); + + case 'forum': + if (!empty($entity->forum_title)) { + return (string) $entity->forum_title; + } + + break; + + case 'forum_category': + if (!empty($entity->cat_title)) { + return (string) $entity->cat_title; + } + + break; + + case 'link': + if (!empty($obj->title)) { + return (string) $obj->title; + } + if (!empty($obj->url)) { + return (string) $obj->url; + } + + break; + + case 'survey': + if (!empty($obj->title)) { + return trim((string) $obj->title); + } + + break; + + case 'learnpath': + if (!empty($obj->name)) { + return (string) $obj->name; + } + + break; + + case 'thematic': + if (isset($obj->params['title']) && \is_string($obj->params['title'])) { + return (string) $obj->params['title']; + } + + break; + + case 'quiz': + if (!empty($entity->title)) { + return (string) $entity->title; + } + + break; + + case 'forum_topic': + if (!empty($entity->thread_title)) { + return (string) $entity->thread_title; + } + + break; + } + + return '#'.$fallbackId; + } + + /** + * Extract wrapped entity (->obj) or the object itself. + */ + protected function objectEntity(object $resource): object + { + if (isset($resource->obj) && \is_object($resource->obj)) { + return $resource->obj; + } + + return $resource; + } + + /** + * Extra payload per item for UI (optional). + * + * @return array + */ + protected function buildExtra(string $type, object $obj): array + { + $extra = []; + + $get = static function (object $o, string $k, $default = null) { + return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default; + }; + + switch ($type) { + case 'document': + $extra['path'] = (string) ($get($obj, 'path', '') ?? ''); + $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? ''); + $extra['size'] = (string) ($get($obj, 'size', '') ?? ''); + + break; + + case 'link': + $extra['url'] = (string) ($get($obj, 'url', '') ?? ''); + $extra['target'] = (string) ($get($obj, 'target', '') ?? ''); + + break; + + case 'forum': + $entity = $this->objectEntity($obj); + $extra['category_id'] = (string) ($entity->forum_category ?? ''); + $extra['default_view'] = (string) ($entity->default_view ?? ''); + + break; + + case 'learnpath': + $extra['name'] = (string) ($get($obj, 'name', '') ?? ''); + $extra['items'] = isset($obj->items) && \is_array($obj->items) + ? array_map( + static function ($i) { + return [ + 'id' => (int) ($i['id'] ?? 0), + 'title' => (string) ($i['title'] ?? ''), + 'type' => (string) ($i['item_type'] ?? ''), + 'path' => (string) ($i['path'] ?? ''), + ]; + }, + $obj->items + ) + : []; + + break; + + case 'quiz': + case 'survey': + $entity = $this->objectEntity($obj); + $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids) + ? array_map('intval', $entity->question_ids) + : []; + + break; + } + + return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v)); + } + + /** + * Get first existing key from candidates. + */ + protected function firstExistingKey(array $orig, array $candidates): ?string + { + foreach ($candidates as $k) { + if (!\is_string($k) || '' === $k) { + continue; + } + if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) { + return $k; + } + } + + return null; + } + + /** + * Find the real bucket key in $orig that matches the given logical type. + */ + protected function findBucketKey(array $orig, string $type): ?string + { + $want = strtolower($this->normalizeTypeKey($type)); + + foreach ($orig as $k => $_v) { + if (!\is_string($k)) { + continue; + } + $nk = strtolower($this->normalizeTypeKey($k)); + if ($nk === $want) { + return $k; + } + } + + return null; + } + + /** + * Return bucket array for a logical type (handles legacy casings). + */ + protected function findBucket(array $orig, string $type): array + { + $k = $this->findBucketKey($orig, $type); + if (null === $k) { + return []; + } + + return (isset($orig[$k]) && \is_array($orig[$k])) ? $orig[$k] : []; + } + + /** + * Intersect a bucket by IDs (IDs map: string=>true). + */ + protected function intersectBucketByIds(array $bucket, array $idsMap): array + { + // Bucket keys can be int or string, normalize to string for intersection + $out = []; + foreach ($bucket as $id => $obj) { + $key = is_numeric($id) ? (string) (int) $id : (string) $id; + if (isset($idsMap[$key])) { + $out[$id] = $obj; + } + } + + return $out; + } + + /** + * Filter legacy Course by UI selections (and pull dependencies). + * + * @param array $selected [type => [id => true]] + */ + protected function filterLegacyCourseBySelection(object $course, array $selected): object + { + // Sanitize incoming selection (frontend sometimes sends synthetic groups) + $selected = array_filter($selected, 'is_array'); + unset($selected['undefined']); + + $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]); + + if (empty($course->resources) || !\is_array($course->resources)) { + $this->logDebug('[filterSelection] course has no resources'); + + return $course; + } + + /** @var array $orig */ + $orig = $course->resources; + + // Preserve meta buckets (keys that start with "__") + $__metaBuckets = []; + foreach ($orig as $k => $v) { + if (\is_string($k) && str_starts_with($k, '__')) { + $__metaBuckets[$k] = $v; + } + } + + $getBucket = fn (array $a, string $key): array => (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : []; + + // ---------- Forums flow ---------- + if (!empty($selected['forum'])) { + $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true); + if (!empty($selForums)) { + $forums = $this->findBucket($orig, 'forum'); + $threads = $this->findBucket($orig, 'forum_topic'); + $posts = $this->findBucket($orig, 'forum_post'); + + $catsToKeep = []; + + foreach ($forums as $fid => $f) { + if (!isset($selForums[(string) (int) $fid]) && !isset($selForums[(string) $fid])) { + continue; + } + $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f; + $cid = (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0); + if ($cid > 0) { + $catsToKeep[$cid] = true; + } + } + + $threadToKeep = []; + foreach ($threads as $tid => $t) { + $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t; + $fid = (string) ((int) ($e->forum_id ?? 0)); + if ('' !== $fid && isset($selForums[$fid])) { + $threadToKeep[(int) $tid] = true; + } + } + + $postToKeep = []; + foreach ($posts as $pid => $p) { + $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p; + if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) { + $postToKeep[(int) $pid] = true; + } + } + + $out = []; + foreach ($selected as $type => $ids) { + if (!\is_array($ids) || empty($ids)) { + continue; + } + $bucket = $this->findBucket($orig, (string) $type); + $key = $this->findBucketKey($orig, (string) $type); + if (null !== $key && !empty($bucket)) { + $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true); + $out[$key] = $this->intersectBucketByIds($bucket, $idsMap); + } + } + + $forumCat = $this->findBucket($orig, 'forum_category'); + $forumBucket = $this->findBucket($orig, 'forum'); + $threadBucket = $this->findBucket($orig, 'forum_topic'); + $postBucket = $this->findBucket($orig, 'forum_post'); + + if (!empty($forumCat) && !empty($catsToKeep)) { + $out[$this->findBucketKey($orig, 'forum_category') ?? 'Forum_Category'] = + array_intersect_key( + $forumCat, + array_fill_keys(array_map('strval', array_keys($catsToKeep)), true) + ); + } + + if (!empty($forumBucket)) { + $out[$this->findBucketKey($orig, 'forum') ?? 'forum'] = + array_intersect_key($forumBucket, $selForums); + } + if (!empty($threadBucket)) { + $out[$this->findBucketKey($orig, 'forum_topic') ?? 'thread'] = + array_intersect_key( + $threadBucket, + array_fill_keys(array_map('strval', array_keys($threadToKeep)), true) + ); + } + if (!empty($postBucket)) { + $out[$this->findBucketKey($orig, 'forum_post') ?? 'post'] = + array_intersect_key( + $postBucket, + array_fill_keys(array_map('strval', array_keys($postToKeep)), true) + ); + } + + // If we kept forums but no category bucket exists, keep original categories (best effort) + if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) { + $out['Forum_Category'] = $forumCat; + } + + $out = array_filter($out); + $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $out) : $out; + + $this->logDebug('[filterSelection] end (forums)', [ + 'kept_types' => array_keys((array) $course->resources), + ]); + + return $course; + } + } + + // ---------- Generic + quiz/survey/gradebook ---------- + $keep = []; + foreach ($selected as $type => $ids) { + if (!\is_array($ids) || empty($ids)) { + continue; + } + $legacyKey = $this->findBucketKey($orig, (string) $type); + if (null === $legacyKey) { + continue; + } + $bucket = $orig[$legacyKey] ?? []; + if (!empty($bucket) && \is_array($bucket)) { + $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true); + $keep[$legacyKey] = $this->intersectBucketByIds($bucket, $idsMap); + } + } + + // Gradebook + $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']); + if ($gbKey && !empty($selected['gradebook'])) { + $gbBucket = $getBucket($orig, $gbKey); + if (!empty($gbBucket)) { + $selIds = array_keys(array_filter((array) $selected['gradebook'])); + $firstItem = reset($gbBucket); + + if (\in_array('all', $selIds, true) || !\is_object($firstItem)) { + $keep[$gbKey] = $gbBucket; + $this->logDebug('[filterSelection] kept full gradebook', ['key' => $gbKey, 'count' => \count($gbBucket)]); + } else { + $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true)); + $this->logDebug('[filterSelection] kept partial gradebook', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]); + } + } + } + + // Documents: add parent folders for selected files + $docKey = $this->firstExistingKey($orig, ['document', 'Document']); + if ($docKey && !empty($keep[$docKey])) { + $docBucket = $getBucket($orig, $docKey); + + $foldersByRel = []; + foreach ($docBucket as $fid => $res) { + $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res; + $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); + $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1)); + if (!$isFolder) { + continue; + } + + $p = (string) ($e->path ?? ''); + if ('' === $p) { + continue; + } + + // Match your current logic (strip "document/") + $rel = '/'.ltrim(substr($p, 8), '/'); + $rel = rtrim($rel, '/').'/'; + if ('//' !== $rel) { + $foldersByRel[$rel] = $fid; + } + } + + $needFolderIds = []; + foreach ($keep[$docKey] as $res) { + $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res; + $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); + $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1)); + if ($isFolder) { + continue; + } + + $p = (string) ($e->path ?? ''); + if ('' === $p) { + continue; + } + + $rel = '/'.ltrim(substr($p, 8), '/'); + $dir = rtrim(\dirname($rel), '/'); + if ('' === $dir) { + continue; + } + + $acc = ''; + foreach (array_filter(explode('/', $dir)) as $seg) { + $acc .= '/'.$seg; + $accKey = rtrim($acc, '/').'/'; + if (isset($foldersByRel[$accKey])) { + $needFolderIds[$foldersByRel[$accKey]] = true; + } + } + } + + if (!empty($needFolderIds)) { + $added = array_intersect_key($docBucket, $needFolderIds); + $keep[$docKey] += $added; + } + } + + // Links: keep categories used by selected links + $lnkKey = $this->firstExistingKey($orig, ['link', 'Link']); + if ($lnkKey && !empty($keep[$lnkKey])) { + $catIdsUsed = []; + foreach ($keep[$lnkKey] as $lWrap) { + $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap; + $cid = (int) ($L->category_id ?? 0); + if ($cid > 0) { + $catIdsUsed[(string) $cid] = true; + } + } + + $catKey = $this->firstExistingKey($orig, ['link_category', 'Link_Category']); + if ($catKey && !empty($catIdsUsed)) { + $catBucket = $getBucket($orig, $catKey); + if (!empty($catBucket)) { + $keep[$catKey] = array_intersect_key($catBucket, $catIdsUsed); + } + } + } + + $keep = array_filter($keep); + $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $keep) : $keep; + + $this->logDebug('[filterSelection] end (generic)', [ + 'kept_types' => array_keys((array) $course->resources), + ]); + + return $course; + } + + // ----------------------------------------------------------------------------- + // CC 1.3 helpers (optional, used by your controller) + // ----------------------------------------------------------------------------- + + /** + * Expand CC13 selection when categories/folders are selected. + * + * @param array $sel + * + * @return array> + */ + protected function expandCc13SelectionFromCategories(object $courseFull, array $sel): array + { + $res = (isset($courseFull->resources) && \is_array($courseFull->resources)) ? $courseFull->resources : []; + + $out = [ + 'documents' => [], + 'links' => [], + 'forums' => [], + ]; + + // Documents: if a selected doc is a folder, include all docs under its path prefix + $docBucket = $res['document'] ?? []; + $selectedDocs = (array) ($sel['documents'] ?? []); + if (\is_array($docBucket) && \is_array($selectedDocs)) { + $folderPaths = []; + foreach (array_keys($selectedDocs) as $idStr) { + $id = (int) $idStr; + if ($id <= 0) { + continue; + } + foreach ($docBucket as $d) { + if (!\is_object($d)) { + continue; + } + if ((int) ($d->source_id ?? 0) !== $id) { + continue; + } + $ft = strtolower((string) ($d->filetype ?? $d->file_type ?? '')); + if ('folder' === $ft) { + $p = (string) ($d->path ?? ''); + if ('' !== $p) { + $folderPaths[] = rtrim($p, '/').'/'; + } + } + } + $out['documents'][(string) $id] = true; + } + + if (!empty($folderPaths)) { + foreach ($docBucket as $d) { + if (!\is_object($d)) { + continue; + } + $sid = (int) ($d->source_id ?? 0); + if ($sid <= 0) { + continue; + } + $p = (string) ($d->path ?? ''); + foreach ($folderPaths as $fp) { + if ('' !== $p && str_starts_with($p, $fp)) { + $out['documents'][(string) $sid] = true; + } + } + } + } + } + + // Links: if a category is selected (rare), include all links from that category + $linkBucket = $res['link'] ?? []; + $selectedLinks = (array) ($sel['links'] ?? []); + if (\is_array($linkBucket) && \is_array($selectedLinks)) { + foreach (array_keys($selectedLinks) as $idStr) { + $id = (int) $idStr; + if ($id > 0) { + $out['links'][(string) $id] = true; + } + } + } + + // Forums: keep as-is + $selectedForums = (array) ($sel['forums'] ?? []); + foreach (array_keys($selectedForums) as $idStr) { + $id = (int) $idStr; + if ($id > 0) { + $out['forums'][(string) $id] = true; + } + } + + return $out; + } + + /** + * Filter course resources using a simplified selection structure (documents/links/forums). + * + * @param array> $selected + */ + protected function filterCourseResources(object $course, array $selected): void + { + if (empty($course->resources) || !\is_array($course->resources)) { + return; + } + + $orig = $course->resources; + $out = []; + + // documents -> document bucket + if (!empty($selected['documents'])) { + $docKey = $this->firstExistingKey($orig, ['document', 'Document']); + if ($docKey) { + $out[$docKey] = $this->intersectBucketByIds((array) ($orig[$docKey] ?? []), $selected['documents']); + } + } + + // links -> link bucket (+ keep categories best-effort) + if (!empty($selected['links'])) { + $lnkKey = $this->firstExistingKey($orig, ['link', 'Link']); + if ($lnkKey) { + $out[$lnkKey] = $this->intersectBucketByIds((array) ($orig[$lnkKey] ?? []), $selected['links']); + } + } + + // forums -> forum bucket + if (!empty($selected['forums'])) { + $fKey = $this->firstExistingKey($orig, ['forum', 'Forum']); + if ($fKey) { + $out[$fKey] = $this->intersectBucketByIds((array) ($orig[$fKey] ?? []), $selected['forums']); + } + } + + $course->resources = array_filter($out); + } +} diff --git a/src/CoreBundle/Controller/CourseMaintenanceController.php b/src/CoreBundle/Controller/CourseMaintenanceController.php index 9e7163a8828..767bc6d561d 100644 --- a/src/CoreBundle/Controller/CourseMaintenanceController.php +++ b/src/CoreBundle/Controller/CourseMaintenanceController.php @@ -6,7 +6,6 @@ namespace Chamilo\CoreBundle\Controller; -use __PHP_Incomplete_Class; use Chamilo\CoreBundle\Repository\Node\UserRepository; use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Capabilities; use Chamilo\CourseBundle\Component\CourseCopy\CommonCartridge\Builder\Cc13Export; @@ -16,38 +15,74 @@ use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder; use Chamilo\CourseBundle\Component\CourseCopy\CourseRecycler; use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer; -use Chamilo\CourseBundle\Component\CourseCopy\CourseSelectForm; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleExport; use Chamilo\CourseBundle\Component\CourseCopy\Moodle\Builder\MoodleImport; use CourseManager; use Doctrine\ORM\EntityManagerInterface; use RuntimeException; use stdClass; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\HttpFoundation\{BinaryFileResponse, JsonResponse, Request, ResponseHeaderBag}; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; use Throwable; -use UnserializeApi; use ZipArchive; -use const ARRAY_FILTER_USE_BOTH; use const DIRECTORY_SEPARATOR; use const FILTER_VALIDATE_BOOL; -use const JSON_PARTIAL_OUTPUT_ON_ERROR; -use const JSON_UNESCAPED_SLASHES; -use const JSON_UNESCAPED_UNICODE; use const PATHINFO_EXTENSION; #[IsGranted('ROLE_TEACHER')] #[Route('/course_maintenance/{node}', name: 'cm_', requirements: ['node' => '\d+'])] -class CourseMaintenanceController extends AbstractController +class CourseMaintenanceController extends AbstractCourseMaintenanceController { - /** - * @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1 - */ - private bool $debug = true; + private const IMPORT_ALLOWED_EXTENSIONS = ['zip', 'mbz', 'gz', 'tgz']; + private const CC13_ALLOWED_EXTENSIONS = ['imscc', 'zip']; + + private const LEGACY_SNAPSHOT_TOOLS = [ + 'documents', + 'forums', + 'tool_intro', + 'links', + 'quizzes', + 'quiz_questions', + 'assets', + 'surveys', + 'survey_questions', + 'announcements', + 'events', + 'course_descriptions', + 'glossary', + 'wiki', + 'thematic', + 'attendance', + 'works', + 'gradebook', + 'learnpath_category', + 'learnpaths', + ]; + + private const MOODLE_EXPORT_DEFAULT_TOOLS = [ + 'documents', 'links', 'forums', + 'quizzes', 'quiz_questions', + 'surveys', 'survey_questions', + 'learnpaths', 'learnpath_category', + 'works', 'glossary', + 'course_descriptions', + ]; + + private const MOODLE_EXPORT_RESOURCE_PICKER_DEFAULT_TOOLS = [ + 'documents', 'links', 'forums', + 'quizzes', 'quiz_questions', + 'surveys', 'survey_questions', + 'learnpaths', 'learnpath_category', + 'works', 'glossary', + 'tool_intro', + 'course_descriptions', + ]; #[Route('/import/options', name: 'import_options', methods: ['GET'])] public function importOptions(int $node, Request $req): JsonResponse @@ -74,32 +109,32 @@ public function importUpload(int $node, Request $req): JsonResponse $file = $req->files->get('file'); if (!$file || !$file->isValid()) { - return $this->json(['error' => 'Invalid upload'], 400); + return $this->jsonError('Invalid upload', 400); } $maxBytes = self::getPhpUploadLimitBytes(); $fileSize = (int) ($file->getSize() ?? 0); if ($maxBytes > 0 && $fileSize > $maxBytes) { - return $this->json(['error' => 'File too large'], 413); + return $this->jsonError('File too large', 413, ['maxBytes' => $maxBytes]); } - $allowed = ['zip', 'mbz', 'gz', 'tgz']; - $ext = strtolower($file->guessExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION)); - if (!\in_array($ext, $allowed, true)) { - return $this->json(['error' => 'Unsupported file type'], 415); + $ext = $this->detectUploadExtension($file->guessExtension(), $file->getClientOriginalName()); + if (!\in_array($ext, self::IMPORT_ALLOWED_EXTENSIONS, true)) { + return $this->jsonError('Unsupported file type', 415, ['allowed' => self::IMPORT_ALLOWED_EXTENSIONS]); } $this->logDebug('[importUpload] received', [ 'original_name' => $file->getClientOriginalName(), 'size' => $file->getSize(), 'mime' => $file->getClientMimeType(), + 'ext' => $ext, ]); $backupId = CourseArchiver::importUploadedFile($file->getRealPath()); if (false === $backupId) { $this->logDebug('[importUpload] archive dir not writable'); - return $this->json(['error' => 'Archive directory is not writable'], 500); + return $this->jsonError('Archive directory is not writable', 500); } $this->logDebug('[importUpload] stored', ['backupId' => $backupId]); @@ -114,20 +149,26 @@ public function importUpload(int $node, Request $req): JsonResponse public function importServerPick(int $node, Request $req): JsonResponse { $this->setDebugFromRequest($req); - $payload = json_decode($req->getContent() ?: '{}', true); + $payload = $this->getJsonPayload($req); - $filename = basename((string) ($payload['filename'] ?? '')); - if ('' === $filename || preg_match('/[\/\\\]/', $filename)) { - return $this->json(['error' => 'Invalid filename'], 400); + $filename = $this->validateBackupFilename((string) ($payload['filename'] ?? '')); + if ('' === $filename) { + return $this->jsonError('Invalid filename', 400); } - $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename; - $realBase = realpath(CourseArchiver::getBackupDir()); + $path = rtrim((string) CourseArchiver::getBackupDir(), '/').'/'.$filename; + $realBase = realpath((string) CourseArchiver::getBackupDir()); $realPath = realpath($path); - if (!$realBase || !$realPath || 0 !== strncmp($realBase, $realPath, \strlen($realBase)) || !is_file($realPath)) { + + if ( + !$realBase + || !$realPath + || 0 !== strncmp($realBase, $realPath, \strlen($realBase)) + || !is_file($realPath) + ) { $this->logDebug('[importServerPick] file not found or outside base', ['path' => $path]); - return $this->json(['error' => 'File not found'], 404); + return $this->jsonError('File not found', 404); } $this->logDebug('[importServerPick] ok', ['backupId' => $filename]); @@ -144,9 +185,11 @@ public function importServerPick(int $node, Request $req): JsonResponse public function importResources(int $node, string $backupId, Request $req): JsonResponse { $this->setDebugFromRequest($req); + $mode = strtolower((string) $req->query->get('mode', 'auto')); // 'auto' | 'dat' | 'moodle' + $source = ('dat' === $mode) ? 'chamilo' : $mode; - $course = $this->loadLegacyCourseForAnyBackup($backupId, 'dat' === $mode ? 'chamilo' : $mode); + $course = $this->loadLegacyCourseForAnyBackup($backupId, $source); $this->logDebug('[importResources] course loaded', [ 'has_resources' => \is_array($course->resources ?? null), @@ -155,14 +198,9 @@ public function importResources(int $node, string $backupId, Request $req): Json $tree = $this->buildResourceTreeForVue($course); - $warnings = []; - if (empty($tree)) { - $warnings[] = 'Backup has no selectable resources.'; - } - return $this->json([ 'tree' => $tree, - 'warnings' => $warnings, + 'warnings' => empty($tree) ? ['Backup has no selectable resources.'] : [], 'meta' => ['import_source' => $course->resources['__meta']['import_source'] ?? null], ]); } @@ -173,21 +211,18 @@ public function importResources(int $node, string $backupId, Request $req): Json requirements: ['backupId' => '.+'], methods: ['POST'] )] - public function importRestore( - int $node, - string $backupId, - Request $req, - EntityManagerInterface $em - ): JsonResponse { + public function importRestore(int $node, string $backupId, Request $req, 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 + $payload = $this->getJsonPayload($req); + $mode = strtolower((string) ($payload['mode'] ?? 'auto')); + $source = ('dat' === $mode) ? 'chamilo' : $mode; - $importOption = (string) ($payload['importOption'] ?? 'full_backup'); + $importOption = (string) ($payload['importOption'] ?? 'full_backup'); // full_backup | select_items $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); /** @var array $selectedResources */ @@ -204,68 +239,52 @@ public function importRestore( 'mode' => $mode, ]); - // Load with same mode to avoid switching source on POST - $course = $this->loadLegacyCourseForAnyBackup($backupId, 'dat' === $mode ? 'chamilo' : $mode); + $course = $this->loadLegacyCourseForAnyBackup($backupId, $source); if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) { - return $this->json(['error' => 'Backup has no resources'], 400); + return $this->jsonError('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) + // Detect source BEFORE any filtering $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; + $this->logDebug('[importRestore] detected import source', [ + 'import_source' => $importSource, + 'isMoodle' => $isMoodle, + ]); - break; - } - } - if (!$hasAny) { - return $this->json(['error' => 'No resources selected'], 400); - } + // Selection (optional) + if ('select_items' === $importOption) { + $selectedResources = $this->ensureSelectionMap($course, $selectedResources, $selectedTypes); $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); + return $this->jsonError('Selection produced no resources to restore', 400); } } - $this->logDebug('[importRestore] AFTER filter keys', array_keys((array) $course->resources)); - - // NON-MOODLE + // NON-MOODLE -> CourseRestorer 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)); $restorer = new CourseRestorer($course); $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption)); + if (method_exists($restorer, 'setResourcesAllSnapshot')) { $restorer->setResourcesAllSnapshot($resourcesAll); } if (method_exists($restorer, 'setDebug')) { $restorer->setDebug($this->debug); } - $restorer->restore(); + $restorer->restore(); CourseArchiver::cleanBackupDir(); $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0); @@ -278,35 +297,16 @@ public function importRestore( ]); } - // MOODLE + // MOODLE -> MoodleImport restore* family $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'); - } - + $wantedGroups = $this->computeWantedMoodleGroups($course); if (empty($wantedGroups)) { CourseArchiver::cleanBackupDir(); @@ -320,34 +320,18 @@ public function importRestore( $importer = new MoodleImport(debug: $this->debug); $stats = []; - // LINKS if (!empty($wantedGroups['links']) && method_exists($importer, 'restoreLinks')) { $stats['links'] = $importer->restoreLinks($backupPath, $em, $cid, $sid, $course); } - - // FORUMS if (!empty($wantedGroups['forums']) && method_exists($importer, 'restoreForums')) { $stats['forums'] = $importer->restoreForums($backupPath, $em, $cid, $sid, $course); } - - // DOCUMENTS if (!empty($wantedGroups['documents']) && method_exists($importer, 'restoreDocuments')) { - $stats['documents'] = $importer->restoreDocuments( - $backupPath, - $em, - $cid, - $sid, - $sameFileNameOption, - $course - ); + $stats['documents'] = $importer->restoreDocuments($backupPath, $em, $cid, $sid, $sameFileNameOption, $course); } - - // QUIZZES if (!empty($wantedGroups['quizzes']) && method_exists($importer, 'restoreQuizzes')) { $stats['quizzes'] = $importer->restoreQuizzes($backupPath, $em, $cid, $sid); } - - // SCORM if (!empty($wantedGroups['scorm']) && method_exists($importer, 'restoreScorm')) { $stats['scorm'] = $importer->restoreScorm($backupPath, $em, $cid, $sid); } @@ -382,10 +366,14 @@ public function copyOptions(int $node, Request $req): JsonResponse $courses = []; foreach ($courseList as $c) { - if ((int) $c['real_id'] === (int) $current['real_id']) { + if ((int) $c['real_id'] === (int) ($current['real_id'] ?? 0)) { continue; } - $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']]; + $courses[] = [ + 'id' => (string) $c['code'], + 'code' => (string) $c['code'], + 'title' => (string) $c['title'], + ]; } return $this->json([ @@ -403,45 +391,22 @@ public function copyOptions(int $node, Request $req): JsonResponse public function copyResources(int $node, Request $req): JsonResponse { $this->setDebugFromRequest($req); + $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', '')); if ('' === $sourceCourseCode) { - return $this->json(['error' => 'Missing sourceCourseId'], 400); + return $this->jsonError('Missing sourceCourseId', 400); } $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', - 'thematic', - 'attendance', - 'works', - 'gradebook', - 'learnpath_category', - 'learnpaths', - ]); + $cb->set_tools_to_build(self::LEGACY_SNAPSHOT_TOOLS); $course = $cb->build(0, $sourceCourseCode); - $tree = $this->buildResourceTreeForVue($course); - $warnings = []; - if (empty($tree)) { - $warnings[] = 'Source course has no resources.'; - } - - return $this->json(['tree' => $tree, 'warnings' => $warnings]); + return $this->json([ + 'tree' => $tree, + 'warnings' => empty($tree) ? ['Source course has no resources.'] : [], + ]); } #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])] @@ -450,61 +415,41 @@ public function copyExecute(int $node, Request $req): JsonResponse $this->setDebugFromRequest($req); try { - $payload = json_decode($req->getContent() ?: '{}', true); + $payload = $this->getJsonPayload($req); $sourceCourseId = (string) ($payload['sourceCourseId'] ?? ''); - $copyOption = (string) ($payload['copyOption'] ?? 'full_copy'); + $copyOption = (string) ($payload['copyOption'] ?? 'full_copy'); // full_copy | select_items $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); $selectedResourcesMap = (array) ($payload['resources'] ?? []); if ('' === $sourceCourseId) { - return $this->json(['error' => 'Missing sourceCourseId'], 400); + return $this->jsonError('Missing sourceCourseId', 400); } $cb = new CourseBuilder('partial'); - $cb->set_tools_to_build([ - 'documents', - 'forums', - 'tool_intro', - 'links', - 'quizzes', - 'quiz_questions', - 'assets', - 'surveys', - 'survey_questions', - 'announcements', - 'events', - 'course_descriptions', - 'glossary', - 'wiki', - 'thematic', - 'attendance', - 'works', - 'gradebook', - 'learnpath_category', - 'learnpaths', - ]); + $cb->set_tools_to_build(self::LEGACY_SNAPSHOT_TOOLS); + $legacyCourse = $cb->build(0, $sourceCourseId); if ('select_items' === $copyOption) { $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap); if (empty($legacyCourse->resources) || !\is_array($legacyCourse->resources)) { - return $this->json(['error' => 'Selection produced no resources to copy'], 400); + return $this->jsonError('Selection produced no resources to copy', 400); } } - error_log('$legacyCourse :::: '.print_r($legacyCourse, true)); - $restorer = new CourseRestorer($legacyCourse); $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption)); + if (method_exists($restorer, 'setDebug')) { $restorer->setDebug($this->debug); } + $restorer->restore(); $dest = api_get_course_info(); - $redirectUrl = \sprintf('/course/%d/home', (int) $dest['real_id']); + $redirectUrl = \sprintf('/course/%d/home', (int) ($dest['real_id'] ?? 0)); return $this->json([ 'ok' => true, @@ -524,13 +469,12 @@ public function recycleOptions(int $node, Request $req): JsonResponse { $this->setDebugFromRequest($req); - // current course only - $defaults = [ - 'recycleOption' => 'select_items', // 'full_recycle' | 'select_items' - 'confirmNeeded' => true, // show code-confirm input when full - ]; - - return $this->json(['defaults' => $defaults]); + return $this->json([ + 'defaults' => [ + 'recycleOption' => 'select_items', // full_recycle | select_items + 'confirmNeeded' => true, // show code-confirm input when full + ], + ]); } #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])] @@ -538,39 +482,39 @@ public function recycleResources(int $node, Request $req): JsonResponse { $this->setDebugFromRequest($req); - // Build legacy Course from CURRENT course (not “source”) $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', - 'thematic', 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', - ]); - $course = $cb->build(0, api_get_course_id()); + $cb->set_tools_to_build(self::LEGACY_SNAPSHOT_TOOLS); + $course = $cb->build(0, api_get_course_id()); $tree = $this->buildResourceTreeForVue($course); - $warnings = empty($tree) ? ['This course has no resources.'] : []; - return $this->json(['tree' => $tree, 'warnings' => $warnings]); + return $this->json([ + 'tree' => $tree, + 'warnings' => empty($tree) ? ['This course has no resources.'] : [], + ]); } #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])] public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse { + $this->setDebugFromRequest($req); + try { - $p = json_decode($req->getContent() ?: '{}', true); - $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items' + $p = $this->getJsonPayload($req); + + $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // full_recycle | select_items $resourcesMap = (array) ($p['resources'] ?? []); $confirmCode = (string) ($p['confirm'] ?? ''); - $type = 'full_recycle' === $recycleOption ? 'full_backup' : 'select_items'; + $type = ('full_recycle' === $recycleOption) ? 'full_backup' : 'select_items'; if ('full_backup' === $type) { if ($confirmCode !== api_get_course_id()) { - return $this->json(['error' => 'Course code confirmation mismatch'], 400); + return $this->jsonError('Course code confirmation mismatch', 400); } } else { if (empty($resourcesMap)) { - return $this->json(['error' => 'No resources selected'], 400); + return $this->jsonError('No resources selected', 400); } } @@ -578,15 +522,10 @@ public function recycleExecute(Request $req, EntityManagerInterface $em): JsonRe $courseInfo = api_get_course_info($courseCode); $courseId = (int) ($courseInfo['real_id'] ?? 0); if ($courseId <= 0) { - return $this->json(['error' => 'Invalid course id'], 400); + return $this->jsonError('Invalid course id', 400); } - $recycler = new CourseRecycler( - $em, - $courseCode, - $courseId - ); - + $recycler = new CourseRecycler($em, $courseCode, $courseId); $recycler->recycle($type, $resourcesMap); return $this->json([ @@ -609,62 +548,56 @@ public function deleteCourse(int $node, Request $req): JsonResponse && !$this->isGranted('ROLE_TEACHER') && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER') ) { - return $this->json(['error' => 'You are not allowed to delete this course'], 403); + return $this->jsonError('You are not allowed to delete this course', 403); } + $this->setDebugFromRequest($req); + try { - $payload = json_decode($req->getContent() ?: '{}', true); + $payload = $this->getJsonPayload($req); $confirm = trim((string) ($payload['confirm'] ?? '')); if ('' === $confirm) { - return $this->json(['error' => 'Missing confirmation value'], 400); + return $this->jsonError('Missing confirmation value', 400); } - // Optional flag: also delete orphan documents that belong only to this course - // Accepts 1/0, true/false, "1"/"0" + // Optional: also delete orphan documents that belong only to this course $deleteDocsRaw = $payload['delete_docs'] ?? 0; - $deleteDocs = filter_var($deleteDocsRaw, FILTER_VALIDATE_BOOL); + $deleteDocs = (bool) filter_var($deleteDocsRaw, FILTER_VALIDATE_BOOL); - // Current course $courseInfo = api_get_course_info(); if (empty($courseInfo)) { - return $this->json(['error' => 'Unable to resolve current course'], 400); + return $this->jsonError('Unable to resolve current course', 400); } $officialCode = (string) ($courseInfo['official_code'] ?? ''); - $runtimeCode = (string) api_get_course_id(); // often equals official code - $sysCode = (string) ($courseInfo['sysCode'] ?? ''); // used by legacy delete + $runtimeCode = (string) api_get_course_id(); + $sysCode = (string) ($courseInfo['sysCode'] ?? ''); if ('' === $sysCode) { - return $this->json(['error' => 'Invalid course system code'], 400); + return $this->jsonError('Invalid course system code', 400); } - // Accept either official_code or api_get_course_id() as confirmation $matches = hash_equals($officialCode, $confirm) || hash_equals($runtimeCode, $confirm); if (!$matches) { - return $this->json(['error' => 'Course code confirmation mismatch'], 400); + return $this->jsonError('Course code confirmation mismatch', 400); } - // Legacy delete (removes course data + unregisters members in this course) - // Now with optional orphan-docs deletion flag. CourseManager::delete_course($sysCode, $deleteDocs); - // Best-effort cleanup of legacy course session flags + // Best-effort cleanup try { $ses = $req->getSession(); $ses?->remove('_cid'); $ses?->remove('_real_cid'); - } catch (Throwable $e) { - // swallow — not critical + } catch (Throwable) { + // Not critical } - // Decide where to send the user afterwards - $redirectUrl = '/index.php'; - return $this->json([ 'ok' => true, 'message' => 'Course deleted successfully', - 'redirectUrl' => $redirectUrl, + 'redirectUrl' => '/index.php', ]); } catch (Throwable $e) { return $this->json([ @@ -677,6 +610,8 @@ public function deleteCourse(int $node, Request $req): JsonResponse #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])] public function moodleExportOptions(int $node, Request $req, UserRepository $users): JsonResponse { + $this->setDebugFromRequest($req); + $defaults = [ 'moodleVersion' => '4', 'scope' => 'full', @@ -711,55 +646,35 @@ public function moodleExportOptions(int $node, Request $req, UserRepository $use #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])] public function moodleExportResources(int $node, Request $req): JsonResponse { - // Enable/disable debug from request $this->setDebugFromRequest($req); $this->logDebug('[moodleExportResources] start', ['node' => $node]); try { - // Normalize incoming tools from query (?tools[]=documents&tools[]=links ...) $selectedTools = $this->normalizeSelectedTools($req->query->all('tools')); + $toolsToBuild = !empty($selectedTools) + ? $selectedTools + : self::MOODLE_EXPORT_RESOURCE_PICKER_DEFAULT_TOOLS; - // Default toolset tailored for Moodle export picker: - $defaultToolsForMoodle = [ - 'documents', 'links', 'forums', - 'quizzes', 'quiz_questions', - 'surveys', 'survey_questions', - 'learnpaths', 'learnpath_category', - 'works', 'glossary', - 'tool_intro', - 'course_descriptions', - ]; - - // Use client tools if provided; otherwise our Moodle-safe defaults - $tools = !empty($selectedTools) ? $selectedTools : $defaultToolsForMoodle; - - // Policy for this endpoint: - // - Never show gradebook - // - Always include tool_intro in the picker (harmless if empty) - $tools = array_values(array_diff($tools, ['gradebook'])); - if (!\in_array('tool_intro', $tools, true)) { - $tools[] = 'tool_intro'; + // Policy: never show gradebook, always include tool_intro. + $toolsToBuild = array_values(array_diff($toolsToBuild, ['gradebook'])); + if (!\in_array('tool_intro', $toolsToBuild, true)) { + $toolsToBuild[] = 'tool_intro'; } + $toolsToBuild = array_values(array_unique($toolsToBuild)); - $this->logDebug('[moodleExportResources] tools to build', $tools); + $this->logDebug('[moodleExportResources] tools to build', $toolsToBuild); - // Build legacy Course snapshot from CURRENT course (not from a source course) $cb = new CourseBuilder(); - $cb->set_tools_to_build($tools); + $cb->set_tools_to_build($toolsToBuild); $course = $cb->build(0, api_get_course_id()); - // Build the UI-friendly tree $tree = $this->buildResourceTreeForVue($course); - // Basic warnings for the client - $warnings = empty($tree) ? ['This course has no resources.'] : []; - - // Some compact debug about the resulting tree if ($this->debug) { $this->logDebug( '[moodleExportResources] tree summary', array_map( - fn ($g) => [ + static fn ($g) => [ 'type' => $g['type'] ?? '', 'title' => $g['title'] ?? '', 'items' => isset($g['items']) ? \count((array) $g['items']) : null, @@ -772,10 +687,9 @@ public function moodleExportResources(int $node, Request $req): JsonResponse return $this->json([ 'tree' => $tree, - 'warnings' => $warnings, + 'warnings' => empty($tree) ? ['This course has no resources.'] : [], ]); } catch (Throwable $e) { - // Defensive error path $this->logDebug('[moodleExportResources] exception', [ 'message' => $e->getMessage(), 'file' => $e->getFile().':'.$e->getLine(), @@ -793,31 +707,25 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use { $this->setDebugFromRequest($req); - $p = json_decode($req->getContent() ?: '{}', true) ?: []; - $moodleVersion = (string) ($p['moodleVersion'] ?? '4'); // "3" | "4" - $scope = (string) ($p['scope'] ?? 'full'); // "full" | "selected" + $p = $this->getJsonPayload($req); + + $moodleVersion = (string) ($p['moodleVersion'] ?? '4'); // "3" | "4" + $scope = (string) ($p['scope'] ?? 'full'); // "full" | "selected" + $adminId = (int) ($p['adminId'] ?? 0); $adminLogin = trim((string) ($p['adminLogin'] ?? '')); $adminEmail = trim((string) ($p['adminEmail'] ?? '')); + $selected = \is_array($p['resources'] ?? null) ? (array) $p['resources'] : []; $toolsInput = \is_array($p['tools'] ?? null) ? (array) $p['tools'] : []; if (!\in_array($moodleVersion, ['3', '4'], true)) { - return $this->json(['error' => 'Unsupported Moodle version'], 400); + return $this->jsonError('Unsupported Moodle version', 400); } if ('selected' === $scope && empty($selected)) { - return $this->json(['error' => 'No resources selected'], 400); + return $this->jsonError('No resources selected', 400); } - $defaultTools = [ - 'documents', 'links', 'forums', - 'quizzes', 'quiz_questions', - 'surveys', 'survey_questions', - 'learnpaths', 'learnpath_category', - 'works', 'glossary', - 'course_descriptions', - ]; - $tools = $this->normalizeSelectedTools($toolsInput); // If scope=selected, merge inferred tools from selection @@ -826,18 +734,16 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use $tools = $this->normalizeSelectedTools(array_merge($tools, $inferred)); } - // Remove unsupported tools + // Remove unsupported tools + dedupe $tools = array_values(array_unique(array_diff($tools, ['gradebook']))); + $clientSentNoTools = empty($toolsInput); - $useDefault = ('full' === $scope && $clientSentNoTools); - $toolsToBuild = $useDefault ? $defaultTools : $tools; + $toolsToBuild = ('full' === $scope && $clientSentNoTools) ? self::MOODLE_EXPORT_DEFAULT_TOOLS : $tools; - // Ensure "tool_intro" is present (append only if missing) + // Always ensure tool_intro exists if (!\in_array('tool_intro', $toolsToBuild, true)) { $toolsToBuild[] = 'tool_intro'; } - - // Final dedupe/normalize $toolsToBuild = array_values(array_unique($toolsToBuild)); $this->logDebug('[moodleExportExecute] course tools to build (final)', $toolsToBuild); @@ -851,7 +757,7 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use $courseId = api_get_course_id(); if (empty($courseId)) { - return $this->json(['error' => 'No active course context'], 400); + return $this->jsonError('No active course context', 400); } $cb = new CourseBuilder(); @@ -861,13 +767,13 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use if ('selected' === $scope) { $course = $this->filterLegacyCourseBySelection($course, $selected); if (empty($course->resources) || !\is_array($course->resources)) { - return $this->json(['error' => 'Selection produced no resources to export'], 400); + return $this->jsonError('Selection produced no resources to export', 400); } } try { - // === Export to Moodle MBZ === $selectionMode = ('selected' === $scope); + $exporter = new MoodleExport($course, $selectionMode); $exporter->setAdminUserData($adminId, $adminLogin, $adminEmail); @@ -885,15 +791,11 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use $mbzPath = $exporter->export($courseId, $exportDir, $versionNum); if (!\is_string($mbzPath) || '' === $mbzPath || !is_file($mbzPath)) { - return $this->json(['error' => 'Moodle export failed: artifact not found'], 500); + return $this->jsonError('Moodle export failed: artifact not found', 500); } - // Build download response $resp = new BinaryFileResponse($mbzPath); - $resp->setContentDisposition( - ResponseHeaderBag::DISPOSITION_ATTACHMENT, - basename($mbzPath) - ); + $resp->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, basename($mbzPath)); $resp->headers->set('X-Moodle-Version', (string) $versionNum); $resp->headers->set('X-Export-Scope', $scope); $resp->headers->set('X-Selection-Mode', $selectionMode ? '1' : '0'); @@ -905,48 +807,8 @@ public function moodleExportExecute(int $node, Request $req, UserRepository $use 'code' => (int) $e->getCode(), ]); - return $this->json(['error' => 'Moodle export failed: '.$e->getMessage()], 500); - } - } - - /** - * Normalize tool list to supported ones and add implied dependencies. - * - * @param array|null $tools - * - * @return string[] - */ - 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', - 'attendance', 'works', 'gradebook', 'learnpath_category', 'learnpaths', 'tool_intro', 'forums', - ]; - - // Implied dependencies - $deps = [ - 'quizzes' => ['quiz_questions'], - 'surveys' => ['survey_questions'], - 'learnpaths' => ['learnpath_category'], - ]; - - $sel = \is_array($tools) ? array_values(array_intersect($tools, $all)) : []; - - foreach ($sel as $t) { - foreach ($deps[$t] ?? [] as $d) { - $sel[] = $d; - } + return $this->jsonError('Moodle export failed: '.$e->getMessage(), 500); } - - // Unique and preserve a sane order based on $all - $sel = array_values(array_unique($sel)); - usort($sel, static function ($a, $b) use ($all) { - return array_search($a, $all, true) <=> array_search($b, $all, true); - }); - - return $sel; } #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])] @@ -956,7 +818,7 @@ public function cc13ExportOptions(int $node, Request $req): JsonResponse return $this->json([ 'defaults' => ['scope' => 'full'], - 'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum'] + 'supportedTypes' => Cc13Capabilities::exportableTypes(), 'message' => 'Common Cartridge 1.3: documents (webcontent) and links (HTML stub as webcontent). Forums not exported yet.', ]); } @@ -973,7 +835,6 @@ public function cc13ExportResources(int $node, Request $req): JsonResponse $treeAll = $this->buildResourceTreeForVue($course); $tree = Cc13Capabilities::filterTree($treeAll); - // Count exportables using "items" $exportableCount = 0; foreach ($tree as $group) { if (empty($group['items']) || !\is_array($group['items'])) { @@ -993,31 +854,28 @@ public function cc13ExportResources(int $node, Request $req): JsonResponse } } - $warnings = []; - if (0 === $exportableCount) { - $warnings[] = 'This course has no CC 1.3 exportable resources (documents, links or forums).'; - } - return $this->json([ - 'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document','link','forum'] + 'supportedTypes' => Cc13Capabilities::exportableTypes(), 'tree' => $tree, 'preview' => ['counts' => ['total' => $exportableCount]], - 'warnings' => $warnings, + 'warnings' => (0 === $exportableCount) + ? ['This course has no CC 1.3 exportable resources (documents, links or forums).'] + : [], ]); } #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])] public function cc13ExportExecute(int $node, Request $req): JsonResponse { - $payload = json_decode((string) $req->getContent(), true) ?: []; - // If the client sent "resources", treat as selected even if scope says "full". + $this->setDebugFromRequest($req); + + $payload = $this->getJsonPayload($req); + $scope = (string) ($payload['scope'] ?? (!empty($payload['resources']) ? 'selected' : 'full')); $selected = (array) ($payload['resources'] ?? []); - // Normalize selection structure (documents/links/forums/…) $normSel = Cc13Capabilities::filterSelection($selected); - // Builder setup $tools = ['documents', 'links', 'forums']; $cb = new CourseBuilder(); @@ -1028,14 +886,12 @@ public function cc13ExportExecute(int $node, Request $req): JsonResponse $courseFull = null; if ('selected' === $scope) { - // Build a full snapshot first to expand any category-only selections. $cbFull = new CourseBuilder(); $cbFull->set_tools_to_build($tools); $courseFull = $cbFull->build(0, api_get_course_id()); $expanded = $this->expandCc13SelectionFromCategories($courseFull, $normSel); - // Build per-tool ID map for CourseBuilder $map = []; if (!empty($expanded['documents'])) { $map['documents'] = array_map('intval', array_keys($expanded['documents'])); @@ -1048,7 +904,7 @@ public function cc13ExportExecute(int $node, Request $req): JsonResponse } if (empty($map)) { - return $this->json(['error' => 'Please select at least one resource.'], 400); + return $this->jsonError('Please select at least one resource.', 400); } $cb->set_tools_to_build($tools); @@ -1060,42 +916,41 @@ public function cc13ExportExecute(int $node, Request $req): JsonResponse $course = $cb->build(0, api_get_course_id()); - // Safety net: if selection mode, ensure resources are filtered if ($selectionMode) { - // Convert to the expected structure for filterCourseResources() $safeSelected = [ 'documents' => array_fill_keys(array_map('intval', array_keys($normSel['documents'] ?? [])), true), 'links' => array_fill_keys(array_map('intval', array_keys($normSel['links'] ?? [])), true), 'forums' => array_fill_keys(array_map('intval', array_keys($normSel['forums'] ?? [])), true), ]; - // Also include expansions from categories + $fullSnapshot = $courseFull ?: $course; $expandedAll = $this->expandCc13SelectionFromCategories($fullSnapshot, $normSel); + foreach (['documents', 'links', 'forums'] as $k) { - if (!isset($expandedAll[$k])) { + if (empty($expandedAll[$k])) { continue; } - foreach (array_keys($expandedAll[$k]) as $idStr) { $safeSelected[$k][(int) $idStr] = true; } } $this->filterCourseResources($course, $safeSelected); + if (empty($course->resources) || !\is_array($course->resources)) { - return $this->json(['error' => 'Nothing to export after filtering your selection.'], 400); + return $this->jsonError('Nothing to export after filtering your selection.', 400); } } $exporter = new Cc13Export($course, $selectionMode, /* debug */ false); $imsccPath = $exporter->export(api_get_course_id()); - $fileName = basename($imsccPath); + $fileName = basename($imsccPath); $downloadUrl = $this->generateUrl( - 'cm_cc13_export_download', - ['node' => $node], - UrlGeneratorInterface::ABSOLUTE_URL - ).'?file='.rawurlencode($fileName); + 'cm_cc13_export_download', + ['node' => $node], + UrlGeneratorInterface::ABSOLUTE_URL + ).'?file='.rawurlencode($fileName); return $this->json([ 'ok' => true, @@ -1105,32 +960,30 @@ public function cc13ExportExecute(int $node, Request $req): JsonResponse ]); } catch (RuntimeException $e) { if (false !== stripos($e->getMessage(), 'Nothing to export')) { - return $this->json(['error' => 'Nothing to export (no compatible resources found).'], 400); + return $this->jsonError('Nothing to export (no compatible resources found).', 400); } - return $this->json(['error' => 'CC 1.3 export failed: '.$e->getMessage()], 500); + return $this->jsonError('CC 1.3 export failed: '.$e->getMessage(), 500); } } #[Route('/cc13/export/download', name: 'cc13_export_download', methods: ['GET'])] public function cc13ExportDownload(int $node, Request $req): BinaryFileResponse|JsonResponse { - // Validate the filename we will serve $file = basename((string) $req->query->get('file', '')); + // Example pattern: ABC123_cc13_20251017_195455.imscc if ('' === $file || !preg_match('/^[A-Za-z0-9_-]+_cc13_\d{8}_\d{6}\.imscc$/', $file)) { - return $this->json(['error' => 'Invalid file'], 400); + return $this->jsonError('Invalid file', 400); } $abs = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$file; if (!is_file($abs)) { - return $this->json(['error' => 'File not found'], 404); + return $this->jsonError('File not found', 404); } - // 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; @@ -1144,36 +997,32 @@ public function cc13Import(int $node, Request $req): JsonResponse try { $file = $req->files->get('file'); if (!$file || !$file->isValid()) { - return $this->json(['error' => 'Missing or invalid upload.'], 400); + return $this->jsonError('Missing or invalid upload.', 400); } - $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION)); - if (!\in_array($ext, ['imscc', 'zip'], true)) { - return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 415); + $ext = strtolower(pathinfo((string) ($file->getClientOriginalName() ?? ''), PATHINFO_EXTENSION)); + if (!\in_array($ext, self::CC13_ALLOWED_EXTENSIONS, true)) { + return $this->jsonError('Unsupported file type. Please upload .imscc or .zip', 415); } - // Move to a temp file - $tmpZip = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR. - 'cc13_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)).'.'.$ext; + $tmpZip = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR + .'cc13_'.date('Ymd_His').'_'.bin2hex(random_bytes(3)).'.'.$ext; + $file->move(\dirname($tmpZip), basename($tmpZip)); - // Extract $extractDir = Imscc13Import::unzip($tmpZip); - // Detect and validate format $format = Imscc13Import::detectFormat($extractDir); if (Imscc13Import::FORMAT_IMSCC13 !== $format) { Imscc13Import::rrmdir($extractDir); @unlink($tmpZip); - return $this->json(['error' => 'This package is not a Common Cartridge 1.3.'], 400); + return $this->jsonError('This package is not a Common Cartridge 1.3.', 400); } - // Execute import (creates Chamilo resources) $importer = new Imscc13Import(); $importer->execute($extractDir); - // Cleanup Imscc13Import::rrmdir($extractDir); @unlink($tmpZip); @@ -1182,9 +1031,7 @@ public function cc13Import(int $node, Request $req): JsonResponse 'message' => 'CC 1.3 import completed successfully.', ]); } catch (Throwable $e) { - return $this->json([ - 'error' => 'CC 1.3 import failed: '.$e->getMessage(), - ], 500); + return $this->jsonError('CC 1.3 import failed: '.$e->getMessage(), 500); } } @@ -1200,13 +1047,14 @@ public function importDiagnose(int $node, string $backupId, Request $req): JsonR $this->logDebug('[importDiagnose] begin', ['node' => $node, 'backupId' => $backupId]); try { - // Resolve absolute path of the uploaded/selected backup $path = $this->resolveBackupPath($backupId); if (!is_file($path)) { - return $this->json(['error' => 'Backup file not found', 'path' => $path], 404); + return $this->json([ + 'error' => 'Backup file not found', + 'path' => $path, + ], 404); } - // Read course_info.dat bytes from ZIP $ci = $this->readCourseInfoFromZip($path); if (empty($ci['ok'])) { $this->logDebug('[importDiagnose] course_info.dat not found or unreadable', $ci); @@ -1228,10 +1076,8 @@ public function importDiagnose(int $node, string $backupId, Request $req): JsonR $size = (int) ($ci['size'] ?? \strlen($raw)); $md5 = md5($raw); - // Detect & decode content $probe = $this->decodeCourseInfo($raw); - // Build a tiny scan snapshot (only keys, no grafo) $scan = [ 'has_graph' => false, 'resources_keys' => [], @@ -1239,7 +1085,6 @@ public function importDiagnose(int $node, string $backupId, Request $req): JsonR ]; if (!empty($probe['is_serialized']) && isset($probe['value']) && \is_object($probe['value'])) { - /** @var object $course */ $course = $probe['value']; $scan['has_graph'] = true; $scan['resources_keys'] = (isset($course->resources) && \is_array($course->resources)) @@ -1286,2176 +1131,186 @@ public function importDiagnose(int $node, string $backupId, Request $req): JsonR } catch (Throwable $e) { $this->logDebug('[importDiagnose] exception', ['message' => $e->getMessage()]); - return $this->json([ - 'error' => 'Diagnosis failed: '.$e->getMessage(), - ], 500); + return $this->jsonError('Diagnosis failed: '.$e->getMessage(), 500); } } /** - * Try to detect and decode course_info.dat content. - * Hardened: preprocess typed-prop numeric strings and register legacy aliases - * before attempting unserialize. Falls back to relaxed mode to avoid typed - * property crashes during diagnosis. + * Map UI options (1/2/3) to legacy file policy. */ - private function decodeCourseInfo(string $raw): array + private function mapSameNameOption(int $opt): int { - $r = [ - 'encoding' => 'raw', - 'decoded_len' => \strlen($raw), - 'magic_hex' => bin2hex(substr($raw, 0, 8)), - 'magic_ascii' => preg_replace('/[^\x20-\x7E]/', '.', substr($raw, 0, 16)), - 'steps' => [], - 'decoded' => null, - 'is_serialized' => false, - 'is_json' => false, - 'json_preview' => null, - ]; + $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2; - $isJson = static function (string $s): bool { - $t = ltrim($s); + if (!\defined('FILE_SKIP')) { + \define('FILE_SKIP', 1); + } + if (!\defined('FILE_RENAME')) { + \define('FILE_RENAME', 2); + } + if (!\defined('FILE_OVERWRITE')) { + \define('FILE_OVERWRITE', 3); + } - return '' !== $t && ('{' === $t[0] || '[' === $t[0]); + return match ($opt) { + 1 => FILE_SKIP, + 3 => FILE_OVERWRITE, + default => FILE_RENAME, }; + } - // Centralized tolerant unserialize with typed-props preprocessing - $tryUnserializeTolerant = function (string $s, string $label) use (&$r) { - $ok = false; - $val = null; - $err = null; - $relaxed = false; - - // Ensure legacy aliases and coerce numeric strings before unserialize - try { - CourseArchiver::ensureLegacyAliases(); - } catch (Throwable) { /* ignore */ - } - - try { - $s = CourseArchiver::preprocessSerializedPayloadForTypedProps($s); - } catch (Throwable) { /* ignore */ - } - - // Strict mode - set_error_handler(static function (): void {}); - - try { - $val = @unserialize($s, ['allowed_classes' => true]); - $ok = (false !== $val) || ('b:0;' === trim($s)); - } catch (Throwable $e) { - $err = $e->getMessage(); - $ok = false; - } finally { - restore_error_handler(); - } - $r['steps'][] = ['action' => "unserialize[$label][strict]", 'ok' => $ok, 'error' => $err]; - - // Relaxed fallback (no class instantiation) + deincomplete to stdClass - if (!$ok) { - $err2 = null; - set_error_handler(static function (): void {}); - - try { - $tmp = @unserialize($s, ['allowed_classes' => false]); - if (false !== $tmp || 'b:0;' === trim($s)) { - $val = $this->deincomplete($tmp); - $ok = true; - $relaxed = true; - $err = null; - } - } catch (Throwable $e2) { - $err2 = $e2->getMessage(); - } finally { - restore_error_handler(); - } - $r['steps'][] = ['action' => "unserialize[$label][relaxed]", 'ok' => $ok, 'error' => $err2]; - } - - if ($ok) { - $r['is_serialized'] = true; - $r['decoded'] = null; // keep payload minimal - $r['used_relaxed'] = $relaxed; + private function jsonError(string $message, int $status = 400, array $extra = []): JsonResponse + { + $payload = ['error' => $message] + $extra; - return $val; - } + return $this->json($payload, $status); + } - return null; - }; + /** + * @return array + */ + private function getJsonPayload(Request $req): array + { + $raw = (string) ($req->getContent() ?? ''); + $data = json_decode($raw !== '' ? $raw : '{}', true); - // 0) JSON as-is? - if ($isJson($raw)) { - $r['encoding'] = 'json'; - $r['is_json'] = true; - $r['json_preview'] = json_decode($raw, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); + return \is_array($data) ? $data : []; + } - return $r; + private function detectUploadExtension(?string $guessed, ?string $originalName): string + { + $ext = strtolower((string) ($guessed ?? '')); + if ('' !== $ext && 'bin' !== $ext) { + return $ext; } - // Direct PHP serialize (strict then relaxed, after preprocessing) - if (($u = $tryUnserializeTolerant($raw, 'raw')) !== null) { - $r['encoding'] = 'php-serialize'; + return strtolower(pathinfo((string) ($originalName ?? ''), PATHINFO_EXTENSION)); + } - return $r + ['value' => $u]; + private function validateBackupFilename(string $filename): string + { + $name = basename(trim($filename)); + if ('' === $name) { + return ''; } - - // GZIP - if (0 === strncmp($raw, "\x1F\x8B", 2)) { - $dec = @gzdecode($raw); - $r['steps'][] = ['action' => 'gzdecode', 'ok' => false !== $dec]; - if (false !== $dec) { - if ($isJson($dec)) { - $r['encoding'] = 'gzip+json'; - $r['is_json'] = true; - $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); - - return $r; - } - if (($u = $tryUnserializeTolerant($dec, 'gzip')) !== null) { - $r['encoding'] = 'gzip+php-serialize'; - - return $r + ['value' => $u]; - } - } + if (preg_match('/[\/\\\]/', $name)) { + return ''; } - // ZLIB/DEFLATE - $z2 = substr($raw, 0, 2); - if ("\x78\x9C" === $z2 || "\x78\xDA" === $z2) { - $dec = @gzuncompress($raw); - $r['steps'][] = ['action' => 'gzuncompress', 'ok' => false !== $dec]; - if (false !== $dec) { - if ($isJson($dec)) { - $r['encoding'] = 'zlib+json'; - $r['is_json'] = true; - $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); - - return $r; - } - if (($u = $tryUnserializeTolerant($dec, 'zlib')) !== null) { - $r['encoding'] = 'zlib+php-serialize'; - - return $r + ['value' => $u]; - } - } - $dec2 = @gzinflate($raw); - $r['steps'][] = ['action' => 'gzinflate', 'ok' => false !== $dec2]; - if (false !== $dec2) { - if ($isJson($dec2)) { - $r['encoding'] = 'deflate+json'; - $r['is_json'] = true; - $r['json_preview'] = json_decode($dec2, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); - - return $r; - } - if (($u = $tryUnserializeTolerant($dec2, 'deflate')) !== null) { - $r['encoding'] = 'deflate+php-serialize'; + return $name; + } - return $r + ['value' => $u]; - } - } + /** + * Ensure selection map exists; if only types were selected, build the map from types. + * + * @param array $selectedResources + * @param string[] $selectedTypes + * @return array + */ + private function ensureSelectionMap(object $course, array $selectedResources, array $selectedTypes): array + { + if (empty($selectedResources) && !empty($selectedTypes)) { + $selectedResources = $this->buildSelectionFromTypes($course, $selectedTypes); } - // BASE64 (e.g. "Tzo0ODoi..." -> base64('O:48:"Chamilo...')) - if (preg_match('~^[A-Za-z0-9+/=\r\n]+$~', $raw)) { - $dec = base64_decode($raw, true); - $r['steps'][] = ['action' => 'base64_decode', 'ok' => false !== $dec]; - if (false !== $dec) { - if ($isJson($dec)) { - $r['encoding'] = 'base64(json)'; - $r['is_json'] = true; - $r['json_preview'] = json_decode($dec, true, 512, JSON_PARTIAL_OUTPUT_ON_ERROR); - - return $r; - } - if (($u = $tryUnserializeTolerant($dec, 'base64')) !== null) { - $r['encoding'] = 'base64(php-serialize)'; - - return $r + ['value' => $u]; - } - // base64 + gzip nested - if (0 === strncmp($dec, "\x1F\x8B", 2)) { - $dec2 = @gzdecode($dec); - $r['steps'][] = ['action' => 'base64+gzdecode', 'ok' => false !== $dec2]; - if (false !== $dec2 && ($u = $tryUnserializeTolerant($dec2, 'base64+gzip')) !== null) { - $r['encoding'] = 'base64(gzip+php-serialize)'; - - return $r + ['value' => $u]; - } - } + $hasAny = false; + foreach ($selectedResources as $ids) { + if (\is_array($ids) && !empty($ids)) { + $hasAny = true; + break; } } - // Nested ZIP? - if (0 === strncmp($raw, "PK\x03\x04", 4)) { - $r['encoding'] = 'nested-zip'; + if (!$hasAny) { + throw new RuntimeException('No resources selected'); } - return $r; + return $selectedResources; } /** - * Replace any __PHP_Incomplete_Class instances with stdClass (deep). - * Also traverses arrays and objects (diagnostics-only). + * Resolve absolute path of a backupId inside the backups directory, with safety checks. */ - private function deincomplete(mixed $v): mixed + private function resolveBackupPath(string $backupId): string { - if ($v instanceof __PHP_Incomplete_Class) { - $o = new stdClass(); - foreach (get_object_vars($v) as $k => $vv) { - $o->{$k} = $this->deincomplete($vv); - } - - return $o; - } - if (\is_array($v)) { - foreach ($v as $k => $vv) { - $v[$k] = $this->deincomplete($vv); - } + $base = rtrim((string) CourseArchiver::getBackupDir(), DIRECTORY_SEPARATOR); + $baseReal = realpath($base) ?: $base; - return $v; - } - if (\is_object($v)) { - foreach (get_object_vars($v) as $k => $vv) { - $v->{$k} = $this->deincomplete($vv); - } + $file = basename($backupId); + $path = $baseReal.DIRECTORY_SEPARATOR.$file; - return $v; + $real = realpath($path); + if (false !== $real && 0 === strncmp($real, $baseReal, \strlen($baseReal))) { + return $real; } - return $v; + return $path; } /** - * Return [ok, name, index, size, data] for the first matching entry of course_info.dat (case-insensitive). - * Also tries common subpaths, e.g., "course/course_info.dat". + * Load a legacy Course object from any backup: + * - Chamilo: course_info.dat (strict -> fallback) + * - Moodle: moodle_backup.xml or extensions (.mbz/.tgz/.gz) -> MoodleImport */ - private function readCourseInfoFromZip(string $zipPath): array + private function loadLegacyCourseForAnyBackup(string $backupId, string $force = 'auto'): object { - $candidates = [ - 'course_info.dat', - 'course/course_info.dat', - 'backup/course_info.dat', - ]; + $path = $this->resolveBackupPath($backupId); - $zip = new ZipArchive(); - if (true !== ($err = $zip->open($zipPath))) { - return ['ok' => false, 'error' => 'Failed to open ZIP (ZipArchive::open error '.$err.')']; + $force = strtolower($force); + if ('dat' === $force || 'chamilo' === $force) { + $looksMoodle = false; + $preferChamilo = true; + } elseif ('moodle' === $force) { + $looksMoodle = true; + $preferChamilo = false; + } else { + $looksMoodle = $this->isMoodleByExt($path) || $this->zipHasMoodleBackupXml($path); + $preferChamilo = $this->zipHasCourseInfoDat($path); } - // First: direct name lookup (case-insensitive) - $foundIdx = null; - $foundName = null; + if ($preferChamilo || !$looksMoodle) { + CourseArchiver::setDebug($this->debug); - for ($i = 0; $i < $zip->numFiles; $i++) { - $st = $zip->statIndex($i); - if (!$st || !isset($st['name'])) { - continue; - } - $name = (string) $st['name']; - $base = strtolower(basename($name)); - if ('course_info.dat' === $base) { - $foundIdx = $i; - $foundName = $name; + try { + $course = CourseArchiver::readCourse($backupId, false); + if (\is_object($course)) { + if (!isset($course->resources) || !\is_array($course->resources)) { + $course->resources = []; + } + $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []); + $course->resources['__meta']['import_source'] = 'chamilo'; - break; + return $course; + } + } catch (Throwable $e) { + $this->logDebug('[loadLegacyCourseForAnyBackup] readCourse() failed', ['error' => $e->getMessage()]); } - } - // Try specific candidate paths if direct scan failed - if (null === $foundIdx) { - foreach ($candidates as $cand) { - $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE); - if (false !== $idx) { - $foundIdx = $idx; - $foundName = $zip->getNameIndex($idx); + $ci = $this->readCourseInfoFromZip($path); + if (empty($ci['ok'])) { + if ($looksMoodle) { + $this->logDebug('[loadLegacyCourseForAnyBackup] no course_info.dat, trying MoodleImport as last resort'); - break; + return $this->loadMoodleCourseOrFail($path); } + + throw new RuntimeException('course_info.dat not found in backup'); } - } - if (null === $foundIdx) { - // Build a short listing for debugging - $list = []; - $limit = min($zip->numFiles, 200); - for ($i = 0; $i < $limit; $i++) { - $n = $zip->getNameIndex($i); - if (false !== $n) { - $list[] = $n; - } + $raw = (string) $ci['data']; + $payload = base64_decode($raw, true); + if (false === $payload) { + $payload = $raw; } - $zip->close(); - return [ - 'ok' => false, - 'error' => 'course_info.dat not found in archive', - 'zip_list_sample' => $list, - 'num_files' => $zip->numFiles, - ]; - } + $payload = CourseArchiver::preprocessSerializedPayloadForTypedProps($payload); + CourseArchiver::ensureLegacyAliases(); - $stat = $zip->statIndex($foundIdx); - $size = (int) ($stat['size'] ?? 0); - $fp = $zip->getStream($foundName); - if (!$fp) { - $zip->close(); - - return ['ok' => false, 'error' => 'Failed to open stream for course_info.dat (getStream)']; - } - - $data = stream_get_contents($fp); - fclose($fp); - $zip->close(); - - if (!\is_string($data)) { - return ['ok' => false, 'error' => 'Failed to read course_info.dat contents']; - } - - return [ - 'ok' => true, - 'name' => $foundName, - 'index' => $foundIdx, - 'size' => $size, - 'data' => $data, - ]; - } - - /** - * 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. - */ - private function buildResourceTreeForVue(object $course): array - { - if ($this->debug) { - $this->logDebug('[buildResourceTreeForVue] start'); - } - - $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources) - ? $course->resources - : []; - - $legacyTitles = []; - if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) { - /** @var array $legacyTitles */ - $legacyTitles = CourseSelectForm::getResourceTitleList(); - } - $fallbackTitles = $this->getDefaultTypeTitles(); - $skipTypes = $this->getSkipTypeKeys(); - - $tree = []; - - if (!empty($resources['document']) && \is_array($resources['document'])) { - $docs = $resources['document']; - - $normalize = function (string $rawPath, string $title, string $filetype): string { - $p = trim($rawPath, '/'); - $p = (string) preg_replace('~^(?:document/)+~i', '', $p); - $parts = array_values(array_filter(explode('/', $p), 'strlen')); - - // host - if (!empty($parts) && ('localhost' === $parts[0] || str_contains($parts[0], '.'))) { - array_shift($parts); - } - // course-code - if (!empty($parts) && preg_match('~^[A-Z0-9_-]{6,}$~', $parts[0])) { - array_shift($parts); - } - - $clean = implode('/', $parts); - if ('' === $clean && 'folder' !== $filetype) { - $clean = $title; - } - if ('folder' === $filetype) { - $clean = rtrim($clean, '/').'/'; - } - - return $clean; - }; - - $folderIdByPath = []; - foreach ($docs as $obj) { - if (!\is_object($obj)) { - continue; - } - $ft = (string) ($obj->filetype ?? $obj->file_type ?? ''); - if ('folder' !== $ft) { - continue; - } - $rel = $normalize((string) $obj->path, (string) $obj->title, $ft); - $key = rtrim($rel, '/'); - if ('' !== $key) { - $folderIdByPath[strtolower($key)] = (int) $obj->source_id; - } - } - - $docRoot = []; - $findChild = static function (array &$children, string $label): ?int { - foreach ($children as $i => $n) { - if ((string) ($n['label'] ?? '') === $label) { - return $i; - } - } - - return null; - }; - - foreach ($docs as $obj) { - if (!\is_object($obj)) { - continue; - } - - $title = (string) $obj->title; - $filetype = (string) ($obj->filetype ?? $obj->file_type ?? ''); - $rel = $normalize((string) $obj->path, $title, $filetype); - $parts = array_values(array_filter(explode('/', trim($rel, '/')), 'strlen')); - - $cursor = &$docRoot; - $soFar = ''; - $total = \count($parts); - - for ($i = 0; $i < $total; $i++) { - $seg = $parts[$i]; - $isLast = ($i === $total - 1); - $isFolder = (!$isLast) || ('folder' === $filetype); - - $soFar = ltrim($soFar.'/'.$seg, '/'); - $label = $seg.($isFolder ? '/' : ''); - - $idx = $findChild($cursor, $label); - if (null === $idx) { - if ($isFolder) { - $folderId = $folderIdByPath[strtolower($soFar)] ?? null; - $node = [ - 'id' => $folderId ?? ('dir:'.$soFar), - 'label' => $label, - 'selectable' => true, - 'children' => [], - ]; - } else { - $node = [ - 'id' => (int) $obj->source_id, - 'label' => $label, - 'selectable' => true, - ]; - } - $cursor[] = $node; - $idx = \count($cursor) - 1; - } - - if ($isFolder) { - if (!isset($cursor[$idx]['children']) || !\is_array($cursor[$idx]['children'])) { - $cursor[$idx]['children'] = []; - } - $cursor = &$cursor[$idx]['children']; - } - } - } - - $sortTree = null; - $sortTree = function (array &$nodes) use (&$sortTree): void { - usort($nodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); - foreach ($nodes as &$n) { - if (isset($n['children']) && \is_array($n['children'])) { - $sortTree($n['children']); - } - } - }; - $sortTree($docRoot); - - $tree[] = [ - 'type' => 'document', - 'title' => $legacyTitles['document'] ?? ($fallbackTitles['document'] ?? 'Documents'), - 'children' => $docRoot, - ]; - - $skipTypes['document'] = true; - } - - // Forums block - $hasForumData = - (!empty($resources['forum']) || !empty($resources['Forum'])) - || (!empty($resources['forum_category']) || !empty($resources['Forum_Category'])) - || (!empty($resources['forum_topic']) || !empty($resources['ForumTopic'])) - || (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post'])); - - if ($hasForumData) { - $tree[] = $this->buildForumTreeForVue( - $course, - $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums') - ); - $skipTypes['forum'] = true; - $skipTypes['forum_category'] = true; - $skipTypes['forum_topic'] = true; - $skipTypes['forum_post'] = true; - $skipTypes['thread'] = true; - $skipTypes['post'] = true; - } - - // Links block (Category → Link) - $hasLinkData = - (!empty($resources['link']) || !empty($resources['Link'])) - || (!empty($resources['link_category']) || !empty($resources['Link_Category'])); - - if ($hasLinkData) { - $tree[] = $this->buildLinkTreeForVue( - $course, - $legacyTitles['link'] ?? ($fallbackTitles['link'] ?? 'Links') - ); - $skipTypes['link'] = true; - $skipTypes['link_category'] = true; - } - - foreach ($resources as $rawType => $items) { - if (!\is_array($items) || empty($items)) { - continue; - } - $typeKey = $this->normalizeTypeKey($rawType); - if (isset($skipTypes[$typeKey])) { - continue; - } - - $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey)); - $group = [ - 'type' => $typeKey, - 'title' => (string) $groupTitle, - 'items' => [], - ]; - - if ('gradebook' === $typeKey) { - $group['items'][] = [ - 'id' => 'all', - 'label' => 'Gradebook (all)', - 'extra' => new stdClass(), - 'selectable' => true, - ]; - $tree[] = $group; - - continue; - } - - foreach ($items as $id => $obj) { - if (!\is_object($obj)) { - continue; - } - - $idKey = is_numeric($id) ? (int) $id : (string) $id; - if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) { - continue; - } - - if (!$this->isSelectableItem($typeKey, $obj)) { - continue; - } - - $label = $this->resolveItemLabel($typeKey, $obj, \is_int($idKey) ? $idKey : 0); - - if ('tool_intro' === $typeKey && '#0' === $label && \is_string($idKey)) { - $label = $idKey; - } - - $extra = $this->buildExtra($typeKey, $obj); - - $group['items'][] = [ - 'id' => $idKey, - 'label' => $label, - 'extra' => $extra ?: new stdClass(), - 'selectable' => true, - ]; - } - - if (!empty($group['items'])) { - usort( - $group['items'], - static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']) - ); - $tree[] = $group; - } - } - - // Preferred order - $preferredOrder = [ - 'announcement', 'document', 'course_description', 'learnpath', 'quiz', 'forum', 'glossary', 'link', - 'survey', 'thematic', 'work', 'attendance', 'wiki', 'calendar_event', 'tool_intro', 'gradebook', - ]; - usort($tree, static function ($a, $b) use ($preferredOrder) { - $ia = array_search($a['type'], $preferredOrder, true); - $ib = array_search($b['type'], $preferredOrder, true); - if (false !== $ia && false !== $ib) { - return $ia <=> $ib; - } - if (false !== $ia) { - return -1; - } - if (false !== $ib) { - return 1; - } - - return strcasecmp($a['title'], $b['title']); - }); - - if ($this->debug) { - $this->logDebug( - '[buildResourceTreeForVue] end groups', - array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? []), 'children' => \count($g['children'] ?? [])], $tree) - ); - } - - return $tree; - } - - /** - * Build forum tree (Category → Forum → Topic) for the UI. - * Uses only "items" (no "children") and sets UI hints (has_children, item_count). - */ - private function buildForumTreeForVue(object $course, string $groupTitle): array - { - $this->logDebug('[buildForumTreeForVue] start'); - - $res = \is_array($course->resources ?? null) ? $course->resources : []; - - // Buckets (defensive: accept legacy casings / aliases) - $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? []; - $forumRaw = $res['forum'] ?? $res['Forum'] ?? []; - $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []); - $postRaw = $res['forum_post'] ?? $res['Forum_Post'] ?? ($res['post'] ?? []); - - $this->logDebug('[buildForumTreeForVue] raw counts', [ - 'categories' => \is_array($catRaw) ? \count($catRaw) : 0, - 'forums' => \is_array($forumRaw) ? \count($forumRaw) : 0, - 'topics' => \is_array($topicRaw) ? \count($topicRaw) : 0, - 'posts' => \is_array($postRaw) ? \count($postRaw) : 0, - ]); - - // Quick classifiers (defensive) - $isForum = function (object $o): bool { - $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o; - if (isset($e->forum_title) && \is_string($e->forum_title)) { - return true; - } - if (isset($e->default_view) || isset($e->allow_anonymous)) { - return true; - } - if ((isset($e->forum_category) || isset($e->forum_category_id) || isset($e->category_id)) && !isset($e->forum_id)) { - return true; - } - - return false; - }; - $isTopic = function (object $o): bool { - $e = (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o; - if (isset($e->forum_id) && (isset($e->thread_title) || isset($e->thread_date) || isset($e->poster_name))) { - return true; - } - if (isset($e->forum_id) && !isset($e->forum_title)) { - return true; - } - - return false; - }; - $getForumCategoryId = function (object $forum): int { - $e = (isset($forum->obj) && \is_object($forum->obj)) ? $forum->obj : $forum; - $cid = (int) ($e->forum_category ?? 0); - if ($cid <= 0) { - $cid = (int) ($e->forum_category_id ?? 0); - } - if ($cid <= 0) { - $cid = (int) ($e->category_id ?? 0); - } - - return $cid; - }; - - // Build categories - $cats = []; - foreach ($catRaw as $id => $obj) { - $id = (int) $id; - if ($id <= 0 || !\is_object($obj)) { - continue; - } - $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id); - $cats[$id] = [ - 'id' => $id, - 'type' => 'forum_category', - 'label' => ('' !== $label ? $label : 'Category #'.$id).'/', - 'selectable' => true, - 'items' => [], - 'has_children' => false, - 'item_count' => 0, - 'extra' => ['filetype' => 'folder'], - ]; - } - // Virtual "Uncategorized" - $uncatKey = -9999; - if (!isset($cats[$uncatKey])) { - $cats[$uncatKey] = [ - 'id' => $uncatKey, - 'type' => 'forum_category', - 'label' => 'Uncategorized/', - 'selectable' => true, - 'items' => [], - '_virtual' => true, - 'has_children' => false, - 'item_count' => 0, - 'extra' => ['filetype' => 'folder'], - ]; - } - - // Forums - $forums = []; - foreach ($forumRaw as $id => $obj) { - $id = (int) $id; - if ($id <= 0 || !\is_object($obj)) { - continue; - } - if (!$isForum($obj)) { - $this->logDebug('[buildForumTreeForVue] skipped non-forum in forum bucket', ['id' => $id]); - - continue; - } - $forums[$id] = $this->objectEntity($obj); - } - - // Topics (+ post counts) - $topics = []; - $postCountByTopic = []; - foreach ($topicRaw as $id => $obj) { - $id = (int) $id; - if ($id <= 0 || !\is_object($obj)) { - continue; - } - if ($isForum($obj) && !$isTopic($obj)) { - $this->logDebug('[buildForumTreeForVue] WARNING: forum object found in topic bucket; skipping', ['id' => $id]); - - continue; - } - if (!$isTopic($obj)) { - continue; - } - $topics[$id] = $this->objectEntity($obj); - } - foreach ($postRaw as $id => $obj) { - $id = (int) $id; - if ($id <= 0 || !\is_object($obj)) { - continue; - } - $e = $this->objectEntity($obj); - $tid = (int) ($e->thread_id ?? 0); - if ($tid > 0) { - $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1; - } - } - - // Attach topics to forums and forums to categories - foreach ($forums as $fid => $f) { - $catId = $getForumCategoryId($f); - if (!isset($cats[$catId])) { - $catId = $uncatKey; - } - - $forumNode = [ - 'id' => $fid, - 'type' => 'forum', - 'label' => $this->resolveItemLabel('forum', $f, $fid), - 'extra' => $this->buildExtra('forum', $f) ?: new stdClass(), - 'selectable' => true, - 'items' => [], - // UI hints - 'has_children' => false, - 'item_count' => 0, - 'ui_depth' => 2, - ]; - - foreach ($topics as $tid => $t) { - if ((int) ($t->forum_id ?? 0) !== $fid) { - continue; - } - - $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? ''); - $date = (string) ($t->thread_date ?? ''); - $nPosts = (int) ($postCountByTopic[$tid] ?? 0); - - $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid); - $meta = []; - if ('' !== $author) { - $meta[] = $author; - } - if ('' !== $date) { - $meta[] = $date; - } - if ($meta) { - $topicLabel .= ' ('.implode(', ', $meta).')'; - } - if ($nPosts > 0) { - $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's'); - } - - $forumNode['items'][] = [ - 'id' => $tid, - 'type' => 'forum_topic', - 'label' => $topicLabel, - 'extra' => new stdClass(), - 'selectable' => true, - 'ui_depth' => 3, - 'item_count' => 0, - ]; - } - - if (!empty($forumNode['items'])) { - usort($forumNode['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); - $forumNode['has_children'] = true; - $forumNode['item_count'] = \count($forumNode['items']); - } - - $cats[$catId]['items'][] = $forumNode; - } - - // Remove empty virtual category; sort forums inside each category - $catNodes = array_values(array_filter($cats, static function ($c) { - if (!empty($c['_virtual']) && empty($c['items'])) { - return false; - } - - return true; - })); - - // Flatten stray forums (defensive) and finalize UI hints - foreach ($catNodes as &$cat) { - if (!empty($cat['items'])) { - $lift = []; - foreach ($cat['items'] as &$forumNode) { - if (($forumNode['type'] ?? '') !== 'forum' || empty($forumNode['items'])) { - continue; - } - $keep = []; - foreach ($forumNode['items'] as $child) { - if (($child['type'] ?? '') === 'forum') { - $lift[] = $child; - $this->logDebug('[buildForumTreeForVue] flatten: lifted nested forum', [ - 'parent_forum_id' => $forumNode['id'] ?? null, - 'lifted_forum_id' => $child['id'] ?? null, - 'cat_id' => $cat['id'] ?? null, - ]); - } else { - $keep[] = $child; - } - } - $forumNode['items'] = $keep; - $forumNode['has_children'] = !empty($keep); - $forumNode['item_count'] = \count($keep); - } - unset($forumNode); - - foreach ($lift as $n) { - $cat['items'][] = $n; - } - usort($cat['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); - } - - // UI hints for category - $cat['has_children'] = !empty($cat['items']); - $cat['item_count'] = \count($cat['items'] ?? []); - } - unset($cat); - - $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]); - - return [ - 'type' => 'forum', - 'title' => $groupTitle, - 'items' => $catNodes, - ]; - } - - /** - * Normalize a raw type to a lowercase key. - */ - private function normalizeTypeKey(int|string $raw): string - { - if (\is_int($raw)) { - return (string) $raw; - } - - $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw)); - - $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', - ]; - - return $map[$s] ?? $s; - } - - /** - * Keys to skip as top-level groups in UI. - * - * @return array - */ - private function getSkipTypeKeys(): array - { - return [ - 'forum_category' => true, - 'forum_topic' => true, - 'forum_post' => true, - 'thread' => true, - 'post' => true, - 'exercise_question' => true, - 'survey_question' => true, - 'survey_invitation' => true, - 'session_course' => true, - 'scorm' => true, - 'asset' => true, - 'link_category' => true, - ]; - } - - /** - * Default labels for groups. - * - * @return array - */ - private function getDefaultTypeTitles(): array - { - return [ - 'announcement' => 'Announcements', - 'document' => 'Documents', - 'glossary' => 'Glossaries', - 'calendar_event' => 'Calendar events', - 'event' => 'Calendar events', - 'link' => 'Links', - 'course_description' => 'Course descriptions', - 'learnpath' => 'Parcours', - 'learnpath_category' => 'Learning path categories', - 'forum' => 'Forums', - 'forum_category' => 'Forum categories', - 'quiz' => 'Exercices', - 'test_category' => 'Test categories', - 'wiki' => 'Wikis', - 'thematic' => 'Thematics', - 'attendance' => 'Attendances', - 'work' => 'Works', - 'session_course' => 'Session courses', - 'gradebook' => 'Gradebook', - 'scorm' => 'SCORM packages', - 'survey' => 'Surveys', - 'survey_question' => 'Survey questions', - 'survey_invitation' => 'Survey invitations', - 'asset' => 'Assets', - 'tool_intro' => 'Tool introductions', - ]; - } - - /** - * Decide if an item is selectable (UI). - */ - private function isSelectableItem(string $type, object $obj): bool - { - if ('document' === $type) { - return true; - } - - return true; - } - - /** - * Resolve label for an item with fallbacks. - */ - private function resolveItemLabel(string $type, object $obj, int $fallbackId): string - { - $entity = $this->objectEntity($obj); - - foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) { - if (isset($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) { - return trim((string) $entity->{$k}); - } - } - - 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]; - } - } - } - - switch ($type) { - 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; - } - - break; - - case 'course_description': - if (!empty($obj->title)) { - return (string) $obj->title; - } - $t = (int) ($obj->description_type ?? 0); - $names = [ - 1 => 'Description', - 2 => 'Objectives', - 3 => 'Topics', - 4 => 'Methodology', - 5 => 'Course material', - 6 => 'Resources', - 7 => 'Assessment', - 8 => 'Custom', - ]; - - return $names[$t] ?? ('#'.$fallbackId); - - case 'announcement': - if (!empty($obj->title)) { - return (string) $obj->title; - } - - break; - - case 'forum': - if (!empty($entity->forum_title)) { - return (string) $entity->forum_title; - } - - break; - - case 'forum_category': - if (!empty($entity->cat_title)) { - return (string) $entity->cat_title; - } - - break; - - case 'link': - if (!empty($obj->title)) { - return (string) $obj->title; - } - if (!empty($obj->url)) { - return (string) $obj->url; - } - - break; - - case 'survey': - if (!empty($obj->title)) { - return trim((string) $obj->title); - } - - break; - - case 'learnpath': - if (!empty($obj->name)) { - return (string) $obj->name; - } - - break; - - case 'thematic': - if (isset($obj->params['title']) && \is_string($obj->params['title'])) { - return (string) $obj->params['title']; - } - - break; - - case 'quiz': - if (!empty($entity->title)) { - return (string) $entity->title; - } - - break; - - case 'forum_topic': - if (!empty($entity->thread_title)) { - return (string) $entity->thread_title; - } - - break; - } - - return '#'.$fallbackId; - } - - /** - * Extract wrapped entity (->obj) or the object itself. - */ - private function objectEntity(object $resource): object - { - if (isset($resource->obj) && \is_object($resource->obj)) { - return $resource->obj; - } - - return $resource; - } - - /** - * Extra payload per item for UI (optional). - */ - private function buildExtra(string $type, object $obj): array - { - $extra = []; - - $get = static function (object $o, string $k, $default = null) { - return (isset($o->{$k}) && (\is_string($o->{$k}) || is_numeric($o->{$k}))) ? $o->{$k} : $default; - }; - - switch ($type) { - case 'document': - $extra['path'] = (string) ($get($obj, 'path', '') ?? ''); - $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? ''); - $extra['size'] = (string) ($get($obj, 'size', '') ?? ''); - - break; - - case 'link': - $extra['url'] = (string) ($get($obj, 'url', '') ?? ''); - $extra['target'] = (string) ($get($obj, 'target', '') ?? ''); - - break; - - case 'forum': - $entity = $this->objectEntity($obj); - $extra['category_id'] = (string) ($entity->forum_category ?? ''); - $extra['default_view'] = (string) ($entity->default_view ?? ''); - - break; - - case 'learnpath': - $extra['name'] = (string) ($get($obj, 'name', '') ?? ''); - $extra['items'] = isset($obj->items) && \is_array($obj->items) ? array_map(static function ($i) { - return [ - 'id' => (int) ($i['id'] ?? 0), - 'title' => (string) ($i['title'] ?? ''), - 'type' => (string) ($i['item_type'] ?? ''), - 'path' => (string) ($i['path'] ?? ''), - ]; - }, $obj->items) : []; - - break; - - case 'thematic': - if (isset($obj->params) && \is_array($obj->params)) { - $extra['active'] = (string) ($obj->params['active'] ?? ''); - } - - break; - - case 'quiz': - $entity = $this->objectEntity($obj); - $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids) - ? array_map('intval', $entity->question_ids) - : []; - - break; - - case 'survey': - $entity = $this->objectEntity($obj); - $extra['question_ids'] = isset($entity->question_ids) && \is_array($entity->question_ids) - ? array_map('intval', $entity->question_ids) - : []; - - break; - } - - return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v)); - } - - // -------------------------------------------------------------------------------- - // Selection filtering (used by partial restore) - // -------------------------------------------------------------------------------- - - /** - * Get first existing key from candidates. - */ - private function firstExistingKey(array $orig, array $candidates): ?string - { - foreach ($candidates as $k) { - if (isset($orig[$k]) && \is_array($orig[$k]) && !empty($orig[$k])) { - return $k; - } - } - - return null; - } - - /** - * Filter legacy Course by UI selections (and pull dependencies). - * - * @param array $selected [type => [id => true]] - */ - private function filterLegacyCourseBySelection(object $course, array $selected): object - { - // Sanitize incoming selection (frontend sometimes sends synthetic groups) - $selected = array_filter($selected, 'is_array'); - unset($selected['undefined']); - - $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]); - - if (empty($course->resources) || !\is_array($course->resources)) { - $this->logDebug('[filterSelection] course has no resources'); - - return $course; - } - - /** @var array $orig */ - $orig = $course->resources; - - // Preserve meta buckets (keys that start with "__") - $__metaBuckets = []; - foreach ($orig as $k => $v) { - if (\is_string($k) && str_starts_with($k, '__')) { - $__metaBuckets[$k] = $v; - } - } - - $getBucket = fn (array $a, string $key): array => (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : []; - - // ---------- Forums flow ---------- - if (!empty($selected['forum'])) { - $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'])), true); - if (!empty($selForums)) { - // tolerant lookups - $forums = $this->findBucket($orig, 'forum'); - $threads = $this->findBucket($orig, 'forum_topic'); - $posts = $this->findBucket($orig, 'forum_post'); - - $catsToKeep = []; - - foreach ($forums as $fid => $f) { - if (!isset($selForums[(string) $fid])) { - continue; - } - $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f; - $cid = (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0); - if ($cid > 0) { - $catsToKeep[$cid] = true; - } - } - - $threadToKeep = []; - foreach ($threads as $tid => $t) { - $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t; - if (isset($selForums[(string) ($e->forum_id ?? '')])) { - $threadToKeep[(int) $tid] = true; - } - } - - $postToKeep = []; - foreach ($posts as $pid => $p) { - $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p; - if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) { - $postToKeep[(int) $pid] = true; - } - } - - $out = []; - foreach ($selected as $type => $ids) { - if (!\is_array($ids) || empty($ids)) { - continue; - } - $bucket = $this->findBucket($orig, (string) $type); - $key = $this->findBucketKey($orig, (string) $type); - if (null !== $key && !empty($bucket)) { - $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true); - $out[$key] = $this->intersectBucketByIds($bucket, $idsMap); - } - } - - $forumCat = $this->findBucket($orig, 'forum_category'); - $forumBucket = $this->findBucket($orig, 'forum'); - $threadBucket = $this->findBucket($orig, 'forum_topic'); - $postBucket = $this->findBucket($orig, 'forum_post'); - - if (!empty($forumCat) && !empty($catsToKeep)) { - $out[$this->findBucketKey($orig, 'forum_category') ?? 'Forum_Category'] = - array_intersect_key( - $forumCat, - array_fill_keys(array_map('strval', array_keys($catsToKeep)), true) - ); - } - - if (!empty($forumBucket)) { - $out[$this->findBucketKey($orig, 'forum') ?? 'forum'] = - array_intersect_key($forumBucket, $selForums); - } - if (!empty($threadBucket)) { - $out[$this->findBucketKey($orig, 'forum_topic') ?? 'thread'] = - array_intersect_key( - $threadBucket, - array_fill_keys(array_map('strval', array_keys($threadToKeep)), true) - ); - } - if (!empty($postBucket)) { - $out[$this->findBucketKey($orig, 'forum_post') ?? 'post'] = - array_intersect_key( - $postBucket, - array_fill_keys(array_map('strval', array_keys($postToKeep)), true) - ); - } - - // If we have forums but no Forum_Category (edge), keep original categories - if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) { - $out['Forum_Category'] = $forumCat; - } - - $out = array_filter($out); - $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $out) : $out; - - $this->logDebug('[filterSelection] end (forums)', [ - 'kept_types' => array_keys($course->resources), - 'forum_counts' => [ - 'Forum_Category' => \is_array($course->resources['Forum_Category'] ?? null) ? \count($course->resources['Forum_Category']) : 0, - 'forum' => \is_array($course->resources['forum'] ?? null) ? \count($course->resources['forum']) : 0, - 'thread' => \is_array($course->resources['thread'] ?? null) ? \count($course->resources['thread']) : 0, - 'post' => \is_array($course->resources['post'] ?? null) ? \count($course->resources['post']) : 0, - ], - ]); - - return $course; - } - } - - // ---------- Generic + quiz/survey/gradebook ---------- - $keep = []; - foreach ($selected as $type => $ids) { - if (!\is_array($ids) || empty($ids)) { - continue; - } - $legacyKey = $this->findBucketKey($orig, (string) $type); - if (null === $legacyKey) { - continue; - } - $bucket = $orig[$legacyKey] ?? []; - if (!empty($bucket) && \is_array($bucket)) { - $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true); - $keep[$legacyKey] = $this->intersectBucketByIds($bucket, $idsMap); - } - } - - // Gradebook - $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']); - if ($gbKey && !empty($selected['gradebook'])) { - $gbBucket = $getBucket($orig, $gbKey); - if (!empty($gbBucket)) { - $selIds = array_keys(array_filter((array) $selected['gradebook'])); - $firstItem = reset($gbBucket); - - if (\in_array('all', $selIds, true) || !\is_object($firstItem)) { - $keep[$gbKey] = $gbBucket; - $this->logDebug('[filterSelection] kept full gradebook', ['key' => $gbKey, 'count' => \count($gbBucket)]); - } else { - $keep[$gbKey] = array_intersect_key($gbBucket, array_fill_keys(array_map('strval', $selIds), true)); - $this->logDebug('[filterSelection] kept partial gradebook', ['key' => $gbKey, 'count' => \count($keep[$gbKey])]); - } - } - } - - // Quizzes -> questions (+ images) - $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']); - if ($quizKey && !empty($keep[$quizKey])) { - $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', \defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '']); - if ($questionKey) { - $qids = []; - foreach ($keep[$quizKey] as $qid => $qwrap) { - $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap; - if (!empty($q->question_ids) && \is_array($q->question_ids)) { - foreach ($q->question_ids as $sid) { - $qids[(string) $sid] = true; - } - } - } - if (!empty($qids)) { - $questionBucket = $getBucket($orig, $questionKey); - $selQ = array_intersect_key($questionBucket, $qids); - if (!empty($selQ)) { - $keep[$questionKey] = $selQ; - - $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']); - if ($docKey) { - $docBucket = $getBucket($orig, $docKey); - $imageQuizBucket = (isset($docBucket['image_quiz']) && \is_array($docBucket['image_quiz'])) ? $docBucket['image_quiz'] : []; - if (!empty($imageQuizBucket)) { - $needed = []; - foreach ($keep[$questionKey] as $qid => $qwrap) { - $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap; - $pic = (string) ($q->picture ?? ''); - if ('' !== $pic && isset($imageQuizBucket[$pic])) { - $needed[$pic] = true; - } - } - if (!empty($needed)) { - $keep[$docKey] = $keep[$docKey] ?? []; - $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed); - } - } - } - } - } - } else { - $this->logDebug('[filterSelection] quizzes selected but no question bucket found'); - } - } - - // Surveys -> questions (+ invitations) - $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']); - if ($surveyKey && !empty($keep[$surveyKey])) { - $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', \defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '']); - $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', \defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '']); - - if ($surveyQuestionKey) { - $neededQids = []; - $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey])); - - foreach ($keep[$surveyKey] as $sid => $sWrap) { - $s = (isset($sWrap->obj) && \is_object($sWrap->obj)) ? $sWrap->obj : $sWrap; - if (!empty($s->question_ids) && \is_array($s->question_ids)) { - foreach ($s->question_ids as $qid) { - $neededQids[(string) $qid] = true; - } - } - } - if (empty($neededQids)) { - $surveyQBucket = $getBucket($orig, $surveyQuestionKey); - foreach ($surveyQBucket as $qid => $qWrap) { - $q = (isset($qWrap->obj) && \is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; - $qSurveyId = (string) ($q->survey_id ?? ''); - if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) { - $neededQids[(string) $qid] = true; - } - } - } - if (!empty($neededQids)) { - $surveyQBucket = $getBucket($orig, $surveyQuestionKey); - $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids); - } - } else { - $this->logDebug('[filterSelection] surveys selected but no question bucket found'); - } - - if ($surveyInvitationKey) { - $invBucket = $getBucket($orig, $surveyInvitationKey); - if (!empty($invBucket)) { - $neededInv = []; - foreach ($invBucket as $iid => $invWrap) { - $inv = (isset($invWrap->obj) && \is_object($invWrap->obj)) ? $invWrap->obj : $invWrap; - $sid = (string) ($inv->survey_id ?? ''); - if ('' !== $sid && isset($keep[$surveyKey][$sid])) { - $neededInv[(string) $iid] = true; - } - } - if (!empty($neededInv)) { - $keep[$surveyInvitationKey] = array_intersect_key($invBucket, $neededInv); - } - } - } - } - - // Documents: add parent folders for selected files - $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']); - if ($docKey && !empty($keep[$docKey])) { - $docBucket = $getBucket($orig, $docKey); - - $foldersByRel = []; - foreach ($docBucket as $fid => $res) { - $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res; - $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); - $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1)); - if (!$isFolder) { - continue; - } - - $p = (string) ($e->path ?? ''); - if ('' === $p) { - continue; - } - - $frel = '/'.ltrim(substr($p, 8), '/'); - $frel = rtrim($frel, '/').'/'; - if ('//' !== $frel) { - $foldersByRel[$frel] = $fid; - } - } - - $needFolderIds = []; - foreach ($keep[$docKey] as $id => $res) { - $e = (isset($res->obj) && \is_object($res->obj)) ? $res->obj : $res; - $ftRaw = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); - $isFolder = ('folder' === $ftRaw) || (isset($e->path) && '/' === substr((string) $e->path, -1)); - if ($isFolder) { - continue; - } - - $p = (string) ($e->path ?? ''); - if ('' === $p) { - continue; - } - - $rel = '/'.ltrim(substr($p, 8), '/'); - $dir = rtrim(\dirname($rel), '/'); - if ('' === $dir) { - continue; - } - - $acc = ''; - foreach (array_filter(explode('/', $dir)) as $seg) { - $acc .= '/'.$seg; - $accKey = rtrim($acc, '/').'/'; - if (isset($foldersByRel[$accKey])) { - $needFolderIds[$foldersByRel[$accKey]] = true; - } - } - } - if (!empty($needFolderIds)) { - $added = array_intersect_key($docBucket, $needFolderIds); - $keep[$docKey] += $added; - } - } - - // Links -> pull categories used by the selected links - $lnkKey = $this->firstExistingKey($orig, ['link', 'Link', \defined('RESOURCE_LINK') ? RESOURCE_LINK : '']); - if ($lnkKey && !empty($keep[$lnkKey])) { - $catIdsUsed = []; - foreach ($keep[$lnkKey] as $lid => $lWrap) { - $L = (isset($lWrap->obj) && \is_object($lWrap->obj)) ? $lWrap->obj : $lWrap; - $cid = (int) ($L->category_id ?? 0); - if ($cid > 0) { - $catIdsUsed[(string) $cid] = true; - } - } - - $catKey = $this->firstExistingKey($orig, ['link_category', 'Link_Category', \defined('RESOURCE_LINKCATEGORY') ? (string) RESOURCE_LINKCATEGORY : '']); - if ($catKey && !empty($catIdsUsed)) { - $catBucket = $getBucket($orig, $catKey); - if (!empty($catBucket)) { - $subset = array_intersect_key($catBucket, $catIdsUsed); - $keep[$catKey] = $subset; - $keep['link_category'] = $subset; // mirror for convenience - } - } - } - - $keep = array_filter($keep); - $course->resources = !empty($__metaBuckets) ? ($__metaBuckets + $keep) : $keep; - - $this->logDebug('[filterSelection] non-forum flow end', [ - 'selected_types' => array_keys($selected), - 'orig_types' => array_keys($orig), - 'kept_types' => array_keys($course->resources ?? []), - ]); - - return $course; - } - - /** - * Map UI options (1/2/3) to legacy file policy. - */ - private function mapSameNameOption(int $opt): int - { - $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2; - - if (!\defined('FILE_SKIP')) { - \define('FILE_SKIP', 1); - } - if (!\defined('FILE_RENAME')) { - \define('FILE_RENAME', 2); - } - if (!\defined('FILE_OVERWRITE')) { - \define('FILE_OVERWRITE', 3); - } - - return match ($opt) { - 1 => FILE_SKIP, - 3 => FILE_OVERWRITE, - default => FILE_RENAME, - }; - } - - /** - * Set debug mode from Request (query/header). - */ - private function setDebugFromRequest(?Request $req): void - { - if (!$req) { - return; - } - // Query param wins - if ($req->query->has('debug')) { - $this->debug = $req->query->getBoolean('debug'); - - return; - } - // Fallback to header - $hdr = $req->headers->get('X-Debug'); - if (null !== $hdr) { - $val = trim((string) $hdr); - $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false')); - } - } - - /** - * Debug logger with stage + compact JSON payload. - */ - private function logDebug(string $stage, mixed $payload = null): void - { - if (!$this->debug) { - return; - } - $prefix = 'COURSE_DEBUG'; - if (null === $payload) { - error_log("$prefix: $stage"); - - return; - } - // Safe/short json - $json = null; - - try { - $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if (null !== $json && \strlen($json) > 8000) { - $json = substr($json, 0, 8000).'…(truncated)'; - } - } catch (Throwable $e) { - $json = '[payload_json_error: '.$e->getMessage().']'; - } - error_log("$prefix: $stage -> $json"); - } - - /** - * Snapshot of resources bag for quick inspection. - */ - private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array - { - $out = []; - $res = \is_array($course->resources ?? null) ? $course->resources : []; - $i = 0; - foreach ($res as $type => $bag) { - if ($i++ >= $maxTypes) { - $out['__notice'] = 'types truncated'; - - break; - } - $snap = ['count' => \is_array($bag) ? \count($bag) : 0, 'sample' => []]; - if (\is_array($bag)) { - $j = 0; - foreach ($bag as $id => $obj) { - if ($j++ >= $maxItemsPerType) { - $snap['sample'][] = ['__notice' => 'truncated']; - - break; - } - $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj; - $snap['sample'][] = [ - 'id' => (string) $id, - 'cls' => \is_object($obj) ? $obj::class : \gettype($obj), - 'entity_keys' => \is_object($entity) ? \array_slice(array_keys((array) $entity), 0, 12) : [], - ]; - } - } - $out[(string) $type] = $snap; - } - - return $out; - } - - /** - * Snapshot of forum-family counters. - */ - private function snapshotForumCounts(object $course): array - { - $r = \is_array($course->resources ?? null) ? $course->resources : []; - $get = fn ($a, $b) => \is_array($r[$a] ?? $r[$b] ?? null) ? \count($r[$a] ?? $r[$b]) : 0; - - return [ - 'Forum_Category' => $get('Forum_Category', 'forum_category'), - 'forum' => $get('forum', 'Forum'), - 'thread' => $get('thread', 'forum_topic'), - 'post' => $get('post', 'forum_post'), - ]; - } - - /** - * Builds the selection map [type => [id => true]] from high-level types. - * Assumes that hydrateLpDependenciesFromSnapshot() has already been called, so - * $course->resources contains LP + necessary dependencies (docs, links, quiz, etc.). - * - * @param object $course Legacy Course with already hydrated resources - * @param string[] $selectedTypes Types marked by the UI (e.g., ['learnpath']) - * - * @return array> - */ - private function buildSelectionFromTypes(object $course, array $selectedTypes): array - { - $selectedTypes = array_map( - fn ($t) => $this->normalizeTypeKey((string) $t), - $selectedTypes - ); - - $res = \is_array($course->resources ?? null) ? $course->resources : []; - - $coreDeps = [ - 'document', 'link', 'quiz', 'work', 'survey', - 'Forum_Category', 'forum', 'thread', 'post', - 'exercise_question', 'survey_question', 'link_category', - ]; - - $presentKeys = array_fill_keys(array_map( - fn ($k) => $this->normalizeTypeKey((string) $k), - array_keys($res) - ), true); - - $out = []; - - $addBucket = function (string $typeKey) use (&$out, $res): void { - if (!isset($res[$typeKey]) || !\is_array($res[$typeKey]) || empty($res[$typeKey])) { - return; - } - $ids = []; - foreach ($res[$typeKey] as $id => $_) { - $ids[(string) $id] = true; - } - if ($ids) { - $out[$typeKey] = $ids; - } - }; - - foreach ($selectedTypes as $t) { - $addBucket($t); - - if ('learnpath' === $t) { - foreach ($coreDeps as $depRaw) { - $dep = $this->normalizeTypeKey($depRaw); - if (isset($presentKeys[$dep])) { - $addBucket($dep); - } - } - } - } - - $this->logDebug('[buildSelectionFromTypes] built', [ - 'selectedTypes' => $selectedTypes, - 'kept_types' => array_keys($out), - ]); - - return $out; - } - - /** - * Build link tree (Category → Link) for the UI. - * Categories are not selectable; links are leaves (item_count = 0). - */ - private function buildLinkTreeForVue(object $course, string $groupTitle): array - { - $this->logDebug('[buildLinkTreeForVue] start'); - - $res = \is_array($course->resources ?? null) ? $course->resources : []; - $catRaw = $res['link_category'] ?? $res['Link_Category'] ?? []; - $linkRaw = $res['link'] ?? $res['Link'] ?? []; - - $this->logDebug('[buildLinkTreeForVue] raw counts', [ - 'categories' => \is_array($catRaw) ? \count($catRaw) : 0, - 'links' => \is_array($linkRaw) ? \count($linkRaw) : 0, - ]); - - $cats = []; - foreach ($catRaw as $id => $obj) { - $id = (int) $id; - if ($id <= 0 || !\is_object($obj)) { - continue; - } - $e = $this->objectEntity($obj); - $label = $this->resolveItemLabel('link_category', $e, $id); - $cats[$id] = [ - 'id' => $id, - 'type' => 'link_category', - 'label' => (('' !== $label ? $label : ('Category #'.$id)).'/'), - 'selectable' => true, - 'items' => [], - 'has_children' => false, - 'item_count' => 0, - 'extra' => ['filetype' => 'folder'], - ]; - } - - // Virtual "Uncategorized" - $uncatKey = -9999; - if (!isset($cats[$uncatKey])) { - $cats[$uncatKey] = [ - 'id' => $uncatKey, - 'type' => 'link_category', - 'label' => 'Uncategorized/', - 'selectable' => true, - 'items' => [], - '_virtual' => true, - 'has_children' => false, - 'item_count' => 0, - 'extra' => ['filetype' => 'folder'], - ]; - } - - // Assign links to categories - foreach ($linkRaw as $id => $obj) { - $id = (int) $id; - if ($id <= 0 || !\is_object($obj)) { - continue; - } - $e = $this->objectEntity($obj); - - $cid = (int) ($e->category_id ?? 0); - if (!isset($cats[$cid])) { - $cid = $uncatKey; - } - - $cats[$cid]['items'][] = [ - 'id' => $id, - 'type' => 'link', - 'label' => $this->resolveItemLabel('link', $e, $id), - 'extra' => $this->buildExtra('link', $e) ?: new stdClass(), - 'selectable' => true, - 'item_count' => 0, - ]; - } - - // Drop empty virtual category, sort, and finalize UI hints - $catNodes = array_values(array_filter($cats, static function ($c) { - if (!empty($c['_virtual']) && empty($c['items'])) { - return false; - } - - return true; - })); - - foreach ($catNodes as &$c) { - if (!empty($c['items'])) { - usort($c['items'], static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); - } - $c['has_children'] = !empty($c['items']); - $c['item_count'] = \count($c['items'] ?? []); - } - unset($c); - - usort($catNodes, static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label'])); - - $this->logDebug('[buildLinkTreeForVue] end', ['categories' => \count($catNodes)]); - - return [ - 'type' => 'link', - 'title' => $groupTitle, - 'items' => $catNodes, - ]; - } - - /** - * Leaves only the items selected by the UI in $course->resources. - * Expects $selected with the following form: - * [ - * "documents" => ["123" => true, "124" => true], - * "links" => ["7" => true], - * "quiz" => ["45" => true], - * ... - * ]. - */ - private function filterCourseResources(object $course, array $selected): void - { - if (!isset($course->resources) || !\is_array($course->resources)) { - return; - } - - $typeMap = [ - 'documents' => RESOURCE_DOCUMENT, - 'links' => RESOURCE_LINK, - 'quizzes' => RESOURCE_QUIZ, - 'quiz' => RESOURCE_QUIZ, - 'quiz_questions' => RESOURCE_QUIZQUESTION, - 'surveys' => RESOURCE_SURVEY, - 'survey' => RESOURCE_SURVEY, - 'survey_questions' => RESOURCE_SURVEYQUESTION, - 'announcements' => RESOURCE_ANNOUNCEMENT, - 'events' => RESOURCE_EVENT, - 'course_description' => RESOURCE_COURSEDESCRIPTION, - 'glossary' => RESOURCE_GLOSSARY, - 'wiki' => RESOURCE_WIKI, - 'thematic' => RESOURCE_THEMATIC, - 'attendance' => RESOURCE_ATTENDANCE, - 'works' => RESOURCE_WORK, - 'gradebook' => RESOURCE_GRADEBOOK, - 'learnpaths' => RESOURCE_LEARNPATH, - 'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY, - 'tool_intro' => RESOURCE_TOOL_INTRO, - 'forums' => RESOURCE_FORUM, - 'forum' => RESOURCE_FORUM, - 'forum_topic' => RESOURCE_FORUMTOPIC, - 'forum_post' => RESOURCE_FORUMPOST, - ]; - - $allowed = []; - foreach ($selected as $k => $idsMap) { - $key = $typeMap[$k] ?? $k; - $allowed[$key] = array_fill_keys(array_map('intval', array_keys((array) $idsMap)), true); - } - - foreach ($course->resources as $rtype => $bucket) { - if (!isset($allowed[$rtype])) { - continue; - } - $keep = $allowed[$rtype]; - $filtered = []; - foreach ((array) $bucket as $id => $obj) { - $iid = (int) ($obj->source_id ?? $id); - if (isset($keep[$iid])) { - $filtered[$id] = $obj; - } - } - $course->resources[$rtype] = $filtered; - } - } - - /** - * Resolve absolute path of a backupId inside the backups directory, with safety checks. - */ - private function resolveBackupPath(string $backupId): string - { - $base = rtrim((string) CourseArchiver::getBackupDir(), DIRECTORY_SEPARATOR); - $baseReal = realpath($base) ?: $base; - - $file = basename($backupId); - $path = $baseReal.DIRECTORY_SEPARATOR.$file; - - $real = realpath($path); - - if (false !== $real && 0 === strncmp($real, $baseReal, \strlen($baseReal))) { - return $real; - } - - return $path; - } - - /** - * Load a legacy Course object from any backup: - * - Chamilo (.zip with course_info.dat) → CourseArchiver::readCourse() or lenient fallback (your original logic) - * - Moodle (.mbz/.tgz/.gz or ZIP with moodle_backup.xml) → MoodleImport builder - * - * IMPORTANT: - * - Keeps your original Chamilo flow intact (strict → fallback manual decode/unserialize). - * - Tries Moodle only when the package looks like Moodle. - * - Adds __meta.import_source = "chamilo" | "moodle" for downstream logic. - */ - private function loadLegacyCourseForAnyBackup(string $backupId, string $force = 'auto'): object - { - $path = $this->resolveBackupPath($backupId); - - $force = strtolower($force); - if ('dat' === $force || 'chamilo' === $force) { - $looksMoodle = false; - $preferChamilo = true; - } elseif ('moodle' === $force) { - $looksMoodle = true; - $preferChamilo = false; - } else { - $looksMoodle = $this->isMoodleByExt($path) || $this->zipHasMoodleBackupXml($path); - $preferChamilo = $this->zipHasCourseInfoDat($path); - } - - if ($preferChamilo || !$looksMoodle) { - CourseArchiver::setDebug($this->debug); - - try { - $course = CourseArchiver::readCourse($backupId, false); - if (\is_object($course)) { - // … (resto igual) - if (!isset($course->resources) || !\is_array($course->resources)) { - $course->resources = []; - } - $course->resources['__meta'] = (array) ($course->resources['__meta'] ?? []); - $course->resources['__meta']['import_source'] = 'chamilo'; - - return $course; - } - } catch (Throwable $e) { - $this->logDebug('[loadLegacyCourseForAnyBackup] readCourse() failed', ['error' => $e->getMessage()]); - } - - $zipPath = $this->resolveBackupPath($backupId); - $ci = $this->readCourseInfoFromZip($zipPath); - if (empty($ci['ok'])) { - if ($looksMoodle) { - $this->logDebug('[loadLegacyCourseForAnyBackup] no course_info.dat, trying MoodleImport as last resort'); - - return $this->loadMoodleCourseOrFail($path); - } - - throw new RuntimeException('course_info.dat not found in backup'); - } - - $raw = (string) $ci['data']; - $payload = base64_decode($raw, true); - if (false === $payload) { - $payload = $raw; - } - - $payload = CourseArchiver::preprocessSerializedPayloadForTypedProps($payload); - CourseArchiver::ensureLegacyAliases(); - - set_error_handler(static function (): void {}); + set_error_handler(static function (): void {}); try { - if (class_exists(UnserializeApi::class)) { - $c = UnserializeApi::unserialize('course', $payload); + if (class_exists(\UnserializeApi::class)) { + $c = \UnserializeApi::unserialize('course', $payload); } else { $c = @unserialize($payload, ['allowed_classes' => true]); } @@ -3482,7 +1337,6 @@ private function loadLegacyCourseForAnyBackup(string $backupId, string $force = return $c; } - // Moodle path if ($looksMoodle) { $this->logDebug('[loadLegacyCourseForAnyBackup] using MoodleImport'); @@ -3494,9 +1348,7 @@ private function loadLegacyCourseForAnyBackup(string $backupId, string $force = /** * Normalize resource buckets to the exact keys supported by CourseRestorer. - * Only the canonical keys below are produced; common aliases are mapped. * - Never drop data: merge buckets; keep __meta as-is. - * - Make sure "document" survives if it existed before. */ private function normalizeBucketsForRestorer(object $course): void { @@ -3504,8 +1356,8 @@ private function normalizeBucketsForRestorer(object $course): void return; } - // Split meta buckets $all = $course->resources; + $meta = []; foreach ($all as $k => $v) { if (\is_string($k) && str_starts_with($k, '__')) { @@ -3514,10 +1366,8 @@ private function normalizeBucketsForRestorer(object $course): void } } - // Start from current $out = $all; - // merge array buckets preserving numeric/string ids $merge = static function (array $dst, array $src): array { foreach ($src as $id => $obj) { if (!\array_key_exists($id, $dst)) { @@ -3528,21 +1378,17 @@ private function normalizeBucketsForRestorer(object $course): void return $dst; }; - // safe alias map (input -> canonical). Extend only if needed. $aliases = [ - // documents 'documents' => 'document', - 'Document' => 'document', 'document ' => 'document', + 'Document' => 'document', - // tool intro 'tool introduction' => 'tool_intro', 'tool_introduction' => 'tool_intro', 'tool/introduction' => 'tool_intro', 'tool intro' => 'tool_intro', 'Tool introduction' => 'tool_intro', - // forums 'forums' => 'forum', 'Forum' => 'forum', 'Forum_Category' => 'forum_category', @@ -3554,50 +1400,38 @@ private function normalizeBucketsForRestorer(object $course): void 'Post' => 'forum_post', 'forumpost' => 'forum_post', - // links 'links' => 'link', 'link category' => 'link_category', - // quiz + questions 'Exercise_Question' => 'exercise_question', 'exercisequestion' => 'exercise_question', - // surveys 'surveys' => 'survey', 'surveyquestion' => 'survey_question', - // announcements 'announcements' => 'announcement', 'Announcements' => 'announcement', ]; - // Normalize keys (case/spacing) and apply alias merges - foreach ($all as $rawKey => $_bucket) { - if (!\is_array($_bucket)) { - continue; // defensive + foreach ($all as $rawKey => $bucket) { + if (!\is_array($bucket)) { + continue; } + $k = (string) $rawKey; $norm = strtolower(trim(strtr($k, ['\\' => '/', '-' => '_']))); $norm2 = str_replace('/', '_', $norm); - $canonical = null; - if (isset($aliases[$norm])) { - $canonical = $aliases[$norm]; - } elseif (isset($aliases[$norm2])) { - $canonical = $aliases[$norm2]; - } - + $canonical = $aliases[$norm] ?? $aliases[$norm2] ?? null; if ($canonical && $canonical !== $rawKey) { - // Merge into canonical and drop the alias key - $out[$canonical] = isset($out[$canonical]) && \is_array($out[$canonical]) - ? $merge($out[$canonical], $_bucket) - : $_bucket; + $out[$canonical] = (isset($out[$canonical]) && \is_array($out[$canonical])) + ? $merge($out[$canonical], $bucket) + : $bucket; + unset($out[$rawKey]); } - // else: leave as-is (pass-through) } - // Safety: if there was any docs bucket under an alias, ensure 'document' is present. if (!isset($out['document'])) { if (isset($all['documents']) && \is_array($all['documents'])) { $out['document'] = $all['documents']; @@ -3606,7 +1440,6 @@ private function normalizeBucketsForRestorer(object $course): void } } - // Gentle ordering for readability only (does not affect presence) $order = [ 'announcement', 'document', 'link', 'link_category', 'forum', 'forum_category', 'forum_topic', 'forum_post', @@ -3615,28 +1448,24 @@ private function normalizeBucketsForRestorer(object $course): void 'learnpath', 'tool_intro', 'work', ]; - $w = []; + + $weights = []; foreach ($order as $i => $key) { - $w[$key] = $i; + $weights[$key] = $i; } - uksort($out, static function ($a, $b) use ($w) { - $wa = $w[$a] ?? 9999; - $wb = $w[$b] ?? 9999; + + uksort($out, static function ($a, $b) use ($weights) { + $wa = $weights[$a] ?? 9999; + $wb = $weights[$b] ?? 9999; return $wa <=> $wb ?: strcasecmp((string) $a, (string) $b); }); - // Final assign: meta first, then normalized buckets $course->resources = $meta + $out; - // Debug trace to verify we didn't lose keys $this->logDebug('[normalizeBucketsForRestorer] final keys', array_keys((array) $course->resources)); } - /** - * Read import_source without depending on filtered resources. - * Falls back to $course->info['__import_source'] if needed. - */ private function getImportSource(object $course): string { $src = strtolower((string) ($course->resources['__meta']['import_source'] ?? '')); @@ -3644,261 +1473,31 @@ private function getImportSource(object $course): string return $src; } - // Fallbacks (defensive) return strtolower((string) ($course->info['__import_source'] ?? '')); } - /** - * Builds a CC 1.3 preview from the legacy Course (only the implemented one). - * Returns a structure intended for rendering/committing before the actual export. - */ - private function buildCc13Preview(object $course): array - { - $ims = [ - 'supportedTypes' => Cc13Capabilities::exportableTypes(), // ['document'] - 'resources' => [ - 'webcontent' => [], - ], - 'counts' => ['files' => 0, 'folders' => 0], - 'defaultSelection' => [ - 'documents' => [], - ], - ]; - - $res = \is_array($course->resources ?? null) ? $course->resources : []; - $docKey = null; - - foreach (['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : ''] as $cand) { - if ($cand && isset($res[$cand]) && \is_array($res[$cand]) && !empty($res[$cand])) { - $docKey = $cand; - - break; - } - } - if (!$docKey) { - return $ims; - } - - foreach ($res[$docKey] as $iid => $wrap) { - if (!\is_object($wrap)) { - continue; - } - $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap; - - $rawPath = (string) ($e->path ?? $e->full_path ?? ''); - if ('' === $rawPath) { - continue; - } - $rel = ltrim(preg_replace('~^/?document/?~', '', $rawPath), '/'); - - $fileType = strtolower((string) ($e->file_type ?? $e->filetype ?? '')); - $isDir = ('folder' === $fileType) || ('/' === substr($rawPath, -1)); - - $title = (string) ($e->title ?? $wrap->name ?? basename($rel)); - $ims['resources']['webcontent'][] = [ - 'id' => (int) $iid, - 'cc_type' => 'webcontent', - 'title' => '' !== $title ? $title : basename($rel), - 'rel' => $rel, - 'is_dir' => $isDir, - 'would_be_manifest_entry' => !$isDir, - ]; - - if (!$isDir) { - $ims['defaultSelection']['documents'][(int) $iid] = true; - $ims['counts']['files']++; - } else { - $ims['counts']['folders']++; - } - } - - return $ims; - } - - /** - * Expand category selections (link/forum) to their item IDs using a full course snapshot. - * Returns ['documents'=>[id=>true], 'links'=>[id=>true], 'forums'=>[id=>true]] merged with $normSel. - * - * @return array - */ - private function expandCc13SelectionFromCategories(object $course, array $normSel): array - { - $out = [ - 'documents' => (array) ($normSel['documents'] ?? []), - 'links' => (array) ($normSel['links'] ?? []), - 'forums' => (array) ($normSel['forums'] ?? []), - ]; - - $res = \is_array($course->resources ?? null) ? $course->resources : []; - - // Link categories → link IDs - if (!empty($normSel['link_category']) && \is_array($res['link'] ?? $res['Link'] ?? null)) { - $selCats = array_fill_keys(array_map('strval', array_keys($normSel['link_category'])), true); - $links = $res['link'] ?? $res['Link']; - foreach ($links as $lid => $wrap) { - if (!\is_object($wrap)) { - continue; - } - $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap; - $cid = (string) (int) ($e->category_id ?? 0); - if (isset($selCats[$cid])) { - $out['links'][(string) $lid] = true; - } - } - } - - // Forum categories → forum IDs - if (!empty($normSel['forum_category']) && \is_array($res['forum'] ?? $res['Forum'] ?? null)) { - $selCats = array_fill_keys(array_map('strval', array_keys($normSel['forum_category'])), true); - $forums = $res['forum'] ?? $res['Forum']; - foreach ($forums as $fid => $wrap) { - if (!\is_object($wrap)) { - continue; - } - $e = (isset($wrap->obj) && \is_object($wrap->obj)) ? $wrap->obj : $wrap; - $cid = (string) (int) ($e->forum_category ?? $e->forum_category_id ?? $e->category_id ?? 0); - if (isset($selCats[$cid])) { - $out['forums'][(string) $fid] = true; - } - } - } - - return $out; - } - - /** - * Infer tool buckets required by a given selection payload (used in 'selected' scope). - * - * Expected selection items like: { "type": "document"|"quiz"|"survey"|... , "id": } - * - * @param array> $selected - * - * @return string[] - */ - private function inferToolsFromSelection(array $selected): array + private function computeWantedMoodleGroups(object $course): array { - $has = static fn (string $k): bool => !empty($selected[$k]) && \is_array($selected[$k]) && \count($selected[$k]) > 0; - - $want = []; - - // documents - if ($has('document')) { - $want[] = 'documents'; - } - - // links (categories imply links too) - if ($has('link') || $has('link_category')) { - $want[] = 'links'; - } - - // forums (any of the family implies forums) - if ($has('forum') || $has('forum_category') || $has('forum_topic') || $has('thread') || $has('post') || $has('forum_post')) { - $want[] = 'forums'; - } - - // quizzes / questions - if ($has('quiz') || $has('exercise') || $has('exercise_question')) { - $want[] = 'quizzes'; - $want[] = 'quiz_questions'; - } - - // surveys / questions / invitations - if ($has('survey') || $has('survey_question') || $has('survey_invitation')) { - $want[] = 'surveys'; - $want[] = 'survey_questions'; - } - - // learnpaths - if ($has('learnpath') || $has('learnpath_category')) { - $want[] = 'learnpaths'; - $want[] = 'learnpath_category'; - } + $presentBuckets = array_map('strtolower', array_keys((array) ($course->resources ?? []))); + $present = static fn (string $k): bool => \in_array(strtolower($k), $presentBuckets, true); - // others - if ($has('work')) { - $want[] = 'works'; - } - if ($has('glossary')) { - $want[] = 'glossary'; - } - if ($has('tool_intro')) { - $want[] = 'tool_intro'; - } - if ($has('course_descriptions') || $has('course_description')) { - $tools[] = 'course_descriptions'; - } - - // Dedup - return array_values(array_unique(array_filter($want))); - } - - private function intersectBucketByIds(array $bucket, array $idsMap): array - { - $out = []; - foreach ($bucket as $id => $obj) { - $ent = (isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj; - $k1 = (string) $id; - $k2 = (string) ($ent->source_id ?? $obj->source_id ?? ''); - if (isset($idsMap[$k1]) || ('' !== $k2 && isset($idsMap[$k2]))) { - $out[$id] = $obj; + $wanted = []; + $mark = static function (array &$dst, bool $cond, string $key): void { + if ($cond) { + $dst[$key] = true; } - } - - return $out; - } - - private function bucketKeyCandidates(string $type): array - { - $t = $this->normalizeTypeKey($type); - - // Constants (string values) if defined - $RD = \defined('RESOURCE_DOCUMENT') ? (string) RESOURCE_DOCUMENT : ''; - $RL = \defined('RESOURCE_LINK') ? (string) RESOURCE_LINK : ''; - $RF = \defined('RESOURCE_FORUM') ? (string) RESOURCE_FORUM : ''; - $RFT = \defined('RESOURCE_FORUMTOPIC') ? (string) RESOURCE_FORUMTOPIC : ''; - $RFP = \defined('RESOURCE_FORUMPOST') ? (string) RESOURCE_FORUMPOST : ''; - $RQ = \defined('RESOURCE_QUIZ') ? (string) RESOURCE_QUIZ : ''; - $RQQ = \defined('RESOURCE_QUIZQUESTION') ? (string) RESOURCE_QUIZQUESTION : ''; - $RS = \defined('RESOURCE_SURVEY') ? (string) RESOURCE_SURVEY : ''; - $RSQ = \defined('RESOURCE_SURVEYQUESTION') ? (string) RESOURCE_SURVEYQUESTION : ''; - - $map = [ - 'document' => ['document', 'Document', $RD], - 'link' => ['link', 'Link', $RL], - 'link_category' => ['link_category', 'Link_Category'], - 'forum' => ['forum', 'Forum', $RF], - 'forum_category' => ['forum_category', 'Forum_Category'], - 'forum_topic' => ['forum_topic', 'thread', $RFT], - 'forum_post' => ['forum_post', 'post', $RFP], - 'quiz' => ['quiz', 'Quiz', $RQ], - 'exercise_question' => ['Exercise_Question', 'exercise_question', $RQQ], - 'survey' => ['survey', 'Survey', $RS], - 'survey_question' => ['Survey_Question', 'survey_question', $RSQ], - 'tool_intro' => ['tool_intro', 'Tool introduction'], - ]; - - $c = $map[$t] ?? [$t, ucfirst($t)]; - - return array_values(array_filter($c, static fn ($x) => '' !== $x)); - } - - private function findBucketKey(array $res, string $type): ?string - { - $key = $this->firstExistingKey($res, $this->bucketKeyCandidates($type)); - - return null !== $key ? (string) $key : null; - } + }; - private function findBucket(array $res, string $type): array - { - $k = $this->findBucketKey($res, $type); + // Tolerant (plural + legacy keys) + $mark($wanted, $present('link') || $present('link_category'), 'links'); + $mark($wanted, $present('forum') || $present('forum_category'), 'forums'); + $mark($wanted, $present('document') || $present('documents'), 'documents'); + $mark($wanted, $present('quiz') || $present('exercise'), 'quizzes'); + $mark($wanted, $present('scorm'), 'scorm'); - return (null !== $k && isset($res[$k]) && \is_array($res[$k])) ? $res[$k] : []; + return $wanted; } - /** - * True if file extension suggests a Moodle backup. - */ private function isMoodleByExt(string $path): bool { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); @@ -3906,40 +1505,36 @@ private function isMoodleByExt(string $path): bool return \in_array($ext, ['mbz', 'tgz', 'gz'], true); } - /** - * Quick ZIP probe for 'moodle_backup.xml'. Safe no-op for non-zip files. - */ private function zipHasMoodleBackupXml(string $path): bool { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); - // Many .mbz are plain ZIPs; try to open if extension is zip/mbz if (!\in_array($ext, ['zip', 'mbz'], true)) { return false; } + $zip = new ZipArchive(); - if (true !== ($err = $zip->open($path))) { + if (true !== $zip->open($path)) { return false; } + $idx = $zip->locateName('moodle_backup.xml', ZipArchive::FL_NOCASE); $zip->close(); return false !== $idx; } - /** - * Quick ZIP probe for 'course_info.dat'. Safe no-op for non-zip files. - */ private function zipHasCourseInfoDat(string $path): bool { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if (!\in_array($ext, ['zip', 'mbz'], true)) { return false; } + $zip = new ZipArchive(); - if (true !== ($err = $zip->open($path))) { + if (true !== $zip->open($path)) { return false; } - // common locations + foreach (['course_info.dat', 'course/course_info.dat', 'backup/course_info.dat'] as $cand) { $idx = $zip->locateName($cand, ZipArchive::FL_NOCASE); if (false !== $idx) { @@ -3948,20 +1543,18 @@ private function zipHasCourseInfoDat(string $path): bool return true; } } + $zip->close(); return false; } - /** - * Build legacy Course graph from a Moodle archive and set __meta.import_source. - * Throws RuntimeException on failure. - */ private function loadMoodleCourseOrFail(string $absPath): object { if (!class_exists(MoodleImport::class)) { throw new RuntimeException('MoodleImport class not available'); } + $importer = new MoodleImport(debug: $this->debug); if (!method_exists($importer, 'buildLegacyCourseFromMoodleArchive')) { @@ -3980,67 +1573,14 @@ private function loadMoodleCourseOrFail(string $absPath): object return $course; } - /** - * 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 - */ - private function sanitizePhpGraph(mixed $value): mixed - { - 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); - } - - 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); - } - - return (object) $clean; - } - - return $value; - } - - private static function getPhpUploadLimitBytes(): int - { - // Use the strictest PHP limit (min of upload_max_filesize and post_max_size). - $limits = []; - - $u = \ini_get('upload_max_filesize'); - if (\is_string($u)) { - $limits[] = self::iniSizeToBytes($u); - } - - $p = \ini_get('post_max_size'); - if (\is_string($p)) { - $limits[] = self::iniSizeToBytes($p); - } - - // Keep only positive limits. If both are 0, treat as "no limit". - $limits = array_values(array_filter($limits, static fn (int $v): bool => $v > 0)); - - return empty($limits) ? 0 : min($limits); - } - private static function iniSizeToBytes(string $val): int { - // Parses values like "2G", "512M", "900K", "1048576". $val = trim($val); if ('' === $val) { return 0; } if ('0' === $val) { - return 0; // "no limit" for upload/post. + return 0; } if (!preg_match('/^([0-9]+(?:\.[0-9]+)?)\s*([kmgt])?b?$/i', $val, $m)) { @@ -4053,19 +1593,15 @@ private static function iniSizeToBytes(string $val): int switch ($unit) { case 't': $num *= 1024; - - // no break + // no break case 'g': $num *= 1024; - - // no break + // no break case 'm': $num *= 1024; - - // no break + // no break case 'k': $num *= 1024; - break; } 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 b18572fb683..84d2986c821 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; @@ -5551,115 +5551,189 @@ private function ensureDepsBagsFromSnapshot(): void */ private function rewriteHtmlForCourse(string $html, int $sessionId, string $dbgTag = ''): string { - // Nothing to do if the HTML is empty if ('' === $html) { return ''; } - // Resolve context entities (course/session/group) and repositories - $course = api_get_course_entity($this->destination_course_id); - $session = api_get_session_entity((int) $sessionId); - $group = api_get_group_entity(); + $courseEntity = api_get_course_entity($this->destination_course_id); + $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null; + $groupEntity = api_get_group_entity(); + $docRepo = Container::getDocumentRepository(); - // Determine course directory and source root (when importing from a ZIP/package) - $courseDir = (string) ($this->course->info['path'] ?? ''); - $srcRoot = rtrim((string) ($this->course->backup_path ?? ''), '/'); + $courseDir = (string) ($this->course->courseDir ?? ($this->course->info['path'] ?? '')); + $courseDir = trim($courseDir, '/'); + $courseDir = preg_replace('#^courses/#i', '', $courseDir) ?: $courseDir; + + $DBG = function (string $tag, array $ctx = []) use ($dbgTag): void { + $this->dlog('HTMLRW'.$dbgTag.': '.$tag, $ctx); + }; + + $srcRoot = $this->getHtmlRewriteSourceRoot($courseDir, $DBG); + + $DBG('html.rewrite.context', [ + 'courseDir' => $courseDir, + 'srcRoot' => $srcRoot, + 'srcRootIsDir' => ('' !== $srcRoot) && is_dir($srcRoot), + ]); + + if ('' === $srcRoot || !is_dir($srcRoot)) { + $DBG('html.rewrite.skip', ['reason' => 'No valid srcRoot directory found']); + return $html; + } - // Cache of created folder IIDs per course dir to avoid duplicate folder creation - if (!isset($this->htmlFoldersByCourseDir[$courseDir])) { + if (!isset($this->htmlFoldersByCourseDir[$courseDir]) || !is_array($this->htmlFoldersByCourseDir[$courseDir])) { $this->htmlFoldersByCourseDir[$courseDir] = []; } + + // pass-by-reference array must be a variable (not an expression) $folders = &$this->htmlFoldersByCourseDir[$courseDir]; - // Small debug helper bound to the current dbgTag - $DBG = function (string $tag, array $ctx = []) use ($dbgTag): void { - $this->dlog('HTMLRW'.$dbgTag.': '.$tag, $ctx); - }; + // file_option (skip/overwrite/rename). Keep a safe default if not set. + $fileOption = 0; + if (isset($this->course->file_option)) { + $fileOption = (int) $this->course->file_option; + } elseif (isset($this->file_option)) { + $fileOption = (int) $this->file_option; + } - // Ensure a folder chain exists under /document and return parent IID (0 means root) - $ensureFolder = function (string $relPath) use (&$folders, $course, $session, $DBG) { - // Ignore empty/root markers - if ('/' === $relPath || '/document' === $relPath) { + // Ensure folder callback expected by ChamiloHelper + $ensureFolder = function (string $parentRelPath) use ( + &$folders, + $docRepo, + $courseEntity, + $sessionEntity, + $groupEntity, + $sessionId, + $DBG + ): int { + $parentRelPath = '/'.trim($parentRelPath, '/'); + if ('/' === $parentRelPath) { return 0; } - // Reuse cached IID if we already created/resolved this path - if (!empty($folders[$relPath])) { - return (int) $folders[$relPath]; + // If already cached + if (!empty($folders[$parentRelPath])) { + return (int) $folders[$parentRelPath]; } - try { - // Create the folder via DocumentManager; parent is resolved by the path - $entity = DocumentManager::addDocument( - ['real_id' => $course->getId(), 'code' => method_exists($course, 'getCode') ? $course->getCode() : null], - $relPath, - 'folder', - 0, - basename($relPath), - null, - 0, - null, - 0, - (int) ($session?->getId() ?? 0) + // Create nested folders progressively: /A, /A/B, /A/B/C + $parts = array_values(array_filter(explode('/', trim($parentRelPath, '/')))); + $currentPath = ''; + $parentId = 0; + + // Course info array for DocumentManager + $courseInfo = [ + 'real_id' => method_exists($courseEntity, 'getId') ? (int) $courseEntity->getId() : 0, + 'code' => method_exists($courseEntity, 'getCode') ? (string) $courseEntity->getCode() : null, + ]; + + foreach ($parts as $p) { + $currentPath .= '/'.$p; + + if (!empty($folders[$currentPath])) { + $parentId = (int) $folders[$currentPath]; + continue; + } + + $title = $p; + + // Try to find existing folder under current parent + $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; + $existing = $docRepo->findCourseResourceByTitle( + $title, + $parentRes->getResourceNode(), + $courseEntity, + $sessionEntity, + $groupEntity ); - // Cache the created IID if available - $iid = method_exists($entity, 'getIid') ? (int) $entity->getIid() : 0; - if ($iid > 0) { - $folders[$relPath] = $iid; + if ($existing && method_exists($existing, 'getIid')) { + $parentId = (int) $existing->getIid(); + $folders[$currentPath] = $parentId; + $DBG('html.ensureFolder.reuse', ['path' => $currentPath, 'iid' => $parentId]); + continue; } - return $iid; - } catch (Throwable $e) { - // Do not interrupt restore flow if folder creation fails - $DBG('ensureFolder.error', ['relPath' => $relPath, 'err' => $e->getMessage()]); + // Create folder + try { + $entity = DocumentManager::addDocument( + $courseInfo, + $currentPath, + 'folder', + 0, + $title, + null, + 0, + null, + 0, + (int) $sessionId, + 0, + false, + '', + $parentId, + null + ); - return 0; + $iid = ($entity && method_exists($entity, 'getIid')) ? (int) $entity->getIid() : 0; + if ($iid > 0) { + $folders[$currentPath] = $iid; + $parentId = $iid; + $DBG('html.ensureFolder.created', ['path' => $currentPath, 'iid' => $iid]); + } + } catch (Throwable $e) { + $DBG('html.ensureFolder.error', [ + 'path' => $currentPath, + 'message' => $e->getMessage(), + 'class' => get_class($e), + ]); + } } + + return $parentId; }; - // Only rewrite when we are importing from a package (ZIP) with a known source root - if ('' !== $srcRoot) { - try { - // Build a URL map for all legacy references found in the HTML - $mapDoc = ChamiloHelper::buildUrlMapForHtmlFromPackage( - $html, - $courseDir, - $srcRoot, - $folders, - $ensureFolder, - $docRepo, - $course, - $session, - $group, - (int) $sessionId, - (int) $this->file_option, - $DBG - ); + try { + // Call with the EXACT signature you pasted: + // (html, courseDir, srcRoot, &$folders, $ensureFolder, $docRepo, $courseEntity, $session, $group, session_id, file_option, dbg) + $map = ChamiloHelper::buildUrlMapForHtmlFromPackage( + $html, + $courseDir, + $srcRoot, + $folders, + $ensureFolder, + $docRepo, + $courseEntity, + $sessionEntity, + $groupEntity, + (int) $sessionId, + (int) $fileOption, + $DBG + ); - // Rewrite the HTML using both exact (byRel) and basename (byBase) maps - $rr = ChamiloHelper::rewriteLegacyCourseUrlsWithMap( - $html, - $courseDir, - $mapDoc['byRel'] ?? [], - $mapDoc['byBase'] ?? [] - ); + $byRel = $map['byRel'] ?? []; + $byBase = $map['byBase'] ?? []; - // Log replacement stats for troubleshooting - $DBG('zip.rewrite', ['replaced' => $rr['replaced'] ?? 0, 'misses' => $rr['misses'] ?? 0]); + $DBG('html.rewrite.map', [ + 'byRel' => is_array($byRel) ? count($byRel) : 0, + 'byBase' => is_array($byBase) ? count($byBase) : 0, + ]); - // Return rewritten HTML when available; otherwise the original - return (string) ($rr['html'] ?? $html); - } catch (Throwable $e) { - // Fall back to original HTML if anything fails during mapping/rewrite - $DBG('zip.error', ['err' => $e->getMessage()]); + $result = ChamiloHelper::rewriteLegacyCourseUrlsWithMap($html, $courseDir, $byRel, $byBase); - return $html; - } + $DBG('html.rewrite.result', [ + 'replaced' => (int) ($result['replaced'] ?? 0), + 'misses' => (int) ($result['misses'] ?? 0), + ]); + + return (string) ($result['html'] ?? $html); + } catch (Throwable $e) { + $DBG('html.rewrite.error', [ + 'message' => $e->getMessage(), + 'class' => get_class($e), + ]); } - // If no package source root, return the original HTML unchanged return $html; } @@ -6181,4 +6255,76 @@ private function getCourseBackupsBase(): string return rtrim(api_get_path(SYS_ARCHIVE_PATH), '/').'/course_backups'; } + + /** + * Returns the correct source root used to resolve legacy HTML dependencies + * from the extracted CourseArchiver folder (local), not from a legacy server absolute path. + */ + private function getHtmlRewriteSourceRoot(string $courseDir, ?callable $DBG = null): string + { + $candidates = []; + + // 1) Preferred: __meta.archiver_root (extracted CourseArchiver folder) + $meta = $this->course->resources['__meta'] ?? null; + if (is_array($meta) && !empty($meta['archiver_root'])) { + $candidates[] = (string) $meta['archiver_root']; + } elseif (is_object($meta) && !empty($meta->archiver_root)) { + $candidates[] = (string) $meta->archiver_root; + } + + // 2) If your class has resolveImportRoot(), try it + if (method_exists($this, 'resolveImportRoot')) { + try { + $resolved = $this->resolveImportRoot(); + if (is_array($resolved) && !empty($resolved['dir'])) { + $candidates[] = (string) $resolved['dir']; + } elseif (is_string($resolved) && '' !== $resolved) { + $candidates[] = $resolved; + } + } catch (Throwable $e) { + if (null !== $DBG) { + $DBG('html.sourceRoot.resolveImportRoot.error', [ + 'message' => $e->getMessage(), + 'class' => get_class($e), + ]); + } + } + } + + // 3) Legacy backup_path (only if it exists locally) + if (!empty($this->course->backup_path)) { + $candidates[] = (string) $this->course->backup_path; + } + + foreach ($candidates as $candidate) { + $candidate = rtrim(trim((string) $candidate), '/'); + if ('' === $candidate || !is_dir($candidate)) { + continue; + } + + // Most common: extracted folder contains "document/" directly + if (is_dir($candidate.'/document')) { + if (null !== $DBG) { + $DBG('html.sourceRoot.selected', ['srcRoot' => $candidate, 'reason' => 'has_document_dir']); + } + return $candidate; + } + + // Alternative: candidate//document/ + if ('' !== $courseDir && is_dir($candidate.'/'.$courseDir.'/document')) { + $srcRoot = $candidate.'/'.$courseDir; + if (null !== $DBG) { + $DBG('html.sourceRoot.selected', ['srcRoot' => $srcRoot, 'reason' => 'root_contains_courseDir']); + } + return $srcRoot; + } + } + + if (null !== $DBG) { + $DBG('html.sourceRoot.notFound', ['courseDir' => $courseDir]); + } + + return ''; + } + } 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'); + } + }