diff --git a/assets/vue/views/documents/DocumentsList.vue b/assets/vue/views/documents/DocumentsList.vue index ef725f30577..f7a91afb4c3 100644 --- a/assets/vue/views/documents/DocumentsList.vue +++ b/assets/vue/views/documents/DocumentsList.vue @@ -286,6 +286,13 @@ type="danger" @click="showDeleteMultipleDialog" /> + 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 @@ +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 cae4b29c837..d149f874d02 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\ReplaceDocumentFileAction; use Chamilo\CoreBundle\Controller\Api\UpdateDocumentFileAction; use Chamilo\CoreBundle\Controller\Api\UpdateVisibilityDocument; @@ -136,6 +137,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' => [