diff --git a/composer.json b/composer.json index 142c2ac..fcff823 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^1.24", "presta/sitemap-bundle": "^4.1", + "softcreatr/php-mime-detector": "^5.1", "stof/doctrine-extensions-bundle": "^1.11", "symfony/asset": "7.3.*", "symfony/console": "7.3.*", diff --git a/composer.lock b/composer.lock index 95794ef..5b5974c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "799bb9f2efe4f71d36dff168fe5df4ed", + "content-hash": "2bf1235fcd497ae984276fa3e52940cc", "packages": [ { "name": "behat/transliterator", @@ -2731,6 +2731,75 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "softcreatr/php-mime-detector", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/SoftCreatR/php-mime-detector.git", + "reference": "240d74ec2742cae8493deb88baf996659ecc9113" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SoftCreatR/php-mime-detector/zipball/240d74ec2742cae8493deb88baf996659ecc9113", + "reference": "240d74ec2742cae8493deb88baf996659ecc9113", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=8.1.0" + }, + "require-dev": { + "phpunit/phpunit": ">=10.0", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.10" + }, + "suggest": { + "ext-zip": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "SoftCreatR\\MimeDetector\\": "src/SoftCreatR/MimeDetector" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Sascha Greuel", + "email": "hello@1-2.dev" + }, + { + "name": "Sebastian Zimmer", + "email": "morik@softcreatr.de" + } + ], + "description": "Modern, extensible MIME type detector for PHP", + "homepage": "https://1-2.dev", + "keywords": [ + "Extension Lookup", + "Magic Number", + "detector", + "file type", + "mime", + "mime type" + ], + "support": { + "email": "hello@1-2.dev", + "issues": "https://github.com/SoftCreatR/php-mime-detector/issues", + "source": "https://github.com/SoftCreatR/php-mime-detector" + }, + "funding": [ + { + "url": "https://github.com/softcreatr", + "type": "github" + } + ], + "time": "2026-02-09T11:43:48+00:00" + }, { "name": "stof/doctrine-extensions-bundle", "version": "v1.14.0", @@ -11469,5 +11538,5 @@ "ext-iconv": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/services.yaml b/config/services.yaml index 0a86182..a1470b6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -39,3 +39,7 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + + App\Service\File\SoftCreatRMimeTypeGuesser: + tags: + - { name: mime.mime_type_guesser, priority: 100 } diff --git a/src/EventSubscriber/FileReferenceListener.php b/src/EventSubscriber/FileReferenceListener.php index 9bdbe4b..e82880a 100644 --- a/src/EventSubscriber/FileReferenceListener.php +++ b/src/EventSubscriber/FileReferenceListener.php @@ -7,90 +7,150 @@ use Doctrine\Common\Collections\Collection; use Doctrine\Common\EventSubscriber; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Event\PostPersistEventArgs; -use Doctrine\ORM\Event\PostUpdateEventArgs; -use Doctrine\ORM\Event\PreRemoveEventArgs; +use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Events; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Doctrine\ORM\UnitOfWork; +use Symfony\Component\Console\ConsoleEvents; +use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\KernelEvent; use Symfony\Component\HttpKernel\KernelEvents; -class FileReferenceListener extends AbstractController implements EventSubscriber +class FileReferenceListener implements EventSubscriber { public function __construct( private EntityManagerInterface $entityManager, ) { } - public function getSubscribedEvents() + public function getSubscribedEvents(): array { return [ - Events::postPersist, - Events::postUpdate, - Events::preRemove, + Events::onFlush, ]; } #[AsEventListener(KernelEvents::REQUEST)] - public function setEventListener(KernelEvent $event) + public function setEventListener(KernelEvent $event): void { $this->entityManager->getEventManager()->addEventSubscriber($this); } - public function postPersist(PostPersistEventArgs $args) + #[AsEventListener(ConsoleEvents::COMMAND)] + public function setEventListenerForConsole(ConsoleCommandEvent $event): void { - $object = $args->getObject(); - $this->checkFileReferences($object, 'create'); + $this->entityManager->getEventManager()->addEventSubscriber($this); } - public function postUpdate(PostUpdateEventArgs $args) + public function onFlush(OnFlushEventArgs $args): void { - $object = $args->getObject(); - $this->checkFileReferences($object, 'update'); - } + /** @var EntityManagerInterface $entityManager */ + $entityManager = $args->getObjectManager(); + $unitOfWork = $entityManager->getUnitOfWork(); - public function preRemove(PreRemoveEventArgs $args) - { - $object = $args->getObject(); - $this->checkFileReferences($object, 'remove'); + foreach ($unitOfWork->getScheduledEntityInsertions() ?? [] as $object) { + $this->checkFileReferences($object, FileStatus::Permanent, $entityManager, $unitOfWork); + } + + foreach ($unitOfWork->getScheduledEntityUpdates() ?? [] as $object) { + $this->checkFileReferences($object, FileStatus::Permanent, $entityManager, $unitOfWork); + $this->checkRemovedFileReferences($object, $entityManager, $unitOfWork); + } + + foreach ($unitOfWork->getScheduledCollectionUpdates() ?? [] as $collection) { + foreach ($collection->getDeleteDiff() as $element) { + if ($element instanceof File) { + $this->updateFileStatus($element, FileStatus::Deleted, $entityManager, $unitOfWork); + } + } + } + + foreach ($unitOfWork->getScheduledCollectionDeletions() ?? [] as $collection) { + foreach ($collection as $element) { + if ($element instanceof File) { + $this->updateFileStatus($element, FileStatus::Deleted, $entityManager, $unitOfWork); + } + } + } + + foreach ($unitOfWork->getScheduledEntityDeletions() ?? [] as $object) { + $this->checkFileReferences($object, FileStatus::Deleted, $entityManager, $unitOfWork); + } } - private function checkFileReferences(object $object, string $action): void - { - $newStatus = FileStatus::Permanent; - if ($action == 'remove') { - $newStatus = FileStatus::Deleted; + private function checkRemovedFileReferences( + object $object, + EntityManagerInterface $entityManager, + UnitOfWork $unitOfWork, + ): void { + if ($object instanceof File) { + return; } + + $changeSet = $unitOfWork->getEntityChangeSet($object) ?? []; + foreach ($changeSet as $change) { + [$oldValue, $newValue] = $change; + + if (($oldValue instanceof File) && $oldValue !== $newValue) { + $this->updateFileStatus($oldValue, FileStatus::Deleted, $entityManager, $unitOfWork); + } + } + } + + private function checkFileReferences( + object $object, + FileStatus $newStatus, + EntityManagerInterface $entityManager, + UnitOfWork $unitOfWork, + ): void { if ($object instanceof File) { return; } + $reflectionClass = new \ReflectionClass($object); $properties = $reflectionClass->getProperties(); foreach ($properties as $property) { - $property->setAccessible(true); + if (!$property->isInitialized($object)) { + continue; + } + $value = $property->getValue($object); if ($value instanceof Collection) { - $value->forAll(function ($index, $object) use ($newStatus) { - if ($object instanceof File) { - $this->updateFileStatus($object, $newStatus); + $value->forAll(function ($index, $item) use ($newStatus, $entityManager, $unitOfWork) { + if ($item instanceof File) { + $this->updateFileStatus($item, $newStatus, $entityManager, $unitOfWork); return true; } return false; }); } else { - $this->updateFileStatus($value, $newStatus); + $this->updateFileStatus($value, $newStatus, $entityManager, $unitOfWork); } } } - private function updateFileStatus($object, FileStatus $newStatus) - { - if ($object instanceof File && $object->getStatus() != $newStatus?->value) { - $object->setStatus($newStatus); - $this->entityManager->persist($object); - $this->entityManager->flush(); + private function updateFileStatus( + mixed $object, + FileStatus $newStatus, + EntityManagerInterface $entityManager, + UnitOfWork $unitOfWork, + ): void { + if (!$object instanceof File) { + return; + } + + if ($object->getStatus() === $newStatus->value || $unitOfWork->isScheduledForDelete($object)) { + return; } + + $object + ->setStatus($newStatus) + ->setUpdatedAt(new \DateTime()); + + $unitOfWork->recomputeSingleEntityChangeSet( + $entityManager->getClassMetadata(File::class), + $object, + ); } } diff --git a/src/Service/File/SoftCreatRMimeTypeGuesser.php b/src/Service/File/SoftCreatRMimeTypeGuesser.php new file mode 100644 index 0000000..4822aac --- /dev/null +++ b/src/Service/File/SoftCreatRMimeTypeGuesser.php @@ -0,0 +1,53 @@ +getMimeType(); + + if (\is_array($mime)) { + $mime = $mime['mime'] ?? null; + } + + if (\is_string($mime) && '' !== $mime && 'application/octet-stream' !== $mime) { + return $mime; + } + + // Metin tabanlı CSV dosyaları için fallback kontrolü + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ('csv' === $extension) { + $finfo = new \finfo(\FILEINFO_MIME_TYPE); + $finfoMime = $finfo->file($path); + if (false !== $finfoMime && str_starts_with($finfoMime, 'text/')) { + return 'text/csv'; + } + } + } catch (\Throwable) { + // Hata durumunda ikincil tahmincilere devret + } + + return null; + } +} diff --git a/templates/themes/base_theme/form_theme.html.twig b/templates/themes/base_theme/form_theme.html.twig index eb404b3..ab1621d 100644 --- a/templates/themes/base_theme/form_theme.html.twig +++ b/templates/themes/base_theme/form_theme.html.twig @@ -31,8 +31,9 @@ {%- endblock choice_widget_expanded -%} {%- block choice_widget_collapsed -%} - {%- set attr_class = attr.class|default('select w-full') -%} - {%- set attr = attr|merge({'class': (attr_class ~ ' ' ~ attr.class|default(''))|trim}) -%} + {%- set base_class = uses_autocomplete|default(false) ? 'w-full' : 'select w-full' -%} + {%- set base_class = multiple|default(false) ? base_class ~ ' block' : base_class -%} + {%- set attr = attr|merge({'class': (base_class ~ ' ' ~ attr.class|default(''))|trim}) -%} {{- parent() -}} {%- endblock choice_widget_collapsed -%} diff --git a/templates/themes/base_theme/partials/_datatable.html.twig b/templates/themes/base_theme/partials/_datatable.html.twig index cfef963..a7db4e1 100644 --- a/templates/themes/base_theme/partials/_datatable.html.twig +++ b/templates/themes/base_theme/partials/_datatable.html.twig @@ -7,26 +7,27 @@ {% if filterForm %} - + + {% endif %}
diff --git a/templates/themes/base_theme/partials/_header.html.twig b/templates/themes/base_theme/partials/_header.html.twig index 6dd440b..d0bcfc9 100644 --- a/templates/themes/base_theme/partials/_header.html.twig +++ b/templates/themes/base_theme/partials/_header.html.twig @@ -40,61 +40,15 @@ {% trans %}Theme{% endtrans %} -
  • -
  • {% if app.user %}
  • @@ -117,6 +71,52 @@ + + diff --git a/tests/EventSubscriber/FileReferenceListenerTest.php b/tests/EventSubscriber/FileReferenceListenerTest.php new file mode 100644 index 0000000..3a7c448 --- /dev/null +++ b/tests/EventSubscriber/FileReferenceListenerTest.php @@ -0,0 +1,214 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->unitOfWork = $this->createMock(UnitOfWork::class); + $this->fileMetadata = $this->createMock(ClassMetadata::class); + + $this->entityManager + ->method('getUnitOfWork') + ->willReturn($this->unitOfWork); + + $this->entityManager + ->method('getClassMetadata') + ->with(File::class) + ->willReturn($this->fileMetadata); + + $this->listener = new FileReferenceListener($this->entityManager); + } + + public function testGetSubscribedEvents(): void + { + $this->assertSame([\Doctrine\ORM\Events::onFlush], $this->listener->getSubscribedEvents()); + } + + public function testScheduledEntityInsertionsSetsFileToPermanent(): void + { + $file = new File(); + $file->setStatus(FileStatus::Temporary); + + $user = new User(); + $user->setProfilePhoto($file); + + $this->unitOfWork + ->method('getScheduledEntityInsertions') + ->willReturn([$user]); + $this->unitOfWork + ->method('getScheduledEntityUpdates') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledCollectionUpdates') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledCollectionDeletions') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledEntityDeletions') + ->willReturn([]); + + $this->unitOfWork + ->expects($this->once()) + ->method('recomputeSingleEntityChangeSet') + ->with($this->fileMetadata, $file); + + $eventArgs = new OnFlushEventArgs($this->entityManager); + $this->listener->onFlush($eventArgs); + + $this->assertSame(FileStatus::Permanent->value, $file->getStatus()); + } + + public function testReplacingFileMarksOldFileAsDeletedAndNewAsPermanent(): void + { + $oldFile = new File(); + $oldFile->setStatus(FileStatus::Permanent); + + $newFile = new File(); + $newFile->setStatus(FileStatus::Temporary); + + $user = new User(); + $user->setProfilePhoto($newFile); + + $this->unitOfWork + ->method('getScheduledEntityInsertions') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledEntityUpdates') + ->willReturn([$user]); + $this->unitOfWork + ->method('getEntityChangeSet') + ->with($user) + ->willReturn([ + 'profile_photo' => [$oldFile, $newFile], + ]); + $this->unitOfWork + ->method('getScheduledCollectionUpdates') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledCollectionDeletions') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledEntityDeletions') + ->willReturn([]); + + // Expected to recompute change set for both oldFile (Deleted) and newFile (Permanent) + $recomputedFiles = []; + $this->unitOfWork + ->expects($this->exactly(2)) + ->method('recomputeSingleEntityChangeSet') + ->with( + $this->fileMetadata, + $this->callback(function (File $file) use (&$recomputedFiles) { + $recomputedFiles[] = $file; + return true; + }) + ); + + $eventArgs = new OnFlushEventArgs($this->entityManager); + $this->listener->onFlush($eventArgs); + + $this->assertSame(FileStatus::Permanent->value, $newFile->getStatus()); + $this->assertSame(FileStatus::Deleted->value, $oldFile->getStatus()); + $this->assertContains($oldFile, $recomputedFiles); + $this->assertContains($newFile, $recomputedFiles); + } + + public function testCollectionUpdateMarksRemovedFileAsDeleted(): void + { + $removedFile = new File(); + $removedFile->setStatus(FileStatus::Permanent); + + $collection = new class ($removedFile) { + public function __construct(private File $file) + { + } + + public function getDeleteDiff(): array + { + return [$this->file]; + } + }; + + $this->unitOfWork + ->method('getScheduledEntityInsertions') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledEntityUpdates') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledCollectionUpdates') + ->willReturn([$collection]); + $this->unitOfWork + ->method('getScheduledCollectionDeletions') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledEntityDeletions') + ->willReturn([]); + + $this->unitOfWork + ->expects($this->once()) + ->method('recomputeSingleEntityChangeSet') + ->with($this->fileMetadata, $removedFile); + + $eventArgs = new OnFlushEventArgs($this->entityManager); + $this->listener->onFlush($eventArgs); + + $this->assertSame(FileStatus::Deleted->value, $removedFile->getStatus()); + } + + public function testEntityDeletionMarksAttachedFileAsDeleted(): void + { + $file = new File(); + $file->setStatus(FileStatus::Permanent); + + $user = new User(); + $user->setProfilePhoto($file); + + $this->unitOfWork + ->method('getScheduledEntityInsertions') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledEntityUpdates') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledCollectionUpdates') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledCollectionDeletions') + ->willReturn([]); + $this->unitOfWork + ->method('getScheduledEntityDeletions') + ->willReturn([$user]); + + $this->unitOfWork + ->expects($this->once()) + ->method('recomputeSingleEntityChangeSet') + ->with($this->fileMetadata, $file); + + $eventArgs = new OnFlushEventArgs($this->entityManager); + $this->listener->onFlush($eventArgs); + + $this->assertSame(FileStatus::Deleted->value, $file->getStatus()); + } +}