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' => [