Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Documents: Add batch download with ZIP support - refs #3197 #6035

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions assets/vue/views/documents/DocumentsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -455,6 +462,7 @@ const { relativeDatetime } = useFormatDate()
const isAllowedToEdit = ref(false)
const folders = ref([])
const selectedFolder = ref(null)
const isDownloading = ref(false)

const {
showNewDocumentButton,
Expand Down Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions src/CoreBundle/Controller/Api/DownloadSelectedDocumentsAction.php
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
24 changes: 24 additions & 0 deletions src/CourseBundle/Entity/CDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' => [
Expand Down
Loading