diff --git a/src/CoreBundle/Controller/Admin/AdminController.php b/src/CoreBundle/Controller/Admin/AdminController.php index 49bcc00143e..765d293da4d 100644 --- a/src/CoreBundle/Controller/Admin/AdminController.php +++ b/src/CoreBundle/Controller/Admin/AdminController.php @@ -12,14 +12,18 @@ use Chamilo\CoreBundle\Entity\ResourceFile; use Chamilo\CoreBundle\Entity\ResourceLink; use Chamilo\CoreBundle\Entity\ResourceType; +use Chamilo\CoreBundle\Framework\Container; use Chamilo\CoreBundle\Helpers\AccessUrlHelper; +use Chamilo\CoreBundle\Helpers\CidReqHelper; use Chamilo\CoreBundle\Helpers\QueryCacheHelper; use Chamilo\CoreBundle\Helpers\TempUploadHelper; +use Chamilo\CoreBundle\Helpers\UserHelper; use Chamilo\CoreBundle\Repository\Node\CourseRepository; use Chamilo\CoreBundle\Repository\Node\UserRepository; use Chamilo\CoreBundle\Repository\ResourceFileRepository; use Chamilo\CoreBundle\Repository\ResourceNodeRepository; use Chamilo\CoreBundle\Settings\SettingsManager; +use Chamilo\CourseBundle\Entity\CDocument; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -36,7 +40,9 @@ class AdminController extends BaseController public function __construct( private readonly ResourceNodeRepository $resourceNodeRepository, - private readonly AccessUrlHelper $accessUrlHelper + private readonly AccessUrlHelper $accessUrlHelper, + private readonly UserHelper $userHelper, + private readonly CidReqHelper $cidReqHelper ) {} #[IsGranted('ROLE_ADMIN')] @@ -59,8 +65,11 @@ public function registerCampus(Request $request, SettingsManager $settingsManage #[IsGranted('ROLE_ADMIN')] #[Route('/files_info', name: 'admin_files_info', methods: ['GET'])] - public function listFilesInfo(Request $request, ResourceFileRepository $resourceFileRepository): Response - { + public function listFilesInfo( + Request $request, + ResourceFileRepository $resourceFileRepository, + CourseRepository $courseRepository + ): Response { $page = $request->query->getInt('page', 1); $search = $request->query->get('search', ''); $offset = ($page - 1) * self::ITEMS_PER_PAGE; @@ -73,17 +82,38 @@ public function listFilesInfo(Request $request, ResourceFileRepository $resource $filePaths = []; $orphanFlags = []; $linksCount = []; + $coursesByFile = []; foreach ($files as $file) { $resourceNode = $file->getResourceNode(); $count = 0; + $coursesForThisFile = []; if ($resourceNode) { $fileUrls[$file->getId()] = $this->resourceNodeRepository->getResourceFileUrl($resourceNode); - // Count how many ResourceLinks still point to this node + // Count how many ResourceLinks still point to this node and collect courses. $links = $resourceNode->getResourceLinks(); - $count = $links ? $links->count() : 0; + if ($links) { + $count = $links->count(); + + foreach ($links as $link) { + $course = $link->getCourse(); + if (!$course) { + continue; + } + + $courseId = $course->getId(); + // Avoid duplicates for the same course. + if (!isset($coursesForThisFile[$courseId])) { + $coursesForThisFile[$courseId] = [ + 'id' => $courseId, + 'code' => $course->getCode(), + 'title' => $course->getTitle(), + ]; + } + } + } } else { $fileUrls[$file->getId()] = null; } @@ -92,6 +122,20 @@ public function listFilesInfo(Request $request, ResourceFileRepository $resource $linksCount[$file->getId()] = $count; $orphanFlags[$file->getId()] = 0 === $count; + $coursesByFile[$file->getId()] = array_values($coursesForThisFile); + } + + // Build course selector options for the "Attach to course" form. + $allCourses = $courseRepository->findBy([], ['title' => 'ASC']); + $courseOptions = []; + + /** @var Course $course */ + foreach ($allCourses as $course) { + $courseOptions[] = [ + 'id' => $course->getId(), + 'code' => $course->getCode(), + 'title' => $course->getTitle(), + ]; } return $this->render('@ChamiloCore/Admin/files_info.html.twig', [ @@ -103,6 +147,8 @@ public function listFilesInfo(Request $request, ResourceFileRepository $resource 'search' => $search, 'orphanFlags' => $orphanFlags, 'linksCount' => $linksCount, + 'coursesByFile' => $coursesByFile, + 'courseOptions' => $courseOptions, ]); } @@ -120,8 +166,6 @@ public function attachOrphanFileToCourse( } $fileId = $request->request->getInt('resource_file_id', 0); - $courseCode = trim((string) $request->request->get('course_code', '')); - $page = $request->request->getInt('page', 1); $search = (string) $request->request->get('search', ''); @@ -134,8 +178,31 @@ public function attachOrphanFileToCourse( ]); } - if ('' === $courseCode) { - $this->addFlash('error', 'Please provide a course code.'); + + $courseCodes = []; + $multi = $request->request->all('course_codes'); + if (\is_array($multi)) { + foreach ($multi as $code) { + $code = trim((string) $code); + if ('' !== $code) { + $courseCodes[] = $code; + } + } + } + + if (0 === \count($courseCodes)) { + $single = $request->request->get('course_code'); + $single = null === $single ? '' : trim((string) $single); + if ('' !== $single) { + $courseCodes[] = $single; + } + } + + // Normalize and remove duplicates. + $courseCodes = array_values(array_unique($courseCodes)); + + if (0 === \count($courseCodes)) { + $this->addFlash('error', 'Please select at least one course.'); return $this->redirectToRoute('admin_files_info', [ 'page' => $page, @@ -155,10 +222,125 @@ public function attachOrphanFileToCourse( } $resourceNode = $resourceFile->getResourceNode(); - $linksCount = $resourceNode ? $resourceNode->getResourceLinks()->count() : 0; - if ($linksCount > 0) { - // Safety check: this file is not orphan anymore. - $this->addFlash('warning', 'This file is no longer orphan and cannot be attached.'); + if (!$resourceNode) { + $this->addFlash('error', 'This resource file has no resource node and cannot be attached.'); + + return $this->redirectToRoute('admin_files_info', [ + 'page' => $page, + 'search' => $search, + ]); + } + + // also create visible documents in the Documents tool. + $createDocuments = (bool) $request->request->get('create_documents', false); + + // Map existing links by course id to avoid duplicates. + $existingByCourseId = []; + $links = $resourceNode->getResourceLinks(); + if ($links) { + foreach ($links as $existingLink) { + $course = $existingLink->getCourse(); + if ($course) { + $existingByCourseId[$course->getId()] = true; + } + } + } + + $wasOrphan = 0 === \count($existingByCourseId); + $attachedTitles = []; + $skippedTitles = []; + + foreach ($courseCodes as $code) { + /** @var Course|null $course */ + $course = $courseRepository->findOneBy(['code' => $code]); + if (!$course) { + $skippedTitles[] = \sprintf('%s (not found)', $code); + + continue; + } + + $courseId = $course->getId(); + if (isset($existingByCourseId[$courseId])) { + // Already attached to this course. + $skippedTitles[] = \sprintf('%s (already attached)', (string) $course->getTitle()); + + continue; + } + + // If it was orphan, re-parent the node once to the first target course root. + if ($wasOrphan && method_exists($course, 'getResourceNode')) { + $courseRootNode = $course->getResourceNode(); + if ($courseRootNode) { + $resourceNode->setParent($courseRootNode); + } + $wasOrphan = false; + } + + // Create the ResourceLink for this course. + $link = new ResourceLink(); + $link->setResourceNode($resourceNode); + $link->setCourse($course); + $link->setSession(null); + + $em->persist($link); + $existingByCourseId[$courseId] = true; + $attachedTitles[] = (string) $course->getTitle(); + + // Optional feature: also create a visible document entry for this course. + if ($createDocuments) { + $this->createVisibleDocumentFromResourceFile($resourceFile, $course, $em); + } + } + + $em->flush(); + + if (!empty($attachedTitles)) { + $this->addFlash( + 'success', + \sprintf( + 'File "%s" has been attached to %d course(s): %s.', + (string) ($resourceFile->getOriginalName() ?? $resourceFile->getTitle() ?? $resourceFile->getId()), + \count($attachedTitles), + \implode(', ', $attachedTitles) + ) + ); + } + + if (!empty($skippedTitles)) { + $this->addFlash( + 'warning', + \sprintf( + 'Some courses were skipped: %s.', + \implode(', ', $skippedTitles) + ) + ); + } + + return $this->redirectToRoute('admin_files_info', [ + 'page' => $page, + 'search' => $search, + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/files_info/detach', name: 'admin_files_info_detach', methods: ['POST'])] + public function detachFileFromCourse( + Request $request, + ResourceFileRepository $resourceFileRepository, + EntityManagerInterface $em + ): Response { + $token = (string) $request->request->get('_token', ''); + if (!$this->isCsrfTokenValid('detach_file_from_course', $token)) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $fileId = $request->request->getInt('resource_file_id', 0); + $courseId = $request->request->getInt('course_id', 0); + $page = $request->request->getInt('page', 1); + $search = (string) $request->request->get('search', ''); + + if ($fileId <= 0 || $courseId <= 0) { + $this->addFlash('error', 'Missing file or course identifier.'); return $this->redirectToRoute('admin_files_info', [ 'page' => $page, @@ -166,10 +348,10 @@ public function attachOrphanFileToCourse( ]); } - /** @var Course|null $course */ - $course = $courseRepository->findOneBy(['code' => $courseCode]); - if (!$course) { - $this->addFlash('error', \sprintf('Course with code "%s" was not found.', $courseCode)); + /** @var ResourceFile|null $resourceFile */ + $resourceFile = $resourceFileRepository->find($fileId); + if (!$resourceFile) { + $this->addFlash('error', 'Resource file not found.'); return $this->redirectToRoute('admin_files_info', [ 'page' => $page, @@ -177,8 +359,9 @@ public function attachOrphanFileToCourse( ]); } + $resourceNode = $resourceFile->getResourceNode(); if (!$resourceNode) { - $this->addFlash('error', 'This resource file has no resource node and cannot be attached.'); + $this->addFlash('error', 'This resource file has no resource node and cannot be detached.'); return $this->redirectToRoute('admin_files_info', [ 'page' => $page, @@ -186,31 +369,33 @@ public function attachOrphanFileToCourse( ]); } - // re-parent the ResourceNode to the course documents root - if (method_exists($course, 'getResourceNode')) { - $courseRootNode = $course->getResourceNode(); + $links = $resourceNode->getResourceLinks(); + $removed = 0; - if ($courseRootNode) { - $resourceNode->setParent($courseRootNode); + foreach ($links as $link) { + $course = $link->getCourse(); + if ($course && $course->getId() === $courseId) { + $em->remove($link); + ++$removed; } } - // Create a new ResourceLink so that the file appears in the course context - $link = new ResourceLink(); - $link->setResourceNode($resourceNode); - $link->setCourse($course); - $link->setSession(null); - $em->persist($link); - $em->flush(); + if ($removed > 0) { + $em->flush(); - $this->addFlash( - 'success', - \sprintf( - 'File "%s" has been attached to course "%s" (hidden in the documents root).', - (string) ($resourceFile->getOriginalName() ?? $resourceFile->getTitle() ?? $resourceFile->getId()), - (string) $course->getTitle() - ) - ); + $this->addFlash( + 'success', + sprintf( + 'File has been detached from %d course link(s).', + $removed + ) + ); + } else { + $this->addFlash( + 'warning', + 'This file is not attached to the selected course.' + ); + } return $this->redirectToRoute('admin_files_info', [ 'page' => $page, @@ -371,14 +556,14 @@ public function listResourcesInfo( $showUsers = array_reduce($seen, fn ($acc, $row) => $acc || !empty($row['users']), false); } - /** Normalize output */ - $courses = array_values(array_map(function ($row) { + /** Normalize output. */ + $courses = array_values(array_map(static function ($row) { $row['items'] = array_values(array_unique($row['items'])); return $row; }, $seen)); - usort($courses, fn ($a, $b) => strnatcasecmp($a['title'], $b['title'])); + usort($courses, static fn ($a, $b) => strnatcasecmp($a['title'], $b['title'])); } return $this->render('@ChamiloCore/Admin/resources_info.html.twig', [ @@ -484,7 +669,7 @@ public function runCleanupTempUploads( )); } - // Remove legacy build main.js and hashed variants (best effort) + // Remove legacy build main.js and hashed variants $publicBuild = $this->getParameter('kernel.project_dir').'/public/build'; if (is_dir($publicBuild) && is_readable($publicBuild)) { @unlink($publicBuild.'/main.js'); @@ -508,6 +693,67 @@ public function runCleanupTempUploads( return $this->redirectToRoute('admin_cleanup_temp_uploads', [], Response::HTTP_SEE_OTHER); } + /** + * Create a visible CDocument in a course from an existing ResourceFile. + */ + private function createVisibleDocumentFromResourceFile( + ResourceFile $resourceFile, + Course $course, + EntityManagerInterface $em + ): void { + $userEntity = $this->userHelper->getCurrent(); + if (null === $userEntity) { + return; + } + + $session = $this->cidReqHelper->getDoctrineSessionEntity(); + $group = null; + + $documentRepo = Container::getDocumentRepository(); + + $parentResource = $course; + $parentNode = $parentResource->getResourceNode(); + + $title = $resourceFile->getTitle() + ?? $resourceFile->getOriginalName() + ?? (string) $resourceFile->getId(); + + $existingDocument = $documentRepo->findCourseResourceByTitle( + $title, + $parentNode, + $course, + $session, + $group + ); + + if (null !== $existingDocument) { + return; + } + + $document = (new CDocument()) + ->setFiletype('file') + ->setTitle($title) + ->setComment(null) + ->setReadonly(false) + ->setCreator($userEntity) + ->setParent($parentResource) + ->addCourseLink($course, $session, $group) + ; + + $em->persist($document); + $em->flush(); + + $relativePath = $this->resourceNodeRepository->getFilename($resourceFile); + $storageRoot = $this->getParameter('kernel.project_dir').'/var/upload/resource'; + $absolutePath = $storageRoot.$relativePath; + + if (!is_file($absolutePath)) { + return; + } + + $documentRepo->addFileFromPath($document, $title, $absolutePath); + } + /** * Returns a map key => [user names...] depending on the selected resource type. * @@ -590,7 +836,7 @@ private function fetchDropboxRecipients(EntityManagerInterface $em, array $keysM return []; } - $cids = array_values(array_unique(array_map(fn ($m) => (int) $m['cid'], $keysMeta))); + $cids = array_values(array_unique(array_map(static fn ($m) => (int) $m['cid'], $keysMeta))); if (!$cids) { return []; } diff --git a/src/CoreBundle/Resources/views/Admin/files_info.html.twig b/src/CoreBundle/Resources/views/Admin/files_info.html.twig index a14844404d4..56a6cd2014e 100644 --- a/src/CoreBundle/Resources/views/Admin/files_info.html.twig +++ b/src/CoreBundle/Resources/views/Admin/files_info.html.twig @@ -4,57 +4,48 @@
{{ 'MIME Type:'|trans }}
{{ 'Original Name:'|trans }}
{{ 'Size:'|trans }}
-{{ 'Course:'|trans }}
+ ++ {{ 'Course(s):'|trans }} + + +
+{{ 'User:'|trans }}
{{ 'Resource Node ID:'|trans }}
{{ 'Resource File ID:'|trans }}
@@ -185,12 +229,12 @@ {{ 'Open File'|trans }} - {# Orphan-only actions: attach to course or delete definitively #} - @@ -240,10 +336,66 @@ var span = document.getElementsByClassName("close-button")[0]; var copyPathButton = document.getElementById('copy-path'); - // Orphan-specific elements var orphanActions = document.getElementById('orphan-actions'); + var deleteWrapper = document.getElementById('delete-form-wrapper'); var attachIdInput = document.getElementById('attach-resource-file-id'); var deleteIdInput = document.getElementById('delete-resource-file-id'); + var addCourseBtn = document.getElementById('add-course-btn'); + + var courseTagsContainer = document.getElementById('file-course-tags'); + var detachForm = document.getElementById('detach-form'); + var detachFileIdInput = document.getElementById('detach-resource-file-id'); + var detachCourseIdInput = document.getElementById('detach-course-id'); + + function renderCourseTags(courses, resourceFileId) { + if (!courseTagsContainer) { + return; + } + + courseTagsContainer.innerHTML = ""; + + if (!courses || courses.length === 0) { + var emptySpan = document.createElement("span"); + emptySpan.textContent = "N/A"; + emptySpan.className = "text-sm text-gray-500"; + courseTagsContainer.appendChild(emptySpan); + return; + } + + courses.forEach(function(c) { + var pill = document.createElement("span"); + pill.className = + "inline-flex items-center rounded-full bg-slate-100 text-slate-800 " + + "text-xs font-medium px-2 py-1 mr-1 mb-1 border border-slate-200"; + + var label = document.createElement("span"); + label.textContent = "[" + (c.code || "") + "] " + (c.title || ""); + pill.appendChild(label); + + if (detachForm && detachFileIdInput && detachCourseIdInput) { + var removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = + "ml-1 text-xs leading-none text-slate-500 hover:text-red-600 focus:outline-none"; + removeBtn.innerHTML = "×"; + removeBtn.title = "{{ 'Detach from this course'|trans|e('js') }}"; + + removeBtn.addEventListener("click", function() { + if (!window.confirm("{{ 'Are you sure you want to detach this file from the selected course?'|trans|e('js') }}")) { + return; + } + + detachFileIdInput.value = resourceFileId || ""; + detachCourseIdInput.value = c.id || ""; + detachForm.submit(); + }); + + pill.appendChild(removeBtn); + } + + courseTagsContainer.appendChild(pill); + }); + } document.querySelectorAll('.open-modal').forEach(function(button) { button.onclick = function(event) { @@ -252,7 +404,8 @@ var mimeType = button.getAttribute('data-mime-type'); var originalName = button.getAttribute('data-original-name'); var size = button.getAttribute('data-size'); - var course = button.getAttribute('data-course'); + var courseLabel = button.getAttribute('data-course') || ""; + var coursesJson = button.getAttribute('data-courses-json') || "[]"; var user = button.getAttribute('data-user'); var filePath = button.getAttribute('data-file-path'); var fileUrl = button.getAttribute('data-file-url'); @@ -260,21 +413,35 @@ var resourceFileId = button.getAttribute('data-resource-file-id'); var isOrphan = button.getAttribute('data-is-orphan') === '1'; - // Populate modal static info + var coursesData = []; + try { + coursesData = JSON.parse(coursesJson); + } catch (e) { + coursesData = []; + } + document.getElementById('file-title').textContent = title || ''; document.getElementById('file-mime-type').textContent = mimeType || ''; document.getElementById('file-original-name').textContent = originalName || ''; document.getElementById('file-size').textContent = (size || 0) + ' bytes'; - document.getElementById('file-course').textContent = course || ''; document.getElementById('file-user').textContent = user || ''; document.getElementById('file-path').textContent = filePath || ''; document.getElementById('file-url').href = fileUrl || '#'; document.getElementById('resource-node-id').textContent = resourceNodeId || ''; document.getElementById('resource-file-id').textContent = resourceFileId || ''; - // Toggle orphan actions and set hidden ids + if (coursesData && coursesData.length > 0) { + renderCourseTags(coursesData, resourceFileId); + } else if (courseTagsContainer) { + // Fallback to flat label (or N/A) if JSON is empty + courseTagsContainer.textContent = courseLabel || "N/A"; + } + if (orphanActions) { - orphanActions.style.display = isOrphan ? 'block' : 'none'; + orphanActions.style.display = 'block'; + } + if (deleteWrapper) { + deleteWrapper.style.display = isOrphan ? 'block' : 'none'; } if (attachIdInput) { attachIdInput.value = resourceFileId || ''; @@ -282,34 +449,67 @@ if (deleteIdInput) { deleteIdInput.value = resourceFileId || ''; } + if (detachFileIdInput) { + detachFileIdInput.value = resourceFileId || ''; + } modal.style.display = "block"; }; }); + if (addCourseBtn) { + addCourseBtn.onclick = function() { + if (orphanActions) { + orphanActions.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + var courseSelect = document.getElementById('course-code-input'); + if (courseSelect) { + courseSelect.focus(); + } + }; + } + span.onclick = function() { modal.style.display = "none"; }; window.onclick = function(event) { - if (event.target == modal) { + if (event.target === modal) { modal.style.display = "none"; } }; - copyPathButton.onclick = function() { - var filePath = document.getElementById('file-path').textContent; - navigator.clipboard.writeText(filePath).then(function() { - copyPathButton.classList.remove('mdi-content-copy'); - copyPathButton.classList.add('mdi-check'); - setTimeout(function() { - copyPathButton.classList.remove('mdi-check'); - copyPathButton.classList.add('mdi-content-copy'); - }, 2000); - }, function(err) { - alert('Failed to copy: ' + err); + if (copyPathButton) { + copyPathButton.onclick = function() { + var filePath = document.getElementById('file-path').textContent; + navigator.clipboard.writeText(filePath).then(function() { + copyPathButton.classList.remove('mdi-content-copy'); + copyPathButton.classList.add('mdi-check'); + setTimeout(function() { + copyPathButton.classList.remove('mdi-check'); + copyPathButton.classList.add('mdi-content-copy'); + }, 2000); + }, function(err) { + alert('Failed to copy: ' + err); + }); + }; + } + + // Filter options inside the multi-select when typing + var courseSearchInput = document.getElementById('course-search-input'); + var courseSelect = document.getElementById('course-code-input'); + + if (courseSearchInput && courseSelect) { + courseSearchInput.addEventListener('input', function() { + var filter = courseSearchInput.value.toLowerCase(); + + Array.prototype.forEach.call(courseSelect.options, function(option) { + var text = option.textContent.toLowerCase(); + var match = text.indexOf(filter) !== -1; + option.style.display = match ? '' : 'none'; + }); }); - }; + } }); {% endblock %}