diff --git a/.env.test b/.env.test index 93ae08c8..661ab23d 100644 --- a/.env.test +++ b/.env.test @@ -35,6 +35,7 @@ MESSENGER_DISTRIBUTION_TOPIC=anzu_core_dam_distribution MESSENGER_DISTRIBUTION_REMOTE_PROCESSED_CHECK_TOPIC=anzu_core_dam_distribution_remote_check MESSENGER_NOTIFICATION_TOPIC=notification_server_internal MESSENGER_PROPERTY_REFRESH_TOPIC=anzu_core_dam_property_refresh +MESSENGER_ASSET_COPY_TOPIC=anzu_core_dam_asset_copy YOUTUBE_API_KEY='' diff --git a/psalm.xml b/psalm.xml index 4f2973e2..3efbcf6c 100644 --- a/psalm.xml +++ b/psalm.xml @@ -36,6 +36,7 @@ + diff --git a/src/Controller/Api/Adm/V1/ImageController.php b/src/Controller/Api/Adm/V1/ImageController.php index 9338f97d..292fe800 100644 --- a/src/Controller/Api/Adm/V1/ImageController.php +++ b/src/Controller/Api/Adm/V1/ImageController.php @@ -15,6 +15,7 @@ use AnzuSystems\CoreDamBundle\Domain\AssetFile\AssetFileDownloadFacade; use AnzuSystems\CoreDamBundle\Domain\AssetFileRoute\AssetFileRouteFacade; use AnzuSystems\CoreDamBundle\Domain\Chunk\ChunkFacade; +use AnzuSystems\CoreDamBundle\Domain\Image\ImageCopyFacade; use AnzuSystems\CoreDamBundle\Domain\Image\ImageFacade; use AnzuSystems\CoreDamBundle\Domain\Image\ImagePositionFacade; use AnzuSystems\CoreDamBundle\Domain\Image\ImageStatusFacade; @@ -25,20 +26,26 @@ use AnzuSystems\CoreDamBundle\Exception\AssetSlotUsedException; use AnzuSystems\CoreDamBundle\Exception\ForbiddenOperationException; use AnzuSystems\CoreDamBundle\Exception\InvalidExtSystemConfigurationException; +use AnzuSystems\CoreDamBundle\Model\Attributes\SerializeIterableParam; use AnzuSystems\CoreDamBundle\Model\Dto\Asset\AssetAdmFinishDto; use AnzuSystems\CoreDamBundle\Model\Dto\AssetExternalProvider\UploadAssetFromExternalProviderDto; use AnzuSystems\CoreDamBundle\Model\Dto\AssetFileRoute\AssetFileRouteAdmDetailDecorator; use AnzuSystems\CoreDamBundle\Model\Dto\Audio\AudioFileAdmDetailDto; use AnzuSystems\CoreDamBundle\Model\Dto\Chunk\ChunkAdmCreateDto; +use AnzuSystems\CoreDamBundle\Model\Dto\Image\AssetFileCopyResultDto; use AnzuSystems\CoreDamBundle\Model\Dto\Image\ImageAdmCreateDto; +use AnzuSystems\CoreDamBundle\Model\Dto\Image\ImageCopyDto; use AnzuSystems\CoreDamBundle\Model\Dto\Image\ImageFileAdmDetailDto; +use AnzuSystems\CoreDamBundle\Model\OpenApi\Request\OARequest as OADamRequest; use AnzuSystems\CoreDamBundle\Security\Permission\DamPermissions; use AnzuSystems\SerializerBundle\Attributes\SerializeParam; use AnzuSystems\SerializerBundle\Exception\SerializerException; +use Doctrine\Common\Collections\Collection; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; +use Throwable; #[Route(path: '/image', name: 'adm_image_v1_')] #[OA\Tag('Image')] @@ -51,6 +58,7 @@ public function __construct( private readonly AssetFileDownloadFacade $assetFileDownloadFacade, private readonly ImagePositionFacade $imagePositionFacade, private readonly AssetFileRouteFacade $routeFacade, + private readonly ImageCopyFacade $imageCopyFacade, ) { } @@ -273,6 +281,27 @@ public function generateDownloadUrl(ImageFile $image): JsonResponse ); } + /** + * @param Collection $copyList + * @throws Throwable + * + * @throws ForbiddenOperationException + */ + #[Route( + path: '/copy-to-licence', + name: 'copy_image', + methods: [Request::METHOD_PATCH] + )] + #[OADamRequest([ImageCopyDto::class]), OAResponse([AssetFileCopyResultDto::class]), OAResponseValidation] + public function copyToLicence(#[SerializeIterableParam(type: ImageCopyDto::class)] Collection $copyList): JsonResponse + { + $this->denyAccessUnlessGranted(DamPermissions::DAM_IMAGE_CREATE); + + return $this->okResponse( + $this->imageCopyFacade->prepareCopyList($copyList) + ); + } + /** * @throws ForbiddenOperationException */ diff --git a/src/Controller/ImageController.php b/src/Controller/ImageController.php index 25d35149..f564cae8 100644 --- a/src/Controller/ImageController.php +++ b/src/Controller/ImageController.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Requirement\Requirement; #[Route(path: '/image', name: 'image_')] final class ImageController extends AbstractImageController @@ -77,7 +78,7 @@ public function animation( path: '/{requestWidth}{requestHeight}{regionOfInterestId}{quality}/{imageId}.jpg', name: 'get_one_file_name', requirements: [ - 'imageId' => '[0-9a-zA-Z-]+', + 'imageId' => Requirement::UUID, 'requestWidth' => 'w\d+', 'requestHeight' => '-h\d+', 'regionOfInterestId' => '(-c\d+)|', diff --git a/src/DataFixtures/AssetLicenceFixtures.php b/src/DataFixtures/AssetLicenceFixtures.php index 12fa6b8a..9b90a35a 100644 --- a/src/DataFixtures/AssetLicenceFixtures.php +++ b/src/DataFixtures/AssetLicenceFixtures.php @@ -46,20 +46,19 @@ public function load(ProgressBar $progressBar): void private function getData(): Generator { - $existingLicence = $this->assetLicenceRepository->find(self::DEFAULT_LICENCE_ID); - if ($existingLicence) { - return; - } - /** @var ExtSystem $cmsExtSystem */ $cmsExtSystem = $this->entityManager->find( ExtSystem::class, 1 ); - yield (new AssetLicence()) - ->setId(self::DEFAULT_LICENCE_ID) - ->setExtId('1') - ->setExtSystem($cmsExtSystem); + $existingLicence = $this->assetLicenceRepository->find(self::DEFAULT_LICENCE_ID); + if (null === $existingLicence) { + yield (new AssetLicence()) + ->setId(self::DEFAULT_LICENCE_ID) + ->setExtId('1') + ->setExtSystem($cmsExtSystem); + } + } } diff --git a/src/DependencyInjection/AnzuSystemsCoreDamExtension.php b/src/DependencyInjection/AnzuSystemsCoreDamExtension.php index 192db1a9..0901cd37 100644 --- a/src/DependencyInjection/AnzuSystemsCoreDamExtension.php +++ b/src/DependencyInjection/AnzuSystemsCoreDamExtension.php @@ -20,6 +20,7 @@ use AnzuSystems\CoreDamBundle\Messenger\Message\AssetFileMetadataProcessMessage; use AnzuSystems\CoreDamBundle\Messenger\Message\AssetRefreshPropertiesMessage; use AnzuSystems\CoreDamBundle\Messenger\Message\AudioFileChangeStateMessage; +use AnzuSystems\CoreDamBundle\Messenger\Message\CopyAssetFileMessage; use AnzuSystems\CoreDamBundle\Messenger\Message\DistributeMessage; use AnzuSystems\CoreDamBundle\Messenger\Message\DistributionRemoteProcessingCheckMessage; use AnzuSystems\CoreDamBundle\Messenger\Message\DocumentFileChangeStateMessage; @@ -108,6 +109,8 @@ public function prepend(ContainerBuilder $container): void $distributionRemoteProcessedCheckTopicDsn = "%env(MESSENGER_TRANSPORT_DSN)%/{$distributionRemoteProcessedCheckTopic}"; $assetPropertyRefreshTopic = '%env(MESSENGER_PROPERTY_REFRESH_TOPIC)%'; $assetPropertyRefreshTopicDsn = "%env(MESSENGER_TRANSPORT_DSN)%/{$assetPropertyRefreshTopic}"; + $assetCopyTopic = '%env(MESSENGER_ASSET_COPY_TOPIC)%'; + $assetCopyTopicDsn = "%env(MESSENGER_TRANSPORT_DSN)%/{$assetCopyTopic}"; $container->prependExtensionConfig('framework', [ 'messenger' => [ @@ -362,6 +365,30 @@ public function prepend(ContainerBuilder $container): void ], ], ], + $assetCopyTopic => [ + 'dsn' => $assetCopyTopicDsn, + 'options' => [ + 'topic' => [ + 'name' => $assetCopyTopic, + 'options' => [ + 'labels' => [ + 'application' => $applicationName, + 'name' => $assetCopyTopic, + 'topic' => $assetCopyTopic, + ], + ], + ], + 'subscription' => [ + 'name' => $assetCopyTopic, + 'options' => [ + 'labels' => [ + 'application' => $applicationName, + 'name' => $assetCopyTopic, + ], + ], + ], + ], + ], ], 'routing' => [ VideoFileChangeStateMessage::class => $videoFileChangeStateTopic, @@ -374,6 +401,7 @@ public function prepend(ContainerBuilder $container): void DistributionRemoteProcessingCheckMessage::class => $distributionRemoteProcessedCheckTopic, JwVideoThumbnailPosterMessage::class => $distributionRemoteProcessedCheckTopic, AssetRefreshPropertiesMessage::class => $assetPropertyRefreshTopic, + CopyAssetFileMessage::class => $assetCopyTopic, ], ], ]); diff --git a/src/Domain/Asset/AssetCopyBuilder.php b/src/Domain/Asset/AssetCopyBuilder.php new file mode 100644 index 00000000..8597d001 --- /dev/null +++ b/src/Domain/Asset/AssetCopyBuilder.php @@ -0,0 +1,68 @@ +__copy(); + $assetCopy->getAttributes()->setStatus(AssetStatus::Draft); + $this->trackCreation($assetCopy); + $assetCopy->setLicence($assetLicence); + $assetCopy->setExtSystem($assetLicence->getExtSystem()); + $this->entityManager->persist($assetCopy); + $this->assetMetadataManager->create($assetCopy->getMetadata(), false); + $this->copySlots($asset, $assetCopy); + + foreach ($assetCopy->getSlots() as $assetSlot) { + if ($assetSlot->getFlags()->isMain()) { + $assetCopy->setMainFile($assetSlot->getAssetFile()); + + break; + } + } + + $this->flush($flush); + + return $assetCopy; + } + + private function copySlots(Asset $asset, Asset $assetCopy): void + { + foreach ($asset->getSlots() as $assetSlot) { + $slotCopy = $this->copySlotToAsset($assetSlot, $assetCopy); + $assetCopy->addSlot($slotCopy); + $slotCopy->setAsset($assetCopy); + } + } + + private function copySlotToAsset(AssetSlot $assetSlot, Asset $assetCopy): AssetSlot + { + $slotCopy = $assetSlot->__copy(); + $blankAssetFile = $this->assetFileFactory->createForAsset($assetCopy); + $blankAssetFile->setAsset($assetCopy); + $blankAssetFile->addSlot($slotCopy); + $this->assetSlotManager->create($slotCopy, false); + + return $slotCopy; + } +} diff --git a/src/Domain/AssetFile/AbstractAssetFileFactory.php b/src/Domain/AssetFile/AbstractAssetFileFactory.php index 0c860000..80271eac 100644 --- a/src/Domain/AssetFile/AbstractAssetFileFactory.php +++ b/src/Domain/AssetFile/AbstractAssetFileFactory.php @@ -12,6 +12,7 @@ use AnzuSystems\CoreDamBundle\Domain\AssetFileMetadata\AssetFileMetadataManager; use AnzuSystems\CoreDamBundle\Domain\Configuration\ExtSystemConfigurationProvider; use AnzuSystems\CoreDamBundle\Domain\Image\ImageStatusFacade; +use AnzuSystems\CoreDamBundle\Entity\Asset; use AnzuSystems\CoreDamBundle\Entity\AssetFile; use AnzuSystems\CoreDamBundle\Entity\AssetFileMetadata; use AnzuSystems\CoreDamBundle\Entity\AssetLicence; @@ -104,6 +105,19 @@ public function createFromFile(AdapterFile $file, AssetLicence $assetLicence, ?s return $assetFile; } + public function createForAsset(Asset $asset): AssetFile + { + /** @var T $assetFile */ + $assetFile = match ($asset->getAssetType()) { + AssetType::Image => $this->createBlankImage($asset->getLicence()), + AssetType::Video => $this->createBlankVideo($asset->getLicence()), + AssetType::Audio => $this->createBlankAudio($asset->getLicence()), + AssetType::Document => $this->createBlankDocument($asset->getLicence()), + }; + + return $this->assetFileManager->create($assetFile, false); + } + /** * @return T */ diff --git a/src/Domain/AssetFile/AssetFileCopyBuilder.php b/src/Domain/AssetFile/AssetFileCopyBuilder.php new file mode 100644 index 00000000..f7e5064c --- /dev/null +++ b/src/Domain/AssetFile/AssetFileCopyBuilder.php @@ -0,0 +1,43 @@ +setAssetAttributes(clone $assetFile->getAssetAttributes()); + $this->assetFileStorageOperator->copyToAssetFile($assetFile, $targetAssetFile); + if ($assetFile instanceof ImageFile && $targetAssetFile instanceof ImageFile) { + $this->imageFileCopyBuilder->copy($assetFile, $targetAssetFile); + + return; + } + + throw new RuntimeException( + sprintf( + 'Unsupported copy AssetFile combination. Copy from (%s) to (%s)', + $assetFile::class, + $targetAssetFile::class + ) + ); + } +} diff --git a/src/Domain/AssetFile/AssetFileStatusManager.php b/src/Domain/AssetFile/AssetFileStatusManager.php index d93c7fc0..cb4d5de0 100644 --- a/src/Domain/AssetFile/AssetFileStatusManager.php +++ b/src/Domain/AssetFile/AssetFileStatusManager.php @@ -78,7 +78,7 @@ public function toProcessed(AssetFile $assetFile): AssetFile /** * @throws SerializerException */ - public function toFailed(AssetFile $assetFile, AssetFileFailedType $failedType, Throwable $throwable): AssetFile + public function toFailed(AssetFile $assetFile, AssetFileFailedType $failedType, ?Throwable $throwable = null): AssetFile { $this->changeTransition($assetFile, AssetFileProcessStatus::Failed, $failedType); @@ -88,7 +88,7 @@ public function toFailed(AssetFile $assetFile, AssetFileFailedType $failedType, 'Asset file (%s) process failed reason (%s). (%s', (string) $assetFile->getId(), $failedType->toString(), - $throwable->getMessage() + $throwable?->getMessage() ), exception: $throwable ); diff --git a/src/Domain/AssetFile/FileProcessor/AssetFileStorageOperator.php b/src/Domain/AssetFile/FileProcessor/AssetFileStorageOperator.php index e9d47005..83b32e54 100644 --- a/src/Domain/AssetFile/FileProcessor/AssetFileStorageOperator.php +++ b/src/Domain/AssetFile/FileProcessor/AssetFileStorageOperator.php @@ -42,4 +42,26 @@ public function save(AssetFile $assetFile, AdapterFile $file): AssetFile return $assetFile; } + + /** + * @throws FilesystemException + */ + public function copyToAssetFile(AssetFile $sourceAssetFile, AssetFile $targetAssetFile): void + { + $path = $this->nameGenerator->generatePath( + $this->fileHelper->guessExtension($sourceAssetFile->getAssetAttributes()->getMimeType()), + true + ); + + $sourceFileSystem = $this->fileSystemProvider->getFilesystemByStorable($sourceAssetFile); + $targetFileSystem = $this->fileSystemProvider->getFilesystemByStorable($targetAssetFile); + + $targetFileSystem->writeStream( + $path->getRelativePath(), + $sourceFileSystem->readStream($sourceAssetFile->getFilePath()) + ); + + $targetAssetFile->getAssetAttributes() + ->setFilePath($path->getRelativePath()); + } } diff --git a/src/Domain/Image/ImageCopyFacade.php b/src/Domain/Image/ImageCopyFacade.php new file mode 100644 index 00000000..f58c4cf4 --- /dev/null +++ b/src/Domain/Image/ImageCopyFacade.php @@ -0,0 +1,204 @@ + $collection + * @return Collection + * + * @throws Throwable + */ + public function prepareCopyList(Collection $collection): Collection + { + $this->validateMaxBulkCount($collection); + $this->validator->validate($collection); + + /** @var AssetFileCopyResultDto[] $res */ + $res = []; + + try { + $this->entityManager->beginTransaction(); + + foreach ($collection as $imageCopyDto) { + $this->accessDenier->denyUnlessGranted(DamPermissions::DAM_ASSET_READ, $imageCopyDto->getAsset()->getLicence()); + $this->accessDenier->denyUnlessGranted(DamPermissions::DAM_ASSET_CREATE, $imageCopyDto->getTargetAssetLicence()); + + $resDto = $this->prepareCopy($imageCopyDto); + $res[] = $resDto; + } + + $this->entityManager->commit(); + } catch (Throwable $exception) { + if ($this->entityManager->getConnection()->isTransactionActive()) { + $this->entityManager->rollback(); + } + + throw $exception; + } + + foreach ($res as $imageCopyResultDto) { + if ($imageCopyResultDto->getResult()->is(AssetFileCopyResult::Copying) && $imageCopyResultDto->getTargetAsset()) { + $this->messageBus->dispatch(new CopyAssetFileMessage( + $imageCopyResultDto->getAsset(), + $imageCopyResultDto->getTargetAsset() + )); + } + } + + return new ArrayCollection($res); + } + + /** + * @throws Throwable + */ + public function copyAssetFiles(Asset $asset, Asset $copyAsset): void + { + try { + $this->entityManager->beginTransaction(); + $this->copyAssetSlots($asset, $copyAsset); + $this->assetManager->updateExisting(asset: $copyAsset, trackModification: false); + $this->indexManager->index($copyAsset); + $this->entityManager->commit(); + } catch (Throwable $exception) { + if ($this->entityManager->getConnection()->isTransactionActive()) { + $this->entityManager->rollback(); + } + + throw $exception; + } + + foreach ($copyAsset->getSlots() as $slot) { + $this->assetFileEventDispatcher->dispatchAssetFileChanged($slot->getAssetFile()); + } + } + + private function prepareCopy(ImageCopyDto $copyDto): AssetFileCopyResultDto + { + /** @var array $foundAssets */ + $foundAssets = []; + foreach ($copyDto->getAsset()->getSlots() as $slot) { + $foundAssetFile = $this->imageFileRepository->findProcessedByChecksumAndLicence( + checksum: $slot->getAssetFile()->getAssetAttributes()->getChecksum(), + licence: $copyDto->getTargetAssetLicence() + ); + + if (null === $foundAssetFile) { + continue; + } + + $foundAssets[(string) $foundAssetFile->getAsset()->getId()] = $foundAssetFile->getAsset(); + } + + $firstFoundAsset = $foundAssets[(string) array_key_first($foundAssets)] ?? null; + + if (null === $firstFoundAsset) { + $assetCopy = $this->assetCopyBuilder->buildDraftAssetCopy($copyDto->getAsset(), $copyDto->getTargetAssetLicence()); + + return AssetFileCopyResultDto::create( + asset: $copyDto->getAsset(), + targetAssetLicence: $copyDto->getTargetAssetLicence(), + result: AssetFileCopyResult::Copying, + targetMainFile: $assetCopy->getMainFile(), + targetAsset: $assetCopy, + ); + } + + if (count($foundAssets) > 1 || false === $firstFoundAsset->hasSameFilesIdentityString($copyDto->getAsset())) { + return AssetFileCopyResultDto::create( + asset: $copyDto->getAsset(), + targetAssetLicence: $copyDto->getTargetAssetLicence(), + result: AssetFileCopyResult::NotAllowed, + assetConflicts: array_values($foundAssets) + ); + } + + return AssetFileCopyResultDto::create( + asset: $copyDto->getAsset(), + targetAssetLicence: $copyDto->getTargetAssetLicence(), + result: AssetFileCopyResult::Exists, + targetMainFile: $firstFoundAsset->getMainFile(), + targetAsset: $firstFoundAsset, + ); + } + + private function copyAssetSlots(Asset $asset, Asset $copyAsset): void + { + foreach ($copyAsset->getSlots() as $targetSlot) { + $assetSlot = $asset->getSlots()->findFirst( + fn (int $index, AssetSlot $assetSlot) => $assetSlot->getName() === $targetSlot->getName() + ); + + if ($assetSlot instanceof AssetSlot) { + $this->assetFileCopyBuilder->copy($assetSlot->getAssetFile(), $targetSlot->getAssetFile()); + + continue; + } + + $this->assetFileStatusManager->toFailed( + $targetSlot->getAssetFile(), + AssetFileFailedType::Unknown + ); + } + } + + /** + * @param Collection $dtoList + */ + private function validateMaxBulkCount(Collection $dtoList): void + { + if ($dtoList->count() > self::BULK_COPY_SIZE) { + throw new ForbiddenOperationException(ForbiddenOperationException::DETAIL_BULK_SIZE_EXCEEDED); + } + } +} diff --git a/src/Domain/Image/ImageFileCopyBuilder.php b/src/Domain/Image/ImageFileCopyBuilder.php new file mode 100644 index 00000000..cb107a56 --- /dev/null +++ b/src/Domain/Image/ImageFileCopyBuilder.php @@ -0,0 +1,46 @@ +setImageAttributes(clone $imageFile->getImageAttributes()); + $this->copyRegionsOfInterest($imageFile, $targetImageFile); + $this->copyResizes($imageFile, $targetImageFile); + } + + private function copyResizes(ImageFile $imageFile, ImageFile $targetImageFile): void + { + foreach ($imageFile->getResizes() as $resize) { + $this->imageFileOptimalResizeCopyBuilder->copyResizeToImage($resize, $targetImageFile); + } + } + + private function copyRegionsOfInterest(ImageFile $imageFile, ImageFile $targetImageFile): void + { + foreach ($imageFile->getRegionsOfInterest() as $regionOfInterest) { + $regionOfInterestCopy = $regionOfInterest->__copy(); + $regionOfInterestCopy->setImage($targetImageFile); + $targetImageFile->getRegionsOfInterest()->add($regionOfInterestCopy); + $this->regionOfInterestManager->create($regionOfInterestCopy, false); + } + } +} diff --git a/src/Domain/ImageFileOptimalResize/ImageFileOptimalResizeCopyBuilder.php b/src/Domain/ImageFileOptimalResize/ImageFileOptimalResizeCopyBuilder.php new file mode 100644 index 00000000..1e661b96 --- /dev/null +++ b/src/Domain/ImageFileOptimalResize/ImageFileOptimalResizeCopyBuilder.php @@ -0,0 +1,41 @@ +optimalResizeFactory->createOptimalCropPath( + $targetImageFile, + $resize->getRequestedSize(), + $targetImageFile->getImageAttributes()->getRotation() + ); + + $resizeCopy = $resize->__copy(); + $resizeCopy->setFilePath($targetPath); + $resizeCopy->setImage($targetImageFile); + $targetImageFile->getResizes()->add($resizeCopy); + + $this->fileSystemProvider->getFilesystemByStorable($resizeCopy)->writeStream( + location: $targetPath, + contents: $this->fileSystemProvider->getFilesystemByStorable($resize)->readStream($resize->getFilePath()) + ); + + $this->optimalResizeManager->create($resizeCopy, false); + } +} diff --git a/src/Elasticsearch/QueryFactory/AssetQueryFactory.php b/src/Elasticsearch/QueryFactory/AssetQueryFactory.php index 5f6a2f69..1ffaa9ce 100644 --- a/src/Elasticsearch/QueryFactory/AssetQueryFactory.php +++ b/src/Elasticsearch/QueryFactory/AssetQueryFactory.php @@ -12,7 +12,6 @@ use AnzuSystems\CoreDamBundle\Entity\AssetLicence; use AnzuSystems\CoreDamBundle\Entity\CustomFormElement; use AnzuSystems\CoreDamBundle\Entity\ExtSystem; -use AnzuSystems\CoreDamBundle\Helper\UuidHelper; final class AssetQueryFactory extends AbstractQueryFactory { @@ -41,7 +40,7 @@ protected function getMust(SearchDtoInterface $searchDto, ExtSystem $extSystem): $customDataFields = array_unique($customDataFields); $customDataFields = array_merge($customDataFields, ['title']); - if (UuidHelper::isUuid($searchDto->getText())) { + if (is_string($searchDto->getIdInText())) { return parent::getMust($searchDto, $extSystem); } @@ -70,8 +69,8 @@ protected function getFilter(SearchDtoInterface $searchDto): array $this->applyLicenceCollectionFilter($filter, $searchDto); } - if (UuidHelper::isUuid($searchDto->getText())) { - $filter[] = $this->getAssetIdAndMainFileIdFilter([$searchDto->getText()]); + if (is_string($searchDto->getIdInText())) { + $filter[] = $this->getAssetIdAndMainFileIdFilter([$searchDto->getIdInText()]); // other filters should not be applied return $filter; diff --git a/src/Elasticsearch/QueryFactory/AuthorQueryFactory.php b/src/Elasticsearch/QueryFactory/AuthorQueryFactory.php index cbb906f1..ddc4a3cd 100644 --- a/src/Elasticsearch/QueryFactory/AuthorQueryFactory.php +++ b/src/Elasticsearch/QueryFactory/AuthorQueryFactory.php @@ -40,7 +40,6 @@ protected function getMust(SearchDtoInterface $searchDto, ExtSystem $extSystem): /** * @param AuthorAdmSearchDto $searchDto - * @psalm-suppress PossiblyNullReference */ protected function getFilter(SearchDtoInterface $searchDto): array diff --git a/src/Elasticsearch/QueryFactory/DistributionQueryFactory.php b/src/Elasticsearch/QueryFactory/DistributionQueryFactory.php index d806c427..beffd280 100644 --- a/src/Elasticsearch/QueryFactory/DistributionQueryFactory.php +++ b/src/Elasticsearch/QueryFactory/DistributionQueryFactory.php @@ -18,7 +18,6 @@ public function getSupportedSearchDtoClasses(): array /** * @param DistributionAdmSearchDto $searchDto - * @psalm-suppress PossiblyNullReference */ protected function getFilter(SearchDtoInterface $searchDto): array diff --git a/src/Elasticsearch/QueryFactory/KeywordQueryFactory.php b/src/Elasticsearch/QueryFactory/KeywordQueryFactory.php index 0f7a04a1..2c314e5b 100644 --- a/src/Elasticsearch/QueryFactory/KeywordQueryFactory.php +++ b/src/Elasticsearch/QueryFactory/KeywordQueryFactory.php @@ -40,7 +40,6 @@ protected function getMust(SearchDtoInterface $searchDto, ExtSystem $extSystem): /** * @param KeywordAdmSearchDto $searchDto - * @psalm-suppress PossiblyNullReference */ protected function getFilter(SearchDtoInterface $searchDto): array diff --git a/src/Elasticsearch/SearchDto/AssetAdmSearchDto.php b/src/Elasticsearch/SearchDto/AssetAdmSearchDto.php index 7cbf2e98..9567631a 100644 --- a/src/Elasticsearch/SearchDto/AssetAdmSearchDto.php +++ b/src/Elasticsearch/SearchDto/AssetAdmSearchDto.php @@ -7,6 +7,8 @@ use AnzuSystems\CommonBundle\Exception\ValidationException; use AnzuSystems\CoreDamBundle\Entity\Asset; use AnzuSystems\CoreDamBundle\Entity\AssetLicence; +use AnzuSystems\CoreDamBundle\Helper\UrlHelper; +use AnzuSystems\CoreDamBundle\Helper\UuidHelper; use AnzuSystems\CoreDamBundle\Model\Enum\AssetStatus; use AnzuSystems\CoreDamBundle\Model\Enum\AssetType; use AnzuSystems\CoreDamBundle\Model\Enum\ImageOrientation; @@ -179,6 +181,8 @@ class AssetAdmSearchDto extends AbstractSearchDto #[Serialize] private ?DateTimeImmutable $createdAtUntil = null; + private ?string $idInText = null; + private bool $resolvedIdInText = false; public function getIndexName(): string { @@ -698,4 +702,23 @@ public function setAssetAndMainFileIds(array $assetAndMainFileIds): self return $this; } + + public function getIdInText(): ?string + { + if (false === $this->resolvedIdInText) { + $this->idInText = $this->resolveIdFromText(); + $this->resolvedIdInText = true; + } + + return $this->idInText; + } + + private function resolveIdFromText(): ?string + { + if (UuidHelper::isUuid($this->getText())) { + return $this->getText(); + } + + return UrlHelper::getImageIdFromUrl($this->getText()); + } } diff --git a/src/Entity/Asset.php b/src/Entity/Asset.php index 0eb8f22c..f4be684e 100644 --- a/src/Entity/Asset.php +++ b/src/Entity/Asset.php @@ -4,6 +4,7 @@ namespace AnzuSystems\CoreDamBundle\Entity; +use AnzuSystems\Contracts\Entity\Interfaces\CopyableInterface; use AnzuSystems\Contracts\Entity\Interfaces\TimeTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UserTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UuidIdentifiableInterface; @@ -34,7 +35,7 @@ * @psalm-method DamUser getModifiedBy() */ #[ORM\Entity(repositoryClass: AssetRepository::class)] -#[ORM\Index(fields: ['attributes.status', 'createdAt', 'assetFlags.autoDeleteUnprocessed'], name: 'IDX_status_created_auto_delete')] +#[ORM\Index(name: 'IDX_status_created_auto_delete', fields: ['attributes.status', 'createdAt', 'assetFlags.autoDeleteUnprocessed'])] class Asset implements TimeTrackingInterface, UuidIdentifiableInterface, @@ -42,7 +43,8 @@ class Asset implements ExtSystemIndexableInterface, NotifiableInterface, AssetCustomFormProvidableInterface, - AssetLicenceInterface + AssetLicenceInterface, + CopyableInterface { use TimeTrackingTrait; use UuidIdentityTrait; @@ -60,10 +62,10 @@ class Asset implements #[ORM\ManyToMany(targetEntity: Keyword::class, fetch: App::DOCTRINE_EXTRA_LAZY, indexBy: 'id')] private Collection $keywords; - #[ORM\OneToMany(mappedBy: 'asset', targetEntity: PodcastEpisode::class, fetch: App::DOCTRINE_EXTRA_LAZY)] + #[ORM\OneToMany(targetEntity: PodcastEpisode::class, mappedBy: 'asset', fetch: App::DOCTRINE_EXTRA_LAZY)] private Collection $episodes; - #[ORM\OneToMany(mappedBy: 'asset', targetEntity: VideoShowEpisode::class, fetch: App::DOCTRINE_EXTRA_LAZY)] + #[ORM\OneToMany(targetEntity: VideoShowEpisode::class, mappedBy: 'asset', fetch: App::DOCTRINE_EXTRA_LAZY)] private Collection $videoEpisodes; #[ORM\Embedded(class: AssetTexts::class)] @@ -116,6 +118,29 @@ public function __construct() $this->setExtSystem(null); } + public function __copy(): self + { + return (new self()) + ->setAttributes(clone $this->getAttributes()) + ->setTexts(clone $this->getTexts()) + ->setAssetFlags(clone $this->getAssetFlags()) + ->setAssetFileProperties(clone $this->getAssetFileProperties()) + ->setMetadata($this->getMetadata()->__copy()) +// ->setSlots(clone $this->getSlots()) + ->setAuthors($this->getAuthors()) + ->setKeywords($this->getKeywords()) + ->setDistributionCategory($this->getDistributionCategory()) + ->setMainFile(null) + + ->setExtSystem($this->getExtSystem()) + ; + } + + public function __clone() + { + + } + public function getMainFile(): ?AssetFile { return $this->mainFile; @@ -401,4 +426,21 @@ public static function getIndexName(): string { return self::getResourceName(); } + + public function hasSameFilesIdentityString(self $asset): bool + { + return $asset->getFilesIdentityString() === $this->getFilesIdentityString(); + } + + public function getFilesIdentityString(): string + { + $identityParts = []; + foreach ($this->getSlots() as $slot) { + $identityParts[$slot->getName()] = + $slot->getName() . ':' . $slot->getAssetFile()->getAssetAttributes()->getChecksum(); + } + ksort($identityParts); + + return implode('_', $identityParts); + } } diff --git a/src/Entity/AssetFile.php b/src/Entity/AssetFile.php index 45b9ceb7..b42efd7d 100644 --- a/src/Entity/AssetFile.php +++ b/src/Entity/AssetFile.php @@ -4,6 +4,7 @@ namespace AnzuSystems\CoreDamBundle\Entity; +use AnzuSystems\Contracts\Entity\Interfaces\CopyableInterface; use AnzuSystems\Contracts\Entity\Interfaces\TimeTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UserTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UuidIdentifiableInterface; @@ -28,9 +29,9 @@ * @psalm-method DamUser getModifiedBy() */ #[ORM\Entity(repositoryClass: AssetFileRepository::class)] -#[ORM\Index(fields: ['licence', 'assetAttributes.originExternalProvider'], name: 'IDX_licence_attributes_external_provider')] -#[ORM\Index(fields: ['assetAttributes.status'], name: 'IDX_attributes_status')] -#[ORM\Index(fields: ['licence', 'assetAttributes.status', 'assetAttributes.checksum'], name: 'IDX_licence_attributes_status_checksum')] +#[ORM\Index(name: 'IDX_licence_attributes_external_provider', fields: ['licence', 'assetAttributes.originExternalProvider'])] +#[ORM\Index(name: 'IDX_attributes_status', fields: ['assetAttributes.status'])] +#[ORM\Index(name: 'IDX_licence_attributes_status_checksum', fields: ['licence', 'assetAttributes.status', 'assetAttributes.checksum'])] #[ORM\InheritanceType(value: 'JOINED')] abstract class AssetFile implements TimeTrackingInterface, @@ -39,21 +40,22 @@ abstract class AssetFile implements UserTrackingInterface, FileSystemStorableInterface, NotifiableInterface, - AssetLicenceInterface + AssetLicenceInterface, + CopyableInterface { use TimeTrackingTrait; use UuidIdentityTrait; use UserTrackingTrait; use NotifyToTrait; - #[ORM\OneToMany(mappedBy: 'assetFile', targetEntity: Chunk::class, fetch: App::DOCTRINE_EXTRA_LAZY)] + #[ORM\OneToMany(targetEntity: Chunk::class, mappedBy: 'assetFile', fetch: App::DOCTRINE_EXTRA_LAZY)] #[ORM\OrderBy(value: ['offset' => App::ORDER_ASC])] protected Collection $chunks; #[ORM\OneToOne(targetEntity: AssetFileMetadata::class)] protected AssetFileMetadata $metadata; - #[ORM\OneToMany(mappedBy: 'targetAssetFile', targetEntity: AssetFileRoute::class, fetch: App::DOCTRINE_EXTRA_LAZY)] + #[ORM\OneToMany(targetEntity: AssetFileRoute::class, mappedBy: 'targetAssetFile', fetch: App::DOCTRINE_EXTRA_LAZY)] protected Collection $routes; #[ORM\OneToOne(targetEntity: AssetFileRoute::class)] @@ -187,4 +189,20 @@ public function getExtSystem(): ExtSystem { return $this->getLicence()->getExtSystem(); } + + protected function copyBase(self $assetFile): static + { + $this->setAssetAttributes(new AssetFileAttributes()); + $this->setCreatedAt(App::getAppDate()); + $this->setModifiedAt(App::getAppDate()); + $this->setChunks(new ArrayCollection()); + $this->setFlags(new AssetFileFlags()); + $this->setRoutes(new ArrayCollection()); + $this->setMainRoute(null); + + return $assetFile + ->setAssetAttributes(clone $this->getAssetAttributes()) + ->setFlags(clone $this->getFlags()) + ; + } } diff --git a/src/Entity/AssetMetadata.php b/src/Entity/AssetMetadata.php index 54af4243..37b0b18f 100644 --- a/src/Entity/AssetMetadata.php +++ b/src/Entity/AssetMetadata.php @@ -4,6 +4,7 @@ namespace AnzuSystems\CoreDamBundle\Entity; +use AnzuSystems\Contracts\Entity\Interfaces\CopyableInterface; use AnzuSystems\Contracts\Entity\Interfaces\TimeTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UserTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UuidIdentifiableInterface; @@ -16,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: CustomFormRepository::class)] -class AssetMetadata implements TimeTrackingInterface, UuidIdentifiableInterface, UserTrackingInterface +class AssetMetadata implements TimeTrackingInterface, UuidIdentifiableInterface, UserTrackingInterface, CopyableInterface { use TimeTrackingTrait; use UuidIdentityTrait; @@ -41,6 +42,15 @@ public function __construct() $this->setAuthorSuggestions([]); } + public function __copy(): self + { + return (new self()) + ->setCustomData($this->getCustomData()) + ->setKeywordSuggestions($this->getKeywordSuggestions()) + ->setAuthorSuggestions($this->getAuthorSuggestions()) + ; + } + public function getCustomData(): array { return $this->customData; diff --git a/src/Entity/AssetSlot.php b/src/Entity/AssetSlot.php index 34ee819a..32c385ee 100644 --- a/src/Entity/AssetSlot.php +++ b/src/Entity/AssetSlot.php @@ -4,6 +4,7 @@ namespace AnzuSystems\CoreDamBundle\Entity; +use AnzuSystems\Contracts\Entity\Interfaces\CopyableInterface; use AnzuSystems\Contracts\Entity\Interfaces\TimeTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UserTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UuidIdentifiableInterface; @@ -17,10 +18,10 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: AssetSlotRepository::class)] -#[ORM\Index(fields: ['name'], name: 'IDX_name')] -#[ORM\Index(fields: ['flags.default'], name: 'IDX_default')] +#[ORM\Index(name: 'IDX_name', fields: ['name'])] +#[ORM\Index(name: 'IDX_default', fields: ['flags.default'])] #[ORM\UniqueConstraint(name: 'UNIQ_asset_file_asset_name', fields: ['asset', 'name'])] -class AssetSlot implements UuidIdentifiableInterface, TimeTrackingInterface, UserTrackingInterface +class AssetSlot implements UuidIdentifiableInterface, TimeTrackingInterface, UserTrackingInterface, CopyableInterface { use UuidIdentityTrait; use UserTrackingTrait; @@ -57,6 +58,14 @@ public function __construct() $this->setDocument(null); } + public function __copy(): self + { + return (new self()) + ->setName($this->getName()) + ->setFlags(clone $this->getFlags()) + ; + } + public function getFlags(): AssetSlotFlags { return $this->flags; diff --git a/src/Entity/AudioFile.php b/src/Entity/AudioFile.php index a67da1f1..8ab7b9c6 100644 --- a/src/Entity/AudioFile.php +++ b/src/Entity/AudioFile.php @@ -22,7 +22,7 @@ class AudioFile extends AssetFile #[ORM\JoinColumn(onDelete: 'SET NULL')] private Asset $asset; - #[ORM\OneToMany(mappedBy: 'audio', targetEntity: AssetSlot::class, fetch: App::DOCTRINE_EXTRA_LAZY)] + #[ORM\OneToMany(targetEntity: AssetSlot::class, mappedBy: 'audio', fetch: App::DOCTRINE_EXTRA_LAZY)] private Collection $slots; public function __construct() @@ -32,6 +32,15 @@ public function __construct() parent::__construct(); } + public function __copy(): self + { + $assetFile = (new self()) + ->setAttributes(clone $this->getAttributes()) + ; + + return parent::copyBase($assetFile); + } + public function getAttributes(): AudioAttributes { return $this->attributes; diff --git a/src/Entity/DocumentFile.php b/src/Entity/DocumentFile.php index 4275e06a..4e36d7d6 100644 --- a/src/Entity/DocumentFile.php +++ b/src/Entity/DocumentFile.php @@ -22,7 +22,7 @@ class DocumentFile extends AssetFile #[ORM\JoinColumn(onDelete: 'SET NULL')] private Asset $asset; - #[ORM\OneToMany(mappedBy: 'document', targetEntity: AssetSlot::class, fetch: App::DOCTRINE_EXTRA_LAZY)] + #[ORM\OneToMany(targetEntity: AssetSlot::class, mappedBy: 'document', fetch: App::DOCTRINE_EXTRA_LAZY)] private Collection $slots; public function __construct() @@ -32,6 +32,15 @@ public function __construct() parent::__construct(); } + public function __copy(): self + { + $assetFile = (new self()) + ->setAttributes(clone $this->getAttributes()) + ; + + return parent::copyBase($assetFile); + } + public function getAttributes(): DocumentAttributes { return $this->attributes; diff --git a/src/Entity/ImageFile.php b/src/Entity/ImageFile.php index 1598f1ec..77e8ab6e 100644 --- a/src/Entity/ImageFile.php +++ b/src/Entity/ImageFile.php @@ -18,18 +18,18 @@ class ImageFile extends AssetFile #[ORM\Embedded(class: ImageAttributes::class)] private ImageAttributes $imageAttributes; - #[ORM\OneToMany(mappedBy: 'image', targetEntity: ImageFileOptimalResize::class)] + #[ORM\OneToMany(targetEntity: ImageFileOptimalResize::class, mappedBy: 'image')] #[ORM\OrderBy(value: ['requestedSize' => App::ORDER_ASC])] private Collection $resizes; - #[ORM\OneToMany(mappedBy: 'image', targetEntity: RegionOfInterest::class)] + #[ORM\OneToMany(targetEntity: RegionOfInterest::class, mappedBy: 'image')] private Collection $regionsOfInterest; #[ORM\ManyToOne(targetEntity: Asset::class)] #[ORM\JoinColumn(onDelete: 'SET NULL')] private Asset $asset; - #[ORM\OneToMany(mappedBy: 'image', targetEntity: AssetSlot::class, fetch: App::DOCTRINE_EXTRA_LAZY)] + #[ORM\OneToMany(targetEntity: AssetSlot::class, mappedBy: 'image', fetch: App::DOCTRINE_EXTRA_LAZY)] private Collection $slots; public function __construct() @@ -42,6 +42,20 @@ public function __construct() parent::__construct(); } + public function __copy(): self + { + $regionsOfInterest = $this->getRegionsOfInterest()->map( + static fn (RegionOfInterest $regionOfInterest): RegionOfInterest => $regionOfInterest->__copy() + ); + + $assetFile = (new self()) + ->setImageAttributes(clone $this->getImageAttributes()) + ->setRegionsOfInterest($regionsOfInterest) + ; + + return parent::copyBase($assetFile); + } + /** * @return Collection */ diff --git a/src/Entity/ImageFileOptimalResize.php b/src/Entity/ImageFileOptimalResize.php index 81a8940f..78c7806d 100644 --- a/src/Entity/ImageFileOptimalResize.php +++ b/src/Entity/ImageFileOptimalResize.php @@ -4,6 +4,7 @@ namespace AnzuSystems\CoreDamBundle\Entity; +use AnzuSystems\Contracts\Entity\Interfaces\CopyableInterface; use AnzuSystems\Contracts\Entity\Interfaces\UuidIdentifiableInterface; use AnzuSystems\CoreDamBundle\Entity\Interfaces\FileSystemStorableInterface; use AnzuSystems\CoreDamBundle\Entity\Traits\UuidIdentityTrait; @@ -13,7 +14,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ImageFileOptimalResizeRepository::class)] -class ImageFileOptimalResize implements UuidIdentifiableInterface, FileSystemStorableInterface +class ImageFileOptimalResize implements UuidIdentifiableInterface, FileSystemStorableInterface, CopyableInterface { use UuidIdentityTrait; @@ -44,6 +45,16 @@ public function __construct() $this->setOriginal(false); } + public function __copy(): self + { + return (new self()) + ->setWidth($this->getWidth()) + ->setHeight($this->getHeight()) + ->setRequestedSize($this->getRequestedSize()) + ->setOriginal($this->isOriginal()) + ; + } + public function getRequestedSize(): int { return $this->requestedSize; diff --git a/src/Entity/RegionOfInterest.php b/src/Entity/RegionOfInterest.php index 9c077ccc..954c4754 100644 --- a/src/Entity/RegionOfInterest.php +++ b/src/Entity/RegionOfInterest.php @@ -4,6 +4,7 @@ namespace AnzuSystems\CoreDamBundle\Entity; +use AnzuSystems\Contracts\Entity\Interfaces\CopyableInterface; use AnzuSystems\Contracts\Entity\Interfaces\TimeTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UserTrackingInterface; use AnzuSystems\Contracts\Entity\Interfaces\UuidIdentifiableInterface; @@ -18,8 +19,8 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: RegionOfInterestRepository::class)] -#[ORM\Index(fields: ['position'], name: 'IDX_position')] -class RegionOfInterest implements UuidIdentifiableInterface, UserTrackingInterface, TimeTrackingInterface, PositionableInterface, AssetLicenceInterface +#[ORM\Index(name: 'IDX_position', fields: ['position'])] +class RegionOfInterest implements UuidIdentifiableInterface, UserTrackingInterface, TimeTrackingInterface, PositionableInterface, AssetLicenceInterface, CopyableInterface { use UuidIdentityTrait; use TimeTrackingTrait; @@ -55,6 +56,17 @@ public function __construct() $this->setTitle(''); } + public function __copy(): self + { + return (new self()) + ->setPointX($this->getPointX()) + ->setPointY($this->getPointY()) + ->setPercentageWidth($this->getPercentageWidth()) + ->setPercentageHeight($this->getPercentageHeight()) + ->setTitle($this->getTitle()) + ; + } + public function getPointX(): int { return $this->pointX; diff --git a/src/Entity/VideoFile.php b/src/Entity/VideoFile.php index c51221bc..2a8de27e 100644 --- a/src/Entity/VideoFile.php +++ b/src/Entity/VideoFile.php @@ -30,7 +30,7 @@ class VideoFile extends AssetFile implements ImagePreviewableInterface #[ORM\JoinColumn(onDelete: 'SET NULL')] private Asset $asset; - #[ORM\OneToMany(mappedBy: 'video', targetEntity: AssetSlot::class, fetch: App::DOCTRINE_EXTRA_LAZY)] + #[ORM\OneToMany(targetEntity: AssetSlot::class, mappedBy: 'video', fetch: App::DOCTRINE_EXTRA_LAZY)] private Collection $slots; public function __construct() @@ -41,6 +41,15 @@ public function __construct() parent::__construct(); } + public function __copy(): self + { + $assetFile = (new self()) + ->setAttributes(clone $this->getAttributes()) + ; + + return parent::copyBase($assetFile); + } + public function getImagePreview(): ?ImagePreview { return $this->imagePreview; diff --git a/src/Helper/UrlHelper.php b/src/Helper/UrlHelper.php index 6e73a45e..dad60649 100644 --- a/src/Helper/UrlHelper.php +++ b/src/Helper/UrlHelper.php @@ -5,9 +5,18 @@ namespace AnzuSystems\CoreDamBundle\Helper; use AnzuSystems\CoreDamBundle\Model\ValueObject\Url; +use Symfony\Component\Routing\Requirement\Requirement; final class UrlHelper { + public static function getImageIdFromUrl(string $url): ?string + { + $path = (string) parse_url($url, PHP_URL_PATH); + preg_match('/^\/image\/w\d+\-h\d+(-c\d+)?(-q\d+)?\/(?' . Requirement::UUID . ')\.jpg$/', $path, $matches); + + return $matches['imageId'] ?? null; + } + public static function concatPathWithDomain(string $domain, string $path): string { if (str_ends_with($domain, '/')) { diff --git a/src/Messenger/Handler/CopyAssetFileMessageHandler.php b/src/Messenger/Handler/CopyAssetFileMessageHandler.php new file mode 100644 index 00000000..c75f3532 --- /dev/null +++ b/src/Messenger/Handler/CopyAssetFileMessageHandler.php @@ -0,0 +1,53 @@ +assetRepository->find($message->getAssetId()); + if (null === $asset) { + return; + } + $copyAsset = $this->assetRepository->find($message->getCopyAssetId()); + if (null === $copyAsset) { + return; + } + + $this->imageCopyFacade->copyAssetFiles($asset, $copyAsset); + } +} diff --git a/src/Messenger/Message/CopyAssetFileMessage.php b/src/Messenger/Message/CopyAssetFileMessage.php new file mode 100644 index 00000000..a7892f95 --- /dev/null +++ b/src/Messenger/Message/CopyAssetFileMessage.php @@ -0,0 +1,31 @@ +assetId = (string) $asset->getId(); + $this->copyAssetId = (string) $copyAsset->getId(); + } + + public function getAssetId(): string + { + return $this->assetId; + } + + public function getCopyAssetId(): string + { + return $this->copyAssetId; + } +} diff --git a/src/Model/Dto/Image/AssetFileCopyResultDto.php b/src/Model/Dto/Image/AssetFileCopyResultDto.php new file mode 100644 index 00000000..992cdddb --- /dev/null +++ b/src/Model/Dto/Image/AssetFileCopyResultDto.php @@ -0,0 +1,126 @@ +setAsset($asset) + ->setTargetAssetLicence($targetAssetLicence) + ->setResult($result) + ->setTargetAsset($targetAsset) + ->setTargetMainFile($targetMainFile) + ->setAssetConflicts(new ArrayCollection($assetConflicts)) + ; + } + + public function getTargetAssetLicence(): AssetLicence + { + return $this->targetAssetLicence; + } + + public function setTargetAssetLicence(AssetLicence $targetAssetLicence): self + { + $this->targetAssetLicence = $targetAssetLicence; + + return $this; + } + + public function getAsset(): Asset + { + return $this->asset; + } + + public function setAsset(Asset $asset): self + { + $this->asset = $asset; + + return $this; + } + + #[Serialize(handler: EntityIdHandler::class)] + public function getTargetMainFile(): ?AssetFile + { + return $this->targetMainFile; + } + + public function setTargetMainFile(?AssetFile $targetMainFile): self + { + $this->targetMainFile = $targetMainFile; + + return $this; + } + + public function getResult(): AssetFileCopyResult + { + return $this->result; + } + + public function setResult(AssetFileCopyResult $result): self + { + $this->result = $result; + + return $this; + } + + public function getAssetConflicts(): Collection + { + return $this->assetConflicts; + } + + public function setAssetConflicts(Collection $assetConflicts): self + { + $this->assetConflicts = $assetConflicts; + + return $this; + } + + public function getTargetAsset(): ?Asset + { + return $this->targetAsset; + } + + public function setTargetAsset(?Asset $targetAsset): self + { + $this->targetAsset = $targetAsset; + + return $this; + } +} diff --git a/src/Model/Dto/Image/ImageCopyDto.php b/src/Model/Dto/Image/ImageCopyDto.php new file mode 100644 index 00000000..174ad5a9 --- /dev/null +++ b/src/Model/Dto/Image/ImageCopyDto.php @@ -0,0 +1,57 @@ +setAsset(new Asset()); + $this->setTargetAssetLicence(new AssetLicence()); + } + + public function getTargetAssetLicence(): AssetLicence + { + return $this->targetAssetLicence; + } + + public function setTargetAssetLicence(AssetLicence $targetAssetLicence): self + { + $this->targetAssetLicence = $targetAssetLicence; + + return $this; + } + + public function getAsset(): Asset + { + return $this->asset; + } + + public function setAsset(Asset $asset): self + { + $this->asset = $asset; + + return $this; + } +} diff --git a/src/Model/Enum/AssetFileCopyResult.php b/src/Model/Enum/AssetFileCopyResult.php new file mode 100644 index 00000000..f2a88001 --- /dev/null +++ b/src/Model/Enum/AssetFileCopyResult.php @@ -0,0 +1,17 @@ +getAsset()->getLicence()->getExtSystem()->isNot($value->getTargetAssetLicence()->getExtSystem())) { + $this->context + ->buildViolation(ValidationException::ERROR_INVALID_LICENCE) + ->atPath('targetAssetLicence') + ->addViolation(); + } + } +} diff --git a/tests/Controller/Api/Adm/V1/ImageApiControllerTest.php b/tests/Controller/Api/Adm/V1/ImageApiControllerTest.php index 2f698c40..8c83fc76 100644 --- a/tests/Controller/Api/Adm/V1/ImageApiControllerTest.php +++ b/tests/Controller/Api/Adm/V1/ImageApiControllerTest.php @@ -6,20 +6,22 @@ namespace AnzuSystems\CoreDamBundle\Tests\Controller\Api\Adm\V1; use AnzuSystems\CoreDamBundle\DataFixtures\AssetLicenceFixtures; +use AnzuSystems\CoreDamBundle\DataFixtures\AudioFixtures; +use AnzuSystems\CoreDamBundle\Entity\AssetSlot; +use AnzuSystems\CoreDamBundle\Entity\AudioFile; +use AnzuSystems\CoreDamBundle\Entity\ImageFileOptimalResize; +use AnzuSystems\CoreDamBundle\Exception\ValidationException; +use AnzuSystems\CoreDamBundle\Tests\Data\Fixtures\AssetLicenceFixtures as TestAssetLicenceFixtures; +use AnzuSystems\CoreDamBundle\Tests\Data\Fixtures\ImageFixtures as TestImageFixtures; use AnzuSystems\CoreDamBundle\DataFixtures\ImageFixtures; use AnzuSystems\CoreDamBundle\Domain\Image\ImageUrlFactory; -use AnzuSystems\CoreDamBundle\Entity\Asset; -use AnzuSystems\CoreDamBundle\Entity\AssetFile; -use AnzuSystems\CoreDamBundle\Entity\AssetSlot; use AnzuSystems\CoreDamBundle\Entity\ImageFile; use AnzuSystems\CoreDamBundle\Exception\ForbiddenOperationException; use AnzuSystems\CoreDamBundle\Model\Dto\Asset\AssetAdmDetailDto; use AnzuSystems\CoreDamBundle\Model\Dto\Image\ImageFileAdmDetailDto; -use AnzuSystems\CoreDamBundle\Model\Enum\AssetStatus; use AnzuSystems\CoreDamBundle\Tests\Controller\Api\AbstractAssetFileApiController; use AnzuSystems\CoreDamBundle\Tests\Data\Entity\User; use AnzuSystems\CoreDamBundle\Tests\Data\Model\AssetUrl; -use AnzuSystems\CoreDamBundle\Tests\Data\Model\AssetUrl\AudioUrl; use AnzuSystems\CoreDamBundle\Tests\Data\Model\AssetUrl\ImageUrl; use AnzuSystems\SerializerBundle\Exception\SerializerException; use League\Flysystem\FilesystemException; @@ -27,7 +29,7 @@ final class ImageApiControllerTest extends AbstractAssetFileApiController { - private const TEST_DATA_FILENAME = 'metadata_image.jpeg'; + private const string TEST_DATA_FILENAME = 'metadata_image.jpeg'; protected ImageUrlFactory $imageUrlFactory; @@ -242,6 +244,124 @@ public function addChunkFailedDataProvider(): array ]; } + public function testCopy(): void + { + $client = $this->getApiClient(User::ID_ADMIN); + + $imageFile = $this->entityManager->find(ImageFile::class, ImageFixtures::IMAGE_ID_1_1); + $duplicateFile = $this->entityManager->find(ImageFile::class, ImageFixtures::IMAGE_ID_3); + $conflictFile = $this->entityManager->find(ImageFile::class, ImageFixtures::IMAGE_ID_2); + + $response = $client->patch(( + new ImageUrl(AssetLicenceFixtures::DEFAULT_LICENCE_ID))->copy(), + [ + [ + 'asset' => (string) $imageFile->getAsset()->getId(), + 'targetAssetLicence' => TestAssetLicenceFixtures::FIRST_SYS_SECONDARY_LICENCE + ], + [ + 'asset' => (string) $duplicateFile->getAsset()->getId(), + 'targetAssetLicence' => TestAssetLicenceFixtures::FIRST_SYS_SECONDARY_LICENCE + ], + [ + 'asset' => (string) $conflictFile->getAsset()->getId(), + 'targetAssetLicence' => TestAssetLicenceFixtures::FIRST_SYS_SECONDARY_LICENCE + ] + ] + ); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + + $copyingItem = array_values(array_filter($content, static fn (array $item) => $item['result'] === 'copying'))[0] ?? null; + $this->assertNotNull($copyingItem); + if ($copyingItem) { + $this->assertSame($copyingItem['asset'], (string) $imageFile->getAsset()->getId()); + $this->assertNotNull($copyingItem['targetAsset']); + $this->assertNotNull($copyingItem['targetMainFile']); + $this->assertEmpty($copyingItem['assetConflicts']); + + $copiedImage = $this->entityManager->find(ImageFile::class, $copyingItem['targetMainFile']); + $this->assertNotSame($copiedImage->getAssetAttributes()->getFilePath(), $imageFile->getAssetAttributes()->getFilePath()); + $this->assertCount($imageFile->getResizes()->count(), $copiedImage->getResizes()); + $this->assertCount($imageFile->getRegionsOfInterest()->count(), $copiedImage->getRegionsOfInterest()); + foreach ($imageFile->getAsset()->getSlots() as $slot) { + /** @var AssetSlot $copiedSlot */ + $copiedSlot = $copiedImage->getSlots()->findFirst( + fn (int $id, AssetSlot $copiedSlot) => $copiedSlot->getName() === $slot->getName() + ); + $this->assertNotNull($copiedSlot); + } + + foreach ($imageFile->getResizes() as $resize) { + /** @var ImageFileOptimalResize $copiedResize */ + $copiedResize = $copiedImage->getResizes()->findFirst( + fn (int $id, ImageFileOptimalResize $copiedResize) => $copiedResize->getRequestedSize() === $resize->getRequestedSize() + ); + $this->assertNotNull($copiedResize); + $this->assertSame($resize->getRequestedSize(), $copiedResize->getRequestedSize()); + $this->assertSame($resize->getWidth(), $copiedResize->getWidth()); + $this->assertSame($resize->getHeight(), $copiedResize->getHeight()); + $this->assertSame($resize->isOriginal(), $copiedResize->isOriginal()); + $this->assertNotSame($resize->getFilePath(), $copiedResize->getFilePath()); + } + + $copyImageViewResponse = $client->get('http://image.anzusystems.localhost/image/w800-h450-c0/'.$copyingItem['targetMainFile'].'.jpg'); + $this->assertSame($copyImageViewResponse->getStatusCode(), Response::HTTP_OK); + + } + + $existsItem = array_values(array_filter($content, static fn (array $item) => $item['result'] === 'exists'))[0] ?? null; + $this->assertNotNull($existsItem); + if ($existsItem) { + $this->assertSame($existsItem['asset'], (string) $duplicateFile->getAsset()->getId()); + $this->assertNotNull($existsItem['targetAsset']); + $this->assertNotNull($existsItem['targetMainFile']); + + $duplicateFileInAnotherLicence = $this->entityManager->find(ImageFile::class, TestImageFixtures::IMAGE_ID_4); + $this->assertSame((string) $duplicateFileInAnotherLicence->getAsset()->getId(), $existsItem['targetAsset']); + $this->assertSame((string) $duplicateFileInAnotherLicence->getId(), $existsItem['targetMainFile']); + $this->assertEmpty($copyingItem['assetConflicts']); + } + + $notAllowedItem = array_values(array_filter($content, static fn (array $item) => $item['result'] === 'notAllowed'))[0] ?? null; + $this->assertNotNull($notAllowedItem); + if ($notAllowedItem) { + $this->assertSame($notAllowedItem['asset'], (string) $conflictFile->getAsset()->getId()); + $this->assertNull($notAllowedItem['targetAsset']); + $this->assertNull($notAllowedItem['targetMainFile']); + $this->assertNotEmpty($notAllowedItem['assetConflicts']); + } + } + + public function testCopyFailed(): void + { + $client = $this->getApiClient(User::ID_ADMIN); + + $imageFile = $this->entityManager->find(ImageFile::class, ImageFixtures::IMAGE_ID_1_1); + $audioFile = $this->entityManager->find(AudioFile::class, AudioFixtures::AUDIO_ID_1); + + $response = $client->patch(( + new ImageUrl(AssetLicenceFixtures::DEFAULT_LICENCE_ID))->copy(), + [ + [ + 'asset' => (string) $imageFile->getAsset()->getId(), + 'targetAssetLicence' => TestAssetLicenceFixtures::LICENCE_2_ID + ], + [ + 'asset' => (string) $audioFile->getAsset()->getId(), + 'targetAssetLicence' => TestAssetLicenceFixtures::FIRST_SYS_SECONDARY_LICENCE + ] + ] + ); + + $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertValidationErrors($content, [ + '[0].targetAssetLicence' => [ValidationException::ERROR_INVALID_LICENCE], + '[1].asset' => [ValidationException::ERROR_FIELD_INVALID], + ]); + } public function testFinishUploadFailed(): void { diff --git a/tests/config/services/services.php b/tests/config/services/services.php index 4ea9212c..58547c52 100644 --- a/tests/config/services/services.php +++ b/tests/config/services/services.php @@ -8,6 +8,7 @@ use AnzuSystems\CommonBundle\Domain\Job\JobManager; use AnzuSystems\CommonBundle\Exception\Handler\ValidationExceptionHandler; use AnzuSystems\CoreDamBundle\DataFixtures\AssetLicenceFixtures as BaseAssetLicenceFixtures; +use AnzuSystems\CoreDamBundle\Domain\AssetFile\AssetFilePositionFacade; use AnzuSystems\CoreDamBundle\Domain\AssetFile\AssetFileStatusFacadeProvider; use AnzuSystems\CoreDamBundle\Domain\AssetLicence\AssetLicenceManager; use AnzuSystems\CoreDamBundle\Domain\AssetLicenceGroup\AssetLicenceGroupManager; @@ -18,6 +19,7 @@ use AnzuSystems\CoreDamBundle\Domain\ExtSystem\ExtSystemManager; use AnzuSystems\CoreDamBundle\Domain\Image\ImageFactory; use AnzuSystems\CoreDamBundle\Domain\Image\ImageManager; +use AnzuSystems\CoreDamBundle\Domain\Image\ImagePositionFacade; use AnzuSystems\CoreDamBundle\Domain\User\UserManager; use AnzuSystems\CoreDamBundle\FileSystem\FileSystemProvider; use AnzuSystems\CoreDamBundle\Repository\AssetLicenceRepository; @@ -95,6 +97,7 @@ ->arg('$fileSystemProvider', service(FileSystemProvider::class)) ->arg('$facadeProvider', service(AssetFileStatusFacadeProvider::class)) ->arg('$assetSlotFactory', service(AssetSlotFactory::class)) + ->arg('$assetFilePositionFacade', service(ImagePositionFacade::class)) ->call('setEntityManager', [service(EntityManagerInterface::class)]) ->tag(AnzuSystemsCommonBundle::TAG_DATA_FIXTURE); diff --git a/tests/data/Fixtures/AssetLicenceFixtures.php b/tests/data/Fixtures/AssetLicenceFixtures.php index 51d4a056..c32d4825 100644 --- a/tests/data/Fixtures/AssetLicenceFixtures.php +++ b/tests/data/Fixtures/AssetLicenceFixtures.php @@ -19,6 +19,8 @@ final class AssetLicenceFixtures extends AbstractFixtures { public const int LICENCE_ID = BaseAssetLicenceFixtures::DEFAULT_LICENCE_ID + 1; public const int LICENCE_2_ID = BaseAssetLicenceFixtures::DEFAULT_LICENCE_ID + 2; + public const int FIRST_SYS_SECONDARY_LICENCE = BaseAssetLicenceFixtures::DEFAULT_LICENCE_ID + 3; + public function __construct( private readonly AssetLicenceManager $assetLicenceManager, @@ -70,5 +72,13 @@ private function getData(): Generator ->setId(self::LICENCE_2_ID) ->setExtId('5') ->setExtSystem($blogExtSystem); + + /** @var ExtSystem $cmsExtSystem */ + $cmsExtSystem = $this->entityManager->find(ExtSystem::class, 1); + + yield (new AssetLicence()) + ->setId(self::FIRST_SYS_SECONDARY_LICENCE) + ->setExtId('2') + ->setExtSystem($cmsExtSystem); } } diff --git a/tests/data/Fixtures/ImageFixtures.php b/tests/data/Fixtures/ImageFixtures.php index ab569d0b..cb2381f6 100644 --- a/tests/data/Fixtures/ImageFixtures.php +++ b/tests/data/Fixtures/ImageFixtures.php @@ -5,10 +5,12 @@ namespace AnzuSystems\CoreDamBundle\Tests\Data\Fixtures; use AnzuSystems\CoreDamBundle\DataFixtures\AbstractAssetFileFixtures; +use AnzuSystems\CoreDamBundle\Domain\AssetFile\AssetFilePositionFacade; use AnzuSystems\CoreDamBundle\Domain\AssetFile\AssetFileStatusFacadeProvider; use AnzuSystems\CoreDamBundle\Domain\AssetSlot\AssetSlotFactory; use AnzuSystems\CoreDamBundle\Domain\Image\ImageFactory; use AnzuSystems\CoreDamBundle\Domain\Image\ImageManager; +use AnzuSystems\CoreDamBundle\Domain\Image\ImagePositionFacade; use AnzuSystems\CoreDamBundle\Entity\AssetLicence; use AnzuSystems\CoreDamBundle\Entity\ImageFile; use AnzuSystems\CoreDamBundle\FileSystem\FileSystemProvider; @@ -29,6 +31,10 @@ final class ImageFixtures extends AbstractAssetFileFixtures public const string IMAGE_ID_2_1 = '8e7456dd-80cf-4d09-9ba8-b647d8895358'; public const string IMAGE_ID_3 = '8e7456dd-80cf-4d09-9ba8-b647d8895359'; + public const string IMAGE_ID_5 = '7d7456dd-80cf-4d09-9ba8-a647d8895345'; + + public const string IMAGE_ID_4 = '7d7456dd-80cf-4d09-9ba8-b647d8895345'; + public function __construct( private readonly ImageManager $imageManager, private readonly ImageFactory $imageFactory, @@ -36,6 +42,7 @@ public function __construct( private readonly FileSystemProvider $fileSystemProvider, private readonly AssetFileStatusFacadeProvider $facadeProvider, private readonly AssetSlotFactory $assetSlotFactory, + private readonly ImagePositionFacade $assetFilePositionFacade, ) { } @@ -113,5 +120,31 @@ private function getData(): Generator $this->facadeProvider->getStatusFacade($image)->storeAndProcess($image, $file); yield $image; + + /** @var AssetLicence $secondaryLicence */ + $secondaryLicence = $this->licenceRepository->find(AssetLicenceFixtures::FIRST_SYS_SECONDARY_LICENCE); + + $file = $this->getFile($fileSystem, 'text_image_192x108.jpg'); + $image = $this->imageFactory->createFromFile( + $file, + $secondaryLicence, + self::IMAGE_ID_5 + ); + $image->getAssetAttributes()->setStatus(AssetFileProcessStatus::Uploaded); + $this->facadeProvider->getStatusFacade($image)->storeAndProcess($image, $file); + $this->assetFilePositionFacade->setToSlot($image->getAsset(), $image, 'free'); + + yield $image; + + $file = $this->getFile($fileSystem, 'text_image_200x200.jpg'); + $image = $this->imageFactory->createFromFile( + $file, + $secondaryLicence, + self::IMAGE_ID_4 + ); + $image->getAssetAttributes()->setStatus(AssetFileProcessStatus::Uploaded); + $this->facadeProvider->getStatusFacade($image)->storeAndProcess($image, $file); + + yield $image; } } diff --git a/tests/data/Model/AssetUrl/ImageUrl.php b/tests/data/Model/AssetUrl/ImageUrl.php index 02faa2be..b3bdc12d 100644 --- a/tests/data/Model/AssetUrl/ImageUrl.php +++ b/tests/data/Model/AssetUrl/ImageUrl.php @@ -43,6 +43,11 @@ public function setMainFilePath(string $assetId, string $imageId): string return "/api/adm/v{$this->version}/image/{$imageId}/asset/{$assetId}/main"; } + public function copy(): string + { + return "/api/adm/v{$this->version}/image/copy-to-licence"; + } + public function getSerializeClassString(): string { return ImageFileAdmDetailDto::class;