From 6593e25bf10b9b5475a5214ae63acb011c216657 Mon Sep 17 00:00:00 2001 From: Christian Beeznest <christian.fasanando@beeznest.com> Date: Thu, 16 Jan 2025 16:49:36 -0500 Subject: [PATCH 1/2] Documents: Add batch download with ZIP support - refs #3197 --- assets/vue/views/documents/DocumentsList.vue | 40 +++++ .../Api/DownloadSelectedDocumentsAction.php | 148 ++++++++++++++++++ src/CourseBundle/Entity/CDocument.php | 24 +++ 3 files changed, 212 insertions(+) create mode 100644 src/CoreBundle/Controller/Api/DownloadSelectedDocumentsAction.php diff --git a/assets/vue/views/documents/DocumentsList.vue b/assets/vue/views/documents/DocumentsList.vue index a82904a3ea5..4b51da49ea3 100644 --- a/assets/vue/views/documents/DocumentsList.vue +++ b/assets/vue/views/documents/DocumentsList.vue @@ -269,6 +269,13 @@ type="danger" @click="showDeleteMultipleDialog" /> + <BaseButton + :disabled="isDownloading || !selectedItems || !selectedItems.length" + :label="isDownloading ? t('Preparing...') : t('Download selected ZIP')" + icon="download" + type="primary" + @click="downloadSelectedItems" + /> </BaseToolbar> <BaseDialogConfirmCancel @@ -455,6 +462,7 @@ const { relativeDatetime } = useFormatDate() const isAllowedToEdit = ref(false) const folders = ref([]) const selectedFolder = ref(null) +const isDownloading = ref(false) const { showNewDocumentButton, @@ -620,6 +628,38 @@ function confirmDeleteItem(itemToDelete) { isDeleteItemDialogVisible.value = true } +async function downloadSelectedItems() { + if (!selectedItems.value.length) { + notification.showErrorNotification(t("No items selected.")) + return + } + + isDownloading.value = true + + try { + const response = await axios.post( + "/api/documents/download-selected", + { ids: selectedItems.value.map(item => item.iid) }, + { responseType: "blob" } + ) + + const url = window.URL.createObjectURL(new Blob([response.data])) + const link = document.createElement("a") + link.href = url + link.setAttribute("download", "selected_documents.zip") + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + notification.showSuccessNotification(t("Download started.")) + } catch (error) { + console.error("Error downloading selected items:", error) + notification.showErrorNotification(t("Error downloading selected items.")) + } finally { + isDownloading.value = false; + } +} + async function deleteMultipleItems() { await store.dispatch("documents/delMultiple", selectedItems.value) isDeleteMultipleDialogVisible.value = false diff --git a/src/CoreBundle/Controller/Api/DownloadSelectedDocumentsAction.php b/src/CoreBundle/Controller/Api/DownloadSelectedDocumentsAction.php new file mode 100644 index 00000000000..a681f356307 --- /dev/null +++ b/src/CoreBundle/Controller/Api/DownloadSelectedDocumentsAction.php @@ -0,0 +1,148 @@ +<?php + +declare(strict_types=1); + +/* For licensing terms, see /license.txt */ + +namespace Chamilo\CoreBundle\Controller\Api; + +use Chamilo\CoreBundle\Entity\ResourceNode; +use Chamilo\CourseBundle\Entity\CDocument; +use Doctrine\ORM\EntityManagerInterface; +use Exception; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\KernelInterface; +use Chamilo\CoreBundle\Repository\ResourceNodeRepository; +use ZipArchive; + +class DownloadSelectedDocumentsAction +{ + private KernelInterface $kernel; + private ResourceNodeRepository $resourceNodeRepository; + + public function __construct(KernelInterface $kernel, ResourceNodeRepository $resourceNodeRepository) + { + $this->kernel = $kernel; + $this->resourceNodeRepository = $resourceNodeRepository; + } + + public function __invoke(Request $request, EntityManagerInterface $em): Response + { + ini_set('max_execution_time', '300'); + ini_set('memory_limit', '512M'); + + $data = json_decode($request->getContent(), true); + $documentIds = $data['ids'] ?? []; + + if (empty($documentIds)) { + return new Response('No items selected.', Response::HTTP_BAD_REQUEST); + } + + $documents = $em->getRepository(CDocument::class)->findBy(['iid' => $documentIds]); + + if (empty($documents)) { + return new Response('No documents found.', Response::HTTP_NOT_FOUND); + } + + $zipFilePath = $this->createZipFile($documents); + + if (!$zipFilePath || !file_exists($zipFilePath)) { + return new Response('ZIP file not found or could not be created.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $fileSize = filesize($zipFilePath); + if ($fileSize === false || $fileSize === 0) { + error_log('ZIP file is empty or unreadable.'); + throw new Exception('ZIP file is empty or unreadable.'); + } + + $response = new StreamedResponse(function () use ($zipFilePath) { + $handle = fopen($zipFilePath, 'rb'); + if ($handle) { + while (!feof($handle)) { + echo fread($handle, 8192); + ob_flush(); + flush(); + } + fclose($handle); + } + }); + + $response->headers->set('Content-Type', 'application/zip'); + $response->headers->set('Content-Disposition', 'inline; filename="selected_documents.zip"'); + $response->headers->set('Content-Length', (string) $fileSize); + + return $response; + } + + /** + * Creates a ZIP file containing the specified documents. + * + * @return string The path to the created ZIP file. + * @throws Exception If the ZIP file cannot be created or closed. + */ + private function createZipFile(array $documents): string + { + $cacheDir = $this->kernel->getCacheDir(); + $zipFilePath = $cacheDir . '/selected_documents_' . uniqid() . '.zip'; + + $zip = new ZipArchive(); + $result = $zip->open($zipFilePath, ZipArchive::CREATE); + + if ($result !== true) { + throw new Exception('Unable to create ZIP file'); + } + + $projectDir = $this->kernel->getProjectDir(); + $baseUploadDir = $projectDir . '/var/upload/resource'; + + foreach ($documents as $document) { + $resourceNode = $document->getResourceNode(); + if (!$resourceNode) { + error_log('ResourceNode not found for document ID: ' . $document->getId()); + continue; + } + + $this->addNodeToZip($zip, $resourceNode, $baseUploadDir); + } + + if (!$zip->close()) { + error_log('Failed to close ZIP file.'); + throw new Exception('Failed to close ZIP archive'); + } + + + return $zipFilePath; + } + + /** + * Adds a resource node and its files or children to the ZIP archive. + */ + private function addNodeToZip(ZipArchive $zip, ResourceNode $node, string $baseUploadDir, string $currentPath = ''): void + { + + if ($node->getChildren()->count() > 0) { + $relativePath = $currentPath . $node->getTitle() . '/'; + $zip->addEmptyDir($relativePath); + + foreach ($node->getChildren() as $childNode) { + $this->addNodeToZip($zip, $childNode, $baseUploadDir, $relativePath); + } + } elseif ($node->hasResourceFile()) { + foreach ($node->getResourceFiles() as $resourceFile) { + $filePath = $baseUploadDir . $this->resourceNodeRepository->getFilename($resourceFile); + $fileName = $currentPath . $resourceFile->getOriginalName(); + + if (file_exists($filePath)) { + $zip->addFile($filePath, $fileName); + } else { + error_log('File not found: ' . $filePath); + } + } + } else { + error_log('Node has no children or files: ' . $node->getTitle()); + } + } +} diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php index 92dfc44e5d1..6e8342c328a 100644 --- a/src/CourseBundle/Entity/CDocument.php +++ b/src/CourseBundle/Entity/CDocument.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Put; use ApiPlatform\Serializer\Filter\PropertyFilter; use Chamilo\CoreBundle\Controller\Api\CreateDocumentFileAction; +use Chamilo\CoreBundle\Controller\Api\DownloadSelectedDocumentsAction; use Chamilo\CoreBundle\Controller\Api\UpdateDocumentFileAction; use Chamilo\CoreBundle\Controller\Api\UpdateVisibilityDocument; use Chamilo\CoreBundle\Entity\AbstractResource; @@ -110,6 +111,29 @@ validationContext: ['groups' => ['Default', 'media_object_create', 'document:write']], deserialize: false ), + new Post( + uriTemplate: '/documents/download-selected', + controller: DownloadSelectedDocumentsAction::class, + openapiContext: [ + 'summary' => 'Download selected documents as a ZIP file.', + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'ids' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'] + ], + ], + ], + ], + ], + ], + ], + security: "is_granted('ROLE_USER')", + ), new GetCollection( openapiContext: [ 'parameters' => [ From fa655740d3bf4fb58470880ad1307957bf66519a Mon Sep 17 00:00:00 2001 From: Yannick Warnier <ywarnier@users.noreply.github.com> Date: Sat, 22 Mar 2025 08:13:32 +0100 Subject: [PATCH 2/2] Minor: Update language variables --- assets/vue/views/documents/DocumentsList.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/vue/views/documents/DocumentsList.vue b/assets/vue/views/documents/DocumentsList.vue index 4b51da49ea3..084304562f0 100644 --- a/assets/vue/views/documents/DocumentsList.vue +++ b/assets/vue/views/documents/DocumentsList.vue @@ -271,7 +271,7 @@ /> <BaseButton :disabled="isDownloading || !selectedItems || !selectedItems.length" - :label="isDownloading ? t('Preparing...') : t('Download selected ZIP')" + :label="isDownloading ? t('In progress') : t('Download selected items as ZIP')" icon="download" type="primary" @click="downloadSelectedItems" @@ -651,7 +651,7 @@ async function downloadSelectedItems() { link.click() document.body.removeChild(link) - notification.showSuccessNotification(t("Download started.")) + notification.showSuccessNotification(t("Download started")) } catch (error) { console.error("Error downloading selected items:", error) notification.showErrorNotification(t("Error downloading selected items."))