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