diff --git a/src/Controller/Admin/GameplayFormatCrudController.php b/src/Controller/Admin/GameplayFormatCrudController.php new file mode 100644 index 0000000..2436b3b --- /dev/null +++ b/src/Controller/Admin/GameplayFormatCrudController.php @@ -0,0 +1,157 @@ +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'); + } +} diff --git a/src/Form/GameplayFormatFilterType.php b/src/Form/GameplayFormatFilterType.php new file mode 100644 index 0000000..3530472 --- /dev/null +++ b/src/Form/GameplayFormatFilterType.php @@ -0,0 +1,44 @@ +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 ''; + } +} diff --git a/src/Repository/CardGroupRepository.php b/src/Repository/CardGroupRepository.php index dd2ca42..ab3e8e1 100644 --- a/src/Repository/CardGroupRepository.php +++ b/src/Repository/CardGroupRepository.php @@ -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 */ public function getTriggerStats(): array { diff --git a/src/Repository/CardRepository.php b/src/Repository/CardRepository.php index 6f4280c..254826a 100644 --- a/src/Repository/CardRepository.php +++ b/src/Repository/CardRepository.php @@ -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, 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))); + } } diff --git a/src/Repository/MainEffectRepository.php b/src/Repository/MainEffectRepository.php index 77f52a2..3d4321b 100644 --- a/src/Repository/MainEffectRepository.php +++ b/src/Repository/MainEffectRepository.php @@ -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" ); diff --git a/src/Service/GameplayFormatAdminSearchService.php b/src/Service/GameplayFormatAdminSearchService.php new file mode 100644 index 0000000..67bfd2d --- /dev/null +++ b/src/Service/GameplayFormatAdminSearchService.php @@ -0,0 +1,58 @@ +searchViaMeilisearch($cardNumber, $gameplayFormat, $page, $perPage); + } catch (\Throwable $e) { + $this->logger->warning('Gameplay-format admin search: Meilisearch unavailable, falling back to SQL', [ + 'error' => $e->getMessage(), + ]); + return $this->cardRepository->findFilteredForGameplayFormatAdmin($cardNumber, $gameplayFormat, $page, $perPage); + } + } + + /** @return array{0: Card[], 1: int} */ + private function searchViaMeilisearch(string $cardNumber, string $gameplayFormat, int $page, int $perPage): array + { + $filter = $gameplayFormat !== '' + ? sprintf('gameplay_format = "%s"', addslashes(strtoupper($gameplayFormat))) + : null; + $attrs = $cardNumber !== '' ? ['reference', 'collector_number_formated_id'] : []; + + $ids = $this->meilisearch->searchIds( + query: $cardNumber, + attributesToSearchOn: $attrs, + filter: $filter, + limit: $perPage, + offset: ($page - 1) * $perPage, + ); + + $total = $this->meilisearch->countIds($cardNumber, $filter, $attrs); + + return [$this->cardRepository->findByIdsWithRelations($ids), $total]; + } +} diff --git a/src/Service/GameplayFormatImportResult.php b/src/Service/GameplayFormatImportResult.php new file mode 100644 index 0000000..29f98fa --- /dev/null +++ b/src/Service/GameplayFormatImportResult.php @@ -0,0 +1,44 @@ +httpClient->request('GET', $url, ['timeout' => 10, 'max_duration' => 15])->toArray(false); + } catch (\Throwable $e) { + return GameplayFormatImportResult::error('Impossible de récupérer le fichier : ' . $e->getMessage()); + } + + $errors = $this->validateShape($data); + if (!empty($errors)) { + return GameplayFormatImportResult::error(implode(' ', $errors)); + } + + $references = array_values(array_unique($data['included_refs'])); + [$map, $unmatched] = $this->cardRepository->resolveCardGroupIdsByReferences($references); + + return GameplayFormatImportResult::success( + sourceId: (string) $data['id'], + version: (int) $data['version'], + totalRefs: count($references), + matchedCardGroupIds: array_values(array_unique($map)), + unmatchedRefs: $unmatched, + ); + } + + /** + * Adds $formatName to the gameplayFormat array of every given CardGroup (idempotent). + * + * @param int[] $cardGroupIds + * @return int Number of CardGroups actually modified. + */ + public function apply(string $formatName, array $cardGroupIds): int + { + $formatName = strtoupper(trim($formatName)); + if ($formatName === '' || empty($cardGroupIds)) { + return 0; + } + + $updated = 0; + foreach ($this->cardGroupRepository->findBy(['id' => $cardGroupIds]) as $cardGroup) { + $current = $cardGroup->getGameplayFormat(); + if (!in_array($formatName, $current, true)) { + $cardGroup->setGameplayFormat([...$current, $formatName]); + $updated++; + } + } + $this->em->flush(); + + return $updated; + } + + /** @return string[] */ + private function validateShape(mixed $data): array + { + if (!is_array($data)) { + return ['Le fichier ne contient pas un objet JSON valide.']; + } + + $errors = []; + + if (empty($data['id']) || !is_string($data['id'])) { + $errors[] = 'Champ "id" manquant ou invalide.'; + } + + if (!isset($data['version']) || !is_int($data['version'])) { + $errors[] = 'Champ "version" manquant ou invalide.'; + } + + if (empty($data['included_refs']) || !is_array($data['included_refs'])) { + $errors[] = 'Champ "included_refs" manquant ou vide.'; + } else { + foreach ($data['included_refs'] as $ref) { + if (!is_string($ref) || trim($ref) === '') { + $errors[] = 'Le champ "included_refs" doit contenir uniquement des chaînes non vides.'; + break; + } + } + } + + return $errors; + } +} diff --git a/src/Service/MeilisearchService.php b/src/Service/MeilisearchService.php index 40041bc..cdca10e 100644 --- a/src/Service/MeilisearchService.php +++ b/src/Service/MeilisearchService.php @@ -23,6 +23,8 @@ final class MeilisearchService 'main_effect_en', 'echo_effect_fr', 'echo_effect_en', + 'reference', + 'collector_number_formated_id', ]; /** @@ -189,4 +191,26 @@ public function searchIds(string $query = '', array $attributesToSearchOn = [], return array_column($results->getHits(), 'id'); } + + /** + * Estimated total hits for a query+filter combination (limit=0, instant). + * + * @param string[] $attributesToSearchOn + */ + public function countIds(string $query = '', ?string $filter = null, array $attributesToSearchOn = []): int + { + $params = ['limit' => 0]; + + if ($filter !== null) { + $params['filter'] = $filter; + } + + if (!empty($attributesToSearchOn)) { + $params['attributesToSearchOn'] = $attributesToSearchOn; + } + + $results = $this->getIndex()->search($query ?: null, $params); + + return $results->getEstimatedTotalHits() ?? 0; + } } diff --git a/templates/admin/gameplay_formats/edit.html.twig b/templates/admin/gameplay_formats/edit.html.twig new file mode 100644 index 0000000..4fe9d85 --- /dev/null +++ b/templates/admin/gameplay_formats/edit.html.twig @@ -0,0 +1,63 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Gameplay formats — {{ cardGroup.slug }} — Admin{% endblock %} + +{% block stylesheets %} + +{% endblock %} + +{% block content %} + + + +
+
+ card_group #{{ cardGroup.id }} + {{ cardGroup.slug }} +
+ +
+ +
+ + +

