src/Controller/InstanceController.php line 32

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\DriveImportBatch;
  4. use App\Entity\Instance;
  5. use App\Entity\InstanceView;
  6. use App\Entity\MediaItem;
  7. use App\Form\InstanceType;
  8. use App\Repository\InstanceRepository;
  9. use App\Repository\InstanceViewRepository;
  10. use App\Repository\MediaItemRepository;
  11. use App\Service\DriveImportService;
  12. use App\Service\PropertyListService;
  13. use App\Service\RemotePropertyService;
  14. use App\Service\SwissTransferImportService;
  15. use App\Service\SequenceImportService;
  16. use App\Service\FileStorageService;
  17. use App\Service\ImageResizeService;
  18. use Doctrine\ORM\EntityManagerInterface;
  19. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  20. use Symfony\Component\HttpFoundation\JsonResponse;
  21. use Symfony\Component\HttpFoundation\Request;
  22. use Symfony\Component\HttpFoundation\Response;
  23. use Symfony\Component\Routing\Annotation\Route;
  24. class InstanceController extends AbstractController
  25. {
  26.     /**
  27.      * @Route("/", name="instance_index")
  28.      */
  29.     public function index(InstanceRepository $instanceRepository): Response
  30.     {
  31.         $this->denyAccessUnlessGranted('ROLE_USER');
  32.         $instances $instanceRepository->findAll();
  33.         return $this->render('instance/index.html.twig', [
  34.             'instances' => $instances,
  35.         ]);
  36.     }
  37.     /**
  38.      * @Route("/instances/new", name="instance_new")
  39.      */
  40.     public function new(Request $requestEntityManagerInterface $entityManager): Response
  41.     {
  42.         $this->denyAccessUnlessGranted('ROLE_USER');
  43.         $instance = new Instance();
  44.         $form $this->createForm(InstanceType::class, $instance);
  45.         $form->handleRequest($request);
  46.         if ($form->isSubmitted() && $form->isValid()) {
  47.             $this->applyInstanceFormExtras($instance$form);
  48.             $entityManager->persist($instance);
  49.             $entityManager->flush();
  50.             $this->addFlash('success''Instance created.');
  51.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  52.         }
  53.         return $this->render('instance/form.html.twig', [
  54.             'form' => $form->createView(),
  55.             'instance' => $instance,
  56.             'is_edit' => false,
  57.         ]);
  58.     }
  59.     /**
  60.      * @Route("/instances/{id}", name="instance_show")
  61.      */
  62.     public function show(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryMediaItemRepository $mediaItemRepositoryEntityManagerInterface $entityManagerDriveImportService $driveImportServiceSequenceImportService $sequenceImportServiceSwissTransferImportService $swissTransferImportServiceFileStorageService $storageImageResizeService $imageResizeServiceRemotePropertyService $remotePropertyServicePropertyListService $propertyListService): Response
  63.     {
  64.         $this->denyAccessUnlessGranted('ROLE_USER');
  65.         $instance $instanceRepository->find($id);
  66.         if (!$instance) {
  67.             throw $this->createNotFoundException('Instance not found.');
  68.         }
  69.         $views $viewRepository->findBy(['instance' => $instance], ['sortOrder' => 'ASC''id' => 'ASC']);
  70.         if (!$views) {
  71.             $defaultView = new InstanceView();
  72.             $defaultView->setInstance($instance);
  73.             $defaultView->setName('Default view');
  74.             $defaultView->setIsDefault(true);
  75.             $entityManager->persist($defaultView);
  76.             $entityManager->flush();
  77.             $views = [$defaultView];
  78.         } else {
  79.             $defaultView null;
  80.             foreach ($views as $view) {
  81.                 if ($view->isDefault()) {
  82.                     $defaultView $view;
  83.                     break;
  84.                 }
  85.             }
  86.             if (!$defaultView) {
  87.                 $defaultView $views[0];
  88.                 $defaultView->setIsDefault(true);
  89.                 $entityManager->flush();
  90.             }
  91.         }
  92.         $form $this->createForm(InstanceType::class, $instance);
  93.         $form->handleRequest($request);
  94.         if ($form->isSubmitted() && $form->isValid()) {
  95.             $this->applyInstanceFormExtras($instance$form);
  96.             $entityManager->flush();
  97.             $this->addFlash('success''Instance updated.');
  98.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  99.         }
  100.         $driveImported null;
  101.         $sequenceImported null;
  102.         if ($request->isMethod('POST') && $request->request->has('import_type')) {
  103.             $importType = (string) $request->request->get('import_type');
  104.             $viewId = (int) $request->request->get('view_id');
  105.             $row = (int) $request->request->get('row'1);
  106.             if ($row <= 0) {
  107.                 $row 1;
  108.             }
  109.             $view null;
  110.             if ($viewId) {
  111.                 $view $viewRepository->find($viewId);
  112.                 if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  113.                     $view null;
  114.                 }
  115.             }
  116.             if (!$view) {
  117.                 $view $defaultView;
  118.             }
  119.             if ($view) {
  120.                 $this->deleteViewRowMediaAndFiles($instance$view$row$entityManager$storage);
  121.             }
  122.             if ($importType === 'drive') {
  123.                 $folderUrl trim((string) $request->request->get('drive_folder_url'));
  124.                 if ($folderUrl !== '') {
  125.                     try {
  126.                         $batch $driveImportService->enqueueFolderImport($instance$folderUrl$view$row);
  127.                         $this->addFlash('success'sprintf('Google Drive import was queued (batch #%d). Cron will import 10 images per minute.'$batch->getId()));
  128.                     } catch (\Throwable $e) {
  129.                         $this->addFlash('danger''Drive import failed: ' $e->getMessage());
  130.                     }
  131.                     return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  132.                 }
  133.             } elseif ($importType === 'sequence') {
  134.                 $pattern trim((string) $request->request->get('sequence_pattern'));
  135.                 $fromRaw $request->request->get('sequence_from');
  136.                 $toRaw $request->request->get('sequence_to');
  137.                 $hasFrom $fromRaw !== null && $fromRaw !== '';
  138.                 $hasTo $toRaw !== null && $toRaw !== '';
  139.                 if ($pattern === '' || !$hasFrom || !$hasTo) {
  140.                     $this->addFlash('danger''Please enter a pattern, from and to values for the numbered import.');
  141.                     return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  142.                 }
  143.                 $from = (int) $fromRaw;
  144.                 $to = (int) $toRaw;
  145.                 try {
  146.                     $count $sequenceImportService->importFromPattern($instance$view$pattern$from$to$row);
  147.                     $sequenceImported $count;
  148.                     $this->addFlash('success'sprintf('Imported %d images from numbered URLs into view "%s".'$count$view->getName()));
  149.                 } catch (\Throwable $e) {
  150.                     $this->addFlash('danger''Sequence import failed: ' $e->getMessage());
  151.                 }
  152.                 return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  153.             } elseif ($importType === 'local') {
  154.                 $files $request->files->get('local_files');
  155.                 if (!\is_array($files) || \count($files) === 0) {
  156.                     $this->addFlash('danger''No local images were selected for upload.');
  157.                     return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  158.                 }
  159.                 $importedLocal 0;
  160.                 try {
  161.                     foreach ($files as $uploadedFile) {
  162.                         if (!$uploadedFile || !$uploadedFile->isValid()) {
  163.                             throw new \RuntimeException('One of the uploaded files is not valid.');
  164.                         }
  165.                         $originalName $uploadedFile->getClientOriginalName() ?: ('image_' $importedLocal);
  166.                         $content = @file_get_contents($uploadedFile->getPathname());
  167.                         if ($content === false || $content === '') {
  168.                             throw new \RuntimeException(sprintf('Failed to read uploaded file "%s".'$originalName));
  169.                         }
  170.                         $relativePath $storage->saveOriginalStream($instance$view$originalName$content$row);
  171.                         $mediaItem = new MediaItem();
  172.                         $mediaItem->setInstance($instance);
  173.                         $mediaItem->setView($view);
  174.                         $mediaItem->setType('image');
  175.                         $mediaItem->setOriginalFilename($originalName);
  176.                         $mediaItem->setOriginalPath($relativePath);
  177.                         $mediaItem->setRow($row);
  178.                         $mediaItem->setSourceType('local_upload');
  179.                         $mediaItem->setSourceConfig([
  180.                             'originalName' => $uploadedFile->getClientOriginalName(),
  181.                         ]);
  182.                         $mediaItem->setStatus('processing');
  183.                         $variants $imageResizeService->generateVariants($instance$mediaItem);
  184.                         $mediaItem->setVariants($variants);
  185.                         $mediaItem->setStatus('ready');
  186.                         $entityManager->persist($mediaItem);
  187.                         $importedLocal++;
  188.                     }
  189.                     $entityManager->flush();
  190.                     $this->addFlash('success'sprintf('Uploaded %d local images into view "%s".'$importedLocal$view->getName()));
  191.                 } catch (\Throwable $e) {
  192.                     // Clean up any files written for this view/row during this upload
  193.                     $storage->deleteViewRowDirs($instance$view$row);
  194.                     $this->addFlash('danger''Local upload failed: ' $e->getMessage());
  195.                 }
  196.                 return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  197.             } elseif ($importType === 'swiss') {
  198.                 $url trim((string) $request->request->get('swiss_url'));
  199.                 if ($url === '') {
  200.                     $this->addFlash('danger''SwissTransfer URL is required.');
  201.                     return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  202.                 }
  203.                 try {
  204.                     $count $swissTransferImportService->importFromUrl($instance$view$url$row);
  205.                     $this->addFlash('success'sprintf('Imported %d images from SwissTransfer into view "%s".'$count$view->getName()));
  206.                 } catch (\Throwable $e) {
  207.                     $storage->deleteViewRowDirs($instance$view$row);
  208.                     $this->addFlash('danger''SwissTransfer import failed: ' $e->getMessage());
  209.                 }
  210.                 return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  211.             }
  212.         }
  213.         $driveBatches $entityManager->getRepository(DriveImportBatch::class)->findBy(
  214.             ['instance' => $instance],
  215.             ['updatedAt' => 'DESC''id' => 'DESC'],
  216.             5
  217.         );
  218.         $driveHasActive false;
  219.         foreach ($driveBatches as $batch) {
  220.             if (!\in_array($batch->getStatus(), ['done''failed'], true)) {
  221.                 $driveHasActive true;
  222.                 break;
  223.             }
  224.         }
  225.         $propertyData $remotePropertyService->fetchPropertyListForInstance($instance);
  226.         $biensList $propertyListService->build($propertyData['properties'] ?? [], $request->query->all());
  227.         $criteriaTitles = [];
  228.         $criteriaVues = [];
  229.         $criteriaTypes = [];
  230.         $allProperties $propertyData['properties'] ?? [];
  231.         foreach ($allProperties as $entry) {
  232.             $info $entry['infos']['info'] ?? null;
  233.             if (!\is_array($info)) {
  234.                 continue;
  235.             }
  236.             if (!empty($info['title'])) {
  237.                 $criteriaTitles[(string) $info['title']] = true;
  238.             }
  239.             if (!empty($info['vue'])) {
  240.                 $criteriaVues[(string) $info['vue']] = true;
  241.             }
  242.             if (!empty($info['property_type_criteria'])) {
  243.                 $criteriaTypes[(string) $info['property_type_criteria']] = true;
  244.             }
  245.         }
  246.         $criteriaTitles array_keys($criteriaTitles);
  247.         $criteriaVues array_keys($criteriaVues);
  248.         $criteriaTypes array_keys($criteriaTypes);
  249.         sort($criteriaTitles);
  250.         sort($criteriaVues);
  251.         sort($criteriaTypes);
  252.         $presentationItems $mediaItemRepository->findBy(
  253.             ['instance' => $instance'type' => 'presentation'],
  254.             ['row' => 'ASC''id' => 'ASC']
  255.         );
  256.         return $this->render('instance/show.html.twig', [
  257.             'instance' => $instance,
  258.             'form' => $form->createView(),
  259.             'views' => $views,
  260.             'defaultView' => $defaultView,
  261.             'driveImported' => $driveImported,
  262.             'sequenceImported' => $sequenceImported,
  263.             'driveBatches' => $driveBatches,
  264.             'driveHasActive' => $driveHasActive,
  265.             'driveStatusUrl' => $this->generateUrl('instance_drive_import_status', ['id' => $instance->getId()]),
  266.             'biensList' => $biensList,
  267.             'allProperties' => $propertyData['properties'] ?? [],
  268.             'criteriaTitles' => $criteriaTitles,
  269.             'criteriaVues' => $criteriaVues,
  270.             'criteriaTypes' => $criteriaTypes,
  271.             'criteriaViewMap' => $instance->getCriteriaViewMap(),
  272.             'presentationItems' => $presentationItems,
  273.         ]);
  274.     }
  275.     /**
  276.      * @Route("/instances/{id}/criteria-view-map", name="instance_criteria_view_map_save", methods={"POST"})
  277.      */
  278.     public function saveCriteriaViewMap(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): Response
  279.     {
  280.         $this->denyAccessUnlessGranted('ROLE_USER');
  281.         $instance $instanceRepository->find($id);
  282.         if (!$instance) {
  283.             return $this->redirectToRoute('instance_index');
  284.         }
  285.         if (!$this->isCsrfTokenValid('criteria_view_map_' $instance->getId(), (string) $request->request->get('_token'))) {
  286.             $this->addFlash('danger''Invalid token.');
  287.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  288.         }
  289.         $modes $request->request->all('modes');
  290.         $keys $request->request->all('keys');
  291.         $viewsByRow $request->request->all('views');
  292.         $normalizeKey = static function ($value): string {
  293.             $value trim((string) ($value ?? ''));
  294.             return $value;
  295.         };
  296.         $views $viewRepository->findBy(['instance' => $instance]);
  297.         $allowedViewIds = [];
  298.         foreach ($views as $v) {
  299.             $allowedViewIds[(int) $v->getId()] = true;
  300.         }
  301.         $full = [];
  302.         if (\is_array($modes) && \is_array($keys) && \is_array($viewsByRow)) {
  303.             foreach ($keys as $idx => $rawKey) {
  304.                 $mode = (string) ($modes[$idx] ?? '');
  305.                 if (!\in_array($mode, ['title''vue''type'], true)) {
  306.                     continue;
  307.                 }
  308.                 $k $normalizeKey($rawKey);
  309.                 if ($k === '') {
  310.                     continue;
  311.                 }
  312.                 $selected $viewsByRow[$idx] ?? [];
  313.                 if (!\is_array($selected)) {
  314.                     $selected = [$selected];
  315.                 }
  316.                 $ids = [];
  317.                 foreach ($selected as $rawId) {
  318.                     $vid = (int) $rawId;
  319.                     if ($vid && isset($allowedViewIds[$vid])) {
  320.                         $ids[] = $vid;
  321.                     }
  322.                 }
  323.                 $ids array_values(array_unique($ids));
  324.                 if ($ids === []) {
  325.                     continue;
  326.                 }
  327.                 if (!isset($full[$mode]) || !\is_array($full[$mode])) {
  328.                     $full[$mode] = [];
  329.                 }
  330.                 $full[$mode][$k] = $ids;
  331.             }
  332.         }
  333.         $instance->setCriteriaViewMap($full);
  334.         $entityManager->flush();
  335.         $this->addFlash('success''Criteria/view mapping saved.');
  336.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  337.     }
  338.     /**
  339.      * @Route("/instances/{id}/presentation", name="instance_presentation_upload", methods={"POST"})
  340.      */
  341.     public function uploadPresentation(int $idRequest $requestInstanceRepository $instanceRepositoryMediaItemRepository $mediaItemRepositoryFileStorageService $storageImageResizeService $imageResizeServiceEntityManagerInterface $entityManager): Response
  342.     {
  343.         $this->denyAccessUnlessGranted('ROLE_USER');
  344.         $instance $instanceRepository->find($id);
  345.         if (!$instance) {
  346.             return $this->redirectToRoute('instance_index');
  347.         }
  348.         if (!$this->isCsrfTokenValid('upload_presentation_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  349.             $this->addFlash('danger''Invalid presentation upload token.');
  350.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  351.         }
  352.         $files $request->files->get('presentation_files');
  353.         if (!\is_array($files) || \count($files) === 0) {
  354.             $this->addFlash('danger''No presentation images selected.');
  355.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  356.         }
  357.         $allowed = ['png''jpg''jpeg''webp'];
  358.         $imported 0;
  359.         foreach ($files as $uploadedFile) {
  360.             if (!$uploadedFile || !$uploadedFile->isValid()) {
  361.                 continue;
  362.             }
  363.             $extension strtolower((string) $uploadedFile->getClientOriginalExtension());
  364.             if (!\in_array($extension$allowedtrue)) {
  365.                 continue;
  366.             }
  367.             $originalName $uploadedFile->getClientOriginalName() ?: ('presentation_' $imported '.' $extension);
  368.             $rootDir $storage->getInstanceRootDir($instance);
  369.             $dir $rootDir DIRECTORY_SEPARATOR 'presentation';
  370.             if (!is_dir($dir)) {
  371.                 mkdir($dir0775true);
  372.             }
  373.             $safeBase pathinfo($originalNamePATHINFO_FILENAME);
  374.             $safeBase preg_replace('~[^a-zA-Z0-9_-]+~''_', (string) $safeBase) ?: 'presentation';
  375.             $targetName $safeBase '_' bin2hex(random_bytes(4)) . '.' $extension;
  376.             try {
  377.                 $uploadedFile->move($dir$targetName);
  378.             } catch (\Throwable $e) {
  379.                 continue;
  380.             }
  381.             $absolutePath $dir DIRECTORY_SEPARATOR $targetName;
  382.             $relativePath $storage->getRelativePathFromPublic($absolutePath);
  383.             $mediaItem = new MediaItem();
  384.             $mediaItem->setInstance($instance);
  385.             $mediaItem->setView(null);
  386.             $mediaItem->setType('presentation');
  387.             $mediaItem->setOriginalFilename($originalName);
  388.             $mediaItem->setOriginalPath($relativePath);
  389.             $mediaItem->setRow(1);
  390.             $mediaItem->setSourceType('local_upload');
  391.             $mediaItem->setSourceConfig([
  392.                 'originalName' => $uploadedFile->getClientOriginalName(),
  393.             ]);
  394.             $mediaItem->setStatus('processing');
  395.             $variants $imageResizeService->generateVariants($instance$mediaItem);
  396.             $mediaItem->setVariants($variants);
  397.             $mediaItem->setStatus('ready');
  398.             $entityManager->persist($mediaItem);
  399.             $imported++;
  400.         }
  401.         $entityManager->flush();
  402.         if ($imported 0) {
  403.             $this->addFlash('success'sprintf('Uploaded %d presentation images.'$imported));
  404.         } else {
  405.             $this->addFlash('danger''No presentation images were uploaded.');
  406.         }
  407.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  408.     }
  409.     /**
  410.      * @Route("/instances/{id}/property-view-map", name="instance_property_view_map_generate", methods={"POST"})
  411.      */
  412.     public function generatePropertyViewMap(
  413.         int $id,
  414.         Request $request,
  415.         InstanceRepository $instanceRepository,
  416.         InstanceViewRepository $viewRepository,
  417.         EntityManagerInterface $entityManager,
  418.         RemotePropertyService $remotePropertyService
  419.     ): Response {
  420.         $this->denyAccessUnlessGranted('ROLE_USER');
  421.         $instance $instanceRepository->find($id);
  422.         if (!$instance) {
  423.             return $this->redirectToRoute('instance_index');
  424.         }
  425.         if (!$this->isCsrfTokenValid('property_view_map_' $instance->getId(), (string) $request->request->get('_token'))) {
  426.             $this->addFlash('danger''Invalid token.');
  427.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  428.         }
  429.         $mode = (string) $request->request->get('match_mode''type');
  430.         if (!\in_array($mode, ['type''vue''name'], true)) {
  431.             $mode 'type';
  432.         }
  433.         $views $viewRepository->findBy(['instance' => $instance], ['sortOrder' => 'ASC''id' => 'ASC']);
  434.         $viewsByKey = [];
  435.         foreach ($views as $v) {
  436.             $k strtolower(trim((string) $v->getName()));
  437.             if ($k !== '') {
  438.                 $viewsByKey[$k] = $v;
  439.             }
  440.         }
  441.         $propertyData $remotePropertyService->fetchPropertyListForInstance($instance);
  442.         $allProperties $propertyData['properties'] ?? [];
  443.         $normalize = static function ($value): string {
  444.             $value strtolower(trim((string) ($value ?? '')));
  445.             $value preg_replace('~\s+~'' '$value);
  446.             return $value;
  447.         };
  448.         $extractCriteria = static function (array $infostring $mode) use ($normalize): string {
  449.             if ($mode === 'name') {
  450.                 return $normalize($info['title'] ?? $info['name'] ?? '');
  451.             }
  452.             if ($mode === 'type') {
  453.                 $raw $info['property_type_criteria'] ?? $info['propertyTypeCriteria'] ?? $info['type_criteria'] ?? null;
  454.                 if ($raw !== null && trim((string) $raw) !== '') {
  455.                     return $normalize($raw);
  456.                 }
  457.                 // Fallbacks if remote API doesn't provide a dedicated criteria field
  458.                 if (isset($info['pieces']) && $info['pieces'] !== null) {
  459.                     return $normalize('t' . (string) $info['pieces']);
  460.                 }
  461.                 if (isset($info['property_type']) && $info['property_type'] !== null) {
  462.                     return $normalize($info['property_type']);
  463.                 }
  464.                 return '';
  465.             }
  466.             // vue
  467.             $raw $info['property_vue_criteria'] ?? $info['propertyVueCriteria'] ?? $info['vue_criteria'] ?? null;
  468.             if ($raw !== null && trim((string) $raw) !== '') {
  469.                 return $normalize($raw);
  470.             }
  471.             if (isset($info['vue']) && $info['vue'] !== null) {
  472.                 return $normalize($info['vue']);
  473.             }
  474.             return '';
  475.         };
  476.         $map = [];
  477.         $matched 0;
  478.         $scanned 0;
  479.         foreach ($allProperties as $entry) {
  480.             $info $entry['infos']['info'] ?? null;
  481.             if (!\is_array($info)) {
  482.                 continue;
  483.             }
  484.             $propertyId = isset($info['id']) ? (int) $info['id'] : 0;
  485.             if ($propertyId <= 0) {
  486.                 continue;
  487.             }
  488.             $scanned++;
  489.             $criteriaValue $extractCriteria($info$mode);
  490.             if ($criteriaValue === '') {
  491.                 continue;
  492.             }
  493.             if (isset($viewsByKey[$criteriaValue])) {
  494.                 $view $viewsByKey[$criteriaValue];
  495.                 $map[(string) $propertyId] = $view->getId();
  496.                 $matched++;
  497.             }
  498.         }
  499.         $instance->setPropertyViewMap($map);
  500.         $entityManager->flush();
  501.         $this->addFlash('success'sprintf('Property/view map updated (%d matched out of %d properties).'$matched$scanned));
  502.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  503.     }
  504.     /**
  505.      * @Route("/instances/{id}/views/mesh-map", name="instance_view_mesh_map_save", methods={"POST"})
  506.      */
  507.     public function saveViewMeshMap(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): JsonResponse
  508.     {
  509.         $this->denyAccessUnlessGranted('ROLE_USER');
  510.         $instance $instanceRepository->find($id);
  511.         if (!$instance) {
  512.             return $this->json(['status' => false'message' => 'Instance not found.'], 404);
  513.         }
  514.         $payload json_decode((string) $request->getContent(), true);
  515.         if (!\is_array($payload)) {
  516.             return $this->json(['status' => false'message' => 'Invalid payload.'], 400);
  517.         }
  518.         if (!$this->isCsrfTokenValid('save_mesh_map_instance_' $instance->getId(), (string) ($payload['_token'] ?? ''))) {
  519.             return $this->json(['status' => false'message' => 'Invalid token.'], 403);
  520.         }
  521.         $viewId = (int) ($payload['view_id'] ?? 0);
  522.         $view $viewRepository->find($viewId);
  523.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  524.             return $this->json(['status' => false'message' => 'View not found for this instance.'], 404);
  525.         }
  526.         $groupName trim((string) ($payload['group_name'] ?? ''));
  527.         if ($groupName === '') {
  528.             $groupName null;
  529.         }
  530.         $mapping $payload['mapping'] ?? null;
  531.         if (!\is_array($mapping)) {
  532.             return $this->json(['status' => false'message' => 'Mapping must be an object.'], 400);
  533.         }
  534.         $clean = [];
  535.         foreach ($mapping as $meshName => $entry) {
  536.             $meshName trim((string) $meshName);
  537.             if ($meshName === '') {
  538.                 continue;
  539.             }
  540.             // Backward compatibility: allow either a string slugTitle or an object
  541.             // { property: string|null, color?: string, opacity?: number, opacityHover?: number }
  542.             if (\is_array($entry)) {
  543.                 $slugTitle trim((string) ($entry['property'] ?? ''));
  544.                 $color trim((string) ($entry['color'] ?? ''));
  545.                 $opacity $entry['opacity'] ?? null;
  546.                 $opacityHover $entry['opacityHover'] ?? null;
  547.                 $opacity is_numeric($opacity) ? (float) $opacity null;
  548.                 $opacityHover is_numeric($opacityHover) ? (float) $opacityHover null;
  549.                 $payloadEntry = [
  550.                     'property' => $slugTitle !== '' $slugTitle null,
  551.                 ];
  552.                 if ($color !== '') {
  553.                     $payloadEntry['color'] = $color;
  554.                 }
  555.                 if ($opacity !== null) {
  556.                     $payloadEntry['opacity'] = $opacity;
  557.                 }
  558.                 if ($opacityHover !== null) {
  559.                     $payloadEntry['opacityHover'] = $opacityHover;
  560.                 }
  561.                 // If nothing is set, store null
  562.                 if ($payloadEntry['property'] === null && count($payloadEntry) === 1) {
  563.                     $clean[$meshName] = null;
  564.                 } else {
  565.                     $clean[$meshName] = $payloadEntry;
  566.                 }
  567.                 continue;
  568.             }
  569.             $slugTitle trim((string) ($entry ?? ''));
  570.             $clean[$meshName] = $slugTitle !== '' $slugTitle null;
  571.         }
  572.         $view->setMeshGroupName($groupName);
  573.         $view->setMeshPropertyMap($clean);
  574.         $entityManager->flush();
  575.         return $this->json(['status' => true]);
  576.     }
  577.     /**
  578.      * @Route("/instances/{id}/presentation/{mediaId}/delete", name="instance_presentation_delete", methods={"POST"})
  579.      */
  580.     public function deletePresentation(int $idint $mediaIdRequest $requestInstanceRepository $instanceRepositoryMediaItemRepository $mediaItemRepositoryFileStorageService $storageEntityManagerInterface $entityManager): Response
  581.     {
  582.         $this->denyAccessUnlessGranted('ROLE_USER');
  583.         $instance $instanceRepository->find($id);
  584.         if (!$instance) {
  585.             return $this->redirectToRoute('instance_index');
  586.         }
  587.         if (!$this->isCsrfTokenValid('delete_presentation_' $mediaId, (string) $request->request->get('_token'))) {
  588.             $this->addFlash('danger''Invalid delete token.');
  589.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  590.         }
  591.         $mediaItem $mediaItemRepository->find($mediaId);
  592.         if (!$mediaItem || !$mediaItem->getInstance() || $mediaItem->getInstance()->getId() !== $instance->getId() || $mediaItem->getType() !== 'presentation') {
  593.             $this->addFlash('danger''Presentation image not found.');
  594.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  595.         }
  596.         $originalPath $mediaItem->getOriginalPath();
  597.         if ($originalPath) {
  598.             $absolute $storage->getAbsolutePathFromPublicRelative($originalPath);
  599.             if (is_file($absolute)) {
  600.                 @unlink($absolute);
  601.             }
  602.         }
  603.         $variants $mediaItem->getVariants();
  604.         if (\is_array($variants)) {
  605.             foreach ($variants as $variant) {
  606.                 if (!\is_array($variant)) {
  607.                     continue;
  608.                 }
  609.                 foreach (['webp''jpg'] as $key) {
  610.                     $rel $variant[$key] ?? null;
  611.                     if (!$rel) {
  612.                         continue;
  613.                     }
  614.                     $absolute $storage->getAbsolutePathFromPublicRelative($rel);
  615.                     if (is_file($absolute)) {
  616.                         @unlink($absolute);
  617.                     }
  618.                 }
  619.             }
  620.         }
  621.         $entityManager->remove($mediaItem);
  622.         $entityManager->flush();
  623.         $this->addFlash('success''Presentation image deleted.');
  624.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  625.     }
  626.     /**
  627.      * @Route("/instances/{id}/drive-import-status", name="instance_drive_import_status", methods={"GET"})
  628.      */
  629.     public function driveImportStatus(int $idInstanceRepository $instanceRepositoryEntityManagerInterface $entityManager): Response
  630.     {
  631.         $this->denyAccessUnlessGranted('ROLE_USER');
  632.         $instance $instanceRepository->find($id);
  633.         if (!$instance) {
  634.             throw $this->createNotFoundException('Instance not found.');
  635.         }
  636.         $driveBatches $entityManager->getRepository(DriveImportBatch::class)->findBy(
  637.             ['instance' => $instance],
  638.             ['updatedAt' => 'DESC''id' => 'DESC'],
  639.             5
  640.         );
  641.         $driveHasActive false;
  642.         foreach ($driveBatches as $batch) {
  643.             if (!\in_array($batch->getStatus(), ['done''failed'], true)) {
  644.                 $driveHasActive true;
  645.                 break;
  646.             }
  647.         }
  648.         return $this->render('instance/_drive_import_status.html.twig', [
  649.             'batches' => $driveBatches,
  650.             'hasActive' => $driveHasActive,
  651.         ]);
  652.     }
  653.     /**
  654.      * @Route("/instances/{id}/biens", name="instance_biens_partial", methods={"GET"})
  655.      */
  656.     public function biensPartial(int $idRequest $requestInstanceRepository $instanceRepositoryRemotePropertyService $remotePropertyServicePropertyListService $propertyListService): Response
  657.     {
  658.         $this->denyAccessUnlessGranted('ROLE_USER');
  659.         $instance $instanceRepository->find($id);
  660.         if (!$instance) {
  661.             throw $this->createNotFoundException('Instance not found.');
  662.         }
  663.         $propertyData $remotePropertyService->fetchPropertyListForInstance($instance);
  664.         $biensList $propertyListService->build($propertyData['properties'] ?? [], $request->query->all());
  665.         return $this->render('shared/_biens_list.html.twig', [
  666.             'filters' => $biensList['filters'] ?? [],
  667.             'statsByPieces' => $biensList['statsByPieces'] ?? [],
  668.             'groups' => $biensList['groups'] ?? [],
  669.             'pagination' => $biensList['pagination'] ?? [],
  670.             'basePath' => $this->generateUrl('instance_show', ['id' => $instance->getId()]),
  671.             'ajaxUrl' => $this->generateUrl('instance_biens_partial', ['id' => $instance->getId()]),
  672.             'containerId' => 'biens-list-container-instance-' $instance->getId(),
  673.             'theme' => 'dark',
  674.             'detailUrl' => $this->generateUrl('instance_embed_property_detail', ['slug' => $instance->getSlug()]),
  675.         ]);
  676.     }
  677.     /**
  678.      * @Route("/instances/{id}/drive-import-batches/{batchId}/delete", name="instance_drive_import_batch_delete", methods={"POST"})
  679.      */
  680.     public function deleteDriveImportBatch(int $idint $batchIdRequest $requestInstanceRepository $instanceRepositoryEntityManagerInterface $entityManager): Response
  681.     {
  682.         $this->denyAccessUnlessGranted('ROLE_USER');
  683.         $instance $instanceRepository->find($id);
  684.         if (!$instance) {
  685.             throw $this->createNotFoundException('Instance not found.');
  686.         }
  687.         if (!$this->isCsrfTokenValid('delete_drive_import_batch_' $batchId, (string) $request->request->get('_token'))) {
  688.             $this->addFlash('danger''Invalid delete token.');
  689.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  690.         }
  691.         /** @var DriveImportBatch|null $batch */
  692.         $batch $entityManager->getRepository(DriveImportBatch::class)->find($batchId);
  693.         if (!$batch || !$batch->getInstance() || $batch->getInstance()->getId() !== $instance->getId()) {
  694.             $this->addFlash('danger''Batch not found.');
  695.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  696.         }
  697.         $entityManager->remove($batch);
  698.         $entityManager->flush();
  699.         $this->addFlash('success'sprintf('Drive batch #%d was deleted.'$batchId));
  700.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  701.     }
  702.     /**
  703.      * @Route("/instances/{id}/logo", name="instance_logo_upload", methods={"POST"})
  704.      */
  705.     public function uploadLogo(int $idRequest $requestInstanceRepository $instanceRepositoryFileStorageService $storageEntityManagerInterface $entityManager): Response
  706.     {
  707.         $this->denyAccessUnlessGranted('ROLE_USER');
  708.         $instance $instanceRepository->find($id);
  709.         if (!$instance) {
  710.             return $this->redirectToRoute('instance_index');
  711.         }
  712.         if (!$this->isCsrfTokenValid('upload_logo_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  713.             $this->addFlash('danger''Invalid logo upload token.');
  714.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  715.         }
  716.         $uploadedFile $request->files->get('logo_file');
  717.         if (!$uploadedFile) {
  718.             $this->addFlash('danger''No logo file uploaded.');
  719.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  720.         }
  721.         if (!$uploadedFile->isValid()) {
  722.             $this->addFlash('danger''Uploaded logo file is not valid.');
  723.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  724.         }
  725.         $extension strtolower((string) $uploadedFile->getClientOriginalExtension());
  726.         $allowed = ['png''jpg''jpeg''svg'];
  727.         if (!\in_array($extension$allowedtrue)) {
  728.             $this->addFlash('danger''Only PNG, JPG, JPEG or SVG files are allowed for logos.');
  729.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  730.         }
  731.         $rootDir $storage->getInstanceRootDir($instance);
  732.         $logoDir $rootDir DIRECTORY_SEPARATOR 'logo';
  733.         if (!is_dir($logoDir)) {
  734.             mkdir($logoDir0775true);
  735.         }
  736.         // Remove any existing logo.* for this instance
  737.         foreach ($allowed as $ext) {
  738.             $existing $logoDir DIRECTORY_SEPARATOR 'logo.' $ext;
  739.             if (is_file($existing)) {
  740.                 @unlink($existing);
  741.             }
  742.         }
  743.         $targetName 'logo.' $extension;
  744.         try {
  745.             $uploadedFile->move($logoDir$targetName);
  746.         } catch (\Throwable $e) {
  747.             $this->addFlash('danger''Failed to save logo file: ' $e->getMessage());
  748.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  749.         }
  750.         $absolutePath $logoDir DIRECTORY_SEPARATOR $targetName;
  751.         $relativePath $storage->getRelativePathFromPublic($absolutePath);
  752.         $instance->setLogoPath($relativePath);
  753.         $entityManager->flush();
  754.         $this->addFlash('success''Logo uploaded for instance.');
  755.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  756.     }
  757.     private function applyInstanceFormExtras(Instance $instance$form): void
  758.     {
  759.         $slug $instance->getSlug();
  760.         if (!$slug) {
  761.             $instance->setSlug($this->slugify((string) $instance->getName()));
  762.         }
  763.         // If base URL is left empty, apply a sensible default based on the type
  764.         $baseUrl trim((string) $instance->getBaseUrl());
  765.         if ($baseUrl === '') {
  766.             switch ($instance->getType()) {
  767.                 case 'immodev':
  768.                     $instance->setBaseUrl('https://immodev.max125.com');
  769.                     break;
  770.                 case 'ma':
  771.                     $instance->setBaseUrl('https://ma.immotech.app');
  772.                     break;
  773.             }
  774.         }
  775.         $instance->setUpdatedAt(new \DateTimeImmutable());
  776.     }
  777.     /**
  778.      * @Route("/instances/{id}/views", name="instance_view_create", methods={"POST"})
  779.      */
  780.     public function createView(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): Response
  781.     {
  782.         $this->denyAccessUnlessGranted('ROLE_USER');
  783.         $instance $instanceRepository->find($id);
  784.         if (!$instance) {
  785.             return $this->redirectToRoute('instance_index');
  786.         }
  787.         $name trim((string) $request->request->get('name'));
  788.         if ($name === '') {
  789.             $this->addFlash('danger''View name is required.');
  790.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  791.         }
  792.         $displayName trim((string) $request->request->get('display_name'));
  793.         if ($displayName === '') {
  794.             $displayName $name;
  795.         }
  796.         $row = (int) $request->request->get('row'1);
  797.         if ($row <= 0) {
  798.             $row 1;
  799.         }
  800.         $makeDefault = (bool) $request->request->get('is_default');
  801.         $showOnHomepage = (bool) $request->request->get('show_on_homepage');
  802.         $sortOrder = (int) $request->request->get('sort_order'0);
  803.         $icon trim((string) $request->request->get('icon'));
  804.         // Check uniqueness of view name per instance
  805.         $existingWithName $viewRepository->findOneBy(['instance' => $instance'name' => $name]);
  806.         if ($existingWithName) {
  807.             $this->addFlash('danger''A view with this name already exists for this instance.');
  808.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  809.         }
  810.         if ($makeDefault) {
  811.             $existing $viewRepository->findBy(['instance' => $instance]);
  812.             foreach ($existing as $existingView) {
  813.                 $existingView->setIsDefault(false);
  814.             }
  815.         }
  816.         $view = new InstanceView();
  817.         $view->setInstance($instance);
  818.         $view->setName($name);
  819.         $view->setDisplayName($displayName);
  820.         $view->setIsDefault($makeDefault);
  821.         $view->setShowOnHomepage($showOnHomepage);
  822.         $view->setSortOrder($sortOrder);
  823.         $view->setIcon($icon !== '' $icon null);
  824.         $entityManager->persist($view);
  825.         $entityManager->flush();
  826.         $this->addFlash('success''View created.');
  827.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  828.     }
  829.     /**
  830.      * @Route("/instances/{id}/views/glb", name="instance_view_glb_upload", methods={"POST"})
  831.      */
  832.     public function uploadViewGlb(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManagerFileStorageService $storage): Response
  833.     {
  834.         $this->denyAccessUnlessGranted('ROLE_USER');
  835.         $instance $instanceRepository->find($id);
  836.         if (!$instance) {
  837.             return $this->redirectToRoute('instance_index');
  838.         }
  839.         if (!$this->isCsrfTokenValid('upload_glb_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  840.             $this->addFlash('danger''Invalid GLB upload token.');
  841.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  842.         }
  843.         $viewId = (int) $request->request->get('view_id');
  844.         $view $viewRepository->find($viewId);
  845.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  846.             $this->addFlash('danger''View not found for this instance.');
  847.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  848.         }
  849.         $uploadedFile $request->files->get('glb_file');
  850.         if (!$uploadedFile) {
  851.             $this->addFlash('danger''No GLB file uploaded.');
  852.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  853.         }
  854.         if (!$uploadedFile->isValid()) {
  855.             $this->addFlash('danger''Uploaded GLB file is not valid.');
  856.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  857.         }
  858.         $extension strtolower((string) $uploadedFile->getClientOriginalExtension());
  859.         if ($extension !== 'glb') {
  860.             $this->addFlash('danger''Only .glb files are allowed for view models.');
  861.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  862.         }
  863.         // Store as model.glb under public/uploads/{slug}/glb/{viewSegment}/
  864.         $targetDir $storage->getGlbDir($instance$view);
  865.         $targetName 'model.glb';
  866.         // Remove any existing model.glb for this view
  867.         $existingPath $targetDir DIRECTORY_SEPARATOR $targetName;
  868.         if (is_file($existingPath)) {
  869.             @unlink($existingPath);
  870.         }
  871.         try {
  872.             $uploadedFile->move($targetDir$targetName);
  873.         } catch (\Throwable $e) {
  874.             $this->addFlash('danger''Failed to save GLB file: ' $e->getMessage());
  875.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  876.         }
  877.         $relativePath $storage->getGlbRelativePath($instance$view$targetName);
  878.         $view->setGlbPath($relativePath);
  879.         // Clear old mesh mapping when GLB changes to avoid conflicts
  880.         $view->setMeshGroupName(null);
  881.         $view->setMeshPropertyMap(null);
  882.         $entityManager->flush();
  883.         $this->addFlash('success''GLB file uploaded for view.');
  884.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  885.     }
  886.     /**
  887.      * @Route("/instances/{id}/views/edit", name="instance_view_edit", methods={"POST"})
  888.      */
  889.     public function editView(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): Response
  890.     {
  891.         $this->denyAccessUnlessGranted('ROLE_USER');
  892.         $instance $instanceRepository->find($id);
  893.         if (!$instance) {
  894.             return $this->redirectToRoute('instance_index');
  895.         }
  896.         if (!$this->isCsrfTokenValid('edit_view_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  897.             $this->addFlash('danger''Invalid edit token.');
  898.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  899.         }
  900.         $viewId = (int) $request->request->get('view_id');
  901.         $view $viewRepository->find($viewId);
  902.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  903.             $this->addFlash('danger''View not found for this instance.');
  904.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  905.         }
  906.         $displayName trim((string) $request->request->get('display_name'));
  907.         if ($displayName === '') {
  908.             $displayName $view->getName();
  909.         }
  910.         $sortOrder = (int) $request->request->get('sort_order'$view->getSortOrder());
  911.         $showOnHomepage = (bool) $request->request->get('show_on_homepage');
  912.         $icon trim((string) $request->request->get('icon'));
  913.         $view->setDisplayName($displayName);
  914.         $view->setSortOrder($sortOrder);
  915.         $view->setShowOnHomepage($showOnHomepage);
  916.         $view->setIcon($icon !== '' $icon null);
  917.         $entityManager->flush();
  918.         $this->addFlash('success''View updated.');
  919.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  920.     }
  921.     /**
  922.      * @Route("/instances/{id}/views/{viewId}/default", name="instance_view_default", methods={"POST"})
  923.      */
  924.     public function setDefaultView(int $idint $viewIdRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): Response
  925.     {
  926.         $this->denyAccessUnlessGranted('ROLE_USER');
  927.         $instance $instanceRepository->find($id);
  928.         if (!$instance) {
  929.             return $this->redirectToRoute('instance_index');
  930.         }
  931.         $view $viewRepository->find($viewId);
  932.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  933.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  934.         }
  935.         if (!$this->isCsrfTokenValid('default_view_' $view->getId(), (string) $request->request->get('_token'))) {
  936.             $this->addFlash('danger''Invalid token.');
  937.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  938.         }
  939.         $views $viewRepository->findBy(['instance' => $instance]);
  940.         foreach ($views as $v) {
  941.             $v->setIsDefault($v->getId() === $view->getId());
  942.         }
  943.         $entityManager->flush();
  944.         $this->addFlash('success''Default view updated.');
  945.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  946.     }
  947.     /**
  948.      * @Route("/instances/{id}/views/{viewId}/delete", name="instance_view_delete", methods={"POST"})
  949.      */
  950.     public function deleteView(int $idint $viewIdRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManagerFileStorageService $storage): Response
  951.     {
  952.         $this->denyAccessUnlessGranted('ROLE_USER');
  953.         $instance $instanceRepository->find($id);
  954.         if (!$instance) {
  955.             return $this->redirectToRoute('instance_index');
  956.         }
  957.         $view $viewRepository->find($viewId);
  958.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  959.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  960.         }
  961.         if (!$this->isCsrfTokenValid('delete_view_' $view->getId(), (string) $request->request->get('_token'))) {
  962.             $this->addFlash('danger''Invalid delete token.');
  963.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  964.         }
  965.         $this->deleteViewMediaAndFiles($instance$view$entityManager$storage);
  966.         $entityManager->remove($view);
  967.         $entityManager->flush();
  968.         $this->addFlash('success''View deleted.');
  969.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  970.     }
  971.     /**
  972.      * @Route("/instances/{id}/delete", name="instance_delete", methods={"POST"})
  973.      */
  974.     public function deleteInstance(int $idRequest $requestInstanceRepository $instanceRepositoryEntityManagerInterface $entityManagerFileStorageService $storage): Response
  975.     {
  976.         $this->denyAccessUnlessGranted('ROLE_USER');
  977.         $instance $instanceRepository->find($id);
  978.         if (!$instance) {
  979.             return $this->redirectToRoute('instance_index');
  980.         }
  981.         if (!$this->isCsrfTokenValid('delete_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  982.             $this->addFlash('danger''Invalid delete token.');
  983.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  984.         }
  985.         $storage->deleteInstanceRootDir($instance);
  986.         $entityManager->remove($instance);
  987.         $entityManager->flush();
  988.         $this->addFlash('success''Instance, its views and all images have been deleted.');
  989.         return $this->redirectToRoute('instance_index');
  990.     }
  991.     private function deleteViewMediaAndFiles(Instance $instanceInstanceView $viewEntityManagerInterface $entityManagerFileStorageService $storage): void
  992.     {
  993.         $mediaRepo $entityManager->getRepository(MediaItem::class);
  994.         $items $mediaRepo->findBy(['instance' => $instance'view' => $view]);
  995.         foreach ($items as $item) {
  996.             $entityManager->remove($item);
  997.         }
  998.         $storage->deleteViewDirs($instance$view);
  999.     }
  1000.     private function deleteViewRowMediaAndFiles(Instance $instanceInstanceView $viewint $rowEntityManagerInterface $entityManagerFileStorageService $storage): void
  1001.     {
  1002.         $mediaRepo $entityManager->getRepository(MediaItem::class);
  1003.         $items $mediaRepo->findBy(['instance' => $instance'view' => $view'row' => $row]);
  1004.         foreach ($items as $item) {
  1005.             $entityManager->remove($item);
  1006.         }
  1007.         $storage->deleteViewRowDirs($instance$view$row);
  1008.     }
  1009.     private function slugify(string $value): string
  1010.     {
  1011.         $value strtolower($value);
  1012.         $value preg_replace('~[^a-z0-9]+~''-'$value) ?? '';
  1013.         $value trim($value'-');
  1014.         if ($value === '') {
  1015.             $value 'instance-' bin2hex(random_bytes(4));
  1016.         }
  1017.         return $value;
  1018.     }
  1019. }