diff --git a/assets/vue/components/links/LinkItem.vue b/assets/vue/components/links/LinkItem.vue index d2ca80f7fd9..ff4055ca154 100644 --- a/assets/vue/components/links/LinkItem.vue +++ b/assets/vue/components/links/LinkItem.vue @@ -1,81 +1,85 @@ diff --git a/assets/vue/services/linkService.js b/assets/vue/services/linkService.js index 9f0356a355a..4de2fb5c45f 100644 --- a/assets/vue/services/linkService.js +++ b/assets/vue/services/linkService.js @@ -73,16 +73,53 @@ export default { }, /** - * @param {Number|String} linkId - * @param {Number} position + * Move link (reorder) and optionally move between categories. + * + * @param {number|string} linkId + * @param {number} position + * @param {{categoryId?: number|null, cid?: number|null, sid?: number|null}} opts */ - moveLink: async (linkId, position) => { - const endpoint = `${ENTRYPOINT}links/${linkId}/move` - const response = await axios.put(endpoint, { position }) + moveLink: async (linkId, position, opts = {}) => { + const cid = opts.cid ?? null + const sid = opts.sid ?? null + + const query = [] + if (cid !== null) query.push(`cid=${encodeURIComponent(cid)}`) + if (sid !== null) query.push(`sid=${encodeURIComponent(sid)}`) + + const endpoint = `${ENTRYPOINT}links/${linkId}/move` + (query.length ? `?${query.join("&")}` : "") + const payload = { position } + + if (Object.prototype.hasOwnProperty.call(opts, "categoryId")) { + payload.categoryId = opts.categoryId ?? 0 + } + + const response = await axios.put(endpoint, payload) return response.data }, + /** + * Export links to a real PDF generated by the backend. + * Uses multipart/form-data as defined in the API resource. + * + * @param {"pdf"} format + * @param {{cid:number, sid?:number}} params + */ + exportLinks: async (format, params) => { + const endpoint = `${ENTRYPOINT}links/export?format=${encodeURIComponent(format)}` + + const formData = new FormData() + formData.append("cid", String(params.cid)) + + if (params.sid !== undefined && params.sid !== null && Number(params.sid) > 0) { + formData.append("sid", String(params.sid)) + } + + // responseType 'blob' is required to download a real PDF file. + return axios.post(endpoint, formData, { responseType: "blob" }) + }, + /** * @param {Number|String} linkId */ diff --git a/assets/vue/views/links/LinksList.vue b/assets/vue/views/links/LinksList.vue index 4b95d425cd7..ddf6c12a081 100644 --- a/assets/vue/views/links/LinksList.vue +++ b/assets/vue/views/links/LinksList.vue @@ -21,6 +21,12 @@ type="black" @click="redirectToCreateLinkCategory" /> + @@ -31,13 +37,10 @@ - -
- - + +
+
{{ t("General") }}
-
    -
  • - -
  • -
- - - - - -
    -
  • - -
  • -
-

- {{ t("There are no links in this category") }} + + + + +

+ {{ t("Drop links here to keep them without category") }}

+ + +
+ + + + + + + +

+ {{ t("There are no links in this category") }} +

+
+
+ + + + +

{{ categoryToDelete.info.title }}

-

{{ t("With links") }}: {{ (categoryToDelete.links || []).map((l) => l.title).join(", ") }}

+

+ {{ t("With links") }}: + {{ (categoryToDelete.links || []).map((l) => l.title).join(", ") }} +

@@ -180,54 +342,51 @@ import EmptyState from "../../components/EmptyState.vue" import BaseButton from "../../components/basecomponents/BaseButton.vue" import BaseToolbar from "../../components/basecomponents/BaseToolbar.vue" -import { computed, onMounted, ref, watch } from "vue" -import { useRoute, useRouter } from "vue-router" -import { useI18n } from "vue-i18n" import BaseIcon from "../../components/basecomponents/BaseIcon.vue" import LinkItem from "../../components/links/LinkItem.vue" -import { useNotification } from "../../composables/notification" import LinkCategoryCard from "../../components/links/LinkCategoryCard.vue" -import linkService from "../../services/linkService" import BaseDialogDelete from "../../components/basecomponents/BaseDialogDelete.vue" +import SectionHeader from "../../components/layout/SectionHeader.vue" +import StudentViewButton from "../../components/StudentViewButton.vue" + import Skeleton from "primevue/skeleton" +import Draggable from "vuedraggable" + +import { computed, onMounted, ref, watch } from "vue" +import { useRoute, useRouter } from "vue-router" +import { useI18n } from "vue-i18n" + +import linkService from "../../services/linkService" +import { useNotification } from "../../composables/notification" import { isVisible, toggleVisibilityProperty, visibilityFromBoolean } from "../../components/links/linkVisibility" import { useSecurityStore } from "../../store/securityStore" import { useCidReq } from "../../composables/cidReq" import { checkIsAllowedToEdit } from "../../composables/userPermissions" import { usePlatformConfig } from "../../store/platformConfig" -import SectionHeader from "../../components/layout/SectionHeader.vue" -import StudentViewButton from "../../components/StudentViewButton.vue" const route = useRoute() const router = useRouter() const securityStore = useSecurityStore() const platform = usePlatformConfig() -const { cid, sid, gid } = useCidReq() +const { cid, sid } = useCidReq() const { t } = useI18n() const notifications = useNotification() -// Keep these refs reactive for UI const linksWithoutCategory = ref([]) const categories = ref([]) -const selectedLink = ref(null) -const selectedCategory = ref(null) - const isDeleteLinkDialogVisible = ref(false) const linkToDelete = ref(null) -const linkToDeleteString = computed(() => { - if (linkToDelete.value === null) return "" - return linkToDelete.value.title -}) +const linkToDeleteString = computed(() => (linkToDelete.value ? linkToDelete.value.title : "")) const isDeleteCategoryDialogVisible = ref(false) const categoryToDelete = ref(null) const isLoading = ref(true) - const linkValidationResults = ref({}) const isToggling = ref({}) +const isMoving = ref(false) const isAllowedToEdit = ref(securityStore.isAdmin || securityStore.isCurrentTeacher) const canEditLinks = computed(() => { @@ -235,26 +394,29 @@ const canEditLinks = computed(() => { return Boolean(isAllowedToEdit.value || securityStore.isAdmin || securityStore.isCurrentTeacher) }) +const canMoveCategories = false +const parentId = computed(() => Number(route.query.parent || 0)) +const cidValue = computed(() => Number(route.query.cid || (cid && typeof cid === "object" ? cid.value : cid) || 0)) +const sidValue = computed(() => Number(route.query.sid || (sid && typeof sid === "object" ? sid.value : sid) || 0)) +const showGeneralCard = computed(() => { + if (linksWithoutCategory.value.length > 0) return true + if (categories.value.length > 0) return true + return Boolean(canEditLinks.value) +}) + onMounted(async () => { await reconcileEditGate() - linksWithoutCategory.value = [] - categories.value = [] await fetchLinks() - - console.debug("[LinkList] gating", { - isStudentViewActive: platform.isStudentViewActive, - isAllowedToEdit: isAllowedToEdit.value, - isAdmin: securityStore.isAdmin, - isCurrentTeacher: securityStore.isCurrentTeacher, - canEditLinks: canEditLinks.value, - }) }) +watch(() => platform.isStudentViewActive, refreshForViewToggle) + async function reconcileEditGate() { try { const allowed = await checkIsAllowedToEdit(true, true, true) isAllowedToEdit.value = Boolean(allowed || securityStore.isAdmin || securityStore.isCurrentTeacher) - } catch { + } catch (error) { + console.error("Error checking edit permission:", error) isAllowedToEdit.value = Boolean(securityStore.isAdmin || securityStore.isCurrentTeacher) } } @@ -264,16 +426,152 @@ async function refreshForViewToggle() { await fetchLinks() } -watch(() => platform.isStudentViewActive, refreshForViewToggle) +async function fetchLinks() { + isLoading.value = true + + const params = { + "resourceNode.parent": parentId.value || null, + cid: cidValue.value || null, + sid: sidValue.value || null, + } + + try { + const data = await linkService.getLinks(params) + linksWithoutCategory.value = data.linksWithoutCategory || [] + categories.value = Object.values(data.categories || {}).map((c) => ({ ...c, key: c.info.id })) + } catch (error) { + console.error("Error fetching links:", error) + notifications.showErrorNotification(t("Could not retrieve links")) + } finally { + isLoading.value = false + } +} + +/** + * Backend-generated PDF export. + */ +async function exportToPDF() { + if (!canEditLinks.value) return + try { + const response = await linkService.exportLinks("pdf", { cid: cidValue.value, sid: sidValue.value }) + + const blob = new Blob([response.data], { type: "application/pdf" }) + const url = window.URL.createObjectURL(blob) + + let filename = "links.pdf" + const contentDisposition = response.headers?.["content-disposition"] || response.headers?.["Content-Disposition"] + if (contentDisposition) { + const match = /filename="([^"]+)"/.exec(contentDisposition) + if (match && match[1]) filename = match[1] + } + + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + window.URL.revokeObjectURL(url) + + notifications.showSuccessNotification(t("Export completed")) + } catch (error) { + console.error("Error exporting links:", error) + notifications.showErrorNotification(t("Could not export")) + } +} + +/** + * Robust DnD save: use Sortable's "end" event. + * It reliably gives from/to + oldIndex/newIndex. + * We also attach data-category-id to each list container. + */ +async function onLinkDndEnd(evt) { + if (!canEditLinks.value) return + if (isMoving.value) return + + const newIndex = typeof evt?.newIndex === "number" ? evt.newIndex : null + const oldIndex = typeof evt?.oldIndex === "number" ? evt.oldIndex : null + if (newIndex === null || oldIndex === null) { + // Drop outside / cancelled -> nothing to persist + return + } + + const fromCategoryId = getCategoryIdFromContainer(evt.from) + const toCategoryId = getCategoryIdFromContainer(evt.to) + + // No actual move + if (fromCategoryId === toCategoryId && newIndex === oldIndex) { + return + } + + const link = getDraggedLinkFromEvent(evt) + if (!link || !link.iid) { + console.error("DnD end event has no underlying link element.") + await fetchLinks() + return + } + + isMoving.value = true + try { + await linkService.moveLink(link.iid, newIndex, { + categoryId: toCategoryId, + cid: cidValue.value, + sid: sidValue.value, + }) + + notifications.showSuccessNotification(t("Link moved")) + await fetchLinks() + } catch (error) { + console.error("Error moving link:", error) + notifications.showErrorNotification(t("Could not move link")) + await fetchLinks() + } finally { + isMoving.value = false + } +} + +function getCategoryIdFromContainer(container) { + if (!container) return 0 + const raw = container.getAttribute?.("data-category-id") ?? container.dataset?.categoryId ?? "0" + const n = Number(raw) + return Number.isFinite(n) ? n : 0 +} + +/** + * vuedraggable stores the underlying element on the DOM node. + * We read it in a defensive way to support different builds. + */ +function getDraggedLinkFromEvent(evt) { + const item = evt?.item + if (!item) return null + + // vuedraggable 4 + if (item.__draggable_context && item.__draggable_context.element) { + return item.__draggable_context.element + } + + // fallback + if (item._underlying_vm_) { + return item._underlying_vm_ + } + + return null +} + +/** + * Optional category reorder (only if you add move endpoint + enable canMoveCategories). + */ +async function onCategoryDndChange(evt) { + if (!canEditLinks.value || !canMoveCategories) return + if (!evt.moved) return + notifications.showErrorNotification(t("Category reordering is not enabled")) +} + +/* --- actions --- */ function editLink(link) { if (!canEditLinks.value) return - selectedLink.value = { ...link } - router.push({ - name: "UpdateLink", - params: { id: link.iid }, - query: route.query, - }) + router.push({ name: "UpdateLink", params: { id: link.iid }, query: route.query }) } function confirmDeleteLink(link) { @@ -283,9 +581,10 @@ function confirmDeleteLink(link) { } async function deleteLink() { - if (!canEditLinks.value) return + if (!canEditLinks.value || !linkToDelete.value) return + try { - await linkService.deleteLink(linkToDelete.value.id) + await linkService.deleteLink(linkToDelete.value.iid) linkToDelete.value = null isDeleteLinkDialogVisible.value = false notifications.showSuccessNotification(t("Link deleted")) @@ -316,7 +615,7 @@ async function toggleVisibility(link) { try { const newVisible = !isVisible(link.linkVisible) - const updatedLink = await linkService.toggleLinkVisibility(link.iid, newVisible, cid, sid) + const updatedLink = await linkService.toggleLinkVisibility(link.iid, newVisible, cidValue.value, sidValue.value) const newFlagValue = visibilityFromBoolean(updatedLink.linkVisible) linksWithoutCategory.value.filter((l) => l.iid === link.iid).forEach((l) => (l.linkVisible = newFlagValue)) @@ -327,72 +626,38 @@ async function toggleVisibility(link) { .forEach((l) => (l.linkVisible = newFlagValue)) notifications.showSuccessNotification(t("Link visibility updated")) - } catch (err) { + } catch (error) { + console.error("Error toggling link visibility:", error) notifications.showErrorNotification(t("Could not change visibility of link")) } finally { isToggling.value = { ...isToggling.value, [link.iid]: false } } } -async function moveUp(id, position) { - if (!canEditLinks.value) return // hard guard - let newPosition = parseInt(position) - 1 - if (newPosition < 0) newPosition = 0 - try { - await linkService.moveLink(id, newPosition) - notifications.showSuccessNotification(t("Link moved up")) - await fetchLinks() - } catch (error) { - notifications.showErrorNotification(t("Could not move link up")) - } -} - -async function moveDown(id, position) { - if (!canEditLinks.value) return // hard guard - const newPosition = parseInt(position) + 1 - try { - await linkService.moveLink(id, newPosition) - notifications.showSuccessNotification(t("Link moved down")) - await fetchLinks() - } catch (error) { - notifications.showErrorNotification(t("Could not move link down")) - } -} - function redirectToCreateLink() { - if (!canEditLinks.value) return // hard guard - router.push({ - name: "CreateLink", - query: route.query, - }) + if (!canEditLinks.value) return + router.push({ name: "CreateLink", query: route.query }) } function redirectToCreateLinkCategory() { - if (!canEditLinks.value) return // hard guard - router.push({ - name: "CreateLinkCategory", - query: route.query, - }) + if (!canEditLinks.value) return + router.push({ name: "CreateLinkCategory", query: route.query }) } function editCategory(category) { - if (!canEditLinks.value) return // hard guard - selectedCategory.value = { ...category } - router.push({ - name: "UpdateLinkCategory", - params: { id: category.info.id }, - query: route.query, - }) + if (!canEditLinks.value) return + router.push({ name: "UpdateLinkCategory", params: { id: category.info.id }, query: route.query }) } function confirmDeleteCategory(category) { - if (!canEditLinks.value) return // hard guard + if (!canEditLinks.value) return categoryToDelete.value = category isDeleteCategoryDialogVisible.value = true } async function deleteCategory() { - if (!canEditLinks.value) return // hard guard + if (!canEditLinks.value || !categoryToDelete.value) return + try { await linkService.deleteCategory(categoryToDelete.value.info.id) categoryToDelete.value = null @@ -401,53 +666,43 @@ async function deleteCategory() { await fetchLinks() } catch (error) { console.error("Error deleting category:", error) - notifications.showErrorNotification(t("Could not change visibility of category")) + notifications.showErrorNotification(t("Could not delete category")) } } async function toggleCategoryVisibility(category) { - if (!canEditLinks.value) return // hard guard + if (!canEditLinks.value) return + const visibility = toggleVisibilityProperty(category.info.visible) try { - const updatedLinkCategory = await linkService.toggleCategoryVisibility( + const updated = await linkService.toggleCategoryVisibility( category.info.id, isVisible(visibility), - cid, - sid, + cidValue.value, + sidValue.value, ) - category.info.visible = visibilityFromBoolean(updatedLinkCategory.linkCategoryVisible) + category.info.visible = visibilityFromBoolean(updated.linkCategoryVisible) notifications.showSuccessNotification(t("Visibility of category changed")) } catch (error) { - console.error("Error updating link visibility:", error) + console.error("Error updating category visibility:", error) notifications.showErrorNotification(t("Could not change visibility of category")) } } - -function exportToPDF() { - // TODO + + diff --git a/src/CoreBundle/Controller/Api/ExportCLinksAction.php b/src/CoreBundle/Controller/Api/ExportCLinksAction.php new file mode 100644 index 00000000000..3f5550b59a7 --- /dev/null +++ b/src/CoreBundle/Controller/Api/ExportCLinksAction.php @@ -0,0 +1,189 @@ +get('format'); + $cid = (int) $request->request->get('cid', 0); + $sid = (int) $request->request->get('sid', 0); + + if (!\in_array($format, ['pdf'], true)) { + throw new BadRequestHttpException('Invalid export format.'); + } + + $course = null; + $session = null; + + if ($cid > 0) { + $course = $em->find(Course::class, $cid); + } + if ($sid > 0) { + $session = $em->find(Session::class, $sid); + } + + if (!$course) { + throw new BadRequestHttpException('Course not found.'); + } + + // Links without category + $qbNoCat = $repo->getResourcesByCourse($course, $session, null, null, true, true); + $qbNoCat->andWhere('resource.category = 0 OR resource.category IS NULL'); + /** @var CLink[] $linksWithoutCategory */ + $linksWithoutCategory = $qbNoCat->getQuery()->getResult(); + + // Categories + $qbCat = $repoCategory->getResourcesByCourse($course, $session, null, null, true, true); + /** @var CLinkCategory[] $categories */ + $categories = $qbCat->getQuery()->getResult(); + + // Build a map categoryId => links[] + $categoryLinks = []; + foreach ($categories as $category) { + $categoryId = (int) $category->getIid(); + $qbLinks = $repo->getResourcesByCourse($course, $session); + $qbLinks->andWhere('resource.category = '.$categoryId); + + /** @var CLink[] $links */ + $links = $qbLinks->getQuery()->getResult(); + $categoryLinks[$categoryId] = $links; + } + + $exportFilePath = $this->generatePdfFile( + $course, + $session, + $linksWithoutCategory, + $categories, + $categoryLinks + ); + + $response = new BinaryFileResponse(new File($exportFilePath)); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $response->getFile()->getFilename() + ); + + return $response; + } + + private function generatePdfFile( + Course $course, + ?Session $session, + array $linksWithoutCategory, + array $categories, + array $categoryLinks + ): string { + $title = $this->translator->trans('Links'); + + $html = ''; + + $html .= '

'.$title.'

'; + + $meta = $course->getCode(); + if ($session) { + $meta .= ' / '.$this->translator->trans('Session').' #'.$session->getId(); + } + $html .= '
'.$meta.'
'; + + // General + $html .= '

'.$this->translator->trans('General').'

'; + $html .= $this->renderLinksTable($linksWithoutCategory); + + // Categories + foreach ($categories as $cat) { + $catId = (int) $cat->getIid(); + $html .= '

'.$cat->getTitle().'

'; + + $desc = (string) $cat->getDescription(); + if (!empty($desc)) { + $html .= '
'.htmlspecialchars($desc, ENT_QUOTES).'
'; + } + + $links = $categoryLinks[$catId] ?? []; + $html .= $this->renderLinksTable($links); + } + + $fileBase = 'links_course_'.$course->getCode(); + + return (new PDF()) + ->content_to_pdf( + $html, + null, + $fileBase, + $course->getCode(), + 'F', + false, + null, + false, + true + ); + } + + /** + * @param CLink[] $links + */ + private function renderLinksTable(array $links): string + { + if (empty($links)) { + return '
'.$this->translator->trans('There are no links in this category').'
'; + } + + $html = ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($links as $link) { + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
'.$this->translator->trans('Title').''.$this->translator->trans('URL').''.$this->translator->trans('Description').'
'.htmlspecialchars((string) $link->getTitle(), ENT_QUOTES).''.htmlspecialchars((string) $link->getUrl(), ENT_QUOTES).''.htmlspecialchars((string) ($link->getDescription() ?? ''), ENT_QUOTES).'
'; + + return $html; + } +} diff --git a/src/CoreBundle/Controller/Api/UpdatePositionLink.php b/src/CoreBundle/Controller/Api/UpdatePositionLink.php index 3996a7f1500..308f39b9341 100644 --- a/src/CoreBundle/Controller/Api/UpdatePositionLink.php +++ b/src/CoreBundle/Controller/Api/UpdatePositionLink.php @@ -6,27 +6,209 @@ namespace Chamilo\CoreBundle\Controller\Api; +use Chamilo\CoreBundle\Entity\Course; +use Chamilo\CoreBundle\Entity\Session; use Chamilo\CourseBundle\Entity\CLink; -use Chamilo\CourseBundle\Repository\CLinkRepository; -use Doctrine\ORM\EntityManager; +use Chamilo\CourseBundle\Entity\CLinkCategory; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; #[AsController] class UpdatePositionLink extends AbstractController { - public function __invoke(CLink $link, CLinkRepository $repo, Request $request, EntityManager $em): CLink + public function __invoke(CLink $link, Request $request, EntityManagerInterface $em): CLink { - $requestData = json_decode($request->getContent(), true); - $newPosition = (int) $requestData['position']; + $payload = json_decode((string) $request->getContent(), true) ?: []; + + $newPosition = isset($payload['position']) ? (int) $payload['position'] : 0; + if ($newPosition < 0) { + $newPosition = 0; + } + + // Context is mandatory for ordering + $cid = (int) $request->query->get('cid', 0); + $sid = (int) $request->query->get('sid', 0); + + if ($cid <= 0) { + throw new BadRequestHttpException('Missing or invalid "cid" for link move operation.'); + } + + $course = $em->find(Course::class, $cid); + if (!$course) { + throw new BadRequestHttpException('Course not found for provided "cid".'); + } + + $session = null; + if ($sid > 0) { + $session = $em->find(Session::class, $sid); + if (!$session) { + throw new BadRequestHttpException('Session not found for provided "sid".'); + } + } $resourceNode = $link->getResourceNode(); - if ($resourceNode) { - // $resourceNode->setDisplayOrder($newPosition); + if (!$resourceNode) { + throw new BadRequestHttpException('Link has no resource node.'); + } + + $parentNode = $resourceNode->getParent(); + + $oldCategory = $link->getCategory(); + $oldCategoryId = $oldCategory ? (int) $oldCategory->getIid() : 0; + + $targetCategoryId = $oldCategoryId; + $targetCategory = $oldCategory; + + // assign/move link to a category (DnD between lists) + if (\array_key_exists('categoryId', $payload)) { + $targetCategoryId = (int) ($payload['categoryId'] ?? 0); + + if ($targetCategoryId <= 0) { + $targetCategoryId = 0; + $targetCategory = null; + } else { + /** @var CLinkCategory|null $category */ + $category = $em->getRepository(CLinkCategory::class)->find($targetCategoryId); + if (!$category) { + throw new BadRequestHttpException('Target category not found.'); + } + + $catNode = $category->getResourceNode(); + if (!$catNode) { + throw new BadRequestHttpException('Target category has no resource node.'); + } + + // Validate same parent node (prevents "vanishing" when category is from another folder/parent) + if ($catNode->getParent()?->getId() !== $parentNode?->getId()) { + throw new BadRequestHttpException('Target category is not under the same parent node.'); + } + + // Validate category exists in the same course/session context + $catRl = $catNode->getResourceLinkByContext($course, $session); + if (!$catRl) { + throw new BadRequestHttpException('Target category is not available in the current course/session context.'); + } + + $targetCategory = $category; + } + + $link->setCategory($targetCategory); + + // Flush now so subsequent bucket queries reflect the new category assignment $em->flush(); } + // Reindex destination bucket (with moved link inserted at desired position) + $this->reindexBucket( + em: $em, + parentNode: $parentNode, + categoryId: $targetCategoryId, + course: $course, + session: $session, + movedLink: $link, + insertAt: $newPosition + ); + + // If moved across categories, reindex source bucket too + if ($oldCategoryId !== $targetCategoryId) { + $this->reindexBucket( + em: $em, + parentNode: $parentNode, + categoryId: $oldCategoryId, + course: $course, + session: $session, + movedLink: null, + insertAt: null + ); + } + + $em->flush(); + return $link; } + + /** + * Reshows stable ordering by reindexing ResourceLink.displayOrder for the given bucket & context. + * Bucket = same parent node + same category (or NULL category). + */ + private function reindexBucket( + EntityManagerInterface $em, + ?object $parentNode, + int $categoryId, + Course $course, + ?Session $session, + ?CLink $movedLink, + ?int $insertAt + ): void { + $qb = $em->getRepository(CLink::class)->createQueryBuilder('l') + ->join('l.resourceNode', 'rn') + ; + + if ($parentNode) { + $qb->andWhere('rn.parent = :parent') + ->setParameter('parent', $parentNode); + } else { + $qb->andWhere('rn.parent IS NULL'); + } + + if ($categoryId > 0) { + $qb->andWhere('l.category = :cat') + ->setParameter('cat', $em->getReference(CLinkCategory::class, $categoryId)); + } else { + $qb->andWhere('l.category IS NULL'); + } + + /** @var CLink[] $links */ + $links = $qb->getQuery()->getResult(); + + // Sort by current displayOrder in this context (stable base before reinserting) + usort($links, function (CLink $a, CLink $b) use ($course, $session): int { + $aRl = $a->getResourceNode()?->getResourceLinkByContext($course, $session); + $bRl = $b->getResourceNode()?->getResourceLinkByContext($course, $session); + + $aOrder = $aRl ? (int) $aRl->getDisplayOrder() : PHP_INT_MAX; + $bOrder = $bRl ? (int) $bRl->getDisplayOrder() : PHP_INT_MAX; + + if ($aOrder === $bOrder) { + return $a->getIid() <=> $b->getIid(); + } + + return $aOrder <=> $bOrder; + }); + + // If we are handling the moved link, place it at insertAt + if ($movedLink && $insertAt !== null) { + $movedId = (int) $movedLink->getIid(); + + $links = array_values(array_filter($links, static fn (CLink $x): bool => (int) $x->getIid() !== $movedId)); + + $pos = $insertAt; + if ($pos < 0) { + $pos = 0; + } + if ($pos > count($links)) { + $pos = count($links); + } + + array_splice($links, $pos, 0, [$movedLink]); + } + + // Assign sequential displayOrder in THIS context only + foreach ($links as $idx => $item) { + $rn = $item->getResourceNode(); + if (!$rn) { + continue; + } + + $rl = $rn->getResourceLinkByContext($course, $session); + if (!$rl) { + throw new BadRequestHttpException('ResourceLink not found for link in the current context.'); + } + + $rl->setDisplayOrder($idx); + } + } } diff --git a/src/CourseBundle/Entity/CLink.php b/src/CourseBundle/Entity/CLink.php index ce238e0ff90..51011523a65 100644 --- a/src/CourseBundle/Entity/CLink.php +++ b/src/CourseBundle/Entity/CLink.php @@ -23,6 +23,7 @@ use Chamilo\CoreBundle\Controller\Api\CLinkDetailsController; use Chamilo\CoreBundle\Controller\Api\CLinkImageController; use Chamilo\CoreBundle\Controller\Api\CreateCLinkAction; +use Chamilo\CoreBundle\Controller\Api\ExportCLinksAction; use Chamilo\CoreBundle\Controller\Api\GetLinksCollectionController; use Chamilo\CoreBundle\Controller\Api\UpdateCLinkAction; use Chamilo\CoreBundle\Controller\Api\UpdatePositionLink; @@ -127,6 +128,38 @@ security: "is_granted('EDIT', object.resourceNode)", deserialize: false ), + new Post( + uriTemplate: '/links/export', + controller: ExportCLinksAction::class, + openapi: new Operation( + summary: 'Export links', + parameters: [ + new Parameter( + name: 'format', + in: 'query', + description: 'Export format', + required: true, + schema: ['type' => 'string', 'enum' => ['pdf']], + ), + ], + requestBody: new RequestBody( + content: new ArrayObject([ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'cid' => ['type' => 'integer'], + 'sid' => ['type' => 'integer'], + ], + 'required' => ['cid'], + ], + ], + ]), + ), + ), + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + deserialize: false + ), new Get(security: "is_granted('VIEW', object.resourceNode)"), new Get( uriTemplate: '/links/{iid}/details',