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."))