Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/Controller/Admin/GameplayFormatCrudController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

namespace App\Controller\Admin;

use App\Form\GameplayFormatFilterType;
use App\Repository\CardGroupRepository;
use App\Repository\CardRepository;
use App\Service\GameplayFormatAdminSearchService;
use App\Service\GameplayFormatImportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/admin/gameplay-formats', name: 'admin_gameplay_formats_')]
class GameplayFormatCrudController extends AbstractController
{
private const PER_PAGE = 20;

public function __construct(
private readonly CardRepository $cardRepo,
private readonly CardGroupRepository $cardGroupRepo,
private readonly EntityManagerInterface $em,
private readonly GameplayFormatImportService $importService,
private readonly GameplayFormatAdminSearchService $searchService,
) {}

#[Route('', name: 'index', methods: ['GET'])]
public function index(Request $request): Response
{
$formats = $this->cardGroupRepo->findDistinctGameplayFormats();

$filterForm = $this->createForm(GameplayFormatFilterType::class, null, [
'format_choices' => $formats,
]);
$filterForm->handleRequest($request);

$data = $filterForm->isSubmitted() ? $filterForm->getData() : [];
$cardNumber = trim((string) ($data['cardNumber'] ?? ''));
$gameplayFormat = (string) ($data['gameplayFormat'] ?? '');

// `card` holds every print (millions of rows) — never run the unfiltered
// COUNT/SELECT, it would scan the whole table on every page load.
$hasFilter = $cardNumber !== '' || $gameplayFormat !== '';
$page = max(1, $request->query->getInt('page', 1));

if ($hasFilter) {
[$cards, $total] = $this->searchService->search($cardNumber, $gameplayFormat, $page, self::PER_PAGE);
} else {
$cards = [];
$total = 0;
}

return $this->render('admin/gameplay_formats/index.html.twig', [
'cards' => $cards,
'total' => $total,
'page' => $page,
'totalPages' => max(1, (int) ceil($total / self::PER_PAGE)),
'filterForm' => $filterForm,
'hasFilter' => $hasFilter,
]);
}

#[Route('/{id}/edit', name: 'edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
public function edit(int $id, Request $request): Response
{
$cardGroup = $this->cardGroupRepo->find($id);
if (!$cardGroup) {
throw $this->createNotFoundException("CardGroup #{$id} introuvable.");
}

if ($request->isMethod('POST')) {
if (!$this->isCsrfTokenValid('gameplay_format_edit_' . $id, $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Jeton CSRF invalide.');
}

$values = array_values(array_unique(array_filter(
array_map(fn(string $v) => strtoupper(trim($v)), $request->request->all('gameplayFormat')),
fn(string $v) => $v !== '',
)));
$cardGroup->setGameplayFormat($values);
$this->em->flush();

$this->addFlash('success', 'Gameplay formats mis à jour.');
return $this->redirectToRoute('admin_gameplay_formats_edit', ['id' => $id]);
}

$formats = array_values(array_unique(array_merge(
$this->cardGroupRepo->findDistinctGameplayFormats(),
$cardGroup->getGameplayFormat(),
)));
sort($formats);

return $this->render('admin/gameplay_formats/edit.html.twig', [
'cardGroup' => $cardGroup,
'formats' => $formats,
]);
}

#[Route('/import', name: 'import', methods: ['GET', 'POST'])]
public function import(Request $request): Response
{
$formatName = trim((string) $request->request->get('formatName', ''));
$url = trim((string) $request->request->get('url', ''));
$preview = null;

if ($request->isMethod('POST')) {
if (!$this->isCsrfTokenValid('gameplay_format_import', $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Jeton CSRF invalide.');
}

if ($formatName === '' || $url === '') {
$this->addFlash('error', 'Le nom du gameplay format et l\'URL sont requis.');
} else {
$preview = $this->importService->fetchAndValidate($url);
if (!$preview->ok) {
$this->addFlash('error', $preview->error);
}
}
}

return $this->render('admin/gameplay_formats/import.html.twig', [
'formatName' => $formatName,
'url' => $url,
'preview' => $preview,
]);
}

#[Route('/import/confirm', name: 'import_confirm', methods: ['POST'])]
public function importConfirm(Request $request): Response
{
if (!$this->isCsrfTokenValid('gameplay_format_import_confirm', $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Jeton CSRF invalide.');
}

$formatName = trim((string) $request->request->get('formatName', ''));
$url = trim((string) $request->request->get('url', ''));

$result = $this->importService->fetchAndValidate($url);
if (!$result->ok) {
$this->addFlash('error', 'Import annulé : ' . $result->error);
return $this->redirectToRoute('admin_gameplay_formats_import');
}

$updated = $this->importService->apply($formatName, $result->matchedCardGroupIds);

$this->addFlash('success', sprintf(
'Gameplay format "%s" appliqué à %d carte(s) (%d référence(s) non trouvée(s)).',
strtoupper($formatName),
$updated,
count($result->unmatchedRefs),
));

return $this->redirectToRoute('admin_gameplay_formats_index');
}
}
44 changes: 44 additions & 0 deletions src/Form/GameplayFormatFilterType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class GameplayFormatFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = array_combine($options['format_choices'], $options['format_choices']);

$builder
->add('cardNumber', TextType::class, [
'label' => 'Numéro de carte',
'required' => false,
'attr' => ['placeholder' => 'Référence ou numéro de collection...'],
])
->add('gameplayFormat', ChoiceType::class, [
'label' => 'Gameplay format',
'required' => false,
'choices' => array_merge(['Tous' => ''], $choices),
])
;
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'method' => 'GET',
'csrf_protection' => false,
'format_choices' => [],
]);
}

