Skip to content

Commit edd7fd7

Browse files
Documents: Add batch download with ZIP support - refs #3197 (#6035)
Author: @christianbeeznest
1 parent 831d146 commit edd7fd7

File tree

3 files changed

+212
-0
lines changed

3 files changed

+212
-0
lines changed

assets/vue/views/documents/DocumentsList.vue

+40
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,13 @@
286286
type="danger"
287287
@click="showDeleteMultipleDialog"
288288
/>
289+
<BaseButton
290+
:disabled="isDownloading || !selectedItems || !selectedItems.length"
291+
:label="isDownloading ? t('In progress') : t('Download selected items as ZIP')"
292+
icon="download"
293+
type="primary"
294+
@click="downloadSelectedItems"
295+
/>
289296
</BaseToolbar>
290297

291298
<BaseDialogConfirmCancel
@@ -491,6 +498,7 @@ const { relativeDatetime } = useFormatDate()
491498
const isAllowedToEdit = ref(false)
492499
const folders = ref([])
493500
const selectedFolder = ref(null)
501+
const isDownloading = ref(false)
494502
495503
const {
496504
showNewDocumentButton,
@@ -669,6 +677,38 @@ function confirmDeleteItem(itemToDelete) {
669677
isDeleteItemDialogVisible.value = true
670678
}
671679
680+
async function downloadSelectedItems() {
681+
if (!selectedItems.value.length) {
682+
notification.showErrorNotification(t("No items selected."))
683+
return
684+
}
685+
686+
isDownloading.value = true
687+
688+
try {
689+
const response = await axios.post(
690+
"/api/documents/download-selected",
691+
{ ids: selectedItems.value.map(item => item.iid) },
692+
{ responseType: "blob" }
693+
)
694+
695+
const url = window.URL.createObjectURL(new Blob([response.data]))
696+
const link = document.createElement("a")
697+
link.href = url
698+
link.setAttribute("download", "selected_documents.zip")
699+
document.body.appendChild(link)
700+
link.click()
701+
document.body.removeChild(link)
702+
703+
notification.showSuccessNotification(t("Download started"))
704+
} catch (error) {
705+
console.error("Error downloading selected items:", error)
706+
notification.showErrorNotification(t("Error downloading selected items."))
707+
} finally {
708+
isDownloading.value = false;
709+
}
710+
}
711+
672712
async function deleteMultipleItems() {
673713
await store.dispatch("documents/delMultiple", selectedItems.value)
674714
isDeleteMultipleDialogVisible.value = false
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/* For licensing terms, see /license.txt */
6+
7+
namespace Chamilo\CoreBundle\Controller\Api;
8+
9+
use Chamilo\CoreBundle\Entity\ResourceNode;
10+
use Chamilo\CourseBundle\Entity\CDocument;
11+
use Doctrine\ORM\EntityManagerInterface;
12+
use Exception;
13+
use Symfony\Component\HttpFoundation\Request;
14+
use Symfony\Component\HttpFoundation\Response;
15+
use Symfony\Component\HttpFoundation\StreamedResponse;
16+
use Symfony\Component\HttpKernel\KernelInterface;
17+
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
18+
use ZipArchive;
19+
20+
class DownloadSelectedDocumentsAction
21+
{
22+
private KernelInterface $kernel;
23+
private ResourceNodeRepository $resourceNodeRepository;
24+
25+
public function __construct(KernelInterface $kernel, ResourceNodeRepository $resourceNodeRepository)
26+
{
27+
$this->kernel = $kernel;
28+
$this->resourceNodeRepository = $resourceNodeRepository;
29+
}
30+
31+
public function __invoke(Request $request, EntityManagerInterface $em): Response
32+
{
33+
ini_set('max_execution_time', '300');
34+
ini_set('memory_limit', '512M');
35+
36+
$data = json_decode($request->getContent(), true);
37+
$documentIds = $data['ids'] ?? [];
38+
39+
if (empty($documentIds)) {
40+
return new Response('No items selected.', Response::HTTP_BAD_REQUEST);
41+
}
42+
43+
$documents = $em->getRepository(CDocument::class)->findBy(['iid' => $documentIds]);
44+
45+
if (empty($documents)) {
46+
return new Response('No documents found.', Response::HTTP_NOT_FOUND);
47+
}
48+
49+
$zipFilePath = $this->createZipFile($documents);
50+
51+
if (!$zipFilePath || !file_exists($zipFilePath)) {
52+
return new Response('ZIP file not found or could not be created.', Response::HTTP_INTERNAL_SERVER_ERROR);
53+
}
54+
55+
$fileSize = filesize($zipFilePath);
56+
if ($fileSize === false || $fileSize === 0) {
57+
error_log('ZIP file is empty or unreadable.');
58+
throw new Exception('ZIP file is empty or unreadable.');
59+
}
60+
61+
$response = new StreamedResponse(function () use ($zipFilePath) {
62+
$handle = fopen($zipFilePath, 'rb');
63+
if ($handle) {
64+
while (!feof($handle)) {
65+
echo fread($handle, 8192);
66+
ob_flush();
67+
flush();
68+
}
69+
fclose($handle);
70+
}
71+
});
72+
73+
$response->headers->set('Content-Type', 'application/zip');
74+
$response->headers->set('Content-Disposition', 'inline; filename="selected_documents.zip"');
75+
$response->headers->set('Content-Length', (string) $fileSize);
76+
77+
return $response;
78+
}
79+
80+
/**
81+
* Creates a ZIP file containing the specified documents.
82+
*
83+
* @return string The path to the created ZIP file.
84+
* @throws Exception If the ZIP file cannot be created or closed.
85+
*/
86+
private function createZipFile(array $documents): string
87+
{
88+
$cacheDir = $this->kernel->getCacheDir();
89+
$zipFilePath = $cacheDir . '/selected_documents_' . uniqid() . '.zip';
90+
91+
$zip = new ZipArchive();
92+
$result = $zip->open($zipFilePath, ZipArchive::CREATE);
93+
94+
if ($result !== true) {
95+
throw new Exception('Unable to create ZIP file');
96+
}
97+
98+
$projectDir = $this->kernel->getProjectDir();
99+
$baseUploadDir = $projectDir . '/var/upload/resource';
100+
101+
foreach ($documents as $document) {
102+
$resourceNode = $document->getResourceNode();
103+
if (!$resourceNode) {
104+
error_log('ResourceNode not found for document ID: ' . $document->getId());
105+
continue;
106+
}
107+
108+
$this->addNodeToZip($zip, $resourceNode, $baseUploadDir);
109+
}
110+
111+
if (!$zip->close()) {
112+
error_log('Failed to close ZIP file.');
113+
throw new Exception('Failed to close ZIP archive');
114+
}
115+
116+
117+
return $zipFilePath;
118+
}
119+
120+
/**
121+
* Adds a resource node and its files or children to the ZIP archive.
122+
*/
123+
private function addNodeToZip(ZipArchive $zip, ResourceNode $node, string $baseUploadDir, string $currentPath = ''): void
124+
{
125+
126+
if ($node->getChildren()->count() > 0) {
127+
$relativePath = $currentPath . $node->getTitle() . '/';
128+
$zip->addEmptyDir($relativePath);
129+
130+
foreach ($node->getChildren() as $childNode) {
131+
$this->addNodeToZip($zip, $childNode, $baseUploadDir, $relativePath);
132+
}
133+
} elseif ($node->hasResourceFile()) {
134+
foreach ($node->getResourceFiles() as $resourceFile) {
135+
$filePath = $baseUploadDir . $this->resourceNodeRepository->getFilename($resourceFile);
136+
$fileName = $currentPath . $resourceFile->getOriginalName();
137+
138+
if (file_exists($filePath)) {
139+
$zip->addFile($filePath, $fileName);
140+
} else {
141+
error_log('File not found: ' . $filePath);
142+
}
143+
}
144+
} else {
145+
error_log('Node has no children or files: ' . $node->getTitle());
146+
}
147+
}
148+
}

src/CourseBundle/Entity/CDocument.php

+24
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use ApiPlatform\Metadata\Put;
1919
use ApiPlatform\Serializer\Filter\PropertyFilter;
2020
use Chamilo\CoreBundle\Controller\Api\CreateDocumentFileAction;
21+
use Chamilo\CoreBundle\Controller\Api\DownloadSelectedDocumentsAction;
2122
use Chamilo\CoreBundle\Controller\Api\ReplaceDocumentFileAction;
2223
use Chamilo\CoreBundle\Controller\Api\UpdateDocumentFileAction;
2324
use Chamilo\CoreBundle\Controller\Api\UpdateVisibilityDocument;
@@ -136,6 +137,29 @@
136137
validationContext: ['groups' => ['Default', 'media_object_create', 'document:write']],
137138
deserialize: false
138139
),
140+
new Post(
141+
uriTemplate: '/documents/download-selected',
142+
controller: DownloadSelectedDocumentsAction::class,
143+
openapiContext: [
144+
'summary' => 'Download selected documents as a ZIP file.',
145+
'requestBody' => [
146+
'content' => [
147+
'application/json' => [
148+
'schema' => [
149+
'type' => 'object',
150+
'properties' => [
151+
'ids' => [
152+
'type' => 'array',
153+
'items' => ['type' => 'integer']
154+
],
155+
],
156+
],
157+
],
158+
],
159+
],
160+
],
161+
security: "is_granted('ROLE_USER')",
162+
),
139163
new GetCollection(
140164
openapiContext: [
141165
'parameters' => [

0 commit comments

Comments
 (0)