Tapez une valeur puis Entrée pour créer un nouveau gameplay format.

+
+ +
+ + + Annuler + +
+
+
+ +{% endblock %} + +{% block javascripts %} + + +{% endblock %} diff --git a/templates/admin/gameplay_formats/import.html.twig b/templates/admin/gameplay_formats/import.html.twig new file mode 100644 index 0000000..db3ee18 --- /dev/null +++ b/templates/admin/gameplay_formats/import.html.twig @@ -0,0 +1,100 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Importer un gameplay format — Admin{% endblock %} + +{% block content %} + + + +
+

Importer un gameplay format depuis une URL

+ +
+ + +
+ + +
+ +
+ + +

Le fichier doit contenir { "id": "...", "version": 1, "included_refs": ["..."] }.

+
+ +
+ +
+
+
+ +{% if preview and preview.ok %} +
+

Aperçu de l'import

+ +
+
+
Gameplay format
+
{{ formatName|upper }}
+
+
+
Fichier source
+
{{ preview.sourceId }} (v{{ preview.version }})
+
+
+
Références dans le fichier
+
{{ preview.totalRefs }}
+
+
+
Cartes correspondantes en base
+
{{ preview.matchedCardGroupIds|length }}
+
+
+
Références introuvables
+
+ {{ preview.unmatchedRefs|length }} +
+
+
+ + {% if preview.unmatchedRefs|length %} +
+