public function getBlockPrefix(): string
{
return '';
}
}
8 changes: 8 additions & 0 deletions src/Repository/CardGroupRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ public function findBySlugs(array $slugs): array
return $map;
}

/** Distinct gameplay format keys currently in use, for building admin select choices. */
public function findDistinctGameplayFormats(): array
{
return $this->getEntityManager()->getConnection()->fetchFirstColumn(
'SELECT DISTINCT fmt FROM card_group, unnest(gameplay_format) AS fmt ORDER BY fmt'
);
}

/** @return array<array{trigger_id: int, trigger_text: string|null, nb: int}> */
public function getTriggerStats(): array
{
Expand Down
106 changes: 106 additions & 0 deletions src/Repository/CardRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,110 @@ public function countByEffect(int $effectId): int
->getQuery()
->getSingleScalarResult();
}

/**
* Search cards by number (reference or collector number) and/or gameplay format,
* for the gameplay-format admin screen.
*
* @return array{0: Card[], 1: int}
*/
public function findFilteredForGameplayFormatAdmin(?string $cardNumber, ?string $gameplayFormat, int $page, int $perPage): array
{
$qb = $this->createQueryBuilder('c')
->leftJoin('c.cardGroup', 'cg')
->leftJoin('c.set', 's')
->addSelect('cg', 's');

if ($cardNumber !== null && $cardNumber !== '') {
$qb->andWhere('c.reference LIKE :num OR c.collectorNumberFormatedId LIKE :num')
->setParameter('num', '%' . $cardNumber . '%');
}

if ($gameplayFormat !== null && $gameplayFormat !== '') {
$qb->andWhere('cg.id IN (:gfIds)')
->setParameter('gfIds', $this->cardGroupIdsForGameplayFormat($gameplayFormat));
}

$total = (int) (clone $qb)
->select('COUNT(c.id)')
->getQuery()
->getSingleScalarResult();

$results = $qb
->orderBy('c.reference', 'ASC')
->setFirstResult(($page - 1) * $perPage)
->setMaxResults($perPage)
->getQuery()
->getResult();

return [$results, $total];
}

/** @return int[] */
private function cardGroupIdsForGameplayFormat(string $format): array
{
$escaped = str_replace("'", "''", strtoupper($format));
$ids = $this->getEntityManager()->getConnection()
->fetchFirstColumn("SELECT id FROM card_group WHERE gameplay_format @> ARRAY['$escaped']");

return $ids ?: [0];
}

/**
* Resolves card references (e.g. from a gameplay-format import file) to their CardGroup id.
*
* @param string[] $references
* @return array{0: array<string,int>, 1: string[]} [reference => cardGroupId, unmatched references]
*/
public function resolveCardGroupIdsByReferences(array $references): array
{
if (empty($references)) {
return [[], []];
}

$rows = $this->createQueryBuilder('c')
->select('c.reference AS reference', 'IDENTITY(c.cardGroup) AS cardGroupId')
->where('c.reference IN (:refs)')
->andWhere('c.cardGroup IS NOT NULL')
->setParameter('refs', $references)
->getQuery()
->getArrayResult();

$map = [];
foreach ($rows as $row) {
$map[$row['reference']] = (int) $row['cardGroupId'];
}

return [$map, array_values(array_diff($references, array_keys($map)))];
}

/**
* Fetch cards by id, with cardGroup/set eagerly joined, in the given id order
* (used to hydrate a Meilisearch result page by primary key).
*
* @param int[] $ids
* @return Card[]
*/
public function findByIdsWithRelations(array $ids): array
{
if (empty($ids)) {
return [];
}

$rows = $this->createQueryBuilder('c')
->leftJoin('c.cardGroup', 'cg')
->leftJoin('c.set', 's')
->addSelect('cg', 's')
->where('c.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();

$byId = [];
foreach ($rows as $card) {
$byId[$card->getId()] = $card;
}

return array_values(array_filter(array_map(fn($id) => $byId[$id] ?? null, $ids)));
}
}
6 changes: 4 additions & 2 deletions src/Repository/MainEffectRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ public function getKeywordStats(): array

return $conn->fetchAllAssociative(
"SELECT kw->>'k' AS keyword, COUNT(*) AS nb
FROM main_effect, jsonb_array_elements(keywords) AS kw
WHERE keywords IS NOT NULL
FROM main_effect,
jsonb_array_elements(
CASE WHEN keywords IS NULL OR keywords::text = 'null' THEN '[]' ELSE keywords::text END::jsonb
) AS kw
GROUP BY keyword
ORDER BY nb DESC"
);
Expand Down
Loading
Loading