From fdc046e03ae7445ba6c9c9faa1d34f57ac7302f3 Mon Sep 17 00:00:00 2001 From: AaronLS Date: Tue, 4 May 2021 02:40:51 -0400 Subject: [PATCH] Support for importing TCGPlayer mobile app CSV files. --- Mtgdb.Data/CardRepository.cs | 10 ++ Mtgdb.Data/Model/Mtgjson/Card.cs | 7 + .../DeckSerializationSubsystem.cs | 1 + .../DeckSerialization/TcgCsvDeckFormatter.cs | 129 ++++++++++++++++++ Mtgdb.Gui/Mtgdb.Gui.csproj | 18 ++- Mtgdb.Gui/packages.config | 1 + Mtgdb.Util.Win/Mtgdb.Util.Win.csproj | 50 +++---- Mtgdb.sln | 19 ++- .../Dal/DeckFormattersTests.cs | 13 ++ Test/Mtgdb.Util.Test/Mtgdb.Util.Test.csproj | 5 +- .../TcgCsvDeckFormatterInputTestCase.csv | 36 +++++ 11 files changed, 252 insertions(+), 37 deletions(-) create mode 100644 Mtgdb.Gui/DeckSerialization/TcgCsvDeckFormatter.cs create mode 100644 Test/Mtgdb.Util.Test/TestArtifacts/TcgCsvDeckFormatterInputTestCase.csv diff --git a/Mtgdb.Data/CardRepository.cs b/Mtgdb.Data/CardRepository.cs index 33ac91de..e29d3385 100644 --- a/Mtgdb.Data/CardRepository.cs +++ b/Mtgdb.Data/CardRepository.cs @@ -215,6 +215,8 @@ public void Load() CardPrintingsByName = CardsByName.ToDictionary(_ => _.Key, toPrintings, Str.Comparer); TokenPrintingsByName = TokensByName.ToDictionary(_ => _.Key, toPrintings, Str.Comparer); + CardsAndTokensByTcgPlayerProductId = toTcgPlayerProductIdMap(Cards); + for (int i = 0; i < Cards.Count; i++) { var card = Cards[i]; @@ -249,6 +251,13 @@ Dictionary> toNamesakesMap(IEnumerable cards) => // card_by_name_sorting gr => gr.OrderByDescending(_ => _.ReleaseDate).ToList(), Str.Comparer); + + Dictionary> toTcgPlayerProductIdMap(IEnumerable cards) => + cards.GroupBy(_ => _.Identifiers.TcgPlayerProductId) + .ToDictionary( + gr => gr.Key, + gr => gr//.OrderByDescending(_ => _.ReleaseDate) + .ToList()); } private void preProcessSet(Set set) @@ -539,6 +548,7 @@ public IDictionary> MapPrintingsByName(bool tokens public IDictionary CardsById { get; } = new Dictionary(Str.Comparer); public IDictionary> CardsByName { get; private set; } + public IDictionary> CardsAndTokensByTcgPlayerProductId { get; private set; } public IDictionary> TokensByName { get; private set; } public IDictionary> CardIdsByName { get; private set; } public IDictionary> TokenIdsByName { get; private set; } diff --git a/Mtgdb.Data/Model/Mtgjson/Card.cs b/Mtgdb.Data/Model/Mtgjson/Card.cs index 39e94a69..63ee6b7f 100644 --- a/Mtgdb.Data/Model/Mtgjson/Card.cs +++ b/Mtgdb.Data/Model/Mtgjson/Card.cs @@ -749,5 +749,12 @@ public class CardIdentifiers [JsonProperty("scryfallIllustrationId")] [JsonConverter(typeof(InternedStringConverter))] public string ScryfallIllustrationId { get; set; } + + + /// + /// Unique by printing, alt/extended art, promo. But NOT unique per foil. The foil/non-foil version have same product ID. + /// + [JsonProperty("tcgplayerProductId")] + public int TcgPlayerProductId { get; set; } } } diff --git a/Mtgdb.Gui/DeckSerialization/DeckSerializationSubsystem.cs b/Mtgdb.Gui/DeckSerialization/DeckSerializationSubsystem.cs index 9b9f4ba5..9477610f 100644 --- a/Mtgdb.Gui/DeckSerialization/DeckSerializationSubsystem.cs +++ b/Mtgdb.Gui/DeckSerialization/DeckSerializationSubsystem.cs @@ -23,6 +23,7 @@ public DeckSerializationSubsystem( _formatters = new[] { new JsonDeckFormatter(), + new TcgCsvDeckFormatter(cardRepository), new ForgeDeckFormatter(cardRepository, forgeSetRepo), new MagarenaDeckFormatter(cardRepository), new DeckedBuilderDeckFormatter(cardRepository), diff --git a/Mtgdb.Gui/DeckSerialization/TcgCsvDeckFormatter.cs b/Mtgdb.Gui/DeckSerialization/TcgCsvDeckFormatter.cs new file mode 100644 index 00000000..20aafe51 --- /dev/null +++ b/Mtgdb.Gui/DeckSerialization/TcgCsvDeckFormatter.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using FileHelpers; +using Mtgdb.Data; +using Mtgdb.Ui; +using NLog; + +namespace Mtgdb.Gui +{ + + [DelimitedRecord(",")][IgnoreEmptyLines][IgnoreFirst] + public class CardCsvModel + { + public int Quantity {get;set;} + + // E.g. Castle Locthwain (Extended Art), Faerie Guidemother (Showcase), "Goat // Food (16)" + public string Name {get;set;} + + // E.g. Castle Locthwain + public string SimpleName {get;set;} + public string Set {get;set;} + public string CardNumber {get;set;} + public string SetCode {get;set;} + public string Printing {get;set;} + public string Condition {get;set;} + public string Language {get;set;} + public string Rarity {get;set;} + public int ProductID {get;set;} + public string SKU {get;set;} + } + + /// + /// Supports importing the CSV format created by exporting a list from TCG Player's mobile app. + /// + public class TcgCsvDeckFormatter : IDeckFormatter + { + private FileHelperEngine fileHelperEngine; + + public TcgCsvDeckFormatter(CardRepository cardRepository) + { + fileHelperEngine = new FileHelperEngine(); + this.cardRepository = cardRepository; + } + + public string Description => "TCGPlayer Mobile App CSV"; + public string FileNamePattern => "*.csv"; + public bool ValidateFormat(string serialized) + { + string header = serialized.Lines(StringSplitOptions.RemoveEmptyEntries).First(); + var headers = header.Split(','); + + // validate some of the more unique headers, to differentiate from other possible non-TCGPlayer CSV's + return headers.Contains("Simple Name") && headers.Contains("Product ID") && headers.Contains("SKU"); + } + + public bool SupportsExport => false; + public bool SupportsImport => true; + public bool SupportsFile => true; + public bool UseBom => false; + public string FormatHint => null; + + private static readonly Logger _log = LogManager.GetCurrentClassLogger(); + private readonly CardRepository cardRepository; + + public virtual Deck ImportDeck(string serialized, bool exact = false) + { + var result = Deck.Create(); + + List cards = fileHelperEngine.ReadStringAsList(serialized); + + var unmatched = new HashSet(); + + foreach (CardCsvModel csvCard in cards) + { + int count = csvCard.Quantity; + var card = GetCard(csvCard.SimpleName, csvCard.ProductID, csvCard.SetCode,csvCard.CardNumber); + + if (card == null) { + unmatched.Add(csvCard.SimpleName);// error tracking + continue; + } + + //if (isSideboard) + // add(card, count, result.Sideboard); + //else + add(card, count, result.MainDeck); + + } + + _log.Info("Unmatched cards:\r\n{0}", string.Join("\r\n", unmatched)); + + return result; + } + + + private static void add(Card card, int count, DeckZone collection) + { + if (collection.Count.ContainsKey(card.Id)) + collection.Count[card.Id] += count; + else + { + collection.Count[card.Id] = count; + collection.Order.Add(card.Id); + } + } + + public string ExportDeck(string name, Deck current, bool exact = false) + { + throw new NotImplementedException(); + } + + private Card GetCard(string cardName, int tcgPlayerProductId, string setCode= null, string cardNumber=null) + { + // Note TCGPlayer set codes are unreiable, there's some rogue codes like PPELD and PRE that don't exist mtgjson data models + + // Instead, use TCG product ID. + //Unique by printing, alt / extended art, promo. But not by foil. + var cardVariants = cardRepository.CardsAndTokensByTcgPlayerProductId.TryGet(tcgPlayerProductId); + + // TODO: Foil variant from the "Printing" CSV fiel?d which has values "Normal" or "Foil" + + var card = cardVariants?.FirstOrDefault(); + return card; + } + + } +} diff --git a/Mtgdb.Gui/Mtgdb.Gui.csproj b/Mtgdb.Gui/Mtgdb.Gui.csproj index 4b26a3d0..fcf5af88 100644 --- a/Mtgdb.Gui/Mtgdb.Gui.csproj +++ b/Mtgdb.Gui/Mtgdb.Gui.csproj @@ -59,6 +59,9 @@ + + ..\packages\FileHelpers.3.4.2\lib\net45\FileHelpers.dll + ..\packages\JetBrains.Annotations.2020.1.0\lib\net20\JetBrains.Annotations.dll True @@ -109,6 +112,7 @@ + @@ -118,7 +122,9 @@ - + + Form + @@ -129,20 +135,20 @@ - Form + UserControl FormMain.cs - Form + UserControl - Form + UserControl - Form + UserControl @@ -254,7 +260,7 @@ ResourcesType.resx - Form + UserControl diff --git a/Mtgdb.Gui/packages.config b/Mtgdb.Gui/packages.config index 86ccc84c..a3813006 100644 --- a/Mtgdb.Gui/packages.config +++ b/Mtgdb.Gui/packages.config @@ -1,5 +1,6 @@  + diff --git a/Mtgdb.Util.Win/Mtgdb.Util.Win.csproj b/Mtgdb.Util.Win/Mtgdb.Util.Win.csproj index 680d1e68..af975927 100644 --- a/Mtgdb.Util.Win/Mtgdb.Util.Win.csproj +++ b/Mtgdb.Util.Win/Mtgdb.Util.Win.csproj @@ -4,7 +4,7 @@ Debug AnyCPU - {E1349E1C-77A7-4FC3-BDA8-735CD5CB2735} + {40390DA2-253D-4143-97EA-FA61C1AFA6EA} Exe Properties Mtgdb.Util @@ -12,7 +12,7 @@ v4.6.1 512 latest - true + true AnyCPU @@ -80,28 +80,28 @@ - - - {50A7E9B0-70EF-11D1-B75A-00A0C90564FE} - 1 - 0 - 0 - tlbimp - False - True - - - - - {F935DC20-1CF0-11D0-ADB9-00C04FD58A0B} - 1 - 0 - 0 - tlbimp - False - True - - + + + {50A7E9B0-70EF-11D1-B75A-00A0C90564FE} + 1 + 0 + 0 + tlbimp + False + True + + + + + {F935DC20-1CF0-11D0-ADB9-00C04FD58A0B} + 1 + 0 + 0 + tlbimp + False + True + + {10abce2d-9376-4f1e-b316-a8cc9805fad1} @@ -141,4 +141,4 @@ --> - + \ No newline at end of file diff --git a/Mtgdb.sln b/Mtgdb.sln index ff65059c..6eea6872 100644 --- a/Mtgdb.sln +++ b/Mtgdb.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27130.2036 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31205.134 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mtgdb.Data", "Mtgdb.Data\Mtgdb.Data.csproj", "{4E1F0D65-B2B4-44DE-B2A7-F9F36521E475}" EndProject @@ -55,13 +55,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mtgdb.Util.Win", "Mtgdb.Uti EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution - out\out.projitems*{37029487-6a56-4fa3-aa23-b2e46d5aa1b0}*SharedItemsImports = 13 + shared\shared.projitems*{10abce2d-9376-4f1e-b316-a8cc9805fad1}*SharedItemsImports = 4 + shared\shared.projitems*{1b2fea9b-3d4b-430f-a7d2-8cfe47820238}*SharedItemsImports = 4 + shared\shared.projitems*{24c593f8-e50b-4765-aea9-b152c68ebdbc}*SharedItemsImports = 4 + shared\shared.projitems*{3229ca82-875d-4154-92b7-2f7c47678010}*SharedItemsImports = 4 + shared\shared.projitems*{40390da2-253d-4143-97ea-fa61c1afa6ea}*SharedItemsImports = 4 + shared\shared.projitems*{4e1f0d65-b2b4-44de-b2a7-f9f36521e475}*SharedItemsImports = 4 shared\shared.projitems*{4fe226ac-ec61-451f-a602-c79da136ce25}*SharedItemsImports = 4 + shared\shared.projitems*{65731f8b-3fd8-4893-b35f-371f69c9734d}*SharedItemsImports = 4 + shared\shared.projitems*{6fac0808-416a-4605-a1e7-042f7a270bb3}*SharedItemsImports = 4 shared\shared.projitems*{97545c6d-acd6-4a2c-84dd-cd91293cfbb6}*SharedItemsImports = 4 - tools\tools.projitems*{b1c7f4d9-46ba-4d18-92a0-2e8214973929}*SharedItemsImports = 13 shared\shared.projitems*{c2763956-008b-4e6b-8ac7-b634d8741c3b}*SharedItemsImports = 13 + shared\shared.projitems*{c6c3c03b-b8bd-4208-b2da-727536b5cda1}*SharedItemsImports = 4 + shared\shared.projitems*{c837c025-eb64-4e1a-85c4-306a88ed690f}*SharedItemsImports = 4 + shared\shared.projitems*{d5c61885-5ef9-48fd-bb00-8b6622246ee5}*SharedItemsImports = 4 shared\shared.projitems*{e1349e1c-77a7-4fc3-bda8-735cd5cb2735}*SharedItemsImports = 4 - shared\shared.projitems*{40390da2-253d-4143-97ea-fa61c1afa6ea}*SharedItemsImports = 4 + shared\shared.projitems*{e6dc781a-1e0f-481b-aaa9-3585fbd0ffde}*SharedItemsImports = 4 EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/Test/Mtgdb.Util.Test/Dal/DeckFormattersTests.cs b/Test/Mtgdb.Util.Test/Dal/DeckFormattersTests.cs index b6592ebe..77e8db9d 100644 --- a/Test/Mtgdb.Util.Test/Dal/DeckFormattersTests.cs +++ b/Test/Mtgdb.Util.Test/Dal/DeckFormattersTests.cs @@ -61,6 +61,19 @@ public void Mtgo() Log.Debug(name); } + [TestCase(@"TestArtifacts\TcgCsvDeckFormatterInputTestCase.csv", 36)] + public void TcgPlayerCsv(string inputFilePath, int expectedCount)//, string expectedResultJsonFilePath) + { + var deckFormatter = new TcgCsvDeckFormatter(Repo); + // Test file to Parse. File generated by TCG Player mobile card scanning app: + string tcgCsvPath = Path.Combine(TestContext.CurrentContext.TestDirectory, inputFilePath); + + Ui.Deck deck = deckFormatter.ImportDeck(File.ReadAllText(tcgCsvPath)); + + Assert.AreEqual(deck.MainDeck.Count.Sum(d=>d.Value), expectedCount); + + } + private void findCards(FsPath decksLocation, RegexDeckFormatter formatter) { var matches = decksLocation.EnumerateFiles(formatter.FileNamePattern, SearchOption.AllDirectories) diff --git a/Test/Mtgdb.Util.Test/Mtgdb.Util.Test.csproj b/Test/Mtgdb.Util.Test/Mtgdb.Util.Test.csproj index 6e6b389a..ccde47df 100644 --- a/Test/Mtgdb.Util.Test/Mtgdb.Util.Test.csproj +++ b/Test/Mtgdb.Util.Test/Mtgdb.Util.Test.csproj @@ -99,6 +99,9 @@ Designer + + Always + @@ -181,4 +184,4 @@ - + \ No newline at end of file diff --git a/Test/Mtgdb.Util.Test/TestArtifacts/TcgCsvDeckFormatterInputTestCase.csv b/Test/Mtgdb.Util.Test/TestArtifacts/TcgCsvDeckFormatterInputTestCase.csv new file mode 100644 index 00000000..426d1845 --- /dev/null +++ b/Test/Mtgdb.Util.Test/TestArtifacts/TcgCsvDeckFormatterInputTestCase.csv @@ -0,0 +1,36 @@ +Quantity,Name,Simple Name,Set,Card Number,Set Code,Printing,Condition,Language,Rarity,Product ID,SKU +1,Castle Locthwain (Extended Art),Castle Locthwain,Throne of Eldraine,389,ELD,Foil,Near Mint,English,Rare,199389,4206363 +1,Castle Garenbrig (Extended Art),Castle Garenbrig,Throne of Eldraine,388,ELD,Normal,Near Mint,English,Rare,199289,4203160 +1,Irencrag Feat (Extended Art),Irencrag Feat,Throne of Eldraine,362,ELD,Normal,Near Mint,English,Rare,199245,4201268 +1,Worthy Knight,Worthy Knight,Throne of Eldraine,36,ELD,Foil,Near Mint,English,Rare,198811,4187423 +1,Faerie Guidemother (Showcase),Faerie Guidemother,Throne of Eldraine,274,ELD,Foil,Near Mint,English,Common,198977,4193004 +2,Rimrock Knight (Showcase),Rimrock Knight,Throne of Eldraine,294,ELD,Normal,Near Mint,English,Common,199502,4211949 +1,Giant Killer (Showcase),Giant Killer,Throne of Eldraine,275,ELD,Normal,Near Mint,English,Rare,198829,4187908 +1,Order of Midnight (Showcase),Order of Midnight,Throne of Eldraine,288,ELD,Normal,Near Mint,English,Uncommon,198406,4177465 +1,Epic Downfall,Epic Downfall,Throne of Eldraine,85,ELD,Foil,Near Mint,English,Uncommon,198972,4192454 +1,Golden Egg,Golden Egg,Throne of Eldraine,220,ELD,Foil,Near Mint,English,Common,198395,4176350 +1,Witching Well,Witching Well,Throne of Eldraine,74,ELD,Foil,Near Mint,English,Common,198389,4175690 +1,Reaper of Night (Showcase),Reaper of Night,Throne of Eldraine,289,ELD,Foil,Near Mint,English,Common,199500,4211734 +1,Crashing Drawbridge,Crashing Drawbridge,Throne of Eldraine,217,ELD,Foil,Near Mint,English,Common,199543,4216319 +1,Dwarven Mine,Dwarven Mine,Throne of Eldraine,243,ELD,Foil,Near Mint,English,Common,199542,4216209 +1,Merchant of the Vale,Merchant of the Vale,Throne of Eldraine,131,ELD,Foil,Near Mint,English,Common,198998,4193964 +1,Banish into Fable,Banish into Fable,Throne of Eldraine,325,ELD,Normal,Near Mint,English,Rare,198442,4179340 +1,Corridor Monitor,Corridor Monitor,Throne of Eldraine,41,ELD,Foil,Near Mint,English,Common,198558,4180610 +1,Hypnotic Sprite,Hypnotic Sprite,Throne of Eldraine,49,ELD,Foil,Near Mint,English,Uncommon,198792,4186973 +1,Improbable Alliance,Improbable Alliance,Throne of Eldraine,193,ELD,Foil,Near Mint,English,Uncommon,199403,4206743 +1,Tuinvale Treefolk (Showcase),Tuinvale Treefolk,Throne of Eldraine,301,ELD,Normal,Near Mint,English,Common,199253,4201423 +1,Beloved Princess,Beloved Princess,Throne of Eldraine,7,ELD,Foil,Near Mint,English,Common,198891,4190765 +1,Ferocity of the Wilds,Ferocity of the Wilds,Throne of Eldraine,123,ELD,Foil,Near Mint,English,Uncommon,199095,4198390 +1,Lonesome Unicorn (Showcase),Lonesome Unicorn,Throne of Eldraine,276,ELD,Normal,Near Mint,English,Common,199218,4200812 +1,Rally for the Throne,Rally for the Throne,Throne of Eldraine,25,ELD,Foil,Near Mint,English,Uncommon,199210,4200232 +1,Wind-Scarred Crag,Wind-Scarred Crag,Throne of Eldraine,308,ELD,Normal,Near Mint,English,Common,198728,4184422 +1,Giant's Skewer,Giant's Skewer,Throne of Eldraine,91,ELD,Foil,Near Mint,English,Common,199341,4204735 +1,Fierce Witchstalker,Fierce Witchstalker,Throne of Eldraine,154,ELD,Foil,Near Mint,English,Common,199022,4196170 +1,Festive Funeral,Festive Funeral,Throne of Eldraine,87,ELD,Foil,Near Mint,English,Common,199414,4207848 +1,Knight of the Keep,Knight of the Keep,Throne of Eldraine,19,ELD,Foil,Near Mint,English,Common,199008,4195184 +1,Castle Locthwain,Castle Locthwain,Promo Pack: Throne of Eldraine,241p,PPELD,Normal,Near Mint,English,Rare,200387,4230426 +1,Castle Locthwain,Castle Locthwain,Prerelease Cards,241s,PRE,Foil,Near Mint,English,Rare,199936,4222012 +1,Castle Locthwain (Extended Art),Castle Locthwain,Throne of Eldraine,389,ELD,Foil,Near Mint,English,Rare,199389,4206363 +1,Castle Locthwain,Castle Locthwain,Throne of Eldraine,241,ELD,Foil,Near Mint,English,Rare,199388,4206253 +1,Castle Locthwain (Extended Art),Castle Locthwain,Throne of Eldraine,389,ELD,Normal,Near Mint,English,Rare,199389,4206358 +1,Castle Locthwain,Castle Locthwain,Throne of Eldraine,241,ELD,Normal,Near Mint,English,Rare,199388,4206248