Références non trouvées en base (ignorées) :

+
+ {% for ref in preview.unmatchedRefs|slice(0, 50) %} + {{ ref }} + {% endfor %} + {% if preview.unmatchedRefs|length > 50 %} + +{{ preview.unmatchedRefs|length - 50 }} autres + {% endif %} +
+
+ {% endif %} + + {% if preview.matchedCardGroupIds|length %} +
+ + + + +
+ {% else %} +

Aucune carte correspondante trouvée, rien à importer.

+ {% endif %} +
+{% endif %} + +{% endblock %} diff --git a/templates/admin/gameplay_formats/index.html.twig b/templates/admin/gameplay_formats/index.html.twig new file mode 100644 index 0000000..e6a3cdc --- /dev/null +++ b/templates/admin/gameplay_formats/index.html.twig @@ -0,0 +1,137 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Gameplay formats — Admin{% endblock %} + +{% block content %} + + + +{# ── Filters ──────────────────────────────────────────────────────────────── #} +
+ {{ form_start(filterForm, {attr: {class: 'flex flex-wrap gap-3 items-end'}}) }} +
+ {{ form_label(filterForm.cardNumber, null, {label_attr: {class: 'block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'}}) }} + {{ form_widget(filterForm.cardNumber, {attr: {class: 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-64 p-2 dark:bg-gray-700 dark:border-gray-600 dark:text-white'}}) }} +
+
+ {{ form_label(filterForm.gameplayFormat, null, {label_attr: {class: 'block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'}}) }} + {{ form_widget(filterForm.gameplayFormat, {attr: {class: 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2 dark:bg-gray-700 dark:border-gray-600 dark:text-white'}}) }} +
+ + {% if filterForm.vars.data.cardNumber is defined and (filterForm.vars.data.cardNumber or filterForm.vars.data.gameplayFormat) %} + + Réinitialiser + + {% endif %} + {{ form_end(filterForm) }} +
+ +{# ── Table ────────────────────────────────────────────────────────────────── #} +
+
+ + {% if hasFilter %} + {{ total|number_format(0, ',', ' ') }} carte(s) + {% else %} + Aucune recherche lancée + {% endif %} + +
+ +
+ + + + + + + + + + + + {% for card in cards %} + + + + + + + + {% else %} + + + + {% endfor %} + +
RéférenceN° collectionSetGameplay formats
{{ card.reference }}{{ card.collectorNumberFormatedId ?? '—' }}{{ card.set ? card.set.reference : '—' }} + {% if card.cardGroup and card.cardGroup.gameplayFormat %} +
+ {% for fmt in card.cardGroup.gameplayFormat %} + + {{ fmt }} + + {% endfor %} +
+ {% else %} + + {% endif %} +
+ {% if card.cardGroup %} + Modifier + {% endif %} +
+ {{ hasFilter ? 'Aucune carte trouvée.' : 'Renseignez un numéro de carte ou un gameplay format pour lancer la recherche.' }} +
+
+ + {# Pagination #} + {% if totalPages > 1 %} +
+ + Page {{ page }} / {{ totalPages }} + + +
+ {% endif %} +
+ +{% endblock %} diff --git a/templates/admin/layout.html.twig b/templates/admin/layout.html.twig index f5cb475..0889409 100644 --- a/templates/admin/layout.html.twig +++ b/templates/admin/layout.html.twig @@ -26,6 +26,10 @@ class="text-sm font-medium {{ app.request.attributes.get('_route') starts with 'admin_abilities' ? 'text-blue-600' : 'text-gray-600 hover:text-gray-900' }} dark:text-gray-300"> {{ 'nav.abilities'|trans }} + + Gameplay formats + | {# Locale switcher #}