diff --git a/assets/js/ui.decks.js b/assets/js/ui.decks.js index 354e75ba..edcf91a7 100755 --- a/assets/js/ui.decks.js +++ b/assets/js/ui.decks.js @@ -63,6 +63,14 @@ }, 500); }; + ui.download_selected = function(ids) + { + var $form = $('#download-deck-list-form'); + var $input = $('#download-deck-list-id'); + $input.val(ids.join('-')); + $form.submit(); + }; + ui.tag_remove_process = function tag_remove_process(event) { event.preventDefault(); @@ -187,6 +195,9 @@ case 'btn-delete-selected': ui.confirm_delete_all(ids); break; + case 'btn-download-selected': + ui.download_selected(ids); + $(".selected-decks-dropdown-toggle").dropdown("toggle"); } return false; }; diff --git a/src/Controller/BuilderController.php b/src/Controller/BuilderController.php index e5de9b82..1982b529 100755 --- a/src/Controller/BuilderController.php +++ b/src/Controller/BuilderController.php @@ -201,21 +201,24 @@ function (FactionInterface $faction) { * @param Request $request * @param DeckImportService $deckImportService * @param DeckManager $deckManager - * @return RedirectResponse|Response - * @throws ORMException - * @throws OptimisticLockException + * @param SessionInterface $session + * @param TranslatorInterface $translator + * @throws BadRequestHttpException + * @return RedirectResponse */ - public function fileimportAction(Request $request, DeckImportService $deckImportService, DeckManager $deckManager) - { + public function fileimportAction( + Request $request, + DeckImportService $deckImportService, + DeckManager $deckManager, + SessionInterface $session, + TranslatorInterface $translator + ) { $uploadedFile = $request->files->get('upfile'); if (!isset($uploadedFile)) { throw new BadRequestHttpException("No file"); } - $origname = $uploadedFile->getClientOriginalName(); - $origext = $uploadedFile->getClientOriginalExtension(); $filename = $uploadedFile->getPathname(); - $name = str_replace(".$origext", '', $origname); if (function_exists("finfo_open")) { // return mime type ala mimetype extension @@ -229,33 +232,72 @@ public function fileimportAction(Request $request, DeckImportService $deckImport } } - $data = $deckImportService->parseTextImport(file_get_contents($filename)); + $parsedData = $deckImportService->parseTextImport(file_get_contents($filename)); - if (empty($data['faction'])) { - return $this->render( - 'Default/error.html.twig', - [ - 'error' => "Unable to recognize the Faction of the deck.", - ] + // Cancel import if number of given lists exceeds the number of available deck slots. + // No partial import of (bulk) uploads is supported. + /** @var UserInterface $user */ + $user = $this->getUser(); + $existingDecks = $deckManager->getByUser($user); + $numberSuccessfullyParsedDecks = count($parsedData['decks']); + $numberOfFailedParsedDecks = count($parsedData['errors']); + $errorMessages = array_unique($parsedData['errors']); + $numberOfDecksUploaded = $numberSuccessfullyParsedDecks + $numberOfFailedParsedDecks; + + if ($user->getMaxNbDecks() < $numberOfDecksUploaded + count($existingDecks)) { + $session->getFlashBag()->set( + 'error', + $translator->trans('decks.import.error.general') + . ' ' . + $translator->trans('decks.save.outOfSlots') ); + return $this->redirect($this->generateUrl('decks_list')); } - $deck = new Deck(); - $deck->setUuid(Uuid::uuid4()); + // finally, import all the "good" decks(s) + foreach ($parsedData['decks'] as $data) { + $deck = new Deck(); + $deck->setUuid(Uuid::uuid4()); - $deckManager->save( - $this->getUser(), - $deck, - null, - $name, - $data['faction'], - $data['description'], - null, - $data['content'], - null - ); + $deckManager->save( + $this->getUser(), + $deck, + null, + $data['name'], + $data['faction'], + $data['description'], + null, + $data['content'], + null + ); + } $this->getDoctrine()->getManager()->flush(); + if ($numberSuccessfullyParsedDecks) { + $session->getFlashBag()->set( + 'notice', + $translator->transChoice( + "decks.import.success", + $numberOfDecksUploaded, + [ '%success%' => $numberSuccessfullyParsedDecks, '%all%' => $numberOfDecksUploaded ] + ) + ); + } + if ($numberOfFailedParsedDecks) { + $session->getFlashBag()->set( + 'error', + $translator->transChoice( + "decks.import.failures", + $numberOfDecksUploaded, + [ '%failures%' => $numberOfFailedParsedDecks, '%all%' => $numberOfDecksUploaded ] + ) . " " . + $translator->transChoice( + "decks.import.failureReasons", + count($errorMessages), + [ '%reasons%' => implode('", "', $errorMessages) ] + ) + ); + } return $this->redirect($this->generateUrl('decks_list')); } @@ -350,11 +392,10 @@ public function cloneAction($deck_uuid) * @Route("/deck/save", name="deck_save", methods={"POST"}) * @param Request $request * @param DeckManager $deckManager + * @param TranslatorInterface $translator * @return RedirectResponse|Response - * @throws ORMException - * @throws OptimisticLockException */ - public function saveAction(Request $request, DeckManager $deckManager) + public function saveAction(Request $request, DeckManager $deckManager, TranslatorInterface $translator) { /* @var EntityManager $em*/ @@ -363,7 +404,7 @@ public function saveAction(Request $request, DeckManager $deckManager) $user = $this->getUser(); if (count($user->getDecks()) > $user->getMaxNbDecks()) { return new Response( - 'You have reached the maximum number of decks allowed. Delete some decks or increase your reputation.' + $translator->trans('decks.save.outOfSlots') ); } @@ -463,6 +504,45 @@ public function deleteAction(Request $request, SessionInterface $session) return $this->redirect($this->generateUrl('decks_list')); } + /** + * @Route("/deck/download_list", name="deck_download_list", methods={"POST"}) + * @param Request $request + * @param SessionInterface $session + * @return Response + */ + public function downloadListAction(Request $request, SessionInterface $session) + { + /* @var $em EntityManager */ + $em = $this->getDoctrine()->getManager(); + $ids = explode('-', $request->get('ids')); + $decks = $em->getRepository(Deck::class)->findBy(['id' => $ids]); + + $currentUserId = $this->getUser()->getId(); + $decks = array_values(array_filter($decks, function (DeckInterface $deck) use ($currentUserId) { + return $currentUserId === $deck->getUser()->getId(); + })); + + $exports = []; + foreach ($decks as $deck) { + $content = $this->renderView('Export/default.txt.twig', [ "deck" => $deck->getTextExport() ]); + $exports[] = str_replace("\n", "\r\n", $content); + } + + $response = new Response(); + $response->headers->set('Content-Type', 'text/plain'); + $response->headers->set( + 'Content-Disposition', + $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + 'decks.txt' + ) + ); + + $response->setContent(implode("\r\n===\r\n", $exports)); + + return $response; + } + /** * @Route("/deck/delete_list", name="deck_delete_list", methods={"POST"}) * @param Request $request diff --git a/src/Services/DeckImportService.php b/src/Services/DeckImportService.php index 658ff982..4e258a14 100644 --- a/src/Services/DeckImportService.php +++ b/src/Services/DeckImportService.php @@ -6,8 +6,9 @@ use App\Entity\Faction; use App\Entity\Pack; use App\Entity\PackInterface; -use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; +use Exception; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Description of DeckImportService @@ -16,29 +17,80 @@ */ class DeckImportService { - public EntityManagerInterface $em; + // three equal signs (or more) in a row are used + // to separate multiple decks in one upload + const DECKS_DELIMITER_REGEXP = '/[=]{3,}/'; - public function __construct(EntityManagerInterface $em) + // x () + // () + const CARD_WITH_PACK_INFO_REGEXP = '/^\s*(\d)x?([^(]+) \(([^)]+)/u'; + + // x + // + const CARD_WITHOUT_PACK_INFO_REGEXP = '/^\s*(\d)x?([\pLl\pLu\pN\-.\'!:" ]+)/u'; + + // # x + // # + const CARD_WITHOUT_PACK_INFO_ALT1_REGEXP = '/^\s*#\d{3}\s(\d)x?([\pLl\pLu\pN\-.\'!: ]+)/u'; + + // x + const CARD_WITHOUT_PACK_INFO_ALT2_REGEXP = '/^([^(]+).*x(\d)/'; + + // + const SINGLE_CARD_WITHOUT_PACK_INFO_REGEXP = '/^([^(]+)/'; + + protected EntityManagerInterface $em; + + protected TranslatorInterface $translator; + + public function __construct(EntityManagerInterface $em, TranslatorInterface $translator) { $this->em = $em; + $this->translator = $translator; } /** * @param string $text * @return array */ - public function parseTextImport($text) + public function parseTextImport(string $text): array { - $data = [ - 'content' => [], - 'faction' => null, - 'description' => '' + $rhett = [ + 'decks' => [], + 'errors' => [], ]; - $lines = explode("\n", $text); + $text = trim($text); + if ('' === $text) { + return $rhett; + } + + $textChunks = preg_split(self::DECKS_DELIMITER_REGEXP, $text, null, PREG_SPLIT_NO_EMPTY); + + $decks = []; + + // trim whitespace off of all lines and filter out any blank lines + $removeFiller = function (array $lines) { + $lines = array_map(function ($line) { + return trim($line); + }, $lines); + $lines = array_filter($lines, function ($line) { + return '' !== $line; + }); + return array_values($lines); + }; + + foreach ($textChunks as $text) { + $lines = explode("\n", trim($text)); + $lines = $removeFiller($lines); + + if (!empty($lines)) { + $decks[] = $lines; + } + } - if (empty($lines)) { - return $data; + if (empty($decks)) { + return $rhett; } // load all packs upfront and map them by their names and codes for easy lookup below @@ -50,25 +102,55 @@ public function parseTextImport($text) return $pack->getCode(); }, $packs), $packs); + foreach ($decks as $lines) { + try { + $rhett['decks'][] = $this->parseOneTextImport($lines, $packsByName, $packsByCode); + } catch (Exception $e) { + $rhett['errors'][] = $e->getMessage(); + } + } + + return $rhett; + } + + /** + * @param array $lines + * @param array $packsByName + * @param array $packsByCode + * @return array + * @throws Exception + */ + protected function parseOneTextImport(array $lines, array $packsByName, array $packsByCode): array + { + $data = [ + 'content' => [], + 'faction' => null, + 'description' => '', + 'name' => 'new deck', + ]; + + // set the deck's name from the first line in the given import + $data['name'] = $lines[0]; + foreach ($lines as $line) { $matches = []; $packNameOrCode = null; $card = null; - if (preg_match('/^\s*(\d)x?([^(]+) \(([^)]+)/u', $line, $matches)) { + if (preg_match(self::CARD_WITH_PACK_INFO_REGEXP, $line, $matches)) { $quantity = intval($matches[1]); $name = trim($matches[2]); $packNameOrCode = trim($matches[3]); - } elseif (preg_match('/^\s*(\d)x?([\pLl\pLu\pN\-\.\'\!\:" ]+)/u', $line, $matches)) { + } elseif (preg_match(self::CARD_WITHOUT_PACK_INFO_REGEXP, $line, $matches)) { $quantity = intval($matches[1]); $name = trim($matches[2]); - } elseif (preg_match('/^\s*#\d{3}\s(\d)x?([\pLl\pLu\pN\-\.\'\!\: ]+)/u', $line, $matches)) { + } elseif (preg_match(self::CARD_WITHOUT_PACK_INFO_ALT1_REGEXP, $line, $matches)) { $quantity = intval($matches[1]); $name = trim($matches[2]); - } elseif (preg_match('/^([^\(]+).*x(\d)/', $line, $matches)) { + } elseif (preg_match(self::CARD_WITHOUT_PACK_INFO_ALT2_REGEXP, $line, $matches)) { $quantity = intval($matches[2]); $name = trim($matches[1]); - } elseif (preg_match('/^([^\(]+)/', $line, $matches)) { + } elseif (preg_match(self::SINGLE_CARD_WITHOUT_PACK_INFO_REGEXP, $line, $matches)) { $quantity = 1; $name = trim($matches[1]); } else { @@ -107,6 +189,10 @@ public function parseTextImport($text) } } + if (empty($data['faction'])) { + throw new Exception($this->translator->trans('decks.import.error.cannotFindFaction')); + } + return $data; } } diff --git a/templates/Builder/decks.html.twig b/templates/Builder/decks.html.twig index 01d89a96..2edeb7bf 100755 --- a/templates/Builder/decks.html.twig +++ b/templates/Builder/decks.html.twig @@ -48,12 +48,25 @@
-