diff --git a/public/main/inc/lib/CourseChatUtils.php b/public/main/inc/lib/CourseChatUtils.php index 7e9c2092ab8..5c930eb7c7a 100644 --- a/public/main/inc/lib/CourseChatUtils.php +++ b/public/main/inc/lib/CourseChatUtils.php @@ -156,9 +156,8 @@ private function createNodeWithResource(string $fileTitle, string $slug, Resourc } /** Sanitize and convert message to safe HTML */ - public function prepareMessage($message) + public static function prepareMessage($message): string { - $this->dbg('prepareMessage.in', ['len' => strlen((string) $message)]); if (empty($message)) { return ''; } @@ -181,7 +180,6 @@ public function prepareMessage($message) ); $message = MarkdownExtra::defaultTransform($message); - $this->dbg('prepareMessage.out', ['len' => strlen($message)]); return $message; } @@ -312,7 +310,7 @@ public function saveMessage($message, $friendId = 0) $isMaster = api_is_course_admin(); $timeNow = date('d/m/y H:i:s'); $userPhoto = \UserManager::getUserPicture($this->userId); - $htmlMsg = $this->prepareMessage($message); + $htmlMsg = self::prepareMessage($message); $bubble = $isMaster ? '
' diff --git a/src/CoreBundle/Controller/Admin/SessionAdminController.php b/src/CoreBundle/Controller/Admin/SessionAdminController.php index 29134a2644b..434ed5c6df3 100644 --- a/src/CoreBundle/Controller/Admin/SessionAdminController.php +++ b/src/CoreBundle/Controller/Admin/SessionAdminController.php @@ -207,7 +207,7 @@ public function listIncomplete( return $courseItems; }, $results); - $flatItems = array_merge(...$items); + $flatItems = array_merge([], ...array_values($items)); return $this->json([ 'items' => $flatItems, diff --git a/src/CoreBundle/Controller/ChatController.php b/src/CoreBundle/Controller/ChatController.php index 36febf36b88..1aa03889d97 100644 --- a/src/CoreBundle/Controller/ChatController.php +++ b/src/CoreBundle/Controller/ChatController.php @@ -154,7 +154,7 @@ public function ajax(Request $request, ManagerRegistry $doctrine): Response $msg = (string) $request->get('message', ''); $json = [ 'status' => true, - 'data' => ['message' => $chat->prepareMessage($msg)], + 'data' => ['message' => CourseChatUtils::prepareMessage($msg)], ]; $log('preview.ok', ['len' => \strlen($msg)]); diff --git a/src/CoreBundle/Controller/CourseMaintenanceController.php b/src/CoreBundle/Controller/CourseMaintenanceController.php index a45f54b02bb..9da391177c4 100644 --- a/src/CoreBundle/Controller/CourseMaintenanceController.php +++ b/src/CoreBundle/Controller/CourseMaintenanceController.php @@ -14,9 +14,16 @@ use Chamilo\CourseBundle\Component\CourseCopy\CourseSelectForm; use CourseManager; use Doctrine\ORM\EntityManagerInterface; +use stdClass; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\{JsonResponse, Request}; use Symfony\Component\Routing\Attribute\Route; +use Throwable; + +use const JSON_PRETTY_PRINT; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; +use const PATHINFO_EXTENSION; #[Route( '/course_maintenance/{node}', @@ -25,7 +32,9 @@ )] class CourseMaintenanceController extends AbstractController { - /** @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1 */ + /** + * @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1 + */ private bool $debug = true; #[Route('/import/options', name: 'import_options', methods: ['GET'])] @@ -35,12 +44,12 @@ public function importOptions(int $node, Request $req): JsonResponse $this->logDebug('[importOptions] called', ['node' => $node, 'debug' => $this->debug]); return $this->json([ - 'sources' => ['local', 'server'], + 'sources' => ['local', 'server'], 'importOptions' => ['full_backup', 'select_items'], - 'sameName' => ['skip', 'rename', 'overwrite'], - 'defaults' => [ - 'importOption' => 'full_backup', - 'sameName' => 'rename', + 'sameName' => ['skip', 'rename', 'overwrite'], + 'defaults' => [ + 'importOption' => 'full_backup', + 'sameName' => 'rename', 'sameFileNameOption' => 2, ], ]); @@ -53,18 +62,20 @@ public function importUpload(int $node, Request $req): JsonResponse $file = $req->files->get('file'); if (!$file) { $this->logDebug('[importUpload] missing file'); + return $this->json(['error' => 'Missing file'], 400); } $this->logDebug('[importUpload] received', [ 'original_name' => $file->getClientOriginalName(), - 'size' => $file->getSize(), - 'mime' => $file->getClientMimeType(), + 'size' => $file->getSize(), + 'mime' => $file->getClientMimeType(), ]); $backupId = CourseArchiver::importUploadedFile($file->getRealPath()); - if ($backupId === false) { + if (false === $backupId) { $this->logDebug('[importUpload] archive dir not writable'); + return $this->json(['error' => 'Archive directory is not writable'], 500); } @@ -80,16 +91,18 @@ 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 = json_decode($req->getContent() ?: '{}', true); $filename = $payload['filename'] ?? null; if (!$filename) { $this->logDebug('[importServerPick] missing filename'); + return $this->json(['error' => 'Missing filename'], 400); } $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename; if (!is_file($path)) { $this->logDebug('[importServerPick] file not found', ['path' => $path]); + return $this->json(['error' => 'File not found'], 404); } @@ -114,8 +127,8 @@ public function importResources(int $node, string $backupId, Request $req): Json $course = CourseArchiver::readCourse($backupId, false); $this->logDebug('[importResources] course loaded', [ - 'has_resources' => is_array($course->resources ?? null), - 'keys' => array_keys((array) ($course->resources ?? [])), + 'has_resources' => \is_array($course->resources ?? null), + 'keys' => array_keys((array) ($course->resources ?? [])), ]); $this->logDebug('[importResources] resources snapshot', $this->snapshotResources($course)); $this->logDebug('[importResources] forum counts', $this->snapshotForumCounts($course)); @@ -123,7 +136,7 @@ public function importResources(int $node, string $backupId, Request $req): Json $tree = $this->buildResourceTreeForVue($course); $this->logDebug( '[importResources] UI tree groups', - array_map(fn($g) => ['type' => $g['type'], 'title' => $g['title'], 'items' => count($g['items'] ?? [])], $tree) + array_map(fn ($g) => ['type' => $g['type'], 'title' => $g['title'], 'items' => \count($g['items'] ?? [])], $tree) ); if ($this->debug && $req->query->getBoolean('debug')) { @@ -132,7 +145,7 @@ public function importResources(int $node, string $backupId, Request $req): Json @file_put_contents( $base.'/'.preg_replace('/[^a-zA-Z0-9._-]/', '_', $backupId).'.json', json_encode([ - 'tree' => $tree, + 'tree' => $tree, 'resources_keys' => array_keys((array) ($course->resources ?? [])), ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ); @@ -145,13 +158,14 @@ public function importResources(int $node, string $backupId, Request $req): Json } return $this->json([ - 'tree' => $tree, + 'tree' => $tree, 'warnings' => $warnings, ]); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->logDebug('[importResources] exception', ['message' => $e->getMessage()]); + return $this->json([ - 'tree' => [], + 'tree' => [], 'warnings' => ['Error reading backup: '.$e->getMessage()], ], 200); } @@ -169,54 +183,58 @@ public function importRestore(int $node, string $backupId, Request $req): JsonRe $this->logDebug('[importRestore] begin', ['node' => $node, 'backupId' => $backupId]); try { - $payload = json_decode($req->getContent() ?: "{}", true); - $importOption = (string) ($payload['importOption'] ?? 'full_backup'); - $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); - $selectedResources = (array) ($payload['resources'] ?? []); + $payload = json_decode($req->getContent() ?: '{}', true); + $importOption = (string) ($payload['importOption'] ?? 'full_backup'); + $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); + $selectedResources = (array) ($payload['resources'] ?? []); $this->logDebug('[importRestore] input', [ - 'importOption' => $importOption, + 'importOption' => $importOption, 'sameFileNameOption' => $sameFileNameOption, - 'selectedTypes' => array_keys($selectedResources), + 'selectedTypes' => array_keys($selectedResources), ]); $backupDir = CourseArchiver::getBackupDir(); $this->logDebug('[importRestore] backup dir', $backupDir); $path = rtrim($backupDir, '/').'/'.$backupId; $this->logDebug('[importRestore] path exists?', [ - 'path' => $path, - 'exists' => is_file($path), + 'path' => $path, + 'exists' => is_file($path), 'readable' => is_readable($path), ]); /** @var Course $course */ $course = CourseArchiver::readCourse($backupId, false); - if (!is_object($course) || empty($course->resources) || !is_array($course->resources)) { + if (!\is_object($course) || empty($course->resources) || !\is_array($course->resources)) { $this->logDebug('[importRestore] course empty resources'); + return $this->json(['error' => 'Backup has no resources'], 400); } $this->logDebug('[importRestore] BEFORE filter keys', array_keys($course->resources)); $this->logDebug('[importRestore] BEFORE forum counts', $this->snapshotForumCounts($course)); - if ($importOption === 'select_items') { + if ('select_items' === $importOption) { $hasAny = false; foreach ($selectedResources as $t => $ids) { - if (is_array($ids) && !empty($ids)) { + if (\is_array($ids) && !empty($ids)) { $hasAny = true; + break; } } if (!$hasAny) { $this->logDebug('[importRestore] empty selection'); + return $this->json(['error' => 'No resources selected'], 400); } $course = $this->filterLegacyCourseBySelection($course, $selectedResources); - if (empty($course->resources) || count((array) $course->resources) === 0) { + if (empty($course->resources) || 0 === \count((array) $course->resources)) { $this->logDebug('[importRestore] selection produced no resources'); + return $this->json(['error' => 'Selection produced no resources to restore'], 400); } } @@ -240,25 +258,26 @@ public function importRestore(int $node, string $backupId, Request $req): JsonRe CourseArchiver::cleanBackupDir(); - $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0); - $sessionId = 0; - $groupId = 0; - $redirectUrl = sprintf('/course/%d/home?sid=%d&gid=%d', $courseId, $sessionId, $groupId); + $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0); + $sessionId = 0; + $groupId = 0; + $redirectUrl = \sprintf('/course/%d/home?sid=%d&gid=%d', $courseId, $sessionId, $groupId); $this->logDebug('[importRestore] done, redirect', ['url' => $redirectUrl]); return $this->json([ - 'ok' => true, - 'message' => 'Import finished', + 'ok' => true, + 'message' => 'Import finished', 'redirectUrl' => $redirectUrl, ]); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->logDebug('[importRestore] exception', [ 'message' => $e->getMessage(), - 'file' => $e->getFile().':'.$e->getLine(), + 'file' => $e->getFile().':'.$e->getLine(), ]); + return $this->json([ - 'error' => 'Restore failed: '.$e->getMessage(), + 'error' => 'Restore failed: '.$e->getMessage(), 'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, ], 500); } @@ -270,12 +289,7 @@ public function copyOptions(int $node, Request $req): JsonResponse $this->setDebugFromRequest($req); $current = api_get_course_info(); - $courseList = CourseManager::getCoursesFollowedByUser( - api_get_user_id(), - COURSEMANAGER, - null, null, null, null, false, null, null, false, - 'ORDER BY c.title' - ); + $courseList = CourseManager::getCoursesFollowedByUser(api_get_user_id()); $courses = []; foreach ($courseList as $c) { @@ -286,11 +300,11 @@ public function copyOptions(int $node, Request $req): JsonResponse } return $this->json([ - 'courses' => $courses, + 'courses' => $courses, 'defaults' => [ - 'copyOption' => 'full_copy', - 'includeUsers' => false, - 'resetDates' => true, + 'copyOption' => 'full_copy', + 'includeUsers' => false, + 'resetDates' => true, 'sameFileNameOption' => 2, ], ]); @@ -301,7 +315,7 @@ public function copyResources(int $node, Request $req): JsonResponse { $this->setDebugFromRequest($req); $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', '')); - if ($sourceCourseCode === '') { + if ('' === $sourceCourseCode) { return $this->json(['error' => 'Missing sourceCourseId'], 400); } @@ -347,14 +361,14 @@ public function copyExecute(int $node, Request $req): JsonResponse $this->setDebugFromRequest($req); try { - $payload = json_decode($req->getContent() ?: "{}", true); + $payload = json_decode($req->getContent() ?: '{}', true); - $sourceCourseId = (string) ($payload['sourceCourseId'] ?? ''); - $copyOption = (string) ($payload['copyOption'] ?? 'full_copy'); - $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); - $selectedResourcesMap = (array) ($payload['resources'] ?? []); + $sourceCourseId = (string) ($payload['sourceCourseId'] ?? ''); + $copyOption = (string) ($payload['copyOption'] ?? 'full_copy'); + $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); + $selectedResourcesMap = (array) ($payload['resources'] ?? []); - if ($sourceCourseId === '') { + if ('' === $sourceCourseId) { return $this->json(['error' => 'Missing sourceCourseId'], 400); } @@ -383,10 +397,10 @@ public function copyExecute(int $node, Request $req): JsonResponse ]); $legacyCourse = $cb->build(0, $sourceCourseId); - if ($copyOption === 'select_items') { + if ('select_items' === $copyOption) { $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap); - if (empty($legacyCourse->resources) || !is_array($legacyCourse->resources)) { + if (empty($legacyCourse->resources) || !\is_array($legacyCourse->resources)) { return $this->json(['error' => 'Selection produced no resources to copy'], 400); } } @@ -401,16 +415,16 @@ public function copyExecute(int $node, Request $req): JsonResponse $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']); return $this->json([ - 'ok' => true, - 'message' => 'Copy finished', + 'ok' => true, + 'message' => 'Copy finished', 'redirectUrl' => $redirectUrl, ]); - } catch (\Throwable $e) { + } catch (Throwable $e) { return $this->json([ - 'error' => 'Copy failed: '.$e->getMessage(), + 'error' => 'Copy failed: '.$e->getMessage(), 'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, ], 500); } @@ -438,9 +452,9 @@ public function recycleResources(int $node, Request $req): JsonResponse // 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', + '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()); @@ -454,14 +468,14 @@ public function recycleResources(int $node, Request $req): JsonResponse public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse { try { - $p = json_decode($req->getContent() ?: "{}", true); - $recycleOption = (string)($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items' - $resourcesMap = (array) ($p['resources'] ?? []); - $confirmCode = (string)($p['confirm'] ?? ''); + $p = json_decode($req->getContent() ?: '{}', true); + $recycleOption = (string) ($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items' + $resourcesMap = (array) ($p['resources'] ?? []); + $confirmCode = (string) ($p['confirm'] ?? ''); - $type = $recycleOption === 'full_recycle' ? 'full_backup' : 'select_items'; + $type = 'full_recycle' === $recycleOption ? 'full_backup' : 'select_items'; - if ($type === 'full_backup') { + if ('full_backup' === $type) { if ($confirmCode !== api_get_course_id()) { return $this->json(['error' => 'Course code confirmation mismatch'], 400); } @@ -473,7 +487,7 @@ public function recycleExecute(Request $req, EntityManagerInterface $em): JsonRe $courseCode = api_get_course_id(); $courseInfo = api_get_course_info($courseCode); - $courseId = (int)($courseInfo['real_id'] ?? 0); + $courseId = (int) ($courseInfo['real_id'] ?? 0); if ($courseId <= 0) { return $this->json(['error' => 'Invalid course id'], 400); } @@ -487,12 +501,12 @@ public function recycleExecute(Request $req, EntityManagerInterface $em): JsonRe $recycler->recycle($type, $resourcesMap); return $this->json([ - 'ok' => true, + 'ok' => true, 'message' => 'Recycle finished', ]); - } catch (\Throwable $e) { + } catch (Throwable $e) { return $this->json([ - 'error' => 'Recycle failed: '.$e->getMessage(), + 'error' => 'Recycle failed: '.$e->getMessage(), ], 500); } } @@ -506,10 +520,10 @@ public function deleteCourse(int $node, Request $req): JsonResponse } try { - $payload = json_decode($req->getContent() ?: "{}", true); - $confirm = trim((string)($payload['confirm'] ?? '')); + $payload = json_decode($req->getContent() ?: '{}', true); + $confirm = trim((string) ($payload['confirm'] ?? '')); - if ($confirm === '') { + if ('' === $confirm) { return $this->json(['error' => 'Missing confirmation value'], 400); } @@ -519,30 +533,30 @@ public function deleteCourse(int $node, Request $req): JsonResponse return $this->json(['error' => '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 + $officialCode = (string) ($courseInfo['official_code'] ?? ''); + $runtimeCode = (string) api_get_course_id(); // often equals official code + $sysCode = (string) ($courseInfo['sysCode'] ?? ''); // used by legacy delete - if ($sysCode === '') { + if ('' === $sysCode) { return $this->json(['error' => '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); + $matches = hash_equals($officialCode, $confirm) || hash_equals($runtimeCode, $confirm); if (!$matches) { return $this->json(['error' => 'Course code confirmation mismatch'], 400); } // Legacy delete (removes course data + unregisters members in this course) // Throws on failure or returns void - \CourseManager::delete_course($sysCode); + CourseManager::delete_course($sysCode); // Best-effort cleanup of legacy course session flags try { $ses = $req->getSession(); $ses?->remove('_cid'); $ses?->remove('_real_cid'); - } catch (\Throwable) { + } catch (Throwable) { // swallow — not critical } @@ -551,13 +565,13 @@ public function deleteCourse(int $node, Request $req): JsonResponse $redirectUrl = '/index.php'; return $this->json([ - 'ok' => true, - 'message' => 'Course deleted successfully', + 'ok' => true, + 'message' => 'Course deleted successfully', 'redirectUrl' => $redirectUrl, ]); - } catch (\Throwable $e) { + } catch (Throwable $e) { return $this->json([ - 'error' => 'Failed to delete course: '.$e->getMessage(), + 'error' => 'Failed to delete course: '.$e->getMessage(), 'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, ], 500); } @@ -593,9 +607,9 @@ public function moodleExportResources(int $node, Request $req): JsonResponse // Build legacy Course from CURRENT course (same approach as recycle) $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', + '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()); @@ -611,28 +625,28 @@ public function moodleExportExecute(int $node, Request $req): JsonResponse $this->setDebugFromRequest($req); // Read payload (basic validation) - $p = json_decode($req->getContent() ?: "{}", true); - $moodleVersion = (string)($p['moodleVersion'] ?? '4'); // '3' | '4' - $scope = (string)($p['scope'] ?? 'full'); // 'full' | 'selected' - $adminId = trim((string)($p['adminId'] ?? '')); - $adminLogin = trim((string)($p['adminLogin'] ?? '')); - $adminEmail = trim((string)($p['adminEmail'] ?? '')); - $resources = (array)($p['resources'] ?? []); - - if ($adminId === '' || $adminLogin === '' || $adminEmail === '') { + $p = json_decode($req->getContent() ?: '{}', true); + $moodleVersion = (string) ($p['moodleVersion'] ?? '4'); // '3' | '4' + $scope = (string) ($p['scope'] ?? 'full'); // 'full' | 'selected' + $adminId = trim((string) ($p['adminId'] ?? '')); + $adminLogin = trim((string) ($p['adminLogin'] ?? '')); + $adminEmail = trim((string) ($p['adminEmail'] ?? '')); + $resources = (array) ($p['resources'] ?? []); + + if ('' === $adminId || '' === $adminLogin || '' === $adminEmail) { return $this->json(['error' => 'Missing required fields (adminId, adminLogin, adminEmail)'], 400); } - if ($scope === 'selected' && empty($resources)) { + if ('selected' === $scope && empty($resources)) { return $this->json(['error' => 'No resources selected'], 400); } - if (!in_array($moodleVersion, ['3','4'], true)) { + if (!\in_array($moodleVersion, ['3', '4'], true)) { return $this->json(['error' => 'Unsupported Moodle version'], 400); } // Stub response while implementation is in progress // Use 202 so the frontend can show a notice without treating it as a failure. return new JsonResponse([ - 'ok' => false, + 'ok' => false, 'message' => 'Moodle export is under construction. No .mbz file was generated.', // you may also return a placeholder downloadUrl later // 'downloadUrl' => null, @@ -659,9 +673,9 @@ public function cc13ExportResources(int $node, Request $req): JsonResponse $cb = new CourseBuilder(); $cb->set_tools_to_build([ - 'documents','forums','tool_intro','links','quizzes','quiz_questions','assets','surveys', - 'survey_questions','announcements','events','course_descriptions','glossary','wiki', - 'thematic','attendance','works','gradebook','learnpath_category','learnpaths', + '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()); @@ -676,21 +690,21 @@ public function cc13ExportExecute(int $node, Request $req): JsonResponse { $this->setDebugFromRequest($req); - $p = json_decode($req->getContent() ?: "{}", true); - $scope = (string)($p['scope'] ?? 'full'); // 'full' | 'selected' - $resources= (array)($p['resources'] ?? []); + $p = json_decode($req->getContent() ?: '{}', true); + $scope = (string) ($p['scope'] ?? 'full'); // 'full' | 'selected' + $resources = (array) ($p['resources'] ?? []); - if (!in_array($scope, ['full', 'selected'], true)) { + if (!\in_array($scope, ['full', 'selected'], true)) { return $this->json(['error' => 'Unsupported scope'], 400); } - if ($scope === 'selected' && empty($resources)) { + if ('selected' === $scope && empty($resources)) { return $this->json(['error' => 'No resources selected'], 400); } // TODO: Generate IMS CC 1.3 cartridge (.imscc or .zip) // For now, return an informative 202 “under construction”. return new JsonResponse([ - 'ok' => false, + 'ok' => false, 'message' => 'Common Cartridge 1.3 export is under construction. No file was generated.', // 'downloadUrl' => null, // set when implemented ], 202); @@ -706,14 +720,14 @@ public function cc13Import(int $node, Request $req): JsonResponse return $this->json(['error' => 'Missing file'], 400); } $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION)); - if (!in_array($ext, ['imscc','zip'], true)) { + if (!\in_array($ext, ['imscc', 'zip'], true)) { return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 400); } // TODO: Parse/restore CC 1.3. For now, just acknowledge. // You can temporarily move the uploaded file into a working dir if useful. return $this->json([ - 'ok' => true, + 'ok' => true, 'message' => 'CC 1.3 import endpoint is under construction. File received successfully.', ]); } @@ -724,9 +738,6 @@ public function cc13Import(int $node, Request $req): JsonResponse /** * Build a Vue-friendly tree from legacy Course. - * - * @param object $course - * @return array */ private function buildResourceTreeForVue(object $course): array { @@ -734,7 +745,7 @@ private function buildResourceTreeForVue(object $course): array $this->logDebug('[buildResourceTreeForVue] start'); } - $resources = is_object($course) && isset($course->resources) && is_array($course->resources) + $resources = \is_object($course) && isset($course->resources) && \is_array($course->resources) ? $course->resources : []; @@ -750,27 +761,27 @@ private function buildResourceTreeForVue(object $course): array // 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'])); + (!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'] = true; $skipTypes['forum_category'] = true; - $skipTypes['forum_topic'] = true; - $skipTypes['forum_post'] = true; - $skipTypes['thread'] = true; - $skipTypes['post'] = true; + $skipTypes['forum_topic'] = true; + $skipTypes['forum_post'] = true; + $skipTypes['thread'] = true; + $skipTypes['post'] = true; } // Other tools foreach ($resources as $rawType => $items) { - if (!is_array($items) || empty($items)) { + if (!\is_array($items) || empty($items)) { continue; } $typeKey = $this->normalizeTypeKey($rawType); @@ -780,29 +791,30 @@ private function buildResourceTreeForVue(object $course): array $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey)); $group = [ - 'type' => $typeKey, + 'type' => $typeKey, 'title' => (string) $groupTitle, 'items' => [], ]; - if ($typeKey === 'gradebook') { + if ('gradebook' === $typeKey) { $group['items'][] = [ - 'id' => 'all', - 'label' => 'Gradebook (all)', - 'extra' => new \stdClass(), + 'id' => 'all', + 'label' => 'Gradebook (all)', + 'extra' => new stdClass(), 'selectable' => true, ]; $tree[] = $group; + continue; } foreach ($items as $id => $obj) { - if (!is_object($obj)) { + if (!\is_object($obj)) { continue; } $idKey = is_numeric($id) ? (int) $id : (string) $id; - if ((is_int($idKey) && $idKey <= 0) || (is_string($idKey) && $idKey === '')) { + if ((\is_int($idKey) && $idKey <= 0) || (\is_string($idKey) && '' === $idKey)) { continue; } @@ -810,17 +822,17 @@ private function buildResourceTreeForVue(object $course): array continue; } - $label = $this->resolveItemLabel($typeKey, $obj, is_int($idKey) ? $idKey : 0); - if ($typeKey === 'tool_intro' && $label === '#0' && is_string($idKey)) { + $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(), + 'id' => $idKey, + 'label' => $label, + 'extra' => $extra ?: new stdClass(), 'selectable' => true, ]; } @@ -828,7 +840,7 @@ private function buildResourceTreeForVue(object $course): array if (!empty($group['items'])) { usort( $group['items'], - static fn($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']) + static fn ($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']) ); $tree[] = $group; } @@ -836,28 +848,29 @@ private function buildResourceTreeForVue(object $course): array // Preferred order $preferredOrder = [ - 'announcement','document','course_description','learnpath','quiz','forum','glossary','link', - 'survey','thematic','work','attendance','wiki','calendar_event','tool_intro','gradebook', + '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 ($ia !== false && $ib !== false) { + if (false !== $ia && false !== $ib) { return $ia <=> $ib; } - if ($ia !== false) { + if (false !== $ia) { return -1; } - if ($ib !== false) { + 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'] ?? [])], $tree) + array_map(fn ($g) => ['type' => $g['type'], 'items' => \count($g['items'] ?? [])], $tree) ); } @@ -866,27 +879,23 @@ private function buildResourceTreeForVue(object $course): array /** * Build forum tree (Category → Forum → Topic). - * - * @param object $course - * @param string $groupTitle - * @return array */ private function buildForumTreeForVue(object $course, string $groupTitle): array { $this->logDebug('[buildForumTreeForVue] start'); - $res = is_array($course->resources ?? null) ? $course->resources : []; + $res = \is_array($course->resources ?? null) ? $course->resources : []; - $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'] ?? []); + $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, + '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, ]); $cats = []; @@ -896,22 +905,22 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array foreach ($catRaw as $id => $obj) { $id = (int) $id; - if ($id <= 0 || !is_object($obj)) { + 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, + 'id' => $id, + 'type' => 'forum_category', + 'label' => $label, 'selectable' => false, - 'children' => [], + 'children' => [], ]; } foreach ($forumRaw as $id => $obj) { $id = (int) $id; - if ($id <= 0 || !is_object($obj)) { + if ($id <= 0 || !\is_object($obj)) { continue; } $forums[$id] = $this->objectEntity($obj); @@ -919,7 +928,7 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array foreach ($topicRaw as $id => $obj) { $id = (int) $id; - if ($id <= 0 || !is_object($obj)) { + if ($id <= 0 || !\is_object($obj)) { continue; } $topics[$id] = $this->objectEntity($obj); @@ -927,10 +936,10 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array foreach ($postRaw as $id => $obj) { $id = (int) $id; - if ($id <= 0 || !is_object($obj)) { + if ($id <= 0 || !\is_object($obj)) { continue; } - $e = $this->objectEntity($obj); + $e = $this->objectEntity($obj); $tid = (int) ($e->thread_id ?? 0); if ($tid > 0) { $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1; @@ -940,12 +949,12 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array $uncatKey = -9999; if (!isset($cats[$uncatKey])) { $cats[$uncatKey] = [ - 'id' => $uncatKey, - 'type' => 'forum_category', - 'label' => 'Uncategorized', + 'id' => $uncatKey, + 'type' => 'forum_category', + 'label' => 'Uncategorized', 'selectable' => false, - 'children' => [], - '_virtual' => true, + 'children' => [], + '_virtual' => true, ]; } @@ -956,12 +965,12 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array } $forumNode = [ - 'id' => $fid, - 'type' => 'forum', - 'label' => $this->resolveItemLabel('forum', $f, $fid), - 'extra' => $this->buildExtra('forum', $f) ?: new \stdClass(), + 'id' => $fid, + 'type' => 'forum', + 'label' => $this->resolveItemLabel('forum', $f, $fid), + 'extra' => $this->buildExtra('forum', $f) ?: new stdClass(), 'selectable' => true, - 'children' => [], + 'children' => [], ]; foreach ($topics as $tid => $t) { @@ -970,35 +979,35 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array } $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? ''); - $date = (string) ($t->thread_date ?? ''); + $date = (string) ($t->thread_date ?? ''); $nPosts = (int) ($postCountByTopic[$tid] ?? 0); $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid); $meta = []; - if ($author !== '') { + if ('' !== $author) { $meta[] = $author; } - if ($date !== '') { + if ('' !== $date) { $meta[] = $date; } if ($meta) { $topicLabel .= ' ('.implode(', ', $meta).')'; } if ($nPosts > 0) { - $topicLabel .= ' — '.$nPosts.' post'.($nPosts === 1 ? '' : 's'); + $topicLabel .= ' — '.$nPosts.' post'.(1 === $nPosts ? '' : 's'); } $forumNode['children'][] = [ - 'id' => $tid, - 'type' => 'forum_topic', - 'label' => $topicLabel, - 'extra' => new \stdClass(), + 'id' => $tid, + 'type' => 'forum_topic', + 'label' => $topicLabel, + 'extra' => new stdClass(), 'selectable' => true, ]; } if ($forumNode['children']) { - usort($forumNode['children'], static fn($a, $b) => strcasecmp($a['label'], $b['label'])); + usort($forumNode['children'], static fn ($a, $b) => strcasecmp($a['label'], $b['label'])); } $cats[$catId]['children'][] = $forumNode; @@ -1008,21 +1017,22 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array if (!empty($c['_virtual']) && empty($c['children'])) { return false; } + return true; })); foreach ($catNodes as &$c) { if (!empty($c['children'])) { - usort($c['children'], static fn($a, $b) => strcasecmp($a['label'], $b['label'])); + usort($c['children'], static fn ($a, $b) => strcasecmp($a['label'], $b['label'])); } } unset($c); - usort($catNodes, static fn($a, $b) => strcasecmp($a['label'], $b['label'])); + usort($catNodes, static fn ($a, $b) => strcasecmp($a['label'], $b['label'])); - $this->logDebug('[buildForumTreeForVue] end', ['categories' => count($catNodes)]); + $this->logDebug('[buildForumTreeForVue] end', ['categories' => \count($catNodes)]); return [ - 'type' => 'forum', + 'type' => 'forum', 'title' => $groupTitle, 'items' => $catNodes, ]; @@ -1030,40 +1040,37 @@ private function buildForumTreeForVue(object $course, string $groupTitle): array /** * Normalize a raw type to a lowercase key. - * - * @param int|string $raw - * @return string */ private function normalizeTypeKey(int|string $raw): string { - if (is_int($raw)) { + 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', - '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', + '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', + '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; @@ -1077,17 +1084,17 @@ private function normalizeTypeKey(int|string $raw): string 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, + '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, ]; } @@ -1099,70 +1106,62 @@ private function getSkipTypeKeys(): 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', + '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). - * - * @param string $type - * @param object $obj - * @return bool */ private function isSelectableItem(string $type, object $obj): bool { - if ($type === 'document') { + if ('document' === $type) { return true; } + return true; } /** * Resolve label for an item with fallbacks. - * - * @param string $type - * @param object $obj - * @param int $fallbackId - * @return string */ 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($entity->{$k}) && \is_string($entity->{$k}) && '' !== trim($entity->{$k})) { + return trim((string) $entity->{$k}); } } - if (isset($obj->params) && is_array($obj->params)) { + 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])) { + if (!empty($obj->params[$k]) && \is_string($obj->params[$k])) { return (string) $obj->params[$k]; } } @@ -1175,8 +1174,10 @@ private function resolveItemLabel(string $type, object $obj, int $fallbackId): s } if (!empty($obj->path)) { $base = basename((string) $obj->path); - return $base !== '' ? $base : (string) $obj->path; + + return '' !== $base ? $base : (string) $obj->path; } + break; case 'course_description': @@ -1194,24 +1195,28 @@ private function resolveItemLabel(string $type, object $obj, int $fallbackId): s 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': @@ -1221,36 +1226,42 @@ private function resolveItemLabel(string $type, object $obj, int $fallbackId): s 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'])) { + 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; } @@ -1259,85 +1270,86 @@ private function resolveItemLabel(string $type, object $obj, int $fallbackId): s /** * Extract wrapped entity (->obj) or the object itself. - * - * @param object $resource - * @return object */ private function objectEntity(object $resource): object { - if (isset($resource->obj) && is_object($resource->obj)) { + if (isset($resource->obj) && \is_object($resource->obj)) { return $resource->obj; } + return $resource; } /** * Extra payload per item for UI (optional). - * - * @param string $type - * @param object $obj - * @return array */ 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; + 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['path'] = (string) ($get($obj, 'path', '') ?? ''); $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? ''); - $extra['size'] = (string) ($get($obj, 'size', '') ?? ''); + $extra['size'] = (string) ($get($obj, 'size', '') ?? ''); + break; case 'link': - $extra['url'] = (string) ($get($obj, 'url', '') ?? ''); + $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) { + $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), + 'id' => (int) ($i['id'] ?? 0), 'title' => (string) ($i['title'] ?? ''), - 'type' => (string) ($i['item_type'] ?? ''), - 'path' => (string) ($i['path'] ?? ''), + 'type' => (string) ($i['item_type'] ?? ''), + 'path' => (string) ($i['path'] ?? ''), ]; }, $obj->items) : []; + break; case 'thematic': - if (isset($obj->params) && is_array($obj->params)) { + 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) + $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) + $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 === '' || $v === null || $v === [])); + return array_filter($extra, static fn ($v) => !('' === $v || null === $v || [] === $v)); } // -------------------------------------------------------------------------------- @@ -1346,67 +1358,70 @@ private function buildExtra(string $type, object $obj): array /** * Get first existing key from candidates. - * - * @param array $orig - * @param array $candidates - * @return string|null */ private function firstExistingKey(array $orig, array $candidates): ?string { foreach ($candidates as $k) { - if (isset($orig[$k]) && is_array($orig[$k]) && !empty($orig[$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 object $course - * @param array $selected [type => [id => true]] - * @return object + * @param array $selected [type => [id => true]] */ private function filterLegacyCourseBySelection(object $course, array $selected): object { $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]); - if (empty($course->resources) || !is_array($course->resources)) { + if (empty($course->resources) || !\is_array($course->resources)) { $this->logDebug('[filterSelection] course has no resources'); + return $course; } + + /** @var array $orig */ $orig = $course->resources; + $getBucket = static function (array $a, string $key): array { + return (isset($a[$key]) && \is_array($a[$key])) ? $a[$key] : []; + }; + // Forums flow $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'] ?? [])), true); if (!empty($selForums)) { - $forums = $orig['forum'] ?? []; + $forums = $getBucket($orig, 'forum'); $catsToKeep = []; + foreach ($forums as $fid => $f) { if (!isset($selForums[(string) $fid])) { continue; } - $e = isset($f->obj) && is_object($f->obj) ? $f->obj : $f; + $e = (isset($f->obj) && \is_object($f->obj)) ? $f->obj : $f; $cid = (int) ($e->forum_category ?? 0); if ($cid > 0) { $catsToKeep[$cid] = true; } } - $threads = $orig['thread'] ?? []; + $threads = $getBucket($orig, 'thread'); $threadToKeep = []; foreach ($threads as $tid => $t) { - $e = isset($t->obj) && is_object($t->obj) ? $t->obj : $t; + $e = (isset($t->obj) && \is_object($t->obj)) ? $t->obj : $t; if (isset($selForums[(string) ($e->forum_id ?? '')])) { $threadToKeep[(int) $tid] = true; } } - $posts = $orig['post'] ?? []; + $posts = $getBucket($orig, 'post'); $postToKeep = []; foreach ($posts as $pid => $p) { - $e = isset($p->obj) && is_object($p->obj) ? $p->obj : $p; + $e = (isset($p->obj) && \is_object($p->obj)) ? $p->obj : $p; if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) { $postToKeep[(int) $pid] = true; } @@ -1414,49 +1429,58 @@ private function filterLegacyCourseBySelection(object $course, array $selected): $out = []; foreach ($selected as $type => $ids) { - if (!is_array($ids) || empty($ids)) { + if (!\is_array($ids) || empty($ids)) { continue; } - if (!empty($orig[$type])) { - $out[$type] = array_intersect_key($orig[$type], $ids); + $bucket = $getBucket($orig, (string) $type); + if (!empty($bucket)) { + $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true); + $out[$type] = array_intersect_key($bucket, $idsMap); } } - if (!empty($orig['Forum_Category'])) { + $forumCat = $getBucket($orig, 'Forum_Category'); + if (!empty($forumCat)) { $out['Forum_Category'] = array_intersect_key( - $orig['Forum_Category'], + $forumCat, array_fill_keys(array_map('strval', array_keys($catsToKeep)), true) ); } - if (!empty($orig['forum'])) { - $out['forum'] = array_intersect_key($orig['forum'], $selForums); + + $forumBucket = $getBucket($orig, 'forum'); + if (!empty($forumBucket)) { + $out['forum'] = array_intersect_key($forumBucket, $selForums); } - if (!empty($orig['thread'])) { + + $threadBucket = $getBucket($orig, 'thread'); + if (!empty($threadBucket)) { $out['thread'] = array_intersect_key( - $orig['thread'], + $threadBucket, array_fill_keys(array_map('strval', array_keys($threadToKeep)), true) ); } - if (!empty($orig['post'])) { + + $postBucket = $getBucket($orig, 'post'); + if (!empty($postBucket)) { $out['post'] = array_intersect_key( - $orig['post'], + $postBucket, array_fill_keys(array_map('strval', array_keys($postToKeep)), true) ); } - if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($orig['Forum_Category'])) { - $out['Forum_Category'] = $orig['Forum_Category']; + if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($forumCat)) { + $out['Forum_Category'] = $forumCat; } $course->resources = array_filter($out); $this->logDebug('[filterSelection] end', [ - 'kept_types' => array_keys($course->resources), + '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, + '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, ], ]); @@ -1470,7 +1494,7 @@ private function filterLegacyCourseBySelection(object $course, array $selected): $keep = []; foreach ($selected as $type => $ids) { - if (!is_array($ids) || empty($ids)) { + if (!\is_array($ids) || empty($ids)) { continue; } @@ -1479,73 +1503,77 @@ private function filterLegacyCourseBySelection(object $course, array $selected): $legacyKey = $alias[$type]; } - if (!empty($orig[$legacyKey])) { - $keep[$legacyKey] = array_intersect_key($orig[$legacyKey], $ids); + $bucket = $getBucket($orig, (string) $legacyKey); + if (!empty($bucket)) { + $idsMap = array_fill_keys(array_map('strval', array_keys($ids)), true); + $keep[$legacyKey] = array_intersect_key($bucket, $idsMap); } } // Gradebook bucket $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']); if ($gbKey && !empty($selected['gradebook'])) { - $selIds = array_keys(array_filter((array) $selected['gradebook'])); - $firstItem = is_array($orig[$gbKey]) ? reset($orig[$gbKey]) : null; - - if (in_array('all', $selIds, true) || !is_object($firstItem)) { - $keep[$gbKey] = $orig[$gbKey]; - $this->logDebug('[filterSelection] kept full gradebook bucket', ['key' => $gbKey, 'count' => is_array($orig[$gbKey]) ? count($orig[$gbKey]) : 0]); - } else { - $keep[$gbKey] = array_intersect_key($orig[$gbKey], array_fill_keys(array_map('strval', $selIds), true)); - $this->logDebug('[filterSelection] kept partial gradebook bucket', ['key' => $gbKey, 'count' => is_array($keep[$gbKey]) ? count($keep[$gbKey]) : 0]); + $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 bucket', ['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 bucket', ['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 : '')]); + $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)) { + $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)) { - $selQ = array_intersect_key($orig[$questionKey], $qids); + if (empty($qids)) { + // no-op + } else { + $questionBucket = $getBucket($orig, $questionKey); + $selQ = array_intersect_key($questionBucket, $qids); if (!empty($selQ)) { $keep[$questionKey] = $selQ; $this->logDebug('[filterSelection] pulled question bucket for quizzes', [ - 'quiz_count' => count($keep[$quizKey]), - 'question_key' => $questionKey, - 'questions_kept' => count($keep[$questionKey]), + 'quiz_count' => \count($keep[$quizKey]), + 'question_key' => $questionKey, + 'questions_kept' => \count($keep[$questionKey]), ]); - $docKey = $this->firstExistingKey($orig, ['document', 'Document', (defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '')]); + $docKey = $this->firstExistingKey($orig, ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '']); if ($docKey) { - $imageQuizBucket = $orig[$docKey]['image_quiz'] ?? null; - if (is_array($imageQuizBucket) && !empty($imageQuizBucket)) { + $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; + $q = (isset($qwrap->obj) && \is_object($qwrap->obj)) ? $qwrap->obj : $qwrap; $pic = (string) ($q->picture ?? ''); - if ($pic !== '' && isset($imageQuizBucket[$pic])) { + if ('' !== $pic && isset($imageQuizBucket[$pic])) { $needed[$pic] = true; } } if (!empty($needed)) { - if (!isset($keep[$docKey]) || !is_array($keep[$docKey])) { - $keep[$docKey] = []; - } - if (!isset($keep[$docKey]['image_quiz']) || !is_array($keep[$docKey]['image_quiz'])) { - $keep[$docKey]['image_quiz'] = []; - } + $keep[$docKey] = $keep[$docKey] ?? []; $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed); $this->logDebug('[filterSelection] included image_quiz docs for questions', [ - 'count' => count($keep[$docKey]['image_quiz']), + 'count' => \count($keep[$docKey]['image_quiz']), ]); } } @@ -1560,38 +1588,40 @@ private function filterLegacyCourseBySelection(object $course, array $selected): // 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 : '')]); + $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)) { + $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) && is_array($orig[$surveyQuestionKey])) { - foreach ($orig[$surveyQuestionKey] as $qid => $qWrap) { - $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; + 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)) { + if ('' !== $qSurveyId && \in_array($qSurveyId, $selSurveyIds, true)) { $neededQids[(string) $qid] = true; } } } if (!empty($neededQids)) { - $keep[$surveyQuestionKey] = array_intersect_key($orig[$surveyQuestionKey], $neededQids); + $surveyQBucket = $getBucket($orig, $surveyQuestionKey); + $keep[$surveyQuestionKey] = array_intersect_key($surveyQBucket, $neededQids); $this->logDebug('[filterSelection] pulled question bucket for surveys', [ - 'survey_count' => count($keep[$surveyKey]), - 'question_key' => $surveyQuestionKey, - 'questions_kept' => count($keep[$surveyQuestionKey]), + 'survey_count' => \count($keep[$surveyKey]), + 'question_key' => $surveyQuestionKey, + 'questions_kept' => \count($keep[$surveyQuestionKey]), ]); } else { $this->logDebug('[filterSelection] surveys selected but no matching questions found'); @@ -1600,20 +1630,23 @@ private function filterLegacyCourseBySelection(object $course, array $selected): $this->logDebug('[filterSelection] surveys selected but no question bucket found in backup'); } - if ($surveyInvitationKey && !empty($orig[$surveyInvitationKey])) { - $neededInv = []; - foreach ($orig[$surveyInvitationKey] 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 ($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); + $this->logDebug('[filterSelection] included survey invitations', [ + 'invitations_kept' => \count($keep[$surveyInvitationKey]), + ]); } - } - if (!empty($neededInv)) { - $keep[$surveyInvitationKey] = array_intersect_key($orig[$surveyInvitationKey], $neededInv); - $this->logDebug('[filterSelection] included survey invitations', [ - 'invitations_kept' => count($keep[$surveyInvitationKey]), - ]); } } } @@ -1628,22 +1661,19 @@ private function filterLegacyCourseBySelection(object $course, array $selected): /** * Map UI options (1/2/3) to legacy file policy. - * - * @param int $opt - * @return int */ private function mapSameNameOption(int $opt): int { - $opt = in_array($opt, [1, 2, 3], true) ? $opt : 2; + $opt = \in_array($opt, [1, 2, 3], true) ? $opt : 2; - if (!defined('FILE_SKIP')) { - define('FILE_SKIP', 1); + if (!\defined('FILE_SKIP')) { + \define('FILE_SKIP', 1); } - if (!defined('FILE_RENAME')) { - define('FILE_RENAME', 2); + if (!\defined('FILE_RENAME')) { + \define('FILE_RENAME', 2); } - if (!defined('FILE_OVERWRITE')) { - define('FILE_OVERWRITE', 3); + if (!\defined('FILE_OVERWRITE')) { + \define('FILE_OVERWRITE', 3); } return match ($opt) { @@ -1655,9 +1685,6 @@ private function mapSameNameOption(int $opt): int /** * Set debug mode from Request (query/header). - * - * @param Request|null $req - * @return void */ private function setDebugFromRequest(?Request $req): void { @@ -1667,22 +1694,19 @@ private function setDebugFromRequest(?Request $req): void // 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 ($hdr !== null) { + if (null !== $hdr) { $val = trim((string) $hdr); - $this->debug = ($val !== '' && $val !== '0' && strcasecmp($val, 'false') !== 0); + $this->debug = ('' !== $val && '0' !== $val && 0 !== strcasecmp($val, 'false')); } } /** * Debug logger with stage + compact JSON payload. - * - * @param string $stage - * @param mixed $payload - * @return void */ private function logDebug(string $stage, mixed $payload = null): void { @@ -1690,18 +1714,20 @@ private function logDebug(string $stage, mixed $payload = null): void return; } $prefix = 'COURSE_DEBUG'; - if ($payload === null) { + 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 ($json !== null && strlen($json) > 8000) { + if (null !== $json && \strlen($json) > 8000) { $json = substr($json, 0, 8000).'…(truncated)'; } - } catch (\Throwable $e) { + } catch (Throwable $e) { $json = '[payload_json_error: '.$e->getMessage().']'; } error_log("$prefix: $stage -> $json"); @@ -1709,58 +1735,54 @@ private function logDebug(string $stage, mixed $payload = null): void /** * Snapshot of resources bag for quick inspection. - * - * @param object $course - * @param int $maxTypes - * @param int $maxItemsPerType - * @return array */ private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array { $out = []; - $res = is_array($course->resources ?? null) ? $course->resources : []; + $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)) { + $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; + $entity = (\is_object($obj) && isset($obj->obj) && \is_object($obj->obj)) ? $obj->obj : $obj; $snap['sample'][] = [ - 'id' => (string) $id, - 'cls' => is_object($obj) ? get_class($obj) : gettype($obj), - 'entity_keys' => is_object($entity) ? array_slice(array_keys((array) $entity), 0, 12) : [], + '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. - * - * @param object $course - * @return array */ 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; + $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'), + 'forum' => $get('forum', 'Forum'), + 'thread' => $get('thread', 'forum_topic'), + 'post' => $get('post', 'forum_post'), ]; } }