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.         $presentationItems $mediaItemRepository->findBy(
  228.             ['instance' => $instance'type' => 'presentation'],
  229.             ['row' => 'ASC''id' => 'ASC']
  230.         );
  231.         return $this->render('instance/show.html.twig', [
  232.             'instance' => $instance,
  233.             'form' => $form->createView(),
  234.             'views' => $views,
  235.             'defaultView' => $defaultView,
  236.             'driveImported' => $driveImported,
  237.             'sequenceImported' => $sequenceImported,
  238.             'driveBatches' => $driveBatches,
  239.             'driveHasActive' => $driveHasActive,
  240.             'driveStatusUrl' => $this->generateUrl('instance_drive_import_status', ['id' => $instance->getId()]),
  241.             'biensList' => $biensList,
  242.             'allProperties' => $propertyData['properties'] ?? [],
  243.             'presentationItems' => $presentationItems,
  244.         ]);
  245.     }
  246.     /**
  247.      * @Route("/instances/{id}/presentation", name="instance_presentation_upload", methods={"POST"})
  248.      */
  249.     public function uploadPresentation(int $idRequest $requestInstanceRepository $instanceRepositoryMediaItemRepository $mediaItemRepositoryFileStorageService $storageImageResizeService $imageResizeServiceEntityManagerInterface $entityManager): Response
  250.     {
  251.         $this->denyAccessUnlessGranted('ROLE_USER');
  252.         $instance $instanceRepository->find($id);
  253.         if (!$instance) {
  254.             return $this->redirectToRoute('instance_index');
  255.         }
  256.         if (!$this->isCsrfTokenValid('upload_presentation_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  257.             $this->addFlash('danger''Invalid presentation upload token.');
  258.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  259.         }
  260.         $files $request->files->get('presentation_files');
  261.         if (!\is_array($files) || \count($files) === 0) {
  262.             $this->addFlash('danger''No presentation images selected.');
  263.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  264.         }
  265.         $allowed = ['png''jpg''jpeg''webp'];
  266.         $imported 0;
  267.         foreach ($files as $uploadedFile) {
  268.             if (!$uploadedFile || !$uploadedFile->isValid()) {
  269.                 continue;
  270.             }
  271.             $extension strtolower((string) $uploadedFile->getClientOriginalExtension());
  272.             if (!\in_array($extension$allowedtrue)) {
  273.                 continue;
  274.             }
  275.             $originalName $uploadedFile->getClientOriginalName() ?: ('presentation_' $imported '.' $extension);
  276.             $rootDir $storage->getInstanceRootDir($instance);
  277.             $dir $rootDir DIRECTORY_SEPARATOR 'presentation';
  278.             if (!is_dir($dir)) {
  279.                 mkdir($dir0775true);
  280.             }
  281.             $safeBase pathinfo($originalNamePATHINFO_FILENAME);
  282.             $safeBase preg_replace('~[^a-zA-Z0-9_-]+~''_', (string) $safeBase) ?: 'presentation';
  283.             $targetName $safeBase '_' bin2hex(random_bytes(4)) . '.' $extension;
  284.             try {
  285.                 $uploadedFile->move($dir$targetName);
  286.             } catch (\Throwable $e) {
  287.                 continue;
  288.             }
  289.             $absolutePath $dir DIRECTORY_SEPARATOR $targetName;
  290.             $relativePath $storage->getRelativePathFromPublic($absolutePath);
  291.             $mediaItem = new MediaItem();
  292.             $mediaItem->setInstance($instance);
  293.             $mediaItem->setView(null);
  294.             $mediaItem->setType('presentation');
  295.             $mediaItem->setOriginalFilename($originalName);
  296.             $mediaItem->setOriginalPath($relativePath);
  297.             $mediaItem->setRow(1);
  298.             $mediaItem->setSourceType('local_upload');
  299.             $mediaItem->setSourceConfig([
  300.                 'originalName' => $uploadedFile->getClientOriginalName(),
  301.             ]);
  302.             $mediaItem->setStatus('processing');
  303.             $variants $imageResizeService->generateVariants($instance$mediaItem);
  304.             $mediaItem->setVariants($variants);
  305.             $mediaItem->setStatus('ready');
  306.             $entityManager->persist($mediaItem);
  307.             $imported++;
  308.         }
  309.         $entityManager->flush();
  310.         if ($imported 0) {
  311.             $this->addFlash('success'sprintf('Uploaded %d presentation images.'$imported));
  312.         } else {
  313.             $this->addFlash('danger''No presentation images were uploaded.');
  314.         }
  315.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  316.     }
  317.     /**
  318.      * @Route("/instances/{id}/views/mesh-map", name="instance_view_mesh_map_save", methods={"POST"})
  319.      */
  320.     public function saveViewMeshMap(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): JsonResponse
  321.     {
  322.         $this->denyAccessUnlessGranted('ROLE_USER');
  323.         $instance $instanceRepository->find($id);
  324.         if (!$instance) {
  325.             return $this->json(['status' => false'message' => 'Instance not found.'], 404);
  326.         }
  327.         $payload json_decode((string) $request->getContent(), true);
  328.         if (!\is_array($payload)) {
  329.             return $this->json(['status' => false'message' => 'Invalid payload.'], 400);
  330.         }
  331.         if (!$this->isCsrfTokenValid('save_mesh_map_instance_' $instance->getId(), (string) ($payload['_token'] ?? ''))) {
  332.             return $this->json(['status' => false'message' => 'Invalid token.'], 403);
  333.         }
  334.         $viewId = (int) ($payload['view_id'] ?? 0);
  335.         $view $viewRepository->find($viewId);
  336.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  337.             return $this->json(['status' => false'message' => 'View not found for this instance.'], 404);
  338.         }
  339.         $groupName trim((string) ($payload['group_name'] ?? ''));
  340.         if ($groupName === '') {
  341.             $groupName null;
  342.         }
  343.         $mapping $payload['mapping'] ?? null;
  344.         if (!\is_array($mapping)) {
  345.             return $this->json(['status' => false'message' => 'Mapping must be an object.'], 400);
  346.         }
  347.         $clean = [];
  348.         foreach ($mapping as $meshName => $slugTitle) {
  349.             $meshName trim((string) $meshName);
  350.             if ($meshName === '') {
  351.                 continue;
  352.             }
  353.             $slugTitle trim((string) ($slugTitle ?? ''));
  354.             $clean[$meshName] = $slugTitle !== '' $slugTitle null;
  355.         }
  356.         $view->setMeshGroupName($groupName);
  357.         $view->setMeshPropertyMap($clean);
  358.         $entityManager->flush();
  359.         return $this->json(['status' => true]);
  360.     }
  361.     /**
  362.      * @Route("/instances/{id}/presentation/{mediaId}/delete", name="instance_presentation_delete", methods={"POST"})
  363.      */
  364.     public function deletePresentation(int $idint $mediaIdRequest $requestInstanceRepository $instanceRepositoryMediaItemRepository $mediaItemRepositoryFileStorageService $storageEntityManagerInterface $entityManager): Response
  365.     {
  366.         $this->denyAccessUnlessGranted('ROLE_USER');
  367.         $instance $instanceRepository->find($id);
  368.         if (!$instance) {
  369.             return $this->redirectToRoute('instance_index');
  370.         }
  371.         if (!$this->isCsrfTokenValid('delete_presentation_' $mediaId, (string) $request->request->get('_token'))) {
  372.             $this->addFlash('danger''Invalid delete token.');
  373.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  374.         }
  375.         $mediaItem $mediaItemRepository->find($mediaId);
  376.         if (!$mediaItem || !$mediaItem->getInstance() || $mediaItem->getInstance()->getId() !== $instance->getId() || $mediaItem->getType() !== 'presentation') {
  377.             $this->addFlash('danger''Presentation image not found.');
  378.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  379.         }
  380.         $originalPath $mediaItem->getOriginalPath();
  381.         if ($originalPath) {
  382.             $absolute $storage->getAbsolutePathFromPublicRelative($originalPath);
  383.             if (is_file($absolute)) {
  384.                 @unlink($absolute);
  385.             }
  386.         }
  387.         $variants $mediaItem->getVariants();
  388.         if (\is_array($variants)) {
  389.             foreach ($variants as $variant) {
  390.                 if (!\is_array($variant)) {
  391.                     continue;
  392.                 }
  393.                 foreach (['webp''jpg'] as $key) {
  394.                     $rel $variant[$key] ?? null;
  395.                     if (!$rel) {
  396.                         continue;
  397.                     }
  398.                     $absolute $storage->getAbsolutePathFromPublicRelative($rel);
  399.                     if (is_file($absolute)) {
  400.                         @unlink($absolute);
  401.                     }
  402.                 }
  403.             }
  404.         }
  405.         $entityManager->remove($mediaItem);
  406.         $entityManager->flush();
  407.         $this->addFlash('success''Presentation image deleted.');
  408.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  409.     }
  410.     /**
  411.      * @Route("/instances/{id}/drive-import-status", name="instance_drive_import_status", methods={"GET"})
  412.      */
  413.     public function driveImportStatus(int $idInstanceRepository $instanceRepositoryEntityManagerInterface $entityManager): Response
  414.     {
  415.         $this->denyAccessUnlessGranted('ROLE_USER');
  416.         $instance $instanceRepository->find($id);
  417.         if (!$instance) {
  418.             throw $this->createNotFoundException('Instance not found.');
  419.         }
  420.         $driveBatches $entityManager->getRepository(DriveImportBatch::class)->findBy(
  421.             ['instance' => $instance],
  422.             ['updatedAt' => 'DESC''id' => 'DESC'],
  423.             5
  424.         );
  425.         $driveHasActive false;
  426.         foreach ($driveBatches as $batch) {
  427.             if (!\in_array($batch->getStatus(), ['done''failed'], true)) {
  428.                 $driveHasActive true;
  429.                 break;
  430.             }
  431.         }
  432.         return $this->render('instance/_drive_import_status.html.twig', [
  433.             'batches' => $driveBatches,
  434.             'hasActive' => $driveHasActive,
  435.         ]);
  436.     }
  437.     /**
  438.      * @Route("/instances/{id}/biens", name="instance_biens_partial", methods={"GET"})
  439.      */
  440.     public function biensPartial(int $idRequest $requestInstanceRepository $instanceRepositoryRemotePropertyService $remotePropertyServicePropertyListService $propertyListService): Response
  441.     {
  442.         $this->denyAccessUnlessGranted('ROLE_USER');
  443.         $instance $instanceRepository->find($id);
  444.         if (!$instance) {
  445.             throw $this->createNotFoundException('Instance not found.');
  446.         }
  447.         $propertyData $remotePropertyService->fetchPropertyListForInstance($instance);
  448.         $biensList $propertyListService->build($propertyData['properties'] ?? [], $request->query->all());
  449.         return $this->render('shared/_biens_list.html.twig', [
  450.             'filters' => $biensList['filters'] ?? [],
  451.             'statsByPieces' => $biensList['statsByPieces'] ?? [],
  452.             'groups' => $biensList['groups'] ?? [],
  453.             'pagination' => $biensList['pagination'] ?? [],
  454.             'basePath' => $this->generateUrl('instance_show', ['id' => $instance->getId()]),
  455.             'ajaxUrl' => $this->generateUrl('instance_biens_partial', ['id' => $instance->getId()]),
  456.             'containerId' => 'biens-list-container-instance-' $instance->getId(),
  457.             'theme' => 'dark',
  458.             'detailUrl' => $this->generateUrl('instance_embed_property_detail', ['slug' => $instance->getSlug()]),
  459.         ]);
  460.     }
  461.     /**
  462.      * @Route("/instances/{id}/drive-import-batches/{batchId}/delete", name="instance_drive_import_batch_delete", methods={"POST"})
  463.      */
  464.     public function deleteDriveImportBatch(int $idint $batchIdRequest $requestInstanceRepository $instanceRepositoryEntityManagerInterface $entityManager): Response
  465.     {
  466.         $this->denyAccessUnlessGranted('ROLE_USER');
  467.         $instance $instanceRepository->find($id);
  468.         if (!$instance) {
  469.             throw $this->createNotFoundException('Instance not found.');
  470.         }
  471.         if (!$this->isCsrfTokenValid('delete_drive_import_batch_' $batchId, (string) $request->request->get('_token'))) {
  472.             $this->addFlash('danger''Invalid delete token.');
  473.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  474.         }
  475.         /** @var DriveImportBatch|null $batch */
  476.         $batch $entityManager->getRepository(DriveImportBatch::class)->find($batchId);
  477.         if (!$batch || !$batch->getInstance() || $batch->getInstance()->getId() !== $instance->getId()) {
  478.             $this->addFlash('danger''Batch not found.');
  479.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  480.         }
  481.         $entityManager->remove($batch);
  482.         $entityManager->flush();
  483.         $this->addFlash('success'sprintf('Drive batch #%d was deleted.'$batchId));
  484.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  485.     }
  486.     /**
  487.      * @Route("/instances/{id}/logo", name="instance_logo_upload", methods={"POST"})
  488.      */
  489.     public function uploadLogo(int $idRequest $requestInstanceRepository $instanceRepositoryFileStorageService $storageEntityManagerInterface $entityManager): Response
  490.     {
  491.         $this->denyAccessUnlessGranted('ROLE_USER');
  492.         $instance $instanceRepository->find($id);
  493.         if (!$instance) {
  494.             return $this->redirectToRoute('instance_index');
  495.         }
  496.         if (!$this->isCsrfTokenValid('upload_logo_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  497.             $this->addFlash('danger''Invalid logo upload token.');
  498.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  499.         }
  500.         $uploadedFile $request->files->get('logo_file');
  501.         if (!$uploadedFile) {
  502.             $this->addFlash('danger''No logo file uploaded.');
  503.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  504.         }
  505.         if (!$uploadedFile->isValid()) {
  506.             $this->addFlash('danger''Uploaded logo file is not valid.');
  507.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  508.         }
  509.         $extension strtolower((string) $uploadedFile->getClientOriginalExtension());
  510.         $allowed = ['png''jpg''jpeg''svg'];
  511.         if (!\in_array($extension$allowedtrue)) {
  512.             $this->addFlash('danger''Only PNG, JPG, JPEG or SVG files are allowed for logos.');
  513.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  514.         }
  515.         $rootDir $storage->getInstanceRootDir($instance);
  516.         $logoDir $rootDir DIRECTORY_SEPARATOR 'logo';
  517.         if (!is_dir($logoDir)) {
  518.             mkdir($logoDir0775true);
  519.         }
  520.         // Remove any existing logo.* for this instance
  521.         foreach ($allowed as $ext) {
  522.             $existing $logoDir DIRECTORY_SEPARATOR 'logo.' $ext;
  523.             if (is_file($existing)) {
  524.                 @unlink($existing);
  525.             }
  526.         }
  527.         $targetName 'logo.' $extension;
  528.         try {
  529.             $uploadedFile->move($logoDir$targetName);
  530.         } catch (\Throwable $e) {
  531.             $this->addFlash('danger''Failed to save logo file: ' $e->getMessage());
  532.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  533.         }
  534.         $absolutePath $logoDir DIRECTORY_SEPARATOR $targetName;
  535.         $relativePath $storage->getRelativePathFromPublic($absolutePath);
  536.         $instance->setLogoPath($relativePath);
  537.         $entityManager->flush();
  538.         $this->addFlash('success''Logo uploaded for instance.');
  539.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  540.     }
  541.     private function applyInstanceFormExtras(Instance $instance$form): void
  542.     {
  543.         $slug $instance->getSlug();
  544.         if (!$slug) {
  545.             $instance->setSlug($this->slugify((string) $instance->getName()));
  546.         }
  547.         // If base URL is left empty, apply a sensible default based on the type
  548.         $baseUrl trim((string) $instance->getBaseUrl());
  549.         if ($baseUrl === '') {
  550.             switch ($instance->getType()) {
  551.                 case 'immodev':
  552.                     $instance->setBaseUrl('https://immodev.max125.com');
  553.                     break;
  554.                 case 'ma':
  555.                     $instance->setBaseUrl('https://ma.immotech.app');
  556.                     break;
  557.             }
  558.         }
  559.         $instance->setUpdatedAt(new \DateTimeImmutable());
  560.     }
  561.     /**
  562.      * @Route("/instances/{id}/views", name="instance_view_create", methods={"POST"})
  563.      */
  564.     public function createView(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): Response
  565.     {
  566.         $this->denyAccessUnlessGranted('ROLE_USER');
  567.         $instance $instanceRepository->find($id);
  568.         if (!$instance) {
  569.             return $this->redirectToRoute('instance_index');
  570.         }
  571.         $name trim((string) $request->request->get('name'));
  572.         if ($name === '') {
  573.             $this->addFlash('danger''View name is required.');
  574.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  575.         }
  576.         $displayName trim((string) $request->request->get('display_name'));
  577.         if ($displayName === '') {
  578.             $displayName $name;
  579.         }
  580.         $row = (int) $request->request->get('row'1);
  581.         if ($row <= 0) {
  582.             $row 1;
  583.         }
  584.         $makeDefault = (bool) $request->request->get('is_default');
  585.         $showOnHomepage = (bool) $request->request->get('show_on_homepage');
  586.         $sortOrder = (int) $request->request->get('sort_order'0);
  587.         $icon trim((string) $request->request->get('icon'));
  588.         // Check uniqueness of view name per instance
  589.         $existingWithName $viewRepository->findOneBy(['instance' => $instance'name' => $name]);
  590.         if ($existingWithName) {
  591.             $this->addFlash('danger''A view with this name already exists for this instance.');
  592.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  593.         }
  594.         if ($makeDefault) {
  595.             $existing $viewRepository->findBy(['instance' => $instance]);
  596.             foreach ($existing as $existingView) {
  597.                 $existingView->setIsDefault(false);
  598.             }
  599.         }
  600.         $view = new InstanceView();
  601.         $view->setInstance($instance);
  602.         $view->setName($name);
  603.         $view->setDisplayName($displayName);
  604.         $view->setIsDefault($makeDefault);
  605.         $view->setShowOnHomepage($showOnHomepage);
  606.         $view->setSortOrder($sortOrder);
  607.         $view->setIcon($icon !== '' $icon null);
  608.         $entityManager->persist($view);
  609.         $entityManager->flush();
  610.         $this->addFlash('success''View created.');
  611.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  612.     }
  613.     /**
  614.      * @Route("/instances/{id}/views/glb", name="instance_view_glb_upload", methods={"POST"})
  615.      */
  616.     public function uploadViewGlb(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManagerFileStorageService $storage): Response
  617.     {
  618.         $this->denyAccessUnlessGranted('ROLE_USER');
  619.         $instance $instanceRepository->find($id);
  620.         if (!$instance) {
  621.             return $this->redirectToRoute('instance_index');
  622.         }
  623.         if (!$this->isCsrfTokenValid('upload_glb_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  624.             $this->addFlash('danger''Invalid GLB upload token.');
  625.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  626.         }
  627.         $viewId = (int) $request->request->get('view_id');
  628.         $view $viewRepository->find($viewId);
  629.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  630.             $this->addFlash('danger''View not found for this instance.');
  631.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  632.         }
  633.         $uploadedFile $request->files->get('glb_file');
  634.         if (!$uploadedFile) {
  635.             $this->addFlash('danger''No GLB file uploaded.');
  636.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  637.         }
  638.         if (!$uploadedFile->isValid()) {
  639.             $this->addFlash('danger''Uploaded GLB file is not valid.');
  640.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  641.         }
  642.         $extension strtolower((string) $uploadedFile->getClientOriginalExtension());
  643.         if ($extension !== 'glb') {
  644.             $this->addFlash('danger''Only .glb files are allowed for view models.');
  645.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  646.         }
  647.         // Store as model.glb under public/uploads/{slug}/glb/{viewSegment}/
  648.         $targetDir $storage->getGlbDir($instance$view);
  649.         $targetName 'model.glb';
  650.         // Remove any existing model.glb for this view
  651.         $existingPath $targetDir DIRECTORY_SEPARATOR $targetName;
  652.         if (is_file($existingPath)) {
  653.             @unlink($existingPath);
  654.         }
  655.         try {
  656.             $uploadedFile->move($targetDir$targetName);
  657.         } catch (\Throwable $e) {
  658.             $this->addFlash('danger''Failed to save GLB file: ' $e->getMessage());
  659.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  660.         }
  661.         $relativePath $storage->getGlbRelativePath($instance$view$targetName);
  662.         $view->setGlbPath($relativePath);
  663.         $entityManager->flush();
  664.         $this->addFlash('success''GLB file uploaded for view.');
  665.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  666.     }
  667.     /**
  668.      * @Route("/instances/{id}/views/edit", name="instance_view_edit", methods={"POST"})
  669.      */
  670.     public function editView(int $idRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): Response
  671.     {
  672.         $this->denyAccessUnlessGranted('ROLE_USER');
  673.         $instance $instanceRepository->find($id);
  674.         if (!$instance) {
  675.             return $this->redirectToRoute('instance_index');
  676.         }
  677.         if (!$this->isCsrfTokenValid('edit_view_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  678.             $this->addFlash('danger''Invalid edit token.');
  679.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  680.         }
  681.         $viewId = (int) $request->request->get('view_id');
  682.         $view $viewRepository->find($viewId);
  683.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  684.             $this->addFlash('danger''View not found for this instance.');
  685.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  686.         }
  687.         $displayName trim((string) $request->request->get('display_name'));
  688.         if ($displayName === '') {
  689.             $displayName $view->getName();
  690.         }
  691.         $sortOrder = (int) $request->request->get('sort_order'$view->getSortOrder());
  692.         $showOnHomepage = (bool) $request->request->get('show_on_homepage');
  693.         $icon trim((string) $request->request->get('icon'));
  694.         $view->setDisplayName($displayName);
  695.         $view->setSortOrder($sortOrder);
  696.         $view->setShowOnHomepage($showOnHomepage);
  697.         $view->setIcon($icon !== '' $icon null);
  698.         $entityManager->flush();
  699.         $this->addFlash('success''View updated.');
  700.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  701.     }
  702.     /**
  703.      * @Route("/instances/{id}/views/{viewId}/default", name="instance_view_default", methods={"POST"})
  704.      */
  705.     public function setDefaultView(int $idint $viewIdRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManager): Response
  706.     {
  707.         $this->denyAccessUnlessGranted('ROLE_USER');
  708.         $instance $instanceRepository->find($id);
  709.         if (!$instance) {
  710.             return $this->redirectToRoute('instance_index');
  711.         }
  712.         $view $viewRepository->find($viewId);
  713.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  714.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  715.         }
  716.         if (!$this->isCsrfTokenValid('default_view_' $view->getId(), (string) $request->request->get('_token'))) {
  717.             $this->addFlash('danger''Invalid token.');
  718.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  719.         }
  720.         $views $viewRepository->findBy(['instance' => $instance]);
  721.         foreach ($views as $v) {
  722.             $v->setIsDefault($v->getId() === $view->getId());
  723.         }
  724.         $entityManager->flush();
  725.         $this->addFlash('success''Default view updated.');
  726.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  727.     }
  728.     /**
  729.      * @Route("/instances/{id}/views/{viewId}/delete", name="instance_view_delete", methods={"POST"})
  730.      */
  731.     public function deleteView(int $idint $viewIdRequest $requestInstanceRepository $instanceRepositoryInstanceViewRepository $viewRepositoryEntityManagerInterface $entityManagerFileStorageService $storage): Response
  732.     {
  733.         $this->denyAccessUnlessGranted('ROLE_USER');
  734.         $instance $instanceRepository->find($id);
  735.         if (!$instance) {
  736.             return $this->redirectToRoute('instance_index');
  737.         }
  738.         $view $viewRepository->find($viewId);
  739.         if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
  740.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  741.         }
  742.         if (!$this->isCsrfTokenValid('delete_view_' $view->getId(), (string) $request->request->get('_token'))) {
  743.             $this->addFlash('danger''Invalid delete token.');
  744.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  745.         }
  746.         $this->deleteViewMediaAndFiles($instance$view$entityManager$storage);
  747.         $entityManager->remove($view);
  748.         $entityManager->flush();
  749.         $this->addFlash('success''View deleted.');
  750.         return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  751.     }
  752.     /**
  753.      * @Route("/instances/{id}/delete", name="instance_delete", methods={"POST"})
  754.      */
  755.     public function delete(int $idRequest $requestInstanceRepository $instanceRepositoryEntityManagerInterface $entityManagerFileStorageService $storage): Response
  756.     {
  757.         $this->denyAccessUnlessGranted('ROLE_USER');
  758.         $instance $instanceRepository->find($id);
  759.         if (!$instance) {
  760.             return $this->redirectToRoute('instance_index');
  761.         }
  762.         if (!$this->isCsrfTokenValid('delete_instance_' $instance->getId(), (string) $request->request->get('_token'))) {
  763.             $this->addFlash('danger''Invalid delete token.');
  764.             return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
  765.         }
  766.         $storage->deleteInstanceRootDir($instance);
  767.         $entityManager->remove($instance);
  768.         $entityManager->flush();
  769.         $this->addFlash('success''Instance, its views and all images have been deleted.');
  770.         return $this->redirectToRoute('instance_index');
  771.     }
  772.     private function deleteViewMediaAndFiles(Instance $instanceInstanceView $viewEntityManagerInterface $entityManagerFileStorageService $storage): void
  773.     {
  774.         $mediaRepo $entityManager->getRepository(MediaItem::class);
  775.         $items $mediaRepo->findBy(['instance' => $instance'view' => $view]);
  776.         foreach ($items as $item) {
  777.             $entityManager->remove($item);
  778.         }
  779.         $storage->deleteViewDirs($instance$view);
  780.     }
  781.     private function deleteViewRowMediaAndFiles(Instance $instanceInstanceView $viewint $rowEntityManagerInterface $entityManagerFileStorageService $storage): void
  782.     {
  783.         $mediaRepo $entityManager->getRepository(MediaItem::class);
  784.         $items $mediaRepo->findBy(['instance' => $instance'view' => $view'row' => $row]);
  785.         foreach ($items as $item) {
  786.             $entityManager->remove($item);
  787.         }
  788.         $storage->deleteViewRowDirs($instance$view$row);
  789.     }
  790.     private function slugify(string $value): string
  791.     {
  792.         $value strtolower($value);
  793.         $value preg_replace('~[^a-z0-9]+~''-'$value) ?? '';
  794.         $value trim($value'-');
  795.         if ($value === '') {
  796.             $value 'instance-' bin2hex(random_bytes(4));
  797.         }
  798.         return $value;
  799.     }
  800. }