<?php
namespace App\Controller;
use App\Entity\DriveImportBatch;
use App\Entity\Instance;
use App\Entity\InstanceView;
use App\Entity\MediaItem;
use App\Form\InstanceType;
use App\Repository\InstanceRepository;
use App\Repository\InstanceViewRepository;
use App\Repository\MediaItemRepository;
use App\Service\DriveImportService;
use App\Service\PropertyListService;
use App\Service\RemotePropertyService;
use App\Service\SwissTransferImportService;
use App\Service\SequenceImportService;
use App\Service\FileStorageService;
use App\Service\ImageResizeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class InstanceController extends AbstractController
{
/**
* @Route("/", name="instance_index")
*/
public function index(InstanceRepository $instanceRepository): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instances = $instanceRepository->findAll();
return $this->render('instance/index.html.twig', [
'instances' => $instances,
]);
}
/**
* @Route("/instances/new", name="instance_new")
*/
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = new Instance();
$form = $this->createForm(InstanceType::class, $instance);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->applyInstanceFormExtras($instance, $form);
$entityManager->persist($instance);
$entityManager->flush();
$this->addFlash('success', 'Instance created.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
return $this->render('instance/form.html.twig', [
'form' => $form->createView(),
'instance' => $instance,
'is_edit' => false,
]);
}
/**
* @Route("/instances/{id}", name="instance_show")
*/
public function show(int $id, Request $request, InstanceRepository $instanceRepository, InstanceViewRepository $viewRepository, MediaItemRepository $mediaItemRepository, EntityManagerInterface $entityManager, DriveImportService $driveImportService, SequenceImportService $sequenceImportService, SwissTransferImportService $swissTransferImportService, FileStorageService $storage, ImageResizeService $imageResizeService, RemotePropertyService $remotePropertyService, PropertyListService $propertyListService): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
throw $this->createNotFoundException('Instance not found.');
}
$views = $viewRepository->findBy(['instance' => $instance], ['sortOrder' => 'ASC', 'id' => 'ASC']);
if (!$views) {
$defaultView = new InstanceView();
$defaultView->setInstance($instance);
$defaultView->setName('Default view');
$defaultView->setIsDefault(true);
$entityManager->persist($defaultView);
$entityManager->flush();
$views = [$defaultView];
} else {
$defaultView = null;
foreach ($views as $view) {
if ($view->isDefault()) {
$defaultView = $view;
break;
}
}
if (!$defaultView) {
$defaultView = $views[0];
$defaultView->setIsDefault(true);
$entityManager->flush();
}
}
$form = $this->createForm(InstanceType::class, $instance);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->applyInstanceFormExtras($instance, $form);
$entityManager->flush();
$this->addFlash('success', 'Instance updated.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$driveImported = null;
$sequenceImported = null;
if ($request->isMethod('POST') && $request->request->has('import_type')) {
$importType = (string) $request->request->get('import_type');
$viewId = (int) $request->request->get('view_id');
$row = (int) $request->request->get('row', 1);
if ($row <= 0) {
$row = 1;
}
$view = null;
if ($viewId) {
$view = $viewRepository->find($viewId);
if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
$view = null;
}
}
if (!$view) {
$view = $defaultView;
}
if ($view) {
$this->deleteViewRowMediaAndFiles($instance, $view, $row, $entityManager, $storage);
}
if ($importType === 'drive') {
$folderUrl = trim((string) $request->request->get('drive_folder_url'));
if ($folderUrl !== '') {
try {
$batch = $driveImportService->enqueueFolderImport($instance, $folderUrl, $view, $row);
$this->addFlash('success', sprintf('Google Drive import was queued (batch #%d). Cron will import 10 images per minute.', $batch->getId()));
} catch (\Throwable $e) {
$this->addFlash('danger', 'Drive import failed: ' . $e->getMessage());
}
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
} elseif ($importType === 'sequence') {
$pattern = trim((string) $request->request->get('sequence_pattern'));
$fromRaw = $request->request->get('sequence_from');
$toRaw = $request->request->get('sequence_to');
$hasFrom = $fromRaw !== null && $fromRaw !== '';
$hasTo = $toRaw !== null && $toRaw !== '';
if ($pattern === '' || !$hasFrom || !$hasTo) {
$this->addFlash('danger', 'Please enter a pattern, from and to values for the numbered import.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$from = (int) $fromRaw;
$to = (int) $toRaw;
try {
$count = $sequenceImportService->importFromPattern($instance, $view, $pattern, $from, $to, $row);
$sequenceImported = $count;
$this->addFlash('success', sprintf('Imported %d images from numbered URLs into view "%s".', $count, $view->getName()));
} catch (\Throwable $e) {
$this->addFlash('danger', 'Sequence import failed: ' . $e->getMessage());
}
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
} elseif ($importType === 'local') {
$files = $request->files->get('local_files');
if (!\is_array($files) || \count($files) === 0) {
$this->addFlash('danger', 'No local images were selected for upload.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$importedLocal = 0;
try {
foreach ($files as $uploadedFile) {
if (!$uploadedFile || !$uploadedFile->isValid()) {
throw new \RuntimeException('One of the uploaded files is not valid.');
}
$originalName = $uploadedFile->getClientOriginalName() ?: ('image_' . $importedLocal);
$content = @file_get_contents($uploadedFile->getPathname());
if ($content === false || $content === '') {
throw new \RuntimeException(sprintf('Failed to read uploaded file "%s".', $originalName));
}
$relativePath = $storage->saveOriginalStream($instance, $view, $originalName, $content, $row);
$mediaItem = new MediaItem();
$mediaItem->setInstance($instance);
$mediaItem->setView($view);
$mediaItem->setType('image');
$mediaItem->setOriginalFilename($originalName);
$mediaItem->setOriginalPath($relativePath);
$mediaItem->setRow($row);
$mediaItem->setSourceType('local_upload');
$mediaItem->setSourceConfig([
'originalName' => $uploadedFile->getClientOriginalName(),
]);
$mediaItem->setStatus('processing');
$variants = $imageResizeService->generateVariants($instance, $mediaItem);
$mediaItem->setVariants($variants);
$mediaItem->setStatus('ready');
$entityManager->persist($mediaItem);
$importedLocal++;
}
$entityManager->flush();
$this->addFlash('success', sprintf('Uploaded %d local images into view "%s".', $importedLocal, $view->getName()));
} catch (\Throwable $e) {
// Clean up any files written for this view/row during this upload
$storage->deleteViewRowDirs($instance, $view, $row);
$this->addFlash('danger', 'Local upload failed: ' . $e->getMessage());
}
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
} elseif ($importType === 'swiss') {
$url = trim((string) $request->request->get('swiss_url'));
if ($url === '') {
$this->addFlash('danger', 'SwissTransfer URL is required.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
try {
$count = $swissTransferImportService->importFromUrl($instance, $view, $url, $row);
$this->addFlash('success', sprintf('Imported %d images from SwissTransfer into view "%s".', $count, $view->getName()));
} catch (\Throwable $e) {
$storage->deleteViewRowDirs($instance, $view, $row);
$this->addFlash('danger', 'SwissTransfer import failed: ' . $e->getMessage());
}
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
}
$driveBatches = $entityManager->getRepository(DriveImportBatch::class)->findBy(
['instance' => $instance],
['updatedAt' => 'DESC', 'id' => 'DESC'],
5
);
$driveHasActive = false;
foreach ($driveBatches as $batch) {
if (!\in_array($batch->getStatus(), ['done', 'failed'], true)) {
$driveHasActive = true;
break;
}
}
$propertyData = $remotePropertyService->fetchPropertyListForInstance($instance);
$biensList = $propertyListService->build($propertyData['properties'] ?? [], $request->query->all());
$presentationItems = $mediaItemRepository->findBy(
['instance' => $instance, 'type' => 'presentation'],
['row' => 'ASC', 'id' => 'ASC']
);
return $this->render('instance/show.html.twig', [
'instance' => $instance,
'form' => $form->createView(),
'views' => $views,
'defaultView' => $defaultView,
'driveImported' => $driveImported,
'sequenceImported' => $sequenceImported,
'driveBatches' => $driveBatches,
'driveHasActive' => $driveHasActive,
'driveStatusUrl' => $this->generateUrl('instance_drive_import_status', ['id' => $instance->getId()]),
'biensList' => $biensList,
'allProperties' => $propertyData['properties'] ?? [],
'presentationItems' => $presentationItems,
]);
}
/**
* @Route("/instances/{id}/presentation", name="instance_presentation_upload", methods={"POST"})
*/
public function uploadPresentation(int $id, Request $request, InstanceRepository $instanceRepository, MediaItemRepository $mediaItemRepository, FileStorageService $storage, ImageResizeService $imageResizeService, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
if (!$this->isCsrfTokenValid('upload_presentation_instance_' . $instance->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid presentation upload token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$files = $request->files->get('presentation_files');
if (!\is_array($files) || \count($files) === 0) {
$this->addFlash('danger', 'No presentation images selected.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$allowed = ['png', 'jpg', 'jpeg', 'webp'];
$imported = 0;
foreach ($files as $uploadedFile) {
if (!$uploadedFile || !$uploadedFile->isValid()) {
continue;
}
$extension = strtolower((string) $uploadedFile->getClientOriginalExtension());
if (!\in_array($extension, $allowed, true)) {
continue;
}
$originalName = $uploadedFile->getClientOriginalName() ?: ('presentation_' . $imported . '.' . $extension);
$rootDir = $storage->getInstanceRootDir($instance);
$dir = $rootDir . DIRECTORY_SEPARATOR . 'presentation';
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$safeBase = pathinfo($originalName, PATHINFO_FILENAME);
$safeBase = preg_replace('~[^a-zA-Z0-9_-]+~', '_', (string) $safeBase) ?: 'presentation';
$targetName = $safeBase . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
try {
$uploadedFile->move($dir, $targetName);
} catch (\Throwable $e) {
continue;
}
$absolutePath = $dir . DIRECTORY_SEPARATOR . $targetName;
$relativePath = $storage->getRelativePathFromPublic($absolutePath);
$mediaItem = new MediaItem();
$mediaItem->setInstance($instance);
$mediaItem->setView(null);
$mediaItem->setType('presentation');
$mediaItem->setOriginalFilename($originalName);
$mediaItem->setOriginalPath($relativePath);
$mediaItem->setRow(1);
$mediaItem->setSourceType('local_upload');
$mediaItem->setSourceConfig([
'originalName' => $uploadedFile->getClientOriginalName(),
]);
$mediaItem->setStatus('processing');
$variants = $imageResizeService->generateVariants($instance, $mediaItem);
$mediaItem->setVariants($variants);
$mediaItem->setStatus('ready');
$entityManager->persist($mediaItem);
$imported++;
}
$entityManager->flush();
if ($imported > 0) {
$this->addFlash('success', sprintf('Uploaded %d presentation images.', $imported));
} else {
$this->addFlash('danger', 'No presentation images were uploaded.');
}
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/**
* @Route("/instances/{id}/views/mesh-map", name="instance_view_mesh_map_save", methods={"POST"})
*/
public function saveViewMeshMap(int $id, Request $request, InstanceRepository $instanceRepository, InstanceViewRepository $viewRepository, EntityManagerInterface $entityManager): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->json(['status' => false, 'message' => 'Instance not found.'], 404);
}
$payload = json_decode((string) $request->getContent(), true);
if (!\is_array($payload)) {
return $this->json(['status' => false, 'message' => 'Invalid payload.'], 400);
}
if (!$this->isCsrfTokenValid('save_mesh_map_instance_' . $instance->getId(), (string) ($payload['_token'] ?? ''))) {
return $this->json(['status' => false, 'message' => 'Invalid token.'], 403);
}
$viewId = (int) ($payload['view_id'] ?? 0);
$view = $viewRepository->find($viewId);
if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
return $this->json(['status' => false, 'message' => 'View not found for this instance.'], 404);
}
$groupName = trim((string) ($payload['group_name'] ?? ''));
if ($groupName === '') {
$groupName = null;
}
$mapping = $payload['mapping'] ?? null;
if (!\is_array($mapping)) {
return $this->json(['status' => false, 'message' => 'Mapping must be an object.'], 400);
}
$clean = [];
foreach ($mapping as $meshName => $slugTitle) {
$meshName = trim((string) $meshName);
if ($meshName === '') {
continue;
}
$slugTitle = trim((string) ($slugTitle ?? ''));
$clean[$meshName] = $slugTitle !== '' ? $slugTitle : null;
}
$view->setMeshGroupName($groupName);
$view->setMeshPropertyMap($clean);
$entityManager->flush();
return $this->json(['status' => true]);
}
/**
* @Route("/instances/{id}/presentation/{mediaId}/delete", name="instance_presentation_delete", methods={"POST"})
*/
public function deletePresentation(int $id, int $mediaId, Request $request, InstanceRepository $instanceRepository, MediaItemRepository $mediaItemRepository, FileStorageService $storage, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
if (!$this->isCsrfTokenValid('delete_presentation_' . $mediaId, (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid delete token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$mediaItem = $mediaItemRepository->find($mediaId);
if (!$mediaItem || !$mediaItem->getInstance() || $mediaItem->getInstance()->getId() !== $instance->getId() || $mediaItem->getType() !== 'presentation') {
$this->addFlash('danger', 'Presentation image not found.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$originalPath = $mediaItem->getOriginalPath();
if ($originalPath) {
$absolute = $storage->getAbsolutePathFromPublicRelative($originalPath);
if (is_file($absolute)) {
@unlink($absolute);
}
}
$variants = $mediaItem->getVariants();
if (\is_array($variants)) {
foreach ($variants as $variant) {
if (!\is_array($variant)) {
continue;
}
foreach (['webp', 'jpg'] as $key) {
$rel = $variant[$key] ?? null;
if (!$rel) {
continue;
}
$absolute = $storage->getAbsolutePathFromPublicRelative($rel);
if (is_file($absolute)) {
@unlink($absolute);
}
}
}
}
$entityManager->remove($mediaItem);
$entityManager->flush();
$this->addFlash('success', 'Presentation image deleted.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/**
* @Route("/instances/{id}/drive-import-status", name="instance_drive_import_status", methods={"GET"})
*/
public function driveImportStatus(int $id, InstanceRepository $instanceRepository, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
throw $this->createNotFoundException('Instance not found.');
}
$driveBatches = $entityManager->getRepository(DriveImportBatch::class)->findBy(
['instance' => $instance],
['updatedAt' => 'DESC', 'id' => 'DESC'],
5
);
$driveHasActive = false;
foreach ($driveBatches as $batch) {
if (!\in_array($batch->getStatus(), ['done', 'failed'], true)) {
$driveHasActive = true;
break;
}
}
return $this->render('instance/_drive_import_status.html.twig', [
'batches' => $driveBatches,
'hasActive' => $driveHasActive,
]);
}
/**
* @Route("/instances/{id}/biens", name="instance_biens_partial", methods={"GET"})
*/
public function biensPartial(int $id, Request $request, InstanceRepository $instanceRepository, RemotePropertyService $remotePropertyService, PropertyListService $propertyListService): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
throw $this->createNotFoundException('Instance not found.');
}
$propertyData = $remotePropertyService->fetchPropertyListForInstance($instance);
$biensList = $propertyListService->build($propertyData['properties'] ?? [], $request->query->all());
return $this->render('shared/_biens_list.html.twig', [
'filters' => $biensList['filters'] ?? [],
'statsByPieces' => $biensList['statsByPieces'] ?? [],
'groups' => $biensList['groups'] ?? [],
'pagination' => $biensList['pagination'] ?? [],
'basePath' => $this->generateUrl('instance_show', ['id' => $instance->getId()]),
'ajaxUrl' => $this->generateUrl('instance_biens_partial', ['id' => $instance->getId()]),
'containerId' => 'biens-list-container-instance-' . $instance->getId(),
'theme' => 'dark',
'detailUrl' => $this->generateUrl('instance_embed_property_detail', ['slug' => $instance->getSlug()]),
]);
}
/**
* @Route("/instances/{id}/drive-import-batches/{batchId}/delete", name="instance_drive_import_batch_delete", methods={"POST"})
*/
public function deleteDriveImportBatch(int $id, int $batchId, Request $request, InstanceRepository $instanceRepository, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
throw $this->createNotFoundException('Instance not found.');
}
if (!$this->isCsrfTokenValid('delete_drive_import_batch_' . $batchId, (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid delete token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/** @var DriveImportBatch|null $batch */
$batch = $entityManager->getRepository(DriveImportBatch::class)->find($batchId);
if (!$batch || !$batch->getInstance() || $batch->getInstance()->getId() !== $instance->getId()) {
$this->addFlash('danger', 'Batch not found.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$entityManager->remove($batch);
$entityManager->flush();
$this->addFlash('success', sprintf('Drive batch #%d was deleted.', $batchId));
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/**
* @Route("/instances/{id}/logo", name="instance_logo_upload", methods={"POST"})
*/
public function uploadLogo(int $id, Request $request, InstanceRepository $instanceRepository, FileStorageService $storage, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
if (!$this->isCsrfTokenValid('upload_logo_instance_' . $instance->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid logo upload token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$uploadedFile = $request->files->get('logo_file');
if (!$uploadedFile) {
$this->addFlash('danger', 'No logo file uploaded.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
if (!$uploadedFile->isValid()) {
$this->addFlash('danger', 'Uploaded logo file is not valid.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$extension = strtolower((string) $uploadedFile->getClientOriginalExtension());
$allowed = ['png', 'jpg', 'jpeg', 'svg'];
if (!\in_array($extension, $allowed, true)) {
$this->addFlash('danger', 'Only PNG, JPG, JPEG or SVG files are allowed for logos.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$rootDir = $storage->getInstanceRootDir($instance);
$logoDir = $rootDir . DIRECTORY_SEPARATOR . 'logo';
if (!is_dir($logoDir)) {
mkdir($logoDir, 0775, true);
}
// Remove any existing logo.* for this instance
foreach ($allowed as $ext) {
$existing = $logoDir . DIRECTORY_SEPARATOR . 'logo.' . $ext;
if (is_file($existing)) {
@unlink($existing);
}
}
$targetName = 'logo.' . $extension;
try {
$uploadedFile->move($logoDir, $targetName);
} catch (\Throwable $e) {
$this->addFlash('danger', 'Failed to save logo file: ' . $e->getMessage());
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$absolutePath = $logoDir . DIRECTORY_SEPARATOR . $targetName;
$relativePath = $storage->getRelativePathFromPublic($absolutePath);
$instance->setLogoPath($relativePath);
$entityManager->flush();
$this->addFlash('success', 'Logo uploaded for instance.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
private function applyInstanceFormExtras(Instance $instance, $form): void
{
$slug = $instance->getSlug();
if (!$slug) {
$instance->setSlug($this->slugify((string) $instance->getName()));
}
// If base URL is left empty, apply a sensible default based on the type
$baseUrl = trim((string) $instance->getBaseUrl());
if ($baseUrl === '') {
switch ($instance->getType()) {
case 'immodev':
$instance->setBaseUrl('https://immodev.max125.com');
break;
case 'ma':
$instance->setBaseUrl('https://ma.immotech.app');
break;
}
}
$instance->setUpdatedAt(new \DateTimeImmutable());
}
/**
* @Route("/instances/{id}/views", name="instance_view_create", methods={"POST"})
*/
public function createView(int $id, Request $request, InstanceRepository $instanceRepository, InstanceViewRepository $viewRepository, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
$name = trim((string) $request->request->get('name'));
if ($name === '') {
$this->addFlash('danger', 'View name is required.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$displayName = trim((string) $request->request->get('display_name'));
if ($displayName === '') {
$displayName = $name;
}
$row = (int) $request->request->get('row', 1);
if ($row <= 0) {
$row = 1;
}
$makeDefault = (bool) $request->request->get('is_default');
$showOnHomepage = (bool) $request->request->get('show_on_homepage');
$sortOrder = (int) $request->request->get('sort_order', 0);
$icon = trim((string) $request->request->get('icon'));
// Check uniqueness of view name per instance
$existingWithName = $viewRepository->findOneBy(['instance' => $instance, 'name' => $name]);
if ($existingWithName) {
$this->addFlash('danger', 'A view with this name already exists for this instance.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
if ($makeDefault) {
$existing = $viewRepository->findBy(['instance' => $instance]);
foreach ($existing as $existingView) {
$existingView->setIsDefault(false);
}
}
$view = new InstanceView();
$view->setInstance($instance);
$view->setName($name);
$view->setDisplayName($displayName);
$view->setIsDefault($makeDefault);
$view->setShowOnHomepage($showOnHomepage);
$view->setSortOrder($sortOrder);
$view->setIcon($icon !== '' ? $icon : null);
$entityManager->persist($view);
$entityManager->flush();
$this->addFlash('success', 'View created.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/**
* @Route("/instances/{id}/views/glb", name="instance_view_glb_upload", methods={"POST"})
*/
public function uploadViewGlb(int $id, Request $request, InstanceRepository $instanceRepository, InstanceViewRepository $viewRepository, EntityManagerInterface $entityManager, FileStorageService $storage): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
if (!$this->isCsrfTokenValid('upload_glb_instance_' . $instance->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid GLB upload token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$viewId = (int) $request->request->get('view_id');
$view = $viewRepository->find($viewId);
if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
$this->addFlash('danger', 'View not found for this instance.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$uploadedFile = $request->files->get('glb_file');
if (!$uploadedFile) {
$this->addFlash('danger', 'No GLB file uploaded.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
if (!$uploadedFile->isValid()) {
$this->addFlash('danger', 'Uploaded GLB file is not valid.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$extension = strtolower((string) $uploadedFile->getClientOriginalExtension());
if ($extension !== 'glb') {
$this->addFlash('danger', 'Only .glb files are allowed for view models.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
// Store as model.glb under public/uploads/{slug}/glb/{viewSegment}/
$targetDir = $storage->getGlbDir($instance, $view);
$targetName = 'model.glb';
// Remove any existing model.glb for this view
$existingPath = $targetDir . DIRECTORY_SEPARATOR . $targetName;
if (is_file($existingPath)) {
@unlink($existingPath);
}
try {
$uploadedFile->move($targetDir, $targetName);
} catch (\Throwable $e) {
$this->addFlash('danger', 'Failed to save GLB file: ' . $e->getMessage());
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$relativePath = $storage->getGlbRelativePath($instance, $view, $targetName);
$view->setGlbPath($relativePath);
$entityManager->flush();
$this->addFlash('success', 'GLB file uploaded for view.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/**
* @Route("/instances/{id}/views/edit", name="instance_view_edit", methods={"POST"})
*/
public function editView(int $id, Request $request, InstanceRepository $instanceRepository, InstanceViewRepository $viewRepository, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
if (!$this->isCsrfTokenValid('edit_view_instance_' . $instance->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid edit token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$viewId = (int) $request->request->get('view_id');
$view = $viewRepository->find($viewId);
if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
$this->addFlash('danger', 'View not found for this instance.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$displayName = trim((string) $request->request->get('display_name'));
if ($displayName === '') {
$displayName = $view->getName();
}
$sortOrder = (int) $request->request->get('sort_order', $view->getSortOrder());
$showOnHomepage = (bool) $request->request->get('show_on_homepage');
$icon = trim((string) $request->request->get('icon'));
$view->setDisplayName($displayName);
$view->setSortOrder($sortOrder);
$view->setShowOnHomepage($showOnHomepage);
$view->setIcon($icon !== '' ? $icon : null);
$entityManager->flush();
$this->addFlash('success', 'View updated.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/**
* @Route("/instances/{id}/views/{viewId}/default", name="instance_view_default", methods={"POST"})
*/
public function setDefaultView(int $id, int $viewId, Request $request, InstanceRepository $instanceRepository, InstanceViewRepository $viewRepository, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
$view = $viewRepository->find($viewId);
if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
if (!$this->isCsrfTokenValid('default_view_' . $view->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$views = $viewRepository->findBy(['instance' => $instance]);
foreach ($views as $v) {
$v->setIsDefault($v->getId() === $view->getId());
}
$entityManager->flush();
$this->addFlash('success', 'Default view updated.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/**
* @Route("/instances/{id}/views/{viewId}/delete", name="instance_view_delete", methods={"POST"})
*/
public function deleteView(int $id, int $viewId, Request $request, InstanceRepository $instanceRepository, InstanceViewRepository $viewRepository, EntityManagerInterface $entityManager, FileStorageService $storage): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
$view = $viewRepository->find($viewId);
if (!$view || $view->getInstance()->getId() !== $instance->getId()) {
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
if (!$this->isCsrfTokenValid('delete_view_' . $view->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid delete token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$this->deleteViewMediaAndFiles($instance, $view, $entityManager, $storage);
$entityManager->remove($view);
$entityManager->flush();
$this->addFlash('success', 'View deleted.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
/**
* @Route("/instances/{id}/delete", name="instance_delete", methods={"POST"})
*/
public function delete(int $id, Request $request, InstanceRepository $instanceRepository, EntityManagerInterface $entityManager, FileStorageService $storage): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$instance = $instanceRepository->find($id);
if (!$instance) {
return $this->redirectToRoute('instance_index');
}
if (!$this->isCsrfTokenValid('delete_instance_' . $instance->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid delete token.');
return $this->redirectToRoute('instance_show', ['id' => $instance->getId()]);
}
$storage->deleteInstanceRootDir($instance);
$entityManager->remove($instance);
$entityManager->flush();
$this->addFlash('success', 'Instance, its views and all images have been deleted.');
return $this->redirectToRoute('instance_index');
}
private function deleteViewMediaAndFiles(Instance $instance, InstanceView $view, EntityManagerInterface $entityManager, FileStorageService $storage): void
{
$mediaRepo = $entityManager->getRepository(MediaItem::class);
$items = $mediaRepo->findBy(['instance' => $instance, 'view' => $view]);
foreach ($items as $item) {
$entityManager->remove($item);
}
$storage->deleteViewDirs($instance, $view);
}
private function deleteViewRowMediaAndFiles(Instance $instance, InstanceView $view, int $row, EntityManagerInterface $entityManager, FileStorageService $storage): void
{
$mediaRepo = $entityManager->getRepository(MediaItem::class);
$items = $mediaRepo->findBy(['instance' => $instance, 'view' => $view, 'row' => $row]);
foreach ($items as $item) {
$entityManager->remove($item);
}
$storage->deleteViewRowDirs($instance, $view, $row);
}
private function slugify(string $value): string
{
$value = strtolower($value);
$value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? '';
$value = trim($value, '-');
if ($value === '') {
$value = 'instance-' . bin2hex(random_bytes(4));
}
return $value;
}
}