diff --git a/assets/vue/components/basecomponents/ChamiloIcons.js b/assets/vue/components/basecomponents/ChamiloIcons.js index 3466f590755..6b23dd2061b 100644 --- a/assets/vue/components/basecomponents/ChamiloIcons.js +++ b/assets/vue/components/basecomponents/ChamiloIcons.js @@ -55,6 +55,7 @@ export const chamiloIconToClass = { "file-generic": "mdi mdi-file", "file-image": "mdi mdi-file-image", "file-pdf": "mdi mdi-file-pdf-box", + "file-swap": "mdi mdi-swap-horizontal", "file-text": "mdi mdi-file-document", "file-upload": "mdi mdi-file-upload", "file-video": "mdi mdi-file-video", diff --git a/assets/vue/views/documents/DocumentsList.vue b/assets/vue/views/documents/DocumentsList.vue index a82904a3ea5..569f2b4a9b9 100644 --- a/assets/vue/views/documents/DocumentsList.vue +++ b/assets/vue/views/documents/DocumentsList.vue @@ -182,6 +182,14 @@ type="secondary" @click="openMoveDialog(slotProps.data)" /> + + + + + { const isHtmlFile = (fileData) => isHtml(fileData) +const isReplaceDialogVisible = ref(false) +const selectedReplaceFile = ref(null) +const documentToReplace = ref(null) + onMounted(async () => { isAllowedToEdit.value = await checkIsAllowedToEdit(true, true, true) filters.value.loadNode = 1 @@ -784,6 +811,41 @@ function openMoveDialog(document) { isMoveDialogVisible.value = true } +function openReplaceDialog(document) { + documentToReplace.value = document + isReplaceDialogVisible.value = true +} + +async function replaceDocument() { + if (!selectedReplaceFile.value) { + notification.showErrorNotification(t("No file selected.")) + return + } + + if (documentToReplace.value.filetype !== 'file') { + notification.showErrorNotification(t("Only files can be replaced.")) + return + } + + const formData = new FormData() + console.log(selectedReplaceFile.value) + formData.append('file', selectedReplaceFile.value) + + try { + await axios.post(`/api/documents/${documentToReplace.value.iid}/replace`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + notification.showSuccessNotification(t("Document replaced successfully.")) + isReplaceDialogVisible.value = false + onUpdateOptions(options.value) + } catch (error) { + notification.showErrorNotification(t("Error replacing document.")) + console.error(error) + } +} + async function fetchFolders(nodeId = null, parentPath = "") { const foldersList = [ { diff --git a/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php b/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php new file mode 100644 index 00000000000..d8ef1277711 --- /dev/null +++ b/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php @@ -0,0 +1,99 @@ +uploadBasePath = $kernel->getProjectDir() . '/var/upload/resource'; + } + + public function __invoke( + CDocument $document, + Request $request, + ResourceNodeRepository $resourceNodeRepository, + EntityManagerInterface $em + ): Response { + $uploadedFile = $request->files->get('file'); + if (!$uploadedFile) { + throw new BadRequestHttpException('"file" is required.'); + } + + $resourceNode = $document->getResourceNode(); + if (!$resourceNode) { + throw new BadRequestHttpException('ResourceNode not found.'); + } + + $resourceFile = $resourceNode->getFirstResourceFile(); + if (!$resourceFile) { + throw new BadRequestHttpException('No file found in the resource node.'); + } + + $filePath = $this->uploadBasePath . $resourceNodeRepository->getFilename($resourceFile); + if (!$filePath) { + throw new BadRequestHttpException('File path could not be resolved.'); + } + + $this->prepareDirectory($filePath); + + try { + $uploadedFile->move(dirname($filePath), basename($filePath)); + } catch (FileException $e) { + throw new BadRequestHttpException(sprintf('Failed to move the file: %s', $e->getMessage())); + } + + $movedFilePath = $filePath; + if (!file_exists($movedFilePath)) { + throw new \RuntimeException('The moved file does not exist at the expected location.'); + } + $fileSize = filesize($movedFilePath); + $resourceFile->setSize($fileSize); + + $newFileName = $uploadedFile->getClientOriginalName(); + $document->setTitle($newFileName); + $resourceFile->setOriginalName($newFileName); + + $resourceNode->setUpdatedAt(new \DateTime()); + + $em->persist($document); + $em->persist($resourceFile); + $em->flush(); + + return new Response('Document replaced successfully.', Response::HTTP_OK); + } + + /** + * Prepares the directory to ensure it exists and is writable. + */ + protected function prepareDirectory(string $filePath): void + { + $directory = dirname($filePath); + + if (!is_dir($directory)) { + if (!mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new \RuntimeException(sprintf('Unable to create directory "%s".', $directory)); + } + } + + if (!is_writable($directory)) { + throw new \RuntimeException(sprintf('Directory "%s" is not writable.', $directory)); + } + } + +} diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php index 92dfc44e5d1..cae4b29c837 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\ReplaceDocumentFileAction; use Chamilo\CoreBundle\Controller\Api\UpdateDocumentFileAction; use Chamilo\CoreBundle\Controller\Api\UpdateVisibilityDocument; use Chamilo\CoreBundle\Entity\AbstractResource; @@ -60,6 +61,31 @@ security: "is_granted('EDIT', object.resourceNode)", deserialize: true ), + new Post( + uriTemplate: '/documents/{iid}/replace', + controller: ReplaceDocumentFileAction::class, + openapiContext: [ + 'summary' => 'Replace a document file, maintaining the same IDs.', + 'requestBody' => [ + 'content' => [ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'file' => [ + 'type' => 'string', + 'format' => 'binary', + ], + ], + ], + ], + ], + ], + ], + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + validationContext: ['groups' => ['Default', 'media_object_create', 'document:write']], + deserialize: false + ), new Get(security: "is_granted('VIEW', object.resourceNode)"), new Delete(security: "is_granted('DELETE', object.resourceNode)"), new Post(