diff --git a/assets/vue/components/documents/ResourceFileLink.vue b/assets/vue/components/documents/ResourceFileLink.vue
index 92daf922162..8ec68f7167d 100644
--- a/assets/vue/components/documents/ResourceFileLink.vue
+++ b/assets/vue/components/documents/ResourceFileLink.vue
@@ -25,10 +25,14 @@ export default {
},
computed: {
getDataType() {
- if (this.resource.resourceNode.firstResourceFile.image) {
+ const node = this.resource && this.resource.resourceNode
+ const file = node && node.firstResourceFile
+
+ if (file && file.image) {
return "image"
}
- if (this.resource.resourceNode.firstResourceFile.video) {
+
+ if (file && file.video) {
return "video"
}
diff --git a/assets/vue/components/documents/ResourceIcon.vue b/assets/vue/components/documents/ResourceIcon.vue
index 853db722bee..2b417f980fe 100644
--- a/assets/vue/components/documents/ResourceIcon.vue
+++ b/assets/vue/components/documents/ResourceIcon.vue
@@ -4,19 +4,19 @@
icon="folder-generic"
/>
diff --git a/assets/vue/views/documents/DocumentsList.vue b/assets/vue/views/documents/DocumentsList.vue
index 7e8dc73eee2..db168640c02 100644
--- a/assets/vue/views/documents/DocumentsList.vue
+++ b/assets/vue/views/documents/DocumentsList.vue
@@ -146,7 +146,7 @@
>
{{
- slotProps.data.resourceNode.firstResourceFile
+ slotProps.data.resourceNode && slotProps.data.resourceNode.firstResourceFile
? prettyBytes(slotProps.data.resourceNode.firstResourceFile.size)
: ""
}}
@@ -654,7 +654,14 @@ const showBackButtonIfNotRootFolder = computed(() => {
})
function goToAddVariation(item) {
- const resourceFileId = item.resourceNode.firstResourceFile.id
+ const firstFile = item.resourceNode?.firstResourceFile
+ if (!firstFile) {
+ console.warn("Missing firstResourceFile for document", item.iid)
+ return
+ }
+
+ const resourceFileId = firstFile.id
+
router.push({
name: "DocumentsAddVariation",
params: { resourceFileId, node: route.params.node },
diff --git a/public/main/inc/lib/document.lib.php b/public/main/inc/lib/document.lib.php
index 24987eaf5f7..a19c34266a9 100644
--- a/public/main/inc/lib/document.lib.php
+++ b/public/main/inc/lib/document.lib.php
@@ -5,9 +5,11 @@
use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ResourceLink;
use Chamilo\CoreBundle\Entity\ResourceNode;
+use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\Enums\ObjectIcon;
use Chamilo\CoreBundle\Framework\Container;
use Chamilo\CourseBundle\Entity\CDocument;
+use Chamilo\CourseBundle\Entity\CGroup;
use Chamilo\CourseBundle\Repository\CDocumentRepository;
use Doctrine\ORM\EntityManagerInterface;
@@ -541,172 +543,51 @@ public static function get_all_document_folders(
return [];
}
- $TABLE_DOCUMENT = Database::get_course_table(TABLE_DOCUMENT);
- $groupIid = (int) $groupIid;
- $courseId = $courseInfo['real_id'];
$sessionId = api_get_session_id();
- $folders = [];
- $students = CourseManager::get_user_list_from_course_code(
- $courseInfo['code'],
- api_get_session_id()
- );
-
- $conditionList = [];
- if (!empty($students)) {
- foreach ($students as $studentId => $studentInfo) {
- $conditionList[] = '/shared_folder/sf_user_'.$studentInfo['user_id'];
- }
+ /** @var Course|null $course */
+ $course = api_get_course_entity();
+ if (!$course instanceof Course) {
+ return [];
}
- $groupCondition = " l.group_id = $groupIid";
- if (empty($groupIid)) {
- $groupCondition = ' (l.group_id = 0 OR l.group_id IS NULL)';
+ /** @var Session|null $session */
+ $session = null;
+ if (!empty($sessionId)) {
+ $session = Container::$container
+ ->get('doctrine')
+ ->getRepository(Session::class)
+ ->find($sessionId);
}
- $show_users_condition = '';
- if ($can_see_invisible) {
- $sessionId = $sessionId ?: api_get_session_id();
- $condition_session = " AND (l.session_id = '$sessionId' OR (l.session_id = '0' OR l.session_id IS NULL) )";
- $condition_session .= self::getSessionFolderFilters($path, $sessionId);
-
- $sql = "SELECT DISTINCT docs.iid, n.path
- FROM resource_node AS n
- INNER JOIN $TABLE_DOCUMENT AS docs
- ON (docs.resource_node_id = n.id)
- INNER JOIN resource_link l
- ON (l.resource_node_id = n.id)
- WHERE
- l.c_id = $courseId AND
- docs.filetype = 'folder' AND
- $groupCondition AND
- n.path NOT LIKE '%shared_folder%' AND
- l.deleted_at IS NULL
- $condition_session ";
-
- if (0 != $groupIid) {
- $sql .= " AND n.path NOT LIKE '%shared_folder%' ";
- } else {
- $sql .= $show_users_condition;
- }
-
- $result = Database::query($sql);
- if ($result && 0 != Database::num_rows($result)) {
- while ($row = Database::fetch_assoc($result)) {
- if (self::is_folder_to_avoid($row['path'])) {
- continue;
- }
-
- if (false !== strpos($row['path'], '/shared_folder/')) {
- if (!in_array($row['path'], $conditionList)) {
- continue;
- }
- }
-
- $folders[$row['iid']] = $row['path'];
- }
-
- if (!empty($folders)) {
- natsort($folders);
- }
-
- return $folders;
- }
-
- return false;
- } else {
- // No invisible folders
- // Condition for the session
- $condition_session = api_get_session_condition(
- $sessionId,
- true,
- false,
- 'docs.session_id'
- );
-
- $visibilityCondition = 'l.visibility = 1';
- $fileType = "docs.filetype = 'folder' AND";
- if ($getInvisibleList) {
- $visibilityCondition = 'l.visibility = 0';
- $fileType = '';
- }
-
- //get visible folders
- $sql = "SELECT DISTINCT docs.id
- FROM resource_node AS n
- INNER JOIN $TABLE_DOCUMENT AS docs
- ON (docs.resource_node_id = n.id)
- INNER JOIN resource_link l
- ON (l.resource_node_id = n.id)
- WHERE
- $fileType
- $groupCondition AND
- $visibilityCondition
- $show_users_condition
- $condition_session AND
- l.c_id = $courseId ";
- $result = Database::query($sql);
- $visibleFolders = [];
- while ($row = Database::fetch_assoc($result)) {
- $visibleFolders[$row['id']] = $row['path'];
- }
-
- if ($getInvisibleList) {
- return $visibleFolders;
- }
-
- // get invisible folders
- $sql = "SELECT DISTINCT docs.iid, n.path
- FROM resource_node AS n
- INNER JOIN $TABLE_DOCUMENT AS docs
- ON (docs.resource_node_id = n.id)
- INNER JOIN resource_link l
- ON (l.resource_node_id = n.id)
- WHERE
- docs.filetype = 'folder' AND
- $groupCondition AND
- l.visibility IN ('".ResourceLink::VISIBILITY_PENDING."')
- $condition_session AND
- l.c_id = $courseId ";
- $result = Database::query($sql);
- $invisibleFolders = [];
- while ($row = Database::fetch_assoc($result)) {
- //get visible folders in the invisible ones -> they are invisible too
- $sql = "SELECT DISTINCT docs.iid, n.path
- FROM resource_node AS n
- INNER JOIN $TABLE_DOCUMENT AS docs
- ON (docs.resource_node_id = n.id)
- INNER JOIN resource_link l
- ON (l.resource_node_id = n.id)
- WHERE
- docs.filetype = 'folder' AND
- $groupCondition AND
- l.deleted_at IS NULL
- $condition_session AND
- l.c_id = $courseId ";
- $folder_in_invisible_result = Database::query($sql);
- while ($folders_in_invisible_folder = Database::fetch_assoc($folder_in_invisible_result)) {
- $invisibleFolders[$folders_in_invisible_folder['id']] = $folders_in_invisible_folder['path'];
- }
- }
-
- // If both results are arrays -> //calculate the difference between the 2 arrays -> only visible folders are left :)
- if (is_array($visibleFolders) && is_array($invisibleFolders)) {
- $folders = array_diff($visibleFolders, $invisibleFolders);
- natsort($folders);
-
- return $folders;
- }
+ /** @var CGroup|null $group */
+ $group = null;
+ if (!empty($groupIid)) {
+ $group = Container::$container
+ ->get('doctrine')
+ ->getRepository(CGroup::class)
+ ->find($groupIid);
+ }
- if (is_array($visibleFolders)) {
- natsort($visibleFolders);
+ /** @var CDocumentRepository $docRepo */
+ $docRepo = Container::$container->get('doctrine')
+ ->getRepository(CDocument::class);
- return $visibleFolders;
- }
+ $folders = $docRepo->getAllFoldersForContext(
+ $course,
+ $session,
+ $group,
+ (bool) $can_see_invisible,
+ (bool) $getInvisibleList
+ );
- // no visible folders found
+ if (empty($folders)) {
+ // Keep backward compatibility: some legacy callers expect "false"
+ // when there are no visible folders.
return false;
}
+
+ return $folders;
}
/**
diff --git a/src/CoreBundle/Controller/Admin/AdminController.php b/src/CoreBundle/Controller/Admin/AdminController.php
index c3e9ec4ef6d..a028156c05b 100644
--- a/src/CoreBundle/Controller/Admin/AdminController.php
+++ b/src/CoreBundle/Controller/Admin/AdminController.php
@@ -11,6 +11,7 @@
use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ResourceFile;
use Chamilo\CoreBundle\Entity\ResourceLink;
+use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\ResourceType;
use Chamilo\CoreBundle\Framework\Container;
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
@@ -235,35 +236,38 @@ public function attachOrphanFileToCourse(
]);
}
+ // Existing node for this file (can be null in some legacy cases).
$resourceNode = $resourceFile->getResourceNode();
- 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,
- ]);
- }
// Hidden field in the template: always "1" now.
$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;
+ if ($resourceNode) {
+ $links = $resourceNode->getResourceLinks();
+ if ($links) {
+ foreach ($links as $existingLink) {
+ $course = $existingLink->getCourse();
+ if ($course) {
+ $existingByCourseId[$course->getId()] = true;
+ }
}
}
}
- $wasOrphan = 0 === \count($existingByCourseId);
+ // "Orphan" means: no links yet or no node at all.
+ $wasOrphan = (null === $resourceNode) || (0 === \count($existingByCourseId));
$attachedTitles = [];
$skippedTitles = [];
+ // Try to reuse an existing visible document for this node.
+ $documentRepo = Container::getDocumentRepository();
+ /** @var CDocument|null $sharedDocument */
+ $sharedDocument = $resourceNode
+ ? $documentRepo->findOneBy(['resourceNode' => $resourceNode])
+ : null;
+
foreach ($courseCodes as $code) {
/** @var Course|null $course */
$course = $courseRepository->findOneBy(['code' => $code]);
@@ -281,8 +285,9 @@ public function attachOrphanFileToCourse(
continue;
}
- // If it was orphan, re-parent the node once to the first target course root.
- if ($wasOrphan && method_exists($course, 'getResourceNode')) {
+ // If there is already a node but it was truly orphan (no links),
+ // re-parent the node once to the first target course root.
+ if ($wasOrphan && $resourceNode && method_exists($course, 'getResourceNode')) {
$courseRootNode = $course->getResourceNode();
if ($courseRootNode) {
$resourceNode->setParent($courseRootNode);
@@ -290,22 +295,35 @@ public function attachOrphanFileToCourse(
$wasOrphan = false;
}
- // Create the ResourceLink for this course.
- $link = new ResourceLink();
- $link->setResourceNode($resourceNode);
- $link->setCourse($course);
- $link->setSession(null);
-
- $em->persist($link);
+ // Mark this course as now attached to avoid duplicates in this loop.
$existingByCourseId[$courseId] = true;
$attachedTitles[] = (string) $course->getTitle();
- // Also create a visible document entry for this course (Documents tool).
if ($createDocuments) {
- $this->createVisibleDocumentFromResourceFile($resourceFile, $course, $em);
+ // Ensure we have a shared document for this file.
+ if (null === $sharedDocument) {
+ // This will create the ResourceNode if needed and the CDocument.
+ $sharedDocument = $this->createVisibleDocumentFromResourceFile($resourceFile, $course, $em);
+
+ // Refresh local node reference in case it was created inside the helper.
+ $resourceNode = $resourceFile->getResourceNode();
+ }
+
+ // IMPORTANT: some legacy documents might not have a parent set.
+ // We must set a parent (owning course) before calling addCourseLink,
+ // or AbstractResource::addCourseLink() will throw.
+ if (null === $sharedDocument->getParent()) {
+ $sharedDocument->setParent($course);
+ }
+
+ // For every course, share the same document via ResourceLinks.
+ $session = $this->cidReqHelper->getDoctrineSessionEntity();
+ $group = null;
+ $sharedDocument->addCourseLink($course, $session, $group);
}
}
+ // Single flush for all changes (links + optional new CDocument).
$em->flush();
if (!empty($attachedTitles)) {
@@ -714,63 +732,83 @@ private function createVisibleDocumentFromResourceFile(
ResourceFile $resourceFile,
Course $course,
EntityManagerInterface $em
- ): void {
+ ): CDocument {
$userEntity = $this->userHelper->getCurrent();
if (null === $userEntity) {
- return;
+ throw new \RuntimeException('Current user is required to create or reuse a document.');
}
- $session = $this->cidReqHelper->getDoctrineSessionEntity();
- $group = null;
-
- $documentRepo = Container::getDocumentRepository();
+ // Current node (may be null for truly orphan files).
+ $resourceNode = $resourceFile->getResourceNode();
- $parentResource = $course;
- $parentNode = $parentResource->getResourceNode();
+ if (null === $resourceNode) {
+ $courseRootNode = $course->getResourceNode();
+ if (null === $courseRootNode) {
+ throw new \RuntimeException('Course root node is required to attach a resource node.');
+ }
- $title = $resourceFile->getOriginalName()
- ?? $resourceFile->getTitle()
- ?? (string) $resourceFile->getId();
+ // Create the node that will be shared by all courses.
+ $resourceNode = new ResourceNode();
+ $resourceNode
+ ->setCreator($userEntity)
+ ->setTitle(
+ $resourceFile->getOriginalName()
+ ?? $resourceFile->getTitle()
+ ?? (string) $resourceFile->getId()
+ )
+ ->setParent($courseRootNode)
+ ->setResourceType(
+ $this->resourceNodeRepository->getResourceTypeForClass(CDocument::class)
+ )
+ ;
- $existingDocument = $documentRepo->findCourseResourceByTitle(
- $title,
- $parentNode,
- $course,
- $session,
- $group
- );
+ // Link file <-> node so getFirstResourceFile() returns this file.
+ $resourceNode->addResourceFile($resourceFile);
+ $resourceFile->setResourceNode($resourceNode);
- if (null !== $existingDocument) {
- // Document already exists for this title in this course context.
- return;
+ $em->persist($resourceNode);
+ $em->persist($resourceFile);
}
- $document = (new CDocument())
- ->setFiletype('file')
- ->setTitle($title)
- ->setComment(null)
- ->setReadonly(false)
- ->setCreator($userEntity)
- ->setParent($parentResource)
- ->addCourseLink($course, $session, $group)
- ;
+ $documentRepo = Container::getDocumentRepository();
- $em->persist($document);
- $em->flush();
+ /** @var CDocument|null $document */
+ $document = $documentRepo->findOneBy([
+ 'resourceNode' => $resourceNode,
+ ]);
- // Physical file path in var/upload/resource (hashed filename)
- $relativePath = $this->resourceNodeRepository->getFilename($resourceFile);
- $storageRoot = $this->getParameter('kernel.project_dir').'/var/upload/resource';
- $absolutePath = $storageRoot.$relativePath;
+ if (null === $document) {
+ $title = $resourceFile->getOriginalName()
+ ?? $resourceFile->getTitle()
+ ?? (string) $resourceFile->getId();
+
+ $document = (new CDocument())
+ ->setFiletype('file')
+ ->setTitle($title)
+ ->setComment(null)
+ ->setReadonly(false)
+ ->setTemplate(false)
+ ->setCreator($userEntity)
+ // First course becomes the logical owner. The node is shared.
+ ->setParent($course)
+ ->setResourceNode($resourceNode)
+ ;
+
+ $em->persist($document);
+ }
- if (!is_file($absolutePath)) {
- // If the physical file is missing, we cannot create the document content.
- return;
+ // IMPORTANT: Always ensure the file is linked to the document's node,
+ // even if it already had a resource node.
+ $documentNode = $document->getResourceNode();
+ if (null !== $documentNode && $resourceFile->getResourceNode() !== $documentNode) {
+ // Move the file to the shared document node used by the document.
+ $documentNode->addResourceFile($resourceFile);
+ $resourceFile->setResourceNode($documentNode);
+ $em->persist($resourceFile);
}
- // This will copy the file into the course documents structure,
- // using $title as the base name (Document repository handles dedup and hashing).
- $documentRepo->addFileFromPath($document, $title, $absolutePath);
+ // Do NOT create course links or flush here: this is handled by the caller.
+ return $document;
}
/**
diff --git a/src/CoreBundle/Controller/Api/BaseResourceFileAction.php b/src/CoreBundle/Controller/Api/BaseResourceFileAction.php
index 93d7941412c..bd9a1736a75 100644
--- a/src/CoreBundle/Controller/Api/BaseResourceFileAction.php
+++ b/src/CoreBundle/Controller/Api/BaseResourceFileAction.php
@@ -13,7 +13,9 @@
use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\Entity\User;
+use Chamilo\CoreBundle\Entity\Usergroup;
use Chamilo\CoreBundle\Helpers\CreateUploadedFileHelper;
+use Chamilo\CoreBundle\Repository\ResourceLinkRepository;
use Chamilo\CoreBundle\Repository\ResourceRepository;
use Chamilo\CourseBundle\Entity\CDocument;
use Chamilo\CourseBundle\Entity\CGroup;
@@ -34,6 +36,17 @@ class BaseResourceFileAction
public static function setLinks(AbstractResource $resource, EntityManagerInterface $em): void
{
$resourceNode = $resource->getResourceNode();
+ if (null === $resourceNode) {
+ // Nothing to do if there is no resource node.
+ return;
+ }
+
+ /** @var ResourceNode|null $parentNode */
+ $parentNode = $resourceNode->getParent();
+
+ /** @var ResourceLinkRepository $resourceLinkRepo */
+ $resourceLinkRepo = $em->getRepository(ResourceLink::class);
+
$links = $resource->getResourceLinkArray();
if ($links) {
$groupRepo = $em->getRepository(CGroup::class);
@@ -44,13 +57,19 @@ public static function setLinks(AbstractResource $resource, EntityManagerInterfa
foreach ($links as $link) {
$resourceLink = new ResourceLink();
$linkSet = false;
+
+ $course = null;
+ $session = null;
+ $group = null;
+ $user = null;
+
if (isset($link['cid']) && !empty($link['cid'])) {
$course = $courseRepo->find($link['cid']);
if (null !== $course) {
$linkSet = true;
$resourceLink->setCourse($course);
} else {
- throw new InvalidArgumentException(\sprintf('Course #%s does not exists', $link['cid']));
+ throw new InvalidArgumentException(\sprintf('Course #%s does not exist', $link['cid']));
}
}
@@ -60,7 +79,7 @@ public static function setLinks(AbstractResource $resource, EntityManagerInterfa
$linkSet = true;
$resourceLink->setSession($session);
} else {
- throw new InvalidArgumentException(\sprintf('Session #%s does not exists', $link['sid']));
+ throw new InvalidArgumentException(\sprintf('Session #%s does not exist', $link['sid']));
}
}
@@ -70,7 +89,7 @@ public static function setLinks(AbstractResource $resource, EntityManagerInterfa
$linkSet = true;
$resourceLink->setGroup($group);
} else {
- throw new InvalidArgumentException(\sprintf('Group #%s does not exists', $link['gid']));
+ throw new InvalidArgumentException(\sprintf('Group #%s does not exist', $link['gid']));
}
}
@@ -80,7 +99,7 @@ public static function setLinks(AbstractResource $resource, EntityManagerInterfa
$linkSet = true;
$resourceLink->setUser($user);
} else {
- throw new InvalidArgumentException(\sprintf('User #%s does not exists', $link['uid']));
+ throw new InvalidArgumentException(\sprintf('User #%s does not exist', $link['uid']));
}
}
@@ -91,10 +110,28 @@ public static function setLinks(AbstractResource $resource, EntityManagerInterfa
}
if ($linkSet) {
+ // Attach the node to the link.
+ $resourceLink->setResourceNode($resourceNode);
+
+ // If the resource has a parent node, try to resolve the parent link
+ // in the same context so we can maintain a context-aware hierarchy.
+ if ($parentNode instanceof ResourceNode) {
+ $parentLink = $resourceLinkRepo->findParentLinkForContext(
+ $parentNode,
+ $course,
+ $session,
+ $group,
+ null,
+ $user
+ );
+
+ if (null !== $parentLink) {
+ $resourceLink->setParent($parentLink);
+ }
+ }
+
$em->persist($resourceLink);
$resourceNode->addResourceLink($resourceLink);
- // $em->persist($resourceNode);
- // $em->persist($resource->getResourceNode());
}
}
}
@@ -102,30 +139,8 @@ public static function setLinks(AbstractResource $resource, EntityManagerInterfa
// Use by Chamilo not api platform.
$links = $resource->getResourceLinkEntityList();
if ($links) {
- // error_log('$resource->getResourceLinkEntityList()');
foreach ($links as $link) {
- /*$rights = [];
- * switch ($link->getVisibility()) {
- * case ResourceLink::VISIBILITY_PENDING:
- * case ResourceLink::VISIBILITY_DRAFT:
- * $editorMask = ResourceNodeVoter::getEditorMask();
- * $resourceRight = new ResourceRight();
- * $resourceRight
- * ->setMask($editorMask)
- * ->setRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_TEACHER)
- * ;
- * $rights[] = $resourceRight;
- * break;
- * }
- * if (!empty($rights)) {
- * foreach ($rights as $right) {
- * $link->addResourceRight($right);
- * }
- * }*/
- // error_log('link adding to node: '.$resource->getResourceNode()->getId());
- // error_log('link with user : '.$link->getUser()->getUsername());
$resource->getResourceNode()->addResourceLink($link);
-
$em->persist($link);
}
}
@@ -455,22 +470,35 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos
{
$contentData = $request->getContent();
$resourceLinkList = [];
+ $parentResourceNodeId = 0;
+ $title = null;
+ $content = null;
+
if (!empty($contentData)) {
$contentData = json_decode($contentData, true);
- if (isset($contentData['parentResourceNodeId']) && 1 === \count($contentData)) {
+
+ if (isset($contentData['parentResourceNodeId'])) {
$parentResourceNodeId = (int) $contentData['parentResourceNodeId'];
}
- $title = $contentData['title'] ?? '';
- $content = $contentData['contentFile'] ?? '';
+
+ $title = $contentData['title'] ?? null;
+ $content = $contentData['contentFile'] ?? null;
$resourceLinkList = $contentData['resourceLinkListFromEntity'] ?? [];
} else {
$title = $request->get('title');
$content = $request->request->get('contentFile');
}
- $repo->setResourceName($resource, $title);
+ // Only update the name when a title is explicitly provided.
+ if (null !== $title) {
+ $repo->setResourceName($resource, $title);
+ }
$resourceNode = $resource->getResourceNode();
+ if (null === $resourceNode) {
+ return $resource;
+ }
+
$hasFile = $resourceNode->hasResourceFile();
if ($hasFile && !empty($content)) {
@@ -489,7 +517,9 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos
$linkId = $linkArray['id'] ?? 0;
if (!empty($linkId)) {
/** @var ResourceLink $link */
- $link = $resourceNode->getResourceLinks()->filter(fn ($link) => $link->getId() === $linkId)->first();
+ $link = $resourceNode->getResourceLinks()->filter(
+ static fn ($link) => $link->getId() === $linkId
+ )->first();
if (null !== $link) {
$link->setVisibility((int) $linkArray['visibility']);
@@ -505,16 +535,85 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos
}
$isRecursive = !$hasFile;
- // If it's a folder then change the visibility to the children (That have the same link).
+ // If it's a folder then change the visibility to the children (that have the same link).
if ($isRecursive && null !== $link) {
$repo->copyVisibilityToChildren($resource->getResourceNode(), $link);
}
- if (!empty($parentResourceNodeId)) {
+ // If a new parent node was provided, update the ResourceNode parent
+ // and the ResourceLink parent in the current context.
+ if ($parentResourceNodeId > 0) {
$parentResourceNode = $em->getRepository(ResourceNode::class)->find($parentResourceNodeId);
+
if ($parentResourceNode) {
$resourceNode->setParent($parentResourceNode);
}
+
+ // Only documents use the hierarchical link structure in this way.
+ if ($resource instanceof CDocument) {
+ /** @var ResourceLinkRepository $linkRepo */
+ $linkRepo = $em->getRepository(ResourceLink::class);
+
+ // Resolve context from query parameters (course/session/group/user).
+ $course = null;
+ $session = null;
+ $group = null;
+ $usergroup = null;
+ $user = null;
+
+ $courseId = $request->query->getInt('cid', 0);
+ $sessionId = $request->query->getInt('sid', 0);
+ $groupId = $request->query->getInt('gid', 0);
+ $userId = $request->query->getInt('uid', 0);
+ $usergroupId = $request->query->getInt('ugid', 0);
+
+ if ($courseId > 0) {
+ $course = $em->getRepository(Course::class)->find($courseId);
+ }
+
+ if ($sessionId > 0) {
+ $session = $em->getRepository(Session::class)->find($sessionId);
+ }
+
+ if ($groupId > 0) {
+ $group = $em->getRepository(CGroup::class)->find($groupId);
+ }
+
+ if ($userId > 0) {
+ $user = $em->getRepository(User::class)->find($userId);
+ }
+
+ if ($usergroupId > 0) {
+ $usergroup = $em->getRepository(Usergroup::class)->find($usergroupId);
+ }
+
+ $parentLink = null;
+ if ($parentResourceNode) {
+ $parentLink = $linkRepo->findParentLinkForContext(
+ $parentResourceNode,
+ $course,
+ $session,
+ $group,
+ $usergroup,
+ $user
+ );
+ }
+
+ $currentLink = $linkRepo->findLinkForResourceInContext(
+ $resource,
+ $course,
+ $session,
+ $group,
+ $usergroup,
+ $user
+ );
+
+ if (null !== $currentLink) {
+ // When parentLink is null, the document becomes a root-level item in this context.
+ $currentLink->setParent($parentLink);
+ $em->persist($currentLink);
+ }
+ }
}
$resourceNode->setUpdatedAt(new DateTime());
diff --git a/src/CoreBundle/Entity/ResourceLink.php b/src/CoreBundle/Entity/ResourceLink.php
index 0309ae5a439..77029eacf72 100644
--- a/src/CoreBundle/Entity/ResourceLink.php
+++ b/src/CoreBundle/Entity/ResourceLink.php
@@ -27,6 +27,10 @@
columns: ['c_id', 'session_id', 'usergroup_id', 'group_id', 'user_id', 'resource_type_group'],
name: 'idx_resource_link_sortable_groups'
)]
+#[ORM\Index(
+ columns: ['parent_id'],
+ name: 'idx_resource_link_parent'
+)]
#[ORM\Entity(repositoryClass: ResourceLinkRepository::class)]
#[ORM\EntityListeners([ResourceLinkListener::class])]
#[Gedmo\SoftDeleteable(fieldName: 'deletedAt', timeAware: false, hardDelete: true)]
@@ -48,6 +52,21 @@ class ResourceLink implements Stringable
#[ORM\JoinColumn(name: 'resource_node_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
protected ResourceNode $resourceNode;
+ /**
+ * Parent link for the document hierarchy by context (course/session).
+ */
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
+ private ?self $parent = null;
+
+ /**
+ * @var Collection
+ *
+ * Children links in the document hierarchy by context.
+ */
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ private Collection $children;
+
#[Gedmo\SortableGroup]
#[ORM\ManyToOne(targetEntity: Course::class)]
#[ORM\JoinColumn(name: 'c_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
@@ -113,6 +132,7 @@ public function __construct()
{
$this->resourceRights = new ArrayCollection();
$this->visibility = self::VISIBILITY_DRAFT;
+ $this->children = new ArrayCollection();
}
public function __toString(): string
@@ -125,57 +145,40 @@ public function getId(): ?int
return $this->id;
}
- public function getStartVisibilityAt(): ?DateTimeInterface
+ public function getResourceNode(): ResourceNode
{
- return $this->startVisibilityAt;
+ return $this->resourceNode;
}
- public function setStartVisibilityAt(?DateTimeInterface $startVisibilityAt): self
+ public function setResourceNode(ResourceNode $resourceNode): self
{
- $this->startVisibilityAt = $startVisibilityAt;
+ $this->resourceNode = $resourceNode;
+ $this->resourceTypeGroup = $resourceNode->getResourceType()->getId();
return $this;
}
- public function getEndVisibilityAt(): ?DateTimeInterface
+ /**
+ * Parent link for the document hierarchy by context.
+ */
+ public function getParent(): ?self
{
- return $this->endVisibilityAt;
+ return $this->parent;
}
- public function setEndVisibilityAt(?DateTimeInterface $endVisibilityAt): self
+ public function setParent(?self $parent): self
{
- $this->endVisibilityAt = $endVisibilityAt;
-
- return $this;
- }
-
- public function addResourceRight(ResourceRight $right): self
- {
- if (!$this->resourceRights->contains($right)) {
- $right->setResourceLink($this);
- $this->resourceRights->add($right);
- }
+ $this->parent = $parent;
return $this;
}
/**
- * @return Collection
+ * @return Collection
*/
- public function getResourceRights(): Collection
- {
- return $this->resourceRights;
- }
-
- public function setResourceRights(Collection $rights): self
+ public function getChildren(): Collection
{
- $this->resourceRights = $rights;
-
- /*foreach ($rights as $right) {
- $this->addResourceRight($right);
- }*/
-
- return $this;
+ return $this->children;
}
public function getCourse(): ?Course
@@ -258,22 +261,29 @@ public function hasUser(): bool
return null !== $this->user;
}
- public function getResourceNode(): ResourceNode
+ public function addResourceRight(ResourceRight $right): self
{
- return $this->resourceNode;
+ if (!$this->resourceRights->contains($right)) {
+ $right->setResourceLink($this);
+ $this->resourceRights->add($right);
+ }
+
+ return $this;
}
- public function setResourceNode(ResourceNode $resourceNode): self
+ /**
+ * @return Collection
+ */
+ public function getResourceRights(): Collection
{
- $this->resourceNode = $resourceNode;
- $this->resourceTypeGroup = $resourceNode->getResourceType()->getId();
-
- return $this;
+ return $this->resourceRights;
}
- public function isPublished(): bool
+ public function setResourceRights(Collection $rights): self
{
- return self::VISIBILITY_PUBLISHED === $this->getVisibility();
+ $this->resourceRights = $rights;
+
+ return $this;
}
public function getVisibility(): int
@@ -305,6 +315,11 @@ public static function getVisibilityList(): array
];
}
+ public function isPublished(): bool
+ {
+ return self::VISIBILITY_PUBLISHED === $this->getVisibility();
+ }
+
public function isPending(): bool
{
return self::VISIBILITY_PENDING === $this->getVisibility();
@@ -320,6 +335,30 @@ public function getVisibilityName(): string
return array_flip(static::getVisibilityList())[$this->getVisibility()];
}
+ public function getStartVisibilityAt(): ?DateTimeInterface
+ {
+ return $this->startVisibilityAt;
+ }
+
+ public function setStartVisibilityAt(?DateTimeInterface $startVisibilityAt): self
+ {
+ $this->startVisibilityAt = $startVisibilityAt;
+
+ return $this;
+ }
+
+ public function getEndVisibilityAt(): ?DateTimeInterface
+ {
+ return $this->endVisibilityAt;
+ }
+
+ public function setEndVisibilityAt(?DateTimeInterface $endVisibilityAt): self
+ {
+ $this->endVisibilityAt = $endVisibilityAt;
+
+ return $this;
+ }
+
public function getDisplayOrder(): int
{
return $this->displayOrder;
diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20251201164100.php b/src/CoreBundle/Migrations/Schema/V200/Version20251201164100.php
new file mode 100644
index 00000000000..6abd9f917a2
--- /dev/null
+++ b/src/CoreBundle/Migrations/Schema/V200/Version20251201164100.php
@@ -0,0 +1,43 @@
+addSql('ALTER TABLE resource_link ADD parent_id INT DEFAULT NULL');
+
+ // Add foreign key to self (hierarchical links)
+ $this->addSql(
+ 'ALTER TABLE resource_link
+ ADD CONSTRAINT FK_398C394B727ACA70
+ FOREIGN KEY (parent_id) REFERENCES resource_link (id)
+ ON DELETE SET NULL'
+ );
+
+ // Add index for faster lookups by parent
+ $this->addSql('CREATE INDEX idx_resource_link_parent ON resource_link (parent_id)');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // Drop foreign key, index and column to rollback schema change
+ $this->addSql('ALTER TABLE resource_link DROP FOREIGN KEY FK_398C394B727ACA70');
+ $this->addSql('DROP INDEX idx_resource_link_parent ON resource_link');
+ $this->addSql('ALTER TABLE resource_link DROP parent_id');
+ }
+}
diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20251201173000.php b/src/CoreBundle/Migrations/Schema/V200/Version20251201173000.php
new file mode 100644
index 00000000000..7a8a7f1396d
--- /dev/null
+++ b/src/CoreBundle/Migrations/Schema/V200/Version20251201173000.php
@@ -0,0 +1,78 @@
+addSql($sql);
+ }
+
+ public function down(Schema $schema): void
+ {
+ // Reset parent_id to NULL if we rollback this pre-fill.
+ // This does not touch the schema (column and FK remain).
+ $this->addSql('UPDATE resource_link SET parent_id = NULL');
+ }
+}
diff --git a/src/CoreBundle/Repository/ResourceLinkRepository.php b/src/CoreBundle/Repository/ResourceLinkRepository.php
index a5fa4d1e02d..313c4722b0b 100644
--- a/src/CoreBundle/Repository/ResourceLinkRepository.php
+++ b/src/CoreBundle/Repository/ResourceLinkRepository.php
@@ -9,6 +9,7 @@
use Chamilo\CoreBundle\Entity\AbstractResource;
use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ResourceLink;
+use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\ResourceType;
use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\Entity\Tool;
@@ -170,4 +171,143 @@ public function getToolUsageReportByTools(array $toolIds): array
];
}, $result);
}
+
+ /**
+ * Find the parent link (folder link) for a given parent node in a specific context.
+ *
+ * This is used when creating new document links so that the link hierarchy
+ * is context-aware (course/session/group/usergroup/user).
+ */
+ public function findParentLinkForContext(
+ ResourceNode $parentNode,
+ ?Course $course,
+ ?Session $session,
+ ?CGroup $group,
+ ?Usergroup $usergroup,
+ ?User $user
+ ): ?ResourceLink {
+ $qb = $this->createQueryBuilder('rl')
+ ->andWhere('rl.resourceNode = :parentNode')
+ ->setParameter('parentNode', $parentNode)
+ ->andWhere('rl.deletedAt IS NULL')
+ ->setMaxResults(1);
+
+ // Match course context
+ if (null !== $course) {
+ $qb
+ ->andWhere('rl.course = :course')
+ ->setParameter('course', $course);
+ } else {
+ $qb->andWhere('rl.course IS NULL');
+ }
+
+ // Match session context
+ if (null !== $session) {
+ $qb
+ ->andWhere('rl.session = :session')
+ ->setParameter('session', $session);
+ } else {
+ $qb->andWhere('rl.session IS NULL');
+ }
+
+ // Match group context
+ if (null !== $group) {
+ $qb
+ ->andWhere('rl.group = :group')
+ ->setParameter('group', $group);
+ } else {
+ $qb->andWhere('rl.group IS NULL');
+ }
+
+ if (null !== $usergroup) {
+ $qb
+ ->andWhere('rl.userGroup = :usergroup')
+ ->setParameter('usergroup', $usergroup);
+ } else {
+ $qb->andWhere('rl.userGroup IS NULL');
+ }
+
+ // Match user context
+ if (null !== $user) {
+ $qb
+ ->andWhere('rl.user = :user')
+ ->setParameter('user', $user);
+ } else {
+ $qb->andWhere('rl.user IS NULL');
+ }
+
+ return $qb->getQuery()->getOneOrNullResult();
+ }
+
+ /**
+ * Find the link of a resource in a given context.
+ *
+ * This is mostly used by document move operations to update the link parent
+ * only in the current context.
+ */
+ public function findLinkForResourceInContext(
+ AbstractResource $resource,
+ ?Course $course,
+ ?Session $session,
+ ?CGroup $group,
+ ?Usergroup $usergroup,
+ ?User $user
+ ): ?ResourceLink {
+ $resourceNode = $resource->getResourceNode();
+ if (null === $resourceNode) {
+ return null;
+ }
+
+ $qb = $this->createQueryBuilder('rl')
+ ->andWhere('rl.resourceNode = :resourceNode')
+ ->setParameter('resourceNode', $resourceNode)
+ ->andWhere('rl.deletedAt IS NULL')
+ ->setMaxResults(1);
+
+ // Match course context
+ if (null !== $course) {
+ $qb
+ ->andWhere('rl.course = :course')
+ ->setParameter('course', $course);
+ } else {
+ $qb->andWhere('rl.course IS NULL');
+ }
+
+ // Match session context
+ if (null !== $session) {
+ $qb
+ ->andWhere('rl.session = :session')
+ ->setParameter('session', $session);
+ } else {
+ $qb->andWhere('rl.session IS NULL');
+ }
+
+ // Match group context
+ if (null !== $group) {
+ $qb
+ ->andWhere('rl.group = :group')
+ ->setParameter('group', $group);
+ } else {
+ $qb->andWhere('rl.group IS NULL');
+ }
+
+ if (null !== $usergroup) {
+ $qb
+ ->andWhere('rl.userGroup = :usergroup')
+ ->setParameter('usergroup', $usergroup);
+ } else {
+ $qb->andWhere('rl.userGroup IS NULL');
+ }
+
+ // Match user context
+ if (null !== $user) {
+ $qb
+ ->andWhere('rl.user = :user')
+ ->setParameter('user', $user);
+ } else {
+ $qb->andWhere('rl.user IS NULL');
+ }
+
+ return $qb->getQuery()->getOneOrNullResult();
+ }
}
diff --git a/src/CoreBundle/Resources/views/Admin/files_info.html.twig b/src/CoreBundle/Resources/views/Admin/files_info.html.twig
index ec1b6aa4129..7f220b20f71 100644
--- a/src/CoreBundle/Resources/views/Admin/files_info.html.twig
+++ b/src/CoreBundle/Resources/views/Admin/files_info.html.twig
@@ -338,6 +338,10 @@
var detachFileIdInput = document.getElementById('detach-resource-file-id');
var detachCourseIdInput = document.getElementById('detach-course-id');
+ // Select + search input used in the attach form
+ var courseSearchInput = document.getElementById('course-search-input');
+ var courseSelect = document.getElementById('course-code-input');
+
// Base URL for the course Documents tool (e.g. /resources/document/)
var documentsBaseUrl = "{{ app.request.basePath|e('js') }}/resources/document/";
@@ -377,6 +381,46 @@
);
}
+ /**
+ * Hide already attached courses from the multi-select for the current file.
+ */
+ function refreshCourseSelectOptions(courses) {
+ if (!courseSelect) {
+ return;
+ }
+
+ // Reset all options to a clean state
+ Array.prototype.forEach.call(courseSelect.options, function(option) {
+ option.disabled = false;
+ option.hidden = false;
+ option.style.display = '';
+ option.dataset.attached = '0';
+ });
+
+ if (!courses || !courses.length) {
+ return;
+ }
+
+ var attachedCodes = courses
+ .map(function(c) {
+ return (c.code || '').toString();
+ })
+ .filter(function(code) {
+ return code !== '';
+ });
+
+ Array.prototype.forEach.call(courseSelect.options, function(option) {
+ var code = option.value;
+ if (attachedCodes.indexOf(code) !== -1) {
+ // Mark and hide options that correspond to already attached courses
+ option.dataset.attached = '1';
+ option.disabled = true;
+ option.hidden = true;
+ option.style.display = 'none';
+ }
+ });
+ }
+
/**
* Render course tags for the current file.
* Each tag:
@@ -488,6 +532,12 @@
courseTagsContainer.textContent = courseLabel || "N/A";
}
+ // Reset search input and hide already attached courses in the multi-select
+ if (courseSearchInput) {
+ courseSearchInput.value = '';
+ }
+ refreshCourseSelectOptions(coursesData);
+
if (orphanActions) {
orphanActions.style.display = 'block';
}
@@ -513,7 +563,6 @@
if (orphanActions) {
orphanActions.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
- var courseSelect = document.getElementById('course-code-input');
if (courseSelect) {
courseSelect.focus();
}
@@ -547,14 +596,18 @@
}
// 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 isAttached = option.dataset.attached === '1';
+ if (isAttached) {
+ // Always keep already attached courses hidden
+ option.style.display = 'none';
+ return;
+ }
+
var text = option.textContent.toLowerCase();
var match = text.indexOf(filter) !== -1;
option.style.display = match ? '' : 'none';
diff --git a/src/CoreBundle/State/DocumentCollectionStateProvider.php b/src/CoreBundle/State/DocumentCollectionStateProvider.php
new file mode 100644
index 00000000000..df1e29e8869
--- /dev/null
+++ b/src/CoreBundle/State/DocumentCollectionStateProvider.php
@@ -0,0 +1,191 @@
+
+ */
+final class DocumentCollectionStateProvider implements ProviderInterface
+{
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager,
+ private readonly RequestStack $requestStack,
+ ) {}
+
+ /**
+ * @param array $uriVariables
+ * @param array $context
+ *
+ * @return array|CDocument|null
+ */
+ public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object|null
+ {
+ $request = $this->requestStack->getCurrentRequest();
+ if (null === $request) {
+ return [];
+ }
+
+ $qb = $this->entityManager
+ ->getRepository(CDocument::class)
+ ->createQueryBuilder('d')
+ ->innerJoin('d.resourceNode', 'rn')
+ ->addSelect('rn');
+
+ // Filetype filtering: filetype[]=file&filetype[]=folder&filetype[]=video OR filetype=folder
+ $filetypes = $request->query->all('filetype');
+
+ if (empty($filetypes)) {
+ $singleFiletype = $request->query->get('filetype');
+ if (null !== $singleFiletype && '' !== $singleFiletype) {
+ $filetypes = [$singleFiletype];
+ }
+ }
+
+ if (!empty($filetypes)) {
+ if (!\is_array($filetypes)) {
+ $filetypes = [$filetypes];
+ }
+
+ $qb
+ ->andWhere($qb->expr()->in('d.filetype', ':filetypes'))
+ ->setParameter('filetypes', $filetypes);
+ }
+
+ // Context (course / session / group)
+ $cid = $request->query->getInt('cid', 0);
+ $sid = $request->query->getInt('sid', 0);
+ $gid = $request->query->getInt('gid', 0);
+
+ $hasContext = $cid > 0 || $sid > 0 || $gid > 0;
+
+ // loadNode=1 -> documents list wants children of a folder
+ $loadNode = (bool) $request->query->get('loadNode', false);
+
+ // Current folder node (comes from Vue as resourceNode.parent=XX)
+ $parentNodeId = (int) $request->query->get('resourceNode.parent', 0);
+ if (0 === $parentNodeId) {
+ $parentNodeId = (int) $request->query->get('resourceNode_parent', 0);
+ }
+
+ if ($hasContext) {
+ // Contextual hierarchy based on ResourceLink.parent
+ $qb->innerJoin('rn.resourceLinks', 'rl');
+
+ if ($cid > 0) {
+ $qb
+ ->andWhere('rl.course = :cid')
+ ->setParameter('cid', $cid);
+ }
+
+ if ($sid > 0) {
+ $qb
+ ->andWhere('rl.session = :sid')
+ ->setParameter('sid', $sid);
+ }
+
+ if ($gid > 0) {
+ $qb
+ ->andWhere('rl.group = :gid')
+ ->setParameter('gid', $gid);
+ }
+
+ if ($loadNode) {
+ // We are browsing "inside" a folder in this context
+ if ($parentNodeId > 0) {
+ $resourceNode = $this->entityManager
+ ->getRepository(ResourceNode::class)
+ ->find($parentNodeId);
+
+ if (null === $resourceNode) {
+ // Folder node not found -> nothing to list
+ return [];
+ }
+
+ /** @var ResourceLinkRepository $linkRepo */
+ $linkRepo = $this->entityManager->getRepository(ResourceLink::class);
+
+ $courseEntity = $cid > 0
+ ? $this->entityManager->getRepository(Course::class)->find($cid)
+ : null;
+
+ $sessionEntity = $sid > 0
+ ? $this->entityManager->getRepository(Session::class)->find($sid)
+ : null;
+
+ $groupEntity = $gid > 0
+ ? $this->entityManager->getRepository(CGroup::class)->find($gid)
+ : null;
+
+ // Find the link of this folder in the current context
+ $parentLink = $linkRepo->findParentLinkForContext(
+ $resourceNode,
+ $courseEntity,
+ $sessionEntity,
+ $groupEntity,
+ null,
+ null
+ );
+
+ if (null === $parentLink) {
+ // No link for this node in this context:
+ // treat it as context root → children have rl.parent IS NULL
+ $qb->andWhere('rl.parent IS NULL');
+ } else {
+ // Children inside this folder in this context
+ $qb
+ ->andWhere('rl.parent = :parentLink')
+ ->setParameter('parentLink', $parentLink);
+ }
+ } else {
+ // No parentNodeId -> root of the context (course root)
+ $qb->andWhere('rl.parent IS NULL');
+ }
+ }
+
+ // When the same document is linked multiple times in this context
+ $qb->distinct();
+ } else {
+ // No course / session / group context:
+ // keep legacy behavior using resource_node.parent (global docs, if any)
+ if ($parentNodeId > 0) {
+ $qb
+ ->andWhere('rn.parent = :parentId')
+ ->setParameter('parentId', $parentNodeId);
+ }
+ }
+
+ // Ordering & pagination
+ $qb->orderBy('rn.title', 'ASC');
+
+ $page = (int) $request->query->get('page', 1);
+ $itemsPerPage = (int) $request->query->get('itemsPerPage', 20);
+
+ if ($page < 1) {
+ $page = 1;
+ }
+
+ if ($itemsPerPage > 0) {
+ $qb
+ ->setFirstResult(($page - 1) * $itemsPerPage)
+ ->setMaxResults($itemsPerPage);
+ }
+
+ return $qb->getQuery()->getResult();
+ }
+}
diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php
index 3a7f74b8ab1..f7f2f390f8a 100644
--- a/src/CourseBundle/Entity/CDocument.php
+++ b/src/CourseBundle/Entity/CDocument.php
@@ -19,7 +19,6 @@
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\Parameter;
use ApiPlatform\OpenApi\Model\RequestBody;
-use ApiPlatform\OpenApi\Model\Response;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use ArrayObject;
use Chamilo\CoreBundle\Controller\Api\CreateDocumentFileAction;
@@ -36,6 +35,7 @@
use Chamilo\CoreBundle\Entity\ResourceShowCourseResourcesInSessionInterface;
use Chamilo\CoreBundle\Filter\CidFilter;
use Chamilo\CoreBundle\Filter\SidFilter;
+use Chamilo\CoreBundle\State\DocumentCollectionStateProvider;
use Chamilo\CourseBundle\Repository\CDocumentRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -164,9 +164,7 @@
controller: DownloadSelectedDocumentsAction::class,
openapi: new Operation(
summary: 'Download selected documents as a ZIP file.',
- description: 'Streams a ZIP archive generated on-the-fly. The ZIP file includes folders and files selected.',
requestBody: new RequestBody(
- description: 'List of document IDs to include in the ZIP file',
content: new ArrayObject([
'application/json' => [
'schema' => [
@@ -177,45 +175,12 @@
'items' => ['type' => 'integer'],
],
],
- 'required' => ['ids'],
],
],
]),
- required: true,
),
- responses: [
- 201 => new Response(
- description: 'The ZIP file is being streamed to the client',
- content: new ArrayObject([
- DownloadSelectedDocumentsAction::CONTENT_TYPE => [
- 'schema' => [
- 'type' => 'string',
- 'format' => 'binary',
- 'description' => 'Streamed ZIP file',
- ],
- ],
- ]),
- headers: new ArrayObject([
- 'Content-Type' => [
- 'description' => 'MIME type identifying the streamed file',
- 'schema' => [
- 'type' => 'string',
- 'example' => DownloadSelectedDocumentsAction::CONTENT_TYPE,
- ],
- ],
- 'Content-Disposition' => [
- 'description' => 'Indicates that the response is meant to be downloaded as a file',
- 'schema' => [
- 'type' => 'string',
- 'example' => 'attachment; filename="selected_documents.zip"',
- ],
- ],
- ]),
- ),
- ]
),
security: "is_granted('ROLE_USER')",
- outputFormats: ['zip' => DownloadSelectedDocumentsAction::CONTENT_TYPE],
),
new GetCollection(
openapi: new Operation(
@@ -228,7 +193,8 @@
schema: ['type' => 'integer'],
),
],
- )
+ ),
+ provider: DocumentCollectionStateProvider::class
),
],
normalizationContext: [
@@ -243,7 +209,7 @@
#[ORM\Entity(repositoryClass: CDocumentRepository::class)]
#[ORM\EntityListeners([ResourceListener::class])]
#[ApiFilter(filterClass: PropertyFilter::class)]
-#[ApiFilter(filterClass: SearchFilter::class, properties: ['title' => 'partial', 'resourceNode.parent' => 'exact', 'filetype' => 'exact'])]
+#[ApiFilter(filterClass: SearchFilter::class, properties: ['title' => 'partial', 'filetype' => 'exact'])]
#[ApiFilter(
filterClass: OrderFilter::class,
properties: [
diff --git a/src/CourseBundle/Repository/CDocumentRepository.php b/src/CourseBundle/Repository/CDocumentRepository.php
index 22e2c67723d..3812cf9d37b 100644
--- a/src/CourseBundle/Repository/CDocumentRepository.php
+++ b/src/CourseBundle/Repository/CDocumentRepository.php
@@ -645,4 +645,239 @@ private function em(): EntityManagerInterface
/** @var EntityManagerInterface $em */
return $this->getEntityManager();
}
+
+ /**
+ * Returns the document folders for a given course/session/group context,
+ * as [ document_iid => "Full/Path/To/Folder" ].
+ *
+ * This implementation uses ResourceLink as the main source,
+ * assuming ResourceLink has a parent (context-aware hierarchy).
+ */
+ public function getAllFoldersForContext(
+ Course $course,
+ ?Session $session = null,
+ ?CGroup $group = null,
+ bool $canSeeInvisible = false,
+ bool $getInvisibleList = false
+ ): array {
+ $em = $this->getEntityManager();
+
+ $qb = $em->createQueryBuilder()
+ ->select('d')
+ ->from(CDocument::class, 'd')
+ ->innerJoin('d.resourceNode', 'rn')
+ ->innerJoin('rn.resourceLinks', 'rl')
+ ->where('rl.course = :course')
+ ->andWhere('d.filetype = :folderType')
+ ->andWhere('rl.deletedAt IS NULL')
+ ->setParameter('course', $course)
+ ->setParameter('folderType', 'folder')
+ ;
+
+ // Session filter
+ if (null !== $session) {
+ $qb
+ ->andWhere('rl.session = :session')
+ ->setParameter('session', $session);
+ } else {
+ // In C2 many "global course documents" have session = NULL
+ $qb->andWhere('rl.session IS NULL');
+ }
+
+ // Group filter
+ if (null !== $group) {
+ $qb
+ ->andWhere('rl.group = :group')
+ ->setParameter('group', $group);
+ } else {
+ $qb->andWhere('rl.group IS NULL');
+ }
+
+ // Visibility
+ if (!$canSeeInvisible) {
+ if ($getInvisibleList) {
+ // Only non-published folders (hidden/pending/etc.)
+ $qb
+ ->andWhere('rl.visibility <> :published')
+ ->setParameter('published', ResourceLink::VISIBILITY_PUBLISHED);
+ } else {
+ // Only visible folders
+ $qb
+ ->andWhere('rl.visibility = :published')
+ ->setParameter('published', ResourceLink::VISIBILITY_PUBLISHED);
+ }
+ }
+ // If $canSeeInvisible = true, do not filter by visibility (see everything).
+
+ /** @var CDocument[] $documents */
+ $documents = $qb->getQuery()->getResult();
+
+ if (empty($documents)) {
+ return [];
+ }
+
+ // 1) Index by ResourceLink id to be able to rebuild the path using the parent link
+ $linksById = [];
+
+ foreach ($documents as $doc) {
+ if (!$doc instanceof CDocument) {
+ continue;
+ }
+
+ $node = $doc->getResourceNode();
+ if (!$node instanceof ResourceNode) {
+ continue;
+ }
+
+ $links = $node->getResourceLinks();
+ if (!$links instanceof \Doctrine\Common\Collections\Collection) {
+ continue;
+ }
+
+ $matchingLink = null;
+
+ foreach ($links as $candidate) {
+ if (!$candidate instanceof ResourceLink) {
+ continue;
+ }
+
+ // Deleted links must be ignored
+ if (null !== $candidate->getDeletedAt()) {
+ continue;
+ }
+
+ // Match same course
+ if ($candidate->getCourse()?->getId() !== $course->getId()) {
+ continue;
+ }
+
+ // Match same session context
+ if (null !== $session) {
+ if ($candidate->getSession()?->getId() !== $session->getId()) {
+ continue;
+ }
+ } else {
+ if (null !== $candidate->getSession()) {
+ continue;
+ }
+ }
+
+ // Match same group context
+ if (null !== $group) {
+ if ($candidate->getGroup()?->getIid() !== $group->getIid()) {
+ continue;
+ }
+ } else {
+ if (null !== $candidate->getGroup()) {
+ continue;
+ }
+ }
+
+ // Visibility filter (when not allowed to see invisible items)
+ if (!$canSeeInvisible) {
+ $visibility = $candidate->getVisibility();
+
+ if ($getInvisibleList) {
+ // We only want non-published items
+ if (ResourceLink::VISIBILITY_PUBLISHED === $visibility) {
+ continue;
+ }
+ } else {
+ // We only want published items
+ if (ResourceLink::VISIBILITY_PUBLISHED !== $visibility) {
+ continue;
+ }
+ }
+ }
+
+ $matchingLink = $candidate;
+ break;
+ }
+
+ if (!$matchingLink instanceof ResourceLink) {
+ // No valid link for this context, skip
+ continue;
+ }
+
+ $linksById[$matchingLink->getId()] = [
+ 'doc' => $doc,
+ 'link' => $matchingLink,
+ 'node' => $node,
+ 'parent_id' => $matchingLink->getParent()?->getId(),
+ 'title' => $node->getTitle(),
+ ];
+ }
+
+ if (empty($linksById)) {
+ return [];
+ }
+
+ // 2) Build full folder paths per context (using ResourceLink.parent)
+ $pathCache = [];
+ $folders = [];
+
+ foreach ($linksById as $id => $data) {
+ $path = $this->buildFolderPathForLink($id, $linksById, $pathCache);
+
+ if ('' === $path) {
+ continue;
+ }
+
+ /** @var CDocument $doc */
+ $doc = $data['doc'];
+
+ // Keep the key as CDocument iid (as before)
+ $folders[$doc->getIid()] = $path;
+ }
+
+ if (empty($folders)) {
+ return [];
+ }
+
+ // Natural sort so that paths appear in a human-friendly order
+ natsort($folders);
+
+ // If the caller explicitly requested the invisible list, the filtering was done above
+ return $folders;
+ }
+
+ /**
+ * Rebuild the "Parent folder/Child folder/..." path for a folder ResourceLink,
+ * walking up the parent chain until a link without parent is found.
+ *
+ * Uses a small cache to avoid recalculating the same paths many times.
+ *
+ * @param array> $linksById
+ * @param array $pathCache
+ */
+ private function buildFolderPathForLink(
+ int $id,
+ array $linksById,
+ array &$pathCache
+ ): string {
+ if (isset($pathCache[$id])) {
+ return $pathCache[$id];
+ }
+
+ if (!isset($linksById[$id])) {
+ return $pathCache[$id] = '';
+ }
+
+ $current = $linksById[$id];
+ $segments = [$current['title']];
+
+ $parentId = $current['parent_id'] ?? null;
+ $guard = 0;
+
+ while (null !== $parentId && isset($linksById[$parentId]) && $guard < 50) {
+ $parent = $linksById[$parentId];
+ array_unshift($segments, $parent['title']);
+ $parentId = $parent['parent_id'] ?? null;
+ $guard++;
+ }
+
+ $path = implode('/', $segments);
+
+ return $pathCache[$id] = $path;
+ }
}