From 32dbf5d996080ae10b878219bf597b5d39c789af Mon Sep 17 00:00:00 2001 From: Jules Date: Tue, 7 Jul 2026 18:37:06 +0200 Subject: [PATCH] Attempt to import format from a file --- .env | 7 + composer.json | 2 + composer.lock | 177 +++++++++++++++++- config/packages/messenger.yaml | 22 +++ config/reference.php | 6 +- config/services.yaml | 18 ++ migrations/Version20260707120000.php | 26 +++ src/Command/SyncGameplayFormatsCommand.php | 47 +++++ src/Entity/GameplayFormat.php | 45 +++++ src/Message/SyncGameplayFormatsMessage.php | 7 + .../SyncGameplayFormatsMessageHandler.php | 27 +++ src/Repository/GameplayFormatRepository.php | 18 ++ src/Schedule.php | 28 +++ src/Service/GameplayFormatManifestClient.php | 40 ++++ src/Service/GameplayFormatSyncService.php | 161 ++++++++++++++++ symfony.lock | 24 +++ 16 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 config/packages/messenger.yaml create mode 100644 migrations/Version20260707120000.php create mode 100644 src/Command/SyncGameplayFormatsCommand.php create mode 100644 src/Entity/GameplayFormat.php create mode 100644 src/Message/SyncGameplayFormatsMessage.php create mode 100644 src/MessageHandler/SyncGameplayFormatsMessageHandler.php create mode 100644 src/Repository/GameplayFormatRepository.php create mode 100644 src/Schedule.php create mode 100644 src/Service/GameplayFormatManifestClient.php create mode 100644 src/Service/GameplayFormatSyncService.php diff --git a/.env b/.env index a9c0445..4ca78f4 100644 --- a/.env +++ b/.env @@ -53,3 +53,10 @@ JWT_PASSPHRASE= ###< lexik/jwt-authentication-bundle ### COMMUNITY_DATABASE=datas/community_database + +###> symfony/messenger ### +# Choose one of the transports below +# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages +# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +###< symfony/messenger ### diff --git a/composer.json b/composer.json index 699f4d1..ea7b645 100644 --- a/composer.json +++ b/composer.json @@ -30,10 +30,12 @@ "symfony/form": "8.0.*", "symfony/framework-bundle": "8.0.*", "symfony/http-client": "8.0.*", + "symfony/messenger": "8.0.*", "symfony/monolog-bundle": "*", "symfony/property-access": "8.0.*", "symfony/property-info": "8.0.*", "symfony/runtime": "8.0.*", + "symfony/scheduler": "8.0.*", "symfony/security-bundle": "8.0.*", "symfony/serializer": "8.0.*", "symfony/translation": "8.0.*", diff --git a/composer.lock b/composer.lock index af21a77..8b3529e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d24b10523ca116f475e806de4db7654a", + "content-hash": "b1289613e6ddebc4e05272bd32307a65", "packages": [ { "name": "api-platform/core", @@ -5138,6 +5138,96 @@ ], "time": "2026-03-31T21:14:05+00:00" }, + { + "name": "symfony/messenger", + "version": "v8.0.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "4f1b672abbcace38c8054d7ab66a0c5529e594c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/4f1b672abbcace38c8054d7ab66a0c5529e594c5", + "reference": "4f1b672abbcace38c8054d7ab66a0c5529e594c5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/clock": "^7.4|^8.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/lock": "<7.4", + "symfony/serializer": "<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v8.0.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-09T10:20:00+00:00" + }, { "name": "symfony/monolog-bridge", "version": "v8.0.8", @@ -6188,6 +6278,91 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "symfony/scheduler", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/scheduler.git", + "reference": "d6efd1d0d6ffc8402e4c421884ac7a3d363d360c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/d6efd1d0d6ffc8402e4c421884ac7a3d363d360c", + "reference": "d6efd1d0d6ffc8402e4c421884ac7a3d363d360c", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/clock": "^7.4|^8.0" + }, + "require-dev": { + "dragonmantank/cron-expression": "^3.1", + "symfony/cache": "^7.4.8|^8.0.8", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Scheduler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sergey Rabochiy", + "email": "upyx.00@gmail.com" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides scheduling through Symfony Messenger", + "homepage": "https://symfony.com", + "keywords": [ + "cron", + "schedule", + "scheduler" + ], + "support": { + "source": "https://github.com/symfony/scheduler/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T06:08:44+00:00" + }, { "name": "symfony/security-bundle", "version": "v8.0.8", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 0000000..19db483 --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,22 @@ +framework: + messenger: + # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. + # failure_transport: failed + + transports: + # https://symfony.com/doc/current/messenger.html#transport-configuration + # async: '%env(MESSENGER_TRANSPORT_DSN)%' + # failed: 'doctrine://default?queue_name=failed' + sync: 'sync://' + + routing: + # Route your messages to the transports + # 'App\Message\YourMessage': async + +# when@test: +# framework: +# messenger: +# transports: +# # replace with your transport name here (e.g., my_transport: 'in-memory://') +# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test +# async: 'in-memory://' diff --git a/config/reference.php b/config/reference.php index 019e0c1..f3c0a81 100644 --- a/config/reference.php +++ b/config/reference.php @@ -419,7 +419,7 @@ * resources?: array, * }, * messenger?: bool|array{ // Messenger configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * routing?: array, * }>, @@ -461,7 +461,7 @@ * }>, * }, * scheduler?: bool|array{ // Scheduler configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * }, * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration @@ -1394,7 +1394,7 @@ * include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false * }, * messenger?: bool|array{ - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * }, * elasticsearch?: bool|array{ * enabled?: bool|Param, // Default: false diff --git a/config/services.yaml b/config/services.yaml index 2a2dfb1..669272b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,6 +7,9 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + app.gameplay_formats_manifest_url: 'https://altered-reunion-formats-prod.s3.fr-par.scw.cloud' + # Only these manifest format ids are synced into CardGroup.gameplayFormat. + app.gameplay_formats_allowlist: ['frontier'] services: # default configuration for services in *this* file @@ -38,6 +41,21 @@ services: arguments: - { timeout: 2.0, max_duration: 5.0 } + app.http_client.gameplay_formats: + class: Symfony\Component\HttpClient\HttpClient + factory: ['Symfony\Component\HttpClient\HttpClient', 'create'] + arguments: + - { timeout: 5.0, max_duration: 15.0 } + + App\Service\GameplayFormatManifestClient: + arguments: + $httpClient: '@app.http_client.gameplay_formats' + $baseUrl: '%app.gameplay_formats_manifest_url%' + + App\Service\GameplayFormatSyncService: + arguments: + $allowedFormatIds: '%app.gameplay_formats_allowlist%' + App\Service\MeilisearchService: arguments: $httpClient: '@app.http_client.meilisearch' diff --git a/migrations/Version20260707120000.php b/migrations/Version20260707120000.php new file mode 100644 index 0000000..3000341 --- /dev/null +++ b/migrations/Version20260707120000.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE gameplay_format (id VARCHAR(100) NOT NULL, name VARCHAR(255) NOT NULL, version INT NOT NULL, creation_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, update_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE gameplay_format'); + } +} diff --git a/src/Command/SyncGameplayFormatsCommand.php b/src/Command/SyncGameplayFormatsCommand.php new file mode 100644 index 0000000..1bdbf1e --- /dev/null +++ b/src/Command/SyncGameplayFormatsCommand.php @@ -0,0 +1,47 @@ +syncService->syncAll(); + + if (empty($results)) { + $io->success('No new or changed gameplay format version. Nothing to do.'); + return Command::SUCCESS; + } + + $io->table( + ['Format', 'Name', 'Version', 'Added', 'Removed'], + array_map( + fn (array $r) => [$r['id'], $r['name'], $r['version'], $r['added'], $r['removed']], + $results, + ), + ); + + $io->success(sprintf('%d gameplay format(s) synced.', count($results))); + + return Command::SUCCESS; + } +} diff --git a/src/Entity/GameplayFormat.php b/src/Entity/GameplayFormat.php new file mode 100644 index 0000000..783286d --- /dev/null +++ b/src/Entity/GameplayFormat.php @@ -0,0 +1,45 @@ +id = $id; + $this->creationDate = new \DateTimeImmutable(); + } + + public function getId(): string { return $this->id; } + + public function getName(): string { return $this->name; } + public function setName(string $name): self { $this->name = $name; return $this; } + + public function getVersion(): int { return $this->version; } + public function setVersion(int $version): self { $this->version = $version; return $this; } +} diff --git a/src/Message/SyncGameplayFormatsMessage.php b/src/Message/SyncGameplayFormatsMessage.php new file mode 100644 index 0000000..268d84f --- /dev/null +++ b/src/Message/SyncGameplayFormatsMessage.php @@ -0,0 +1,7 @@ +syncService->syncAll(); + + foreach ($results as $result) { + $this->logger->info('Gameplay format synced', $result); + } + } +} diff --git a/src/Repository/GameplayFormatRepository.php b/src/Repository/GameplayFormatRepository.php new file mode 100644 index 0000000..cfd17fa --- /dev/null +++ b/src/Repository/GameplayFormatRepository.php @@ -0,0 +1,18 @@ + + */ +class GameplayFormatRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, GameplayFormat::class); + } +} diff --git a/src/Schedule.php b/src/Schedule.php new file mode 100644 index 0000000..6885b5a --- /dev/null +++ b/src/Schedule.php @@ -0,0 +1,28 @@ +stateful($this->cache) // ensure missed tasks are executed + ->processOnlyLastMissedRun(true) // ensure only last missed task is run + ->add(RecurringMessage::every('5 minutes', new SyncGameplayFormatsMessage())) + ; + } +} diff --git a/src/Service/GameplayFormatManifestClient.php b/src/Service/GameplayFormatManifestClient.php new file mode 100644 index 0000000..b6243a8 --- /dev/null +++ b/src/Service/GameplayFormatManifestClient.php @@ -0,0 +1,40 @@ + + */ + public function fetchManifest(): array + { + $response = $this->httpClient->request('GET', $this->baseUrl . '/manifest.json'); + + return $response->toArray(); + } + + /** + * @return array{id: string, version: int, included_refs?: string[], excluded_sets?: string[], excluded_refs?: string[]} + */ + public function fetchDefinition(string $path): array + { + $response = $this->httpClient->request('GET', $this->baseUrl . '/' . $path); + + return $response->toArray(); + } +} diff --git a/src/Service/GameplayFormatSyncService.php b/src/Service/GameplayFormatSyncService.php new file mode 100644 index 0000000..3dbc9f5 --- /dev/null +++ b/src/Service/GameplayFormatSyncService.php @@ -0,0 +1,161 @@ + + */ + public function syncAll(): array + { + $results = []; + + foreach ($this->manifestClient->fetchManifest() as $entry) { + if (!in_array($entry['id'], $this->allowedFormatIds, true)) { + continue; + } + + $known = $this->gameplayFormatRepository->find($entry['id']); + + if ($known !== null && $known->getVersion() === $entry['version']) { + continue; + } + + try { + $definition = $this->manifestClient->fetchDefinition($entry['path']); + [$added, $removed] = $this->applyDefinition($entry['id'], $definition); + + $format = $known ?? new GameplayFormat($entry['id']); + $format->setName($entry['name']); + $format->setVersion($entry['version']); + $this->em->persist($format); + $this->em->flush(); + + $results[] = [ + 'id' => $entry['id'], + 'name' => $entry['name'], + 'version' => $entry['version'], + 'added' => $added, + 'removed' => $removed, + ]; + } catch (\Throwable $e) { + $this->logger->error('Gameplay format sync failed', [ + 'format' => $entry['id'], + 'error' => $e->getMessage(), + ]); + } + } + + return $results; + } + + /** + * @return array{0: int, 1: int} [added, removed] + */ + private function applyDefinition(string $formatKey, array $definition): array + { + [$legalSql, $params, $types] = $this->buildLegalPrintingCondition($definition); + $params['key'] = $formatKey; + $types['key'] = \PDO::PARAM_STR; + + $added = $this->connection->executeStatement( + "UPDATE card_group cg + SET gameplay_format = array_append(gameplay_format, :key) + WHERE NOT (:key = ANY(gameplay_format)) + AND EXISTS ( + SELECT 1 FROM card c + LEFT JOIN card_set s ON c.set_id = s.id + LEFT JOIN rarity r ON c.rarity_id = r.id + WHERE c.card_group_id = cg.id AND ($legalSql) + )", + $params, + $types, + ); + + $removed = $this->connection->executeStatement( + "UPDATE card_group cg + SET gameplay_format = array_remove(gameplay_format, :key) + WHERE :key = ANY(gameplay_format) + AND NOT EXISTS ( + SELECT 1 FROM card c + LEFT JOIN card_set s ON c.set_id = s.id + LEFT JOIN rarity r ON c.rarity_id = r.id + WHERE c.card_group_id = cg.id AND ($legalSql) + )", + $params, + $types, + ); + + return [$added, $removed]; + } + + /** + * @return array{0: string, 1: array, 2: array} + */ + private function buildLegalPrintingCondition(array $definition): array + { + $conditions = ['1 = 1']; + $params = []; + $types = []; + + $excludedSets = $definition['excluded_sets'] ?? []; + if ($excludedSets !== []) { + $conditions[] = 's.reference NOT IN (:excludedSets)'; + $params['excludedSets'] = $excludedSets; + $types['excludedSets'] = ArrayParameterType::STRING; + } + + $excludedRefs = $definition['excluded_refs'] ?? []; + if ($excludedRefs !== []) { + $conditions[] = 'c.reference NOT IN (:excludedRefs)'; + $params['excludedRefs'] = $excludedRefs; + $types['excludedRefs'] = ArrayParameterType::STRING; + } + + if (array_key_exists('included_refs', $definition)) { + $includedRefs = $definition['included_refs']; + $conditions[] = $includedRefs === [] + ? "r.reference IS DISTINCT FROM 'UNIQUE'" + : "(r.reference IS DISTINCT FROM 'UNIQUE' OR c.reference IN (:includedRefs))"; + + if ($includedRefs !== []) { + $params['includedRefs'] = $includedRefs; + $types['includedRefs'] = ArrayParameterType::STRING; + } + } + + return [implode(' AND ', $conditions), $params, $types]; + } +} diff --git a/symfony.lock b/symfony.lock index 9b0cecc..62e78ea 100644 --- a/symfony.lock +++ b/symfony.lock @@ -223,6 +223,18 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/messenger": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b" + }, + "files": [ + "config/packages/messenger.yaml" + ] + }, "symfony/monolog-bundle": { "version": "4.0", "recipe": { @@ -260,6 +272,18 @@ "config/routes.yaml" ] }, + "symfony/scheduler": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.2", + "ref": "caea3c928ee9e1b21288fd76aef36f16ea355515" + }, + "files": [ + "src/Schedule.php" + ] + }, "symfony/security-bundle": { "version": "8.0", "recipe": {