Skip to content
Merged
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.*",
Expand Down
73 changes: 71 additions & 2 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
134 changes: 97 additions & 37 deletions src/EventSubscriber/FileReferenceListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review these changes. When file entity removed, existing file should be removed also from disk.

];
}

#[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,
);
}
}
53 changes: 53 additions & 0 deletions src/Service/File/SoftCreatRMimeTypeGuesser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace App\Service\File;

use SoftCreatR\MimeDetector\MimeDetector;
use Symfony\Component\Mime\MimeTypeGuesserInterface;

final class SoftCreatRMimeTypeGuesser implements MimeTypeGuesserInterface
{
public function isGuesserSupported(): bool
{
return true;
}

public function isGuaranteed(): bool
{
return true;
}

public function guessMimeType(string $path): ?string
{
if (!is_file($path) || !is_readable($path)) {
return null;
}

try {
$detector = new MimeDetector($path);
$mime = $detector->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;
}
}
5 changes: 3 additions & 2 deletions templates/themes/base_theme/form_theme.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 -%}

Expand Down
15 changes: 8 additions & 7 deletions templates/themes/base_theme/partials/_datatable.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,27 @@
<input type="search" data-model="debounce(300)|query" autocomplete='off' value="{{ this.query }}" placeholder="{{'Search'|trans}}" class="grow">
</label>
{% if filterForm %}
<div class="dropdown end-0 top-0 dropdown-end">
<button class="btn btn-soft bg-base-100" tabindex="0" role="button">
{% set hasActiveFilters = this.filters|default([])|filter(value => value is not same as(null) and value is not same as(''))|length > 0 %}
<details class="dropdown end-0 top-0 dropdown-end" {% if hasActiveFilters %}open{% endif %}>
<summary class="btn btn-soft bg-base-100 list-none">
<i class="i-tabler-filter"></i>
<i class="i-tabler-chevron-down"></i>
</button>
<ul class="dropdown-content w-64 max-h-96 overflow-y-auto bg-base-100 shadow pt-4">
</summary>
<div class="dropdown-content w-72 max-h-96 overflow-y-auto bg-base-100 shadow pt-4 rounded-box z-[1]">
{{ form_start(filterForm) }}
{% for field in filterForm %}
<div class='px-4'>
{{ form_row(field) }}
</div>
{% endfor %}
<div class="sticky bottom-0">
<div class="sticky bottom-0 bg-base-100 px-4 py-3">
<button type="reset" class="btn btn-error btn-soft btn-sm w-full justify-center hover:text-base-100" data-action="live#action" data-live-action-param="resetFilters">
{{ 'Clear'|trans }}
</button>
</div>
{{ form_end(filterForm) }}
</ul>
</div>
</div>
</details>
{% endif %}
</div>
<div class="divider"></div>
Expand Down
Loading
Loading