diff --git a/forge-core/src/main/java/forge/deck/Deck.java b/forge-core/src/main/java/forge/deck/Deck.java index 399d70edeac..36e8393536a 100644 --- a/forge-core/src/main/java/forge/deck/Deck.java +++ b/forge-core/src/main/java/forge/deck/Deck.java @@ -55,6 +55,8 @@ public class Deck extends DeckBase implements Iterable draftNotes = new HashMap<>(); private Map> deferredSections = null; private Map> loadedSections = null; + private DeckFormat deckFormat; + private String sourceUrl; private String lastCardArtPreferenceUsed = ""; private Boolean lastCardArtOptimisationOptionUsed = null; private boolean includeCardsFromUnspecifiedSet = false; @@ -253,6 +255,8 @@ protected void cloneFieldsTo(final DeckBase clone) { } result.setAiHints(StringUtils.join(aiHints, " | ")); result.setDraftNotes(draftNotes); + result.setDeckFormat(deckFormat); + result.setSourceUrl(sourceUrl); //noinspection ConstantValue if(tags != null) //Can happen deserializing old Decks. result.tags.addAll(this.tags); @@ -613,6 +617,22 @@ public Map getDraftNotes() { return draftNotes; } + public void setDeckFormat(DeckFormat deckFormat0) { + deckFormat = deckFormat0; + } + + public DeckFormat getDeckFormat() { + return deckFormat; + } + + public void setSourceUrl(String sourceUrl0) { + sourceUrl = sourceUrl0; + } + + public String getSourceUrl() { + return sourceUrl; + } + public void setAiHints(String aiHintsInfo) { if (aiHintsInfo == null || aiHintsInfo.trim().isEmpty()) { return; diff --git a/forge-core/src/main/java/forge/deck/io/DeckFileHeader.java b/forge-core/src/main/java/forge/deck/io/DeckFileHeader.java index 8be6dce6dee..888a2b363e5 100644 --- a/forge-core/src/main/java/forge/deck/io/DeckFileHeader.java +++ b/forge-core/src/main/java/forge/deck/io/DeckFileHeader.java @@ -38,6 +38,7 @@ public class DeckFileHeader { /** The Constant DECK_TYPE. */ public static final String DECK_TYPE = "Deck Type"; + public static final String SOURCE_URL = "Source URL"; public static final String TAGS = "Tags"; public static final String TAGS_SEPARATOR = ","; @@ -52,6 +53,7 @@ public class DeckFileHeader { public static final String AI_HINTS = "AiHints"; private final DeckFormat deckType; + private final String sourceUrl; private final boolean customPool; private final String name; @@ -76,6 +78,7 @@ public DeckFileHeader(final FileSection kvPairs) { this.name = kvPairs.get(DeckFileHeader.NAME); this.comment = kvPairs.get(DeckFileHeader.COMMENT); this.deckType = DeckFormat.smartValueOf(kvPairs.get(DeckFileHeader.DECK_TYPE), DeckFormat.Constructed); + this.sourceUrl = kvPairs.get(DeckFileHeader.SOURCE_URL); this.customPool = kvPairs.getBoolean(DeckFileHeader.CSTM_POOL); this.intendedForAi = "computer".equalsIgnoreCase(kvPairs.get(DeckFileHeader.PLAYER)) || "ai".equalsIgnoreCase(kvPairs.get(DeckFileHeader.PLAYER_TYPE)); this.aiHints = kvPairs.get(DeckFileHeader.AI_HINTS); @@ -136,6 +139,10 @@ public final DeckFormat getDeckType() { return this.deckType; } + public String getSourceUrl() { + return sourceUrl; + } + public final Set getTags() { return tags; } diff --git a/forge-core/src/main/java/forge/deck/io/DeckSerializer.java b/forge-core/src/main/java/forge/deck/io/DeckSerializer.java index b591dcde9f6..2592a20bb78 100644 --- a/forge-core/src/main/java/forge/deck/io/DeckSerializer.java +++ b/forge-core/src/main/java/forge/deck/io/DeckSerializer.java @@ -45,6 +45,12 @@ private static List serializeDeck(Deck d) { out.add(TextUtil.enclosedBracket("metadata")); out.add(TextUtil.concatNoSpace(DeckFileHeader.NAME,"=", d.getName().replaceAll("\n", ""))); + if (d.getDeckFormat() != null) { + out.add(TextUtil.concatNoSpace(DeckFileHeader.DECK_TYPE, "=", d.getDeckFormat().name())); + } + if (d.getSourceUrl() != null) { + out.add(TextUtil.concatNoSpace(DeckFileHeader.SOURCE_URL, "=", d.getSourceUrl().replaceAll("\n", ""))); + } // these are optional if (d.getComment() != null) { out.add(TextUtil.concatNoSpace(DeckFileHeader.COMMENT,"=", d.getComment().replaceAll("\n", ""))); @@ -100,6 +106,8 @@ public static Deck fromSections(final Map> sections) { Deck d = new Deck(dh.getName()); d.setComment(dh.getComment()); + d.setDeckFormat(dh.getDeckType()); + d.setSourceUrl(dh.getSourceUrl()); d.setAiHints(dh.getAiHints()); d.getTags().addAll(dh.getTags()); d.setDraftNotes(dh.getDraftNotes()); @@ -109,4 +117,4 @@ public static Deck fromSections(final Map> sections) { d.setDeferredSections(sections); return d; } -} \ No newline at end of file +} diff --git a/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java b/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java index e625ea3ba12..9cf5c0250f6 100644 --- a/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java +++ b/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java @@ -21,12 +21,14 @@ import forge.screens.match.controllers.CDetailPicture; import forge.toolbox.FLabel; import forge.toolbox.FOptionPane; +import forge.toolbox.FTextField; import forge.util.Localizer; import net.miginfocom.swing.MigLayout; import org.apache.commons.lang3.StringUtils; import javax.swing.*; import java.awt.*; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -54,6 +56,12 @@ public class FDeckChooser extends JPanel implements IDecksComboBoxListener { private final FLabel btnViewDeck = new FLabel.ButtonBuilder().text(localizer.getMessage("lblViewDeck")).fontSize(14).build(); private final FLabel btnRandom = new FLabel.ButtonBuilder().fontSize(14).build(); + private JPanel pnlDeckUrl; + private FTextField txtDeckUrl; + private FLabel btnReloadUrl; + private String lastLoadedUrlDeckName; + private UiCommand deckSelectionCommand; + private boolean updatingDeckPool; private boolean isAi; @@ -96,6 +104,7 @@ public FDeckChooser(final CDetailPicture cDetailPicture, final boolean forAi, Ga } }; lstDecks.setItemActivateCommand(cmdViewDeck); + lstDecks.setSelectCommand(this::handleDeckSelection); btnViewDeck.setCommand(cmdViewDeck); } @@ -117,11 +126,14 @@ public void setSelectedDeckType(final DeckType selectedDeckType0) { public DeckManager getLstDecks() { return lstDecks; } + public void setDeckSelectionCommand(final UiCommand command) { + deckSelectionCommand = command; + } + private void updateDecks(final Iterable decks, final ItemManagerConfig config) { lstDecks.setAllowMultipleSelections(false); - lstDecks.setPool(decks); - lstDecks.setup(config); + setDeckPoolWithConfig(decks, config); btnRandom.setText(localizer.getMessage("lblRandomDeck")); btnRandom.setCommand((UiCommand) () -> DeckgenUtil.randomSelect(lstDecks)); @@ -153,8 +165,7 @@ private void updateCustom() { private void updateColors(Predicate formatFilter) { lstDecks.setAllowMultipleSelections(true); - lstDecks.setPool(ColorDeckGenerator.getColorDecks(lstDecks, formatFilter, isAi)); - lstDecks.setup(ItemManagerConfig.STRING_ONLY); + setDeckPoolWithConfig(ColorDeckGenerator.getColorDecks(lstDecks, formatFilter, isAi), ItemManagerConfig.STRING_ONLY); btnRandom.setText(localizer.getMessage("lblRandomColors")); btnRandom.setCommand((UiCommand) () -> DeckgenUtil.randomSelectColors(lstDecks)); @@ -166,8 +177,7 @@ private void updateColors(Predicate formatFilter) { private void updateMatrix(GameFormat format) { lstDecks.setAllowMultipleSelections(false); - lstDecks.setPool(ArchetypeDeckGenerator.getMatrixDecks(format, isAi)); - lstDecks.setup(ItemManagerConfig.STRING_ONLY); + setDeckPoolWithConfig(ArchetypeDeckGenerator.getMatrixDecks(format, isAi), ItemManagerConfig.STRING_ONLY); btnRandom.setText("Random"); btnRandom.setCommand((UiCommand) () -> DeckgenUtil.randomSelect(lstDecks)); @@ -183,8 +193,7 @@ private void updateRandomCommander() { } lstDecks.setAllowMultipleSelections(false); - lstDecks.setPool(CommanderDeckGenerator.getCommanderDecks(deckFormat, isAi, false)); - lstDecks.setup(ItemManagerConfig.STRING_ONLY); + setDeckPoolWithConfig(CommanderDeckGenerator.getCommanderDecks(deckFormat, isAi, false), ItemManagerConfig.STRING_ONLY); btnRandom.setText("Random"); btnRandom.setCommand((UiCommand) () -> DeckgenUtil.randomSelect(lstDecks)); @@ -200,8 +209,7 @@ private void updateRandomCardGenCommander() { } lstDecks.setAllowMultipleSelections(false); - lstDecks.setPool(CommanderDeckGenerator.getCommanderDecks(deckFormat, isAi, true)); - lstDecks.setup(ItemManagerConfig.STRING_ONLY); + setDeckPoolWithConfig(CommanderDeckGenerator.getCommanderDecks(deckFormat, isAi, true), ItemManagerConfig.STRING_ONLY); btnRandom.setText("Random"); btnRandom.setCommand((UiCommand) () -> DeckgenUtil.randomSelect(lstDecks)); @@ -289,6 +297,33 @@ private void updateNetArchiveBlockDecks() { updateDecks(DeckProxy.getNetArchiveBlockDecks(NetDeckArchiveBlock), ItemManagerConfig.NET_DECKS); } + private void updateProvidedDeckUrl() { + lstDecks.setAllowMultipleSelections(false); + setDeckPoolWithConfig(DeckUrlLoader.getUrlDecks(), ItemManagerConfig.NET_DECKS); + + btnRandom.setText(localizer.getMessage("lblReload")); + btnRandom.setCommand(this::loadDeckFromUrl); + + if (lastLoadedUrlDeckName != null) { + lstDecks.setSelectedString(lastLoadedUrlDeckName); + } + if (lstDecks.getSelectedIndex() < 0) { + lstDecks.setSelectedIndex(0); + } + syncUrlFieldWithSelectedDeck(); + } + + private void setDeckPoolWithConfig(final Iterable decks, final ItemManagerConfig config) { + updatingDeckPool = true; + try { + lstDecks.setPool(ImmutableList.of()); + lstDecks.setup(config); + lstDecks.setPool(decks); + } finally { + updatingDeckPool = false; + } + } + public Deck getDeck() { final DeckProxy proxy = lstDecks.getSelectedItem(); if (proxy == null) { @@ -319,22 +354,104 @@ public void populate() { if (decksComboBox == null) { //initialize components with delayed initialization the first time this is populated decksComboBox = new DecksComboBox(); lstDecksContainer = new ItemManagerContainer(lstDecks); + initializeDeckUrlPanel(); decksComboBox.addListener(this); restoreSavedState(); } else { removeAll(); } - this.setLayout(new MigLayout("insets 0, gap 0")); + this.setLayout(new MigLayout("insets 0, gap 0, hidemode 3")); decksComboBox.addTo(this, "w 100%, h 30px!, gapbottom 5px, spanx 2, wrap"); + this.add(pnlDeckUrl, "w 100%, h 30px!, gapbottom 5px, spanx 2, wrap"); this.add(lstDecksContainer, "w 100%, growy, pushy, spanx 2, wrap"); this.add(btnViewDeck, "w 50%-3px, h 30px!, gaptop 5px, gapright 6px"); this.add(btnRandom, "w 50%-3px, h 30px!, gaptop 5px"); + updateDeckUrlPanelVisibility(); if (isShowing()) { revalidate(); repaint(); } } + private void initializeDeckUrlPanel() { + pnlDeckUrl = new JPanel(new MigLayout("insets 0, gap 0")); + pnlDeckUrl.setOpaque(false); + pnlDeckUrl.add(new FLabel.Builder().text(localizer.getMessage("lblDeckUrlLabel")).fontSize(12).fontStyle(Font.BOLD).build(), + "h " + FTextField.HEIGHT + "px!, gapright 6px"); + txtDeckUrl = new FTextField.Builder().build(); + txtDeckUrl.addActionListener(e -> loadDeckFromUrl()); + pnlDeckUrl.add(txtDeckUrl, "growx, pushx, h " + FTextField.HEIGHT + "px!, gapright 6px"); + btnReloadUrl = new FLabel.ButtonBuilder().text(localizer.getMessage("lblReload")).fontSize(14).build(); + btnReloadUrl.setCommand(this::loadDeckFromUrl); + pnlDeckUrl.add(btnReloadUrl, "h " + FTextField.HEIGHT + "px!, w pref!"); + } + + private void updateDeckUrlPanelVisibility() { + if (pnlDeckUrl != null) { + pnlDeckUrl.setVisible(selectedDeckType == DeckType.PROVIDED_DECK_URL); + } + } + + private void syncUrlFieldWithSelectedDeck() { + if (txtDeckUrl == null || selectedDeckType != DeckType.PROVIDED_DECK_URL) { + return; + } + final DeckProxy selected = lstDecks.getSelectedItem(); + if (selected != null && selected.getSourceUrl() != null) { + txtDeckUrl.setText(selected.getSourceUrl()); + } + } + + private void handleDeckSelection() { + if (updatingDeckPool) { + return; + } + syncUrlFieldWithSelectedDeck(); + if (deckSelectionCommand != null) { + deckSelectionCommand.run(); + } + } + + private void loadDeckFromUrl() { + if (txtDeckUrl == null) { + return; + } + final String deckUrl = txtDeckUrl.getText().trim(); + if (deckUrl.isBlank()) { + return; + } + + setDeckUrlLoading(true); + FThreads.invokeInBackgroundThread(() -> { + try { + final DeckProxy deck = DeckUrlLoader.load(deckUrl); + FThreads.invokeInEdtLater(() -> { + lastLoadedUrlDeckName = deck.toString(); + if (selectedDeckType == DeckType.PROVIDED_DECK_URL) { + refreshDecksList(DeckType.PROVIDED_DECK_URL, true, null); + } + setDeckUrlLoading(false); + }); + } catch (final IOException ex) { + FThreads.invokeInEdtLater(() -> { + setDeckUrlLoading(false); + FOptionPane.showErrorDialog(ex.getMessage(), localizer.getMessage("lblUnableToLoadDeckUrl")); + }); + } + }); + } + + private void setDeckUrlLoading(final boolean loading) { + txtDeckUrl.setEnabled(!loading); + btnReloadUrl.setEnabled(!loading); + btnRandom.setEnabled(!loading); + if (loading) { + btnReloadUrl.setText(localizer.getMessage("lblLoadingEllipsis")); + } else { + btnReloadUrl.setText(localizer.getMessage("lblReload")); + } + } + public final boolean isAi() { return isAi; } @@ -650,9 +767,13 @@ private void refreshDecksList(final DeckType deckType, final boolean forceRefres case NET_ARCHIVE_BLOCK_DECK: updateNetArchiveBlockDecks(); break; + case PROVIDED_DECK_URL: + updateProvidedDeckUrl(); + break; default: break; //other deck types not currently supported here } + updateDeckUrlPanelVisibility(); } private final String SELECTED_DECK_DELIMITER = "::"; diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/VLobby.java b/forge-gui-desktop/src/main/java/forge/screens/home/VLobby.java index 77d2dfc63f0..c3738d9da77 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/VLobby.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/VLobby.java @@ -824,7 +824,7 @@ private FDeckChooser createDeckChooser(final GameType type, final int iSlot, fin final GameType gameType = forCommander ? type : GameType.Constructed; final FDeckChooser fdc = new FDeckChooser(null, ai, gameType, forCommander); fdc.initialize(prefKey, deckType); - fdc.getLstDecks().setSelectCommand(() -> selectMainDeck(fdc, iSlot, forCommander)); + fdc.setDeckSelectionCommand(() -> selectMainDeck(fdc, iSlot, forCommander)); return fdc; }); } diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties index 4cd3cb386c3..c35b5261c21 100644 --- a/forge-gui/res/languages/de-DE.properties +++ b/forge-gui/res/languages/de-DE.properties @@ -687,6 +687,27 @@ lblNetArchivePioneerDecks=Netz-Archiv Pioneer-Decks lblNetArchiveLegacyDecks=Netz-Archiv Legacy-Decks lblNetArchiveVintageDecks=Netz-Archiv Vintage-Decks lblNetArchiveBlockDecks=Netz-Archiv Block-Decks +lblProvideDeckUrl=Deck-URL angeben +lblDeckUrlLabel=URL: +lblReload=Neu laden +lblLoadingEllipsis=Lädt... +lblUnableToLoadDeckUrl=Deck-URL konnte nicht geladen werden +lblOnlySupportedDeckUrls=Derzeit werden nur Moxfield- und Archidekt-Deck-URLs unterstützt. +lblOnlyMoxfieldDeckUrlsSupported=Derzeit werden nur Moxfield-Deck-URLs unterstützt. +lblMoxfieldUnexpectedResponse=Moxfield hat eine unerwartete Antwort zurückgegeben. +lblNoPlayableCardsInMoxfieldDeck=Im Moxfield-Deck wurden keine spielbaren Karten gefunden. +lblUrlDeck=URL-Deck +lblMoxfieldDeck=Moxfield-Deck +lblMoxfieldCardNotFound=Forge konnte diese Moxfield-Karte nicht in der Datenbank finden: {0} +lblCouldNotFindMoxfieldDeckId=In der URL konnte keine Moxfield-Deck-ID gefunden werden. +lblArchidektUnexpectedResponse=Archidekt hat eine unerwartete Antwort zurückgegeben. +lblNoPlayableCardsInArchidektDeck=Im Archidekt-Deck wurden keine spielbaren Karten gefunden. +lblArchidektDeck=Archidekt-Deck +lblArchidektCardNotFound=Forge konnte diese Archidekt-Karte nicht in der Datenbank finden: {0} +lblCouldNotFindArchidektDeckId=In der URL konnte keine Archidekt-Deck-ID gefunden werden. +lblInvalidDeckUrl=Ungültige Deck-URL. +lblDeckUrlHttpRequestFailed=Die Anfrage an {0} ist mit HTTP {1} fehlgeschlagen. +lblMoxfieldHttpRequestFailed=Die Moxfield-Anfrage ist mit HTTP {0} fehlgeschlagen. lblNetArchivePauperDecks=Netz-Archiv Pauper-Decks #VSubmenuTutorial lblTutorial=Tutorial diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index 3cf177c7aeb..98faedae7ad 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -761,6 +761,27 @@ lblNetArchiveModernDecks=Net Archive Modern Decks lblNetArchiveLegacyDecks=Net Archive Legacy Decks lblNetArchiveVintageDecks=Net Archive Vintage Decks lblNetArchiveBlockDecks=Net Archive Block Decks +lblProvideDeckUrl=Provide Deck URL +lblDeckUrlLabel=URL: +lblReload=Reload +lblLoadingEllipsis=Loading... +lblUnableToLoadDeckUrl=Unable to Load Deck URL +lblOnlySupportedDeckUrls=Only Moxfield and Archidekt deck URLs are supported for now. +lblOnlyMoxfieldDeckUrlsSupported=Only Moxfield deck URLs are supported for now. +lblMoxfieldUnexpectedResponse=Moxfield returned an unexpected response. +lblNoPlayableCardsInMoxfieldDeck=No playable cards were found in the Moxfield deck. +lblUrlDeck=URL Deck +lblMoxfieldDeck=Moxfield Deck +lblMoxfieldCardNotFound=Forge could not find this Moxfield card in the database: {0} +lblCouldNotFindMoxfieldDeckId=Could not find a Moxfield deck id in the URL. +lblArchidektUnexpectedResponse=Archidekt returned an unexpected response. +lblNoPlayableCardsInArchidektDeck=No playable cards were found in the Archidekt deck. +lblArchidektDeck=Archidekt Deck +lblArchidektCardNotFound=Forge could not find this Archidekt card in the database: {0} +lblCouldNotFindArchidektDeckId=Could not find an Archidekt deck id in the URL. +lblInvalidDeckUrl=Invalid deck URL. +lblDeckUrlHttpRequestFailed={0} request failed with HTTP {1}. +lblMoxfieldHttpRequestFailed=Moxfield request failed with HTTP {0}. lblNetArchivePauperDecks=Net Archive Pauper Decks #VSubmenuTutorial lblTutorial=Tutorial diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index 505bb70742c..b5d13fb4138 100644 --- a/forge-gui/res/languages/es-ES.properties +++ b/forge-gui/res/languages/es-ES.properties @@ -665,6 +665,27 @@ lblNetArchiveModernDecks=Archivo online de mazos Modern lblNetArchiveLegacyDecks=Archivo online de mazos Legacy lblNetArchiveVintageDecks=Archivo online de mazos Vintage lblNetArchiveBlockDecks=Archivo online de mazos de Bloque +lblProvideDeckUrl=Proporcionar URL del mazo +lblDeckUrlLabel=URL: +lblReload=Recargar +lblLoadingEllipsis=Cargando... +lblUnableToLoadDeckUrl=No se pudo cargar la URL del mazo +lblOnlySupportedDeckUrls=Por ahora solo se admiten URL de mazos de Moxfield y Archidekt. +lblOnlyMoxfieldDeckUrlsSupported=Por ahora solo se admiten URL de mazos de Moxfield. +lblMoxfieldUnexpectedResponse=Moxfield devolvió una respuesta inesperada. +lblNoPlayableCardsInMoxfieldDeck=No se encontraron cartas jugables en el mazo de Moxfield. +lblUrlDeck=Mazo por URL +lblMoxfieldDeck=Mazo de Moxfield +lblMoxfieldCardNotFound=Forge no pudo encontrar esta carta de Moxfield en la base de datos: {0} +lblCouldNotFindMoxfieldDeckId=No se pudo encontrar un ID de mazo de Moxfield en la URL. +lblArchidektUnexpectedResponse=Archidekt devolvió una respuesta inesperada. +lblNoPlayableCardsInArchidektDeck=No se encontraron cartas jugables en el mazo de Archidekt. +lblArchidektDeck=Mazo de Archidekt +lblArchidektCardNotFound=Forge no pudo encontrar esta carta de Archidekt en la base de datos: {0} +lblCouldNotFindArchidektDeckId=No se pudo encontrar un ID de mazo de Archidekt en la URL. +lblInvalidDeckUrl=URL de mazo no válida. +lblDeckUrlHttpRequestFailed=La solicitud a {0} falló con HTTP {1}. +lblMoxfieldHttpRequestFailed=La solicitud a Moxfield falló con HTTP {0}. lblNetArchivePauperDecks=Archivo online de mazos Pauper #VSubmenuTutorial lblTutorial=Tutorial diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index e6d86b95709..e982d0641ee 100644 --- a/forge-gui/res/languages/fr-FR.properties +++ b/forge-gui/res/languages/fr-FR.properties @@ -664,6 +664,27 @@ lblNetArchiveModernDecks=Net Archiver les decks modernes lblNetArchiveLegacyDecks=Decks hérités de Net Archive lblNetArchiveVintageDecks=Net Archiver les decks vintage lblNetArchiveBlockDecks=Decks de blocs d'archives réseau +lblProvideDeckUrl=Fournir une URL de deck +lblDeckUrlLabel=URL : +lblReload=Recharger +lblLoadingEllipsis=Chargement... +lblUnableToLoadDeckUrl=Impossible de charger l'URL du deck +lblOnlySupportedDeckUrls=Seules les URL de decks Moxfield et Archidekt sont prises en charge pour le moment. +lblOnlyMoxfieldDeckUrlsSupported=Seules les URL de decks Moxfield sont prises en charge pour le moment. +lblMoxfieldUnexpectedResponse=Moxfield a renvoyé une réponse inattendue. +lblNoPlayableCardsInMoxfieldDeck=Aucune carte jouable n'a été trouvée dans le deck Moxfield. +lblUrlDeck=Deck par URL +lblMoxfieldDeck=Deck Moxfield +lblMoxfieldCardNotFound=Forge n'a pas trouvé cette carte Moxfield dans la base de données : {0} +lblCouldNotFindMoxfieldDeckId=Impossible de trouver un ID de deck Moxfield dans l'URL. +lblArchidektUnexpectedResponse=Archidekt a renvoyé une réponse inattendue. +lblNoPlayableCardsInArchidektDeck=Aucune carte jouable n'a été trouvée dans le deck Archidekt. +lblArchidektDeck=Deck Archidekt +lblArchidektCardNotFound=Forge n'a pas trouvé cette carte Archidekt dans la base de données : {0} +lblCouldNotFindArchidektDeckId=Impossible de trouver un ID de deck Archidekt dans l'URL. +lblInvalidDeckUrl=URL de deck invalide. +lblDeckUrlHttpRequestFailed=La requête {0} a échoué avec HTTP {1}. +lblMoxfieldHttpRequestFailed=La requête Moxfield a échoué avec HTTP {0}. lblNetArchivePauperDecks=Decks Pauper Net Archive #VSubmenuTutorial lblTutorial=Tutoriel diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index fae525ace19..0f99954a34d 100644 --- a/forge-gui/res/languages/it-IT.properties +++ b/forge-gui/res/languages/it-IT.properties @@ -662,6 +662,27 @@ lblNetArchiveModernDecks=Mazzi Modern dalla rete lblNetArchiveLegacyDecks=Mazzi Legacy dalla rete lblNetArchiveVintageDecks=Mazzi Vintage dalla rete lblNetArchiveBlockDecks=Mazzi per Blocco dalla rete +lblProvideDeckUrl=Fornisci URL del mazzo +lblDeckUrlLabel=URL: +lblReload=Ricarica +lblLoadingEllipsis=Caricamento... +lblUnableToLoadDeckUrl=Impossibile caricare l'URL del mazzo +lblOnlySupportedDeckUrls=Per ora sono supportati solo gli URL dei mazzi Moxfield e Archidekt. +lblOnlyMoxfieldDeckUrlsSupported=Per ora sono supportati solo gli URL dei mazzi Moxfield. +lblMoxfieldUnexpectedResponse=Moxfield ha restituito una risposta inattesa. +lblNoPlayableCardsInMoxfieldDeck=Non sono state trovate carte giocabili nel mazzo Moxfield. +lblUrlDeck=Mazzo da URL +lblMoxfieldDeck=Mazzo Moxfield +lblMoxfieldCardNotFound=Forge non ha trovato questa carta Moxfield nel database: {0} +lblCouldNotFindMoxfieldDeckId=Impossibile trovare un ID mazzo Moxfield nell'URL. +lblArchidektUnexpectedResponse=Archidekt ha restituito una risposta inattesa. +lblNoPlayableCardsInArchidektDeck=Non sono state trovate carte giocabili nel mazzo Archidekt. +lblArchidektDeck=Mazzo Archidekt +lblArchidektCardNotFound=Forge non ha trovato questa carta Archidekt nel database: {0} +lblCouldNotFindArchidektDeckId=Impossibile trovare un ID mazzo Archidekt nell'URL. +lblInvalidDeckUrl=URL del mazzo non valido. +lblDeckUrlHttpRequestFailed=La richiesta a {0} non è riuscita con HTTP {1}. +lblMoxfieldHttpRequestFailed=La richiesta a Moxfield non è riuscita con HTTP {0}. lblNetArchivePauperDecks=Mazzi per Pauper dalla rete #VSubmenuTutorial lblTutorial=Tutorial diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index 92510c77c30..d5aa800dcd9 100644 --- a/forge-gui/res/languages/ja-JP.properties +++ b/forge-gui/res/languages/ja-JP.properties @@ -663,6 +663,27 @@ lblNetArchiveModernDecks=ネットアーカイブデッキ\u3000モダン lblNetArchiveLegacyDecks=ネットアーカイブデッキ\u3000レガシー lblNetArchiveVintageDecks=ネットアーカイブデッキ\u3000ビンテージ lblNetArchiveBlockDecks=ネットアーカイブデッキ\u3000ブロック +lblProvideDeckUrl=デッキURLを指定 +lblDeckUrlLabel=URL: +lblReload=再読み込み +lblLoadingEllipsis=読み込み中... +lblUnableToLoadDeckUrl=デッキURLを読み込めません +lblOnlySupportedDeckUrls=現在はMoxfieldとArchidektのデッキURLのみ対応しています。 +lblOnlyMoxfieldDeckUrlsSupported=現在はMoxfieldのデッキURLのみ対応しています。 +lblMoxfieldUnexpectedResponse=Moxfieldから予期しない応答が返されました。 +lblNoPlayableCardsInMoxfieldDeck=Moxfieldデッキに使用可能なカードが見つかりませんでした。 +lblUrlDeck=URLデッキ +lblMoxfieldDeck=Moxfieldデッキ +lblMoxfieldCardNotFound=ForgeはこのMoxfieldカードをデータベースで見つけられませんでした: {0} +lblCouldNotFindMoxfieldDeckId=URL内にMoxfieldのデッキIDが見つかりませんでした。 +lblArchidektUnexpectedResponse=Archidektから予期しない応答が返されました。 +lblNoPlayableCardsInArchidektDeck=Archidektデッキに使用可能なカードが見つかりませんでした。 +lblArchidektDeck=Archidektデッキ +lblArchidektCardNotFound=ForgeはこのArchidektカードをデータベースで見つけられませんでした: {0} +lblCouldNotFindArchidektDeckId=URL内にArchidektのデッキIDが見つかりませんでした。 +lblInvalidDeckUrl=無効なデッキURLです。 +lblDeckUrlHttpRequestFailed={0}リクエストがHTTP {1}で失敗しました。 +lblMoxfieldHttpRequestFailed=MoxfieldリクエストがHTTP {0}で失敗しました。 lblNetArchivePauperDecks=ネットアーカイブデッキ\u3000パウパー #VSubmenuTutorial lblTutorial=チュートリアル diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index 26c783a840f..63eed5b11e9 100644 --- a/forge-gui/res/languages/ko-KR.properties +++ b/forge-gui/res/languages/ko-KR.properties @@ -697,6 +697,27 @@ lblNetArchiveModernDecks=넷 아카이브\u3000덱 모던 lblNetArchiveLegacyDecks=넷 아카이브 덱\u3000레거시 lblNetArchiveVintageDecks=넷 아카이브 덱\u3000빈티지 lblNetArchiveBlockDecks=넷 아카이브 덱\u3000블록 +lblProvideDeckUrl=덱 URL 제공 +lblDeckUrlLabel=URL: +lblReload=다시 불러오기 +lblLoadingEllipsis=불러오는 중... +lblUnableToLoadDeckUrl=덱 URL을 불러올 수 없습니다 +lblOnlySupportedDeckUrls=현재는 Moxfield 및 Archidekt 덱 URL만 지원됩니다. +lblOnlyMoxfieldDeckUrlsSupported=현재는 Moxfield 덱 URL만 지원됩니다. +lblMoxfieldUnexpectedResponse=Moxfield에서 예상치 못한 응답을 반환했습니다. +lblNoPlayableCardsInMoxfieldDeck=Moxfield 덱에서 플레이 가능한 카드를 찾을 수 없습니다. +lblUrlDeck=URL 덱 +lblMoxfieldDeck=Moxfield 덱 +lblMoxfieldCardNotFound=Forge가 데이터베이스에서 이 Moxfield 카드를 찾을 수 없습니다: {0} +lblCouldNotFindMoxfieldDeckId=URL에서 Moxfield 덱 ID를 찾을 수 없습니다. +lblArchidektUnexpectedResponse=Archidekt에서 예상치 못한 응답을 반환했습니다. +lblNoPlayableCardsInArchidektDeck=Archidekt 덱에서 플레이 가능한 카드를 찾을 수 없습니다. +lblArchidektDeck=Archidekt 덱 +lblArchidektCardNotFound=Forge가 데이터베이스에서 이 Archidekt 카드를 찾을 수 없습니다: {0} +lblCouldNotFindArchidektDeckId=URL에서 Archidekt 덱 ID를 찾을 수 없습니다. +lblInvalidDeckUrl=잘못된 덱 URL입니다. +lblDeckUrlHttpRequestFailed={0} 요청이 HTTP {1}로 실패했습니다. +lblMoxfieldHttpRequestFailed=Moxfield 요청이 HTTP {0}로 실패했습니다. lblNetArchivePauperDecks=넷 아카이브 덱\u3000파우퍼 #VSubmenuTutorial lblTutorial=튜토리얼 diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index 7b8f451c5b4..682a8e5064d 100644 --- a/forge-gui/res/languages/pt-BR.properties +++ b/forge-gui/res/languages/pt-BR.properties @@ -685,6 +685,27 @@ lblNetArchiveModernDecks=Decks Moderno Arquivados Net lblNetArchiveLegacyDecks=Decks Legado Arquivados Net lblNetArchiveVintageDecks=Decks Vintage Arquivados Net lblNetArchiveBlockDecks=Decks Bloco Arquivados Net +lblProvideDeckUrl=Fornecer URL do deck +lblDeckUrlLabel=URL: +lblReload=Recarregar +lblLoadingEllipsis=Carregando... +lblUnableToLoadDeckUrl=Não foi possível carregar a URL do deck +lblOnlySupportedDeckUrls=Somente URLs de decks do Moxfield e Archidekt são suportadas por enquanto. +lblOnlyMoxfieldDeckUrlsSupported=Somente URLs de decks do Moxfield são suportadas por enquanto. +lblMoxfieldUnexpectedResponse=O Moxfield retornou uma resposta inesperada. +lblNoPlayableCardsInMoxfieldDeck=Nenhuma carta jogável foi encontrada no deck do Moxfield. +lblUrlDeck=Deck por URL +lblMoxfieldDeck=Deck do Moxfield +lblMoxfieldCardNotFound=Forge não encontrou esta carta do Moxfield no banco de dados: {0} +lblCouldNotFindMoxfieldDeckId=Não foi possível encontrar um ID de deck do Moxfield na URL. +lblArchidektUnexpectedResponse=O Archidekt retornou uma resposta inesperada. +lblNoPlayableCardsInArchidektDeck=Nenhuma carta jogável foi encontrada no deck do Archidekt. +lblArchidektDeck=Deck do Archidekt +lblArchidektCardNotFound=Forge não encontrou esta carta do Archidekt no banco de dados: {0} +lblCouldNotFindArchidektDeckId=Não foi possível encontrar um ID de deck do Archidekt na URL. +lblInvalidDeckUrl=URL de deck inválida. +lblDeckUrlHttpRequestFailed=A requisição a {0} falhou com HTTP {1}. +lblMoxfieldHttpRequestFailed=A requisição ao Moxfield falhou com HTTP {0}. lblNetArchivePauperDecks=Decks Pauper Arquivados Net #VSubmenuTutorial lblTutorial=Tutorial diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index 6ebaf5fccf9..ddbdb11d307 100644 --- a/forge-gui/res/languages/zh-CN.properties +++ b/forge-gui/res/languages/zh-CN.properties @@ -665,6 +665,27 @@ lblNetArchivePioneerDecks=网络先驱套牌存档 lblNetArchiveLegacyDecks=网络薪传套牌存档 lblNetArchiveVintageDecks=网络特选套牌存档 lblNetArchiveBlockDecks=网络环境构筑套牌存档 +lblProvideDeckUrl=提供套牌 URL +lblDeckUrlLabel=URL: +lblReload=重新加载 +lblLoadingEllipsis=加载中... +lblUnableToLoadDeckUrl=无法加载套牌 URL +lblOnlySupportedDeckUrls=目前仅支持 Moxfield 和 Archidekt 套牌 URL。 +lblOnlyMoxfieldDeckUrlsSupported=目前仅支持 Moxfield 套牌 URL。 +lblMoxfieldUnexpectedResponse=Moxfield 返回了意外的响应。 +lblNoPlayableCardsInMoxfieldDeck=在 Moxfield 套牌中未找到可用卡牌。 +lblUrlDeck=URL 套牌 +lblMoxfieldDeck=Moxfield 套牌 +lblMoxfieldCardNotFound=Forge 无法在数据库中找到此 Moxfield 卡牌:{0} +lblCouldNotFindMoxfieldDeckId=无法在 URL 中找到 Moxfield 套牌 ID。 +lblArchidektUnexpectedResponse=Archidekt 返回了意外的响应。 +lblNoPlayableCardsInArchidektDeck=在 Archidekt 套牌中未找到可用卡牌。 +lblArchidektDeck=Archidekt 套牌 +lblArchidektCardNotFound=Forge 无法在数据库中找到此 Archidekt 卡牌:{0} +lblCouldNotFindArchidektDeckId=无法在 URL 中找到 Archidekt 套牌 ID。 +lblInvalidDeckUrl=无效的套牌 URL。 +lblDeckUrlHttpRequestFailed={0} 请求失败,HTTP {1}。 +lblMoxfieldHttpRequestFailed=Moxfield 请求失败,HTTP {0}。 lblNetArchivePauperDecks=网络全铁套牌存档 #VSubmenuTutorial lblTutorial=教程 diff --git a/forge-gui/src/main/java/forge/deck/DeckProxy.java b/forge-gui/src/main/java/forge/deck/DeckProxy.java index bb8c91b135e..d5da70882b2 100644 --- a/forge-gui/src/main/java/forge/deck/DeckProxy.java +++ b/forge-gui/src/main/java/forge/deck/DeckProxy.java @@ -87,6 +87,11 @@ public String getPath() { return path; } + public String getSourceUrl() { + final Deck sourceDeck = getDeck(); + return sourceDeck == null ? null : sourceDeck.getSourceUrl(); + } + public CardEdition getEdition() { if (edition == null) { if (deck instanceof PreconDeck pd) { diff --git a/forge-gui/src/main/java/forge/deck/DeckType.java b/forge-gui/src/main/java/forge/deck/DeckType.java index 2f20910ea2d..be6a236bb8e 100644 --- a/forge-gui/src/main/java/forge/deck/DeckType.java +++ b/forge-gui/src/main/java/forge/deck/DeckType.java @@ -40,7 +40,8 @@ public enum DeckType { NET_ARCHIVE_PAUPER_DECK("lblNetArchivePauperDecks"), NET_ARCHIVE_LEGACY_DECK("lblNetArchiveLegacyDecks"), NET_ARCHIVE_VINTAGE_DECK("lblNetArchiveVintageDecks"), - NET_ARCHIVE_BLOCK_DECK("lblNetArchiveBlockDecks"); + NET_ARCHIVE_BLOCK_DECK("lblNetArchiveBlockDecks"), + PROVIDED_DECK_URL("lblProvideDeckUrl"); public static DeckType[] ConstructedOptions; public static DeckType[] CommanderOptions; @@ -71,7 +72,8 @@ public enum DeckType { DeckType.NET_ARCHIVE_PAUPER_DECK, DeckType.NET_ARCHIVE_LEGACY_DECK, DeckType.NET_ARCHIVE_VINTAGE_DECK, - DeckType.NET_ARCHIVE_BLOCK_DECK + DeckType.NET_ARCHIVE_BLOCK_DECK, + DeckType.PROVIDED_DECK_URL }; } else { ConstructedOptions = new DeckType[]{ @@ -91,7 +93,8 @@ public enum DeckType { DeckType.NET_ARCHIVE_PAUPER_DECK, DeckType.NET_ARCHIVE_LEGACY_DECK, DeckType.NET_ARCHIVE_VINTAGE_DECK, - DeckType.NET_ARCHIVE_BLOCK_DECK + DeckType.NET_ARCHIVE_BLOCK_DECK, + DeckType.PROVIDED_DECK_URL }; } } @@ -103,7 +106,8 @@ public enum DeckType { DeckType.RANDOM_COMMANDER_DECK, DeckType.RANDOM_CARDGEN_COMMANDER_DECK, DeckType.RANDOM_DECK, - DeckType.NET_COMMANDER_DECK + DeckType.NET_COMMANDER_DECK, + DeckType.PROVIDED_DECK_URL }; }else{ CommanderOptions = new DeckType[]{ @@ -111,7 +115,8 @@ public enum DeckType { DeckType.PRECON_COMMANDER_DECK, DeckType.RANDOM_COMMANDER_DECK, DeckType.RANDOM_DECK, - DeckType.NET_COMMANDER_DECK + DeckType.NET_COMMANDER_DECK, + DeckType.PROVIDED_DECK_URL }; } diff --git a/forge-gui/src/main/java/forge/deck/DeckUrlLoader.java b/forge-gui/src/main/java/forge/deck/DeckUrlLoader.java new file mode 100644 index 00000000000..b1979fadd03 --- /dev/null +++ b/forge-gui/src/main/java/forge/deck/DeckUrlLoader.java @@ -0,0 +1,624 @@ +package forge.deck; + +import forge.StaticData; +import forge.card.CardDb; +import forge.deck.io.DeckStorage; +import forge.game.GameType; +import forge.item.PaperCard; +import forge.localinstance.properties.ForgeConstants; +import forge.util.Localizer; +import forge.util.storage.StorageImmediatelySerialized; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class DeckUrlLoader { + private static final Pattern MOXFIELD_DECK_URL = Pattern.compile("(?i)(?:^|/)decks/([^/?#]+)"); + private static final Pattern ARCHIDEKT_DECK_URL = Pattern.compile("(?i)(?:^|/)decks/(\\d+)(?:[/?#]|$)"); + private static final String MOXFIELD_API_BASE = "https://api.moxfield.com/v2/decks/all/"; + private static final String ARCHIDEKT_API_BASE = "https://archidekt.com/api/decks/"; + private static final String URL_DECK_DIR_NAME = "URL"; + private static final Localizer localizer = Localizer.getInstance(); + + public static DeckProxy load(final String deckUrl) throws IOException { + final String normalizedUrl = normalizeUrl(deckUrl); + final String host = getHost(normalizedUrl); + final StorageImmediatelySerialized storage = getStorage(); + final Deck deck; + if (host.endsWith("moxfield.com")) { + deck = loadMoxfieldDeck(normalizedUrl, storage); + } else if (host.endsWith("archidekt.com")) { + deck = loadArchidektDeck(normalizedUrl, storage); + } else { + throw new IOException(localizer.getMessage("lblOnlySupportedDeckUrls")); + } + + storage.add(deck); + return new DeckProxy(deck, localizer.getMessage("lblUrlDeck"), GameType.Constructed, storage); + } + + private static Deck loadMoxfieldDeck(final String normalizedUrl, final Iterable savedDecks) throws IOException { + final String deckId = getMoxfieldDeckId(normalizedUrl); + final Map root = readJsonObject(MOXFIELD_API_BASE + deckId, "Moxfield", "lblMoxfieldUnexpectedResponse"); + + final Deck deck = new Deck(getDeckName(root, deckId, normalizedUrl, localizer.getMessage("lblMoxfieldDeck"), savedDecks)); + deck.setSourceUrl(normalizedUrl); + deck.setDeckFormat(getDeckFormat(root.get("format"))); + addMoxfieldSection(deck, root.get("commanders"), DeckSection.Commander); + addMoxfieldSection(deck, root.get("mainboard"), DeckSection.Main); + addMoxfieldSection(deck, root.get("sideboard"), DeckSection.Sideboard); + addMoxfieldSection(deck, root.get("companions"), DeckSection.Sideboard); + + requirePlayableCards(deck, "lblNoPlayableCardsInMoxfieldDeck"); + return deck; + } + + private static Deck loadArchidektDeck(final String normalizedUrl, final Iterable savedDecks) throws IOException { + final String deckId = getArchidektDeckId(normalizedUrl); + final Map root = readJsonObject(ARCHIDEKT_API_BASE + deckId + "/", "Archidekt", "lblArchidektUnexpectedResponse"); + + final Deck deck = new Deck(getDeckName(root, deckId, normalizedUrl, localizer.getMessage("lblArchidektDeck"), savedDecks)); + deck.setSourceUrl(normalizedUrl); + deck.setDeckFormat(getArchidektDeckFormat(root.get("deckFormat"))); + addArchidektCards(deck, root); + + requirePlayableCards(deck, "lblNoPlayableCardsInArchidektDeck"); + return deck; + } + + public static List getUrlDecks() { + final List decks = new ArrayList<>(); + final StorageImmediatelySerialized storage = getStorage(); + for (final Deck deck : storage) { + decks.add(new DeckProxy(deck, localizer.getMessage("lblUrlDeck"), GameType.Constructed, storage)); + } + return decks; + } + + private static void addMoxfieldSection(final Deck deck, final Object sectionValue, final DeckSection section) throws IOException { + if (!(sectionValue instanceof Map cards)) { + return; + } + final CardPool pool = deck.getOrCreate(section); + for (final Map.Entry entry : cards.entrySet()) { + if (!(entry.getValue() instanceof Map cardEntry)) { + continue; + } + + final int quantity = getInt(cardEntry.get("quantity"), 1); + String cardName = getNestedString(cardEntry, "card", "name"); + if (cardName == null) { + cardName = String.valueOf(entry.getKey()); + } + if (!cardName.isBlank() && quantity > 0) { + final String setCode = getNestedString(cardEntry, "card", "set"); + final String collectorNumber = getNestedString(cardEntry, "card", "cn"); + pool.add(getRequiredCard(cardName, setCode, collectorNumber, "lblMoxfieldCardNotFound"), quantity); + } + } + } + + private static void addArchidektCards(final Deck deck, final Map root) throws IOException { + if (!(root.get("cards") instanceof List cards)) { + throw new IOException(localizer.getMessage("lblArchidektUnexpectedResponse")); + } + + final Set excludedCategories = getArchidektExcludedCategories(root); + for (final Object cardValue : cards) { + if (!(cardValue instanceof Map cardEntry) || cardEntry.get("deletedAt") != null) { + continue; + } + + final int quantity = getInt(cardEntry.get("quantity"), 1); + if (quantity <= 0 || isArchidektExcludedCard(cardEntry, excludedCategories)) { + continue; + } + + final String cardName = getArchidektCardName(cardEntry); + if (cardName == null) { + continue; + } + + final String setCode = getNestedString(cardEntry, "card", "edition", "editioncode"); + final String collectorNumber = getNestedString(cardEntry, "card", "collectorNumber"); + deck.getOrCreate(getArchidektSection(cardEntry)).add( + getRequiredCard(cardName, setCode, collectorNumber, "lblArchidektCardNotFound"), quantity); + } + } + + private static void requirePlayableCards(final Deck deck, final String messageKey) throws IOException { + if (deck.getMain().isEmpty() && !deck.has(DeckSection.Commander)) { + throw new IOException(localizer.getMessage(messageKey)); + } + } + + private static PaperCard getRequiredCard(final String cardName, final String setCode, final String collectorNumber, + final String messageKey) throws IOException { + final PaperCard card = findCard(cardName, setCode, collectorNumber); + if (card == null) { + throw new IOException(localizer.getMessage(messageKey, cardName)); + } + return card; + } + + private static Set getArchidektExcludedCategories(final Map root) { + final Set excludedCategories = new HashSet<>(); + if (!(root.get("categories") instanceof List categories)) { + return excludedCategories; + } + for (final Object categoryValue : categories) { + if (categoryValue instanceof Map category && Boolean.FALSE.equals(category.get("includedInDeck"))) { + final String name = getString(category.get("name"), null); + if (name != null && !"Sideboard".equalsIgnoreCase(name)) { + excludedCategories.add(name); + } + } + } + return excludedCategories; + } + + private static boolean isArchidektExcludedCard(final Map cardEntry, final Set excludedCategories) { + if (!(cardEntry.get("categories") instanceof List categories)) { + return false; + } + for (final Object categoryValue : categories) { + if (categoryValue instanceof String category && excludedCategories.contains(category)) { + return true; + } + } + return false; + } + + private static DeckSection getArchidektSection(final Map cardEntry) { + if (Boolean.TRUE.equals(cardEntry.get("companion"))) { + return DeckSection.Sideboard; + } + if (cardEntry.get("categories") instanceof List categories) { + for (final Object categoryValue : categories) { + if (!(categoryValue instanceof String category)) { + continue; + } + if ("Commander".equalsIgnoreCase(category)) { + return DeckSection.Commander; + } + if ("Sideboard".equalsIgnoreCase(category)) { + return DeckSection.Sideboard; + } + } + } + return DeckSection.Main; + } + + private static String getArchidektCardName(final Map cardEntry) { + String cardName = getNestedString(cardEntry, "card", "displayName"); + if (cardName == null) { + cardName = getNestedString(cardEntry, "card", "oracleCard", "name"); + } + return cardName; + } + + private static String getDeckName(final Map root, final String deckId, final String sourceUrl, final String defaultName, + final Iterable savedDecks) throws IOException { + final String requestedName = getString(root.get("name"), defaultName); + for (final Deck deck : savedDecks) { + if (isSameSourceDeck(sourceUrl, deck.getSourceUrl())) { + return deck.getName(); + } + if (requestedName.equals(deck.getName())) { + return requestedName + " " + deckId; + } + } + return requestedName; + } + + private static boolean isSameSourceDeck(final String sourceUrl, final String savedSourceUrl) throws IOException { + try { + return sourceUrl.equals(savedSourceUrl) || Objects.equals(getSourceDeckKey(sourceUrl), getSourceDeckKey(savedSourceUrl)); + } catch (final IOException ignored) { + return false; + } + } + + private static DeckFormat getDeckFormat(final Object formatValue) { + try { + return DeckFormat.smartValueOf(getString(formatValue, null), DeckFormat.Constructed); + } catch (final IllegalArgumentException ex) { + return DeckFormat.Constructed; + } + } + + private static DeckFormat getArchidektDeckFormat(final Object formatValue) { + if (!(formatValue instanceof Number number)) { + return DeckFormat.Constructed; + } + return switch (number.intValue()) { + case 3, 11, 12 -> DeckFormat.Commander; + case 6 -> DeckFormat.Pauper; + case 13 -> DeckFormat.Brawl; + default -> DeckFormat.Constructed; + }; + } + + private static StorageImmediatelySerialized getStorage() { + return new StorageImmediatelySerialized<>("URL decks", + new DeckStorage(new File(ForgeConstants.DECK_BASE_DIR + URL_DECK_DIR_NAME + ForgeConstants.PATH_SEPARATOR), + ForgeConstants.DECK_BASE_DIR)); + } + + private static PaperCard findCard(final String cardName, final String setCode, final String collectorNumber) { + PaperCard card = findCardInDatabases(cardName, setCode, collectorNumber); + if (card != null) { + return card; + } + + StaticData.instance().attemptToLoadCard(cardName, setCode); + card = findCardInDatabases(cardName, setCode, collectorNumber); + if (card != null) { + return card; + } + + final String frontFaceName = getFrontFaceName(cardName); + if (frontFaceName.equals(cardName)) { + return null; + } + + card = findCardInDatabases(frontFaceName, setCode, collectorNumber); + if (card != null) { + return card; + } + + StaticData.instance().attemptToLoadCard(frontFaceName, setCode); + return findCardInDatabases(frontFaceName, setCode, collectorNumber); + } + + private static PaperCard findCardInDatabases(final String cardName, final String setCode, final String collectorNumber) { + final String normalizedSetCode = setCode == null ? null : setCode.toUpperCase(Locale.ROOT); + for (final CardDb db : StaticData.instance().getAvailableDatabases().values()) { + PaperCard card = collectorNumber == null ? null : db.getCard(cardName, normalizedSetCode, collectorNumber); + if (card == null) { + card = db.getCard(cardName, normalizedSetCode); + } + if (card == null) { + card = db.getCard(cardName); + } + if (card != null) { + return card; + } + } + return null; + } + + private static String getFrontFaceName(final String cardName) { + final int splitIndex = cardName.indexOf(" // "); + return splitIndex < 0 ? cardName : cardName.substring(0, splitIndex); + } + + private static String getMoxfieldDeckId(final String deckUrl) throws IOException { + final Matcher matcher = MOXFIELD_DECK_URL.matcher(deckUrl); + if (matcher.find()) { + return matcher.group(1); + } + final int lastSlash = deckUrl.lastIndexOf('/'); + final String id = lastSlash >= 0 ? deckUrl.substring(lastSlash + 1) : deckUrl; + final int query = id.indexOf('?'); + final String cleanId = query >= 0 ? id.substring(0, query) : id; + if (cleanId.isBlank()) { + throw new IOException(localizer.getMessage("lblCouldNotFindMoxfieldDeckId")); + } + return cleanId; + } + + private static String getArchidektDeckId(final String deckUrl) throws IOException { + final Matcher matcher = ARCHIDEKT_DECK_URL.matcher(deckUrl); + if (matcher.find()) { + return matcher.group(1); + } + throw new IOException(localizer.getMessage("lblCouldNotFindArchidektDeckId")); + } + + private static String getSourceDeckKey(final String deckUrl) throws IOException { + if (deckUrl == null || deckUrl.isBlank()) { + return null; + } + final String normalizedUrl = normalizeUrl(deckUrl); + final String host = getHost(normalizedUrl); + if (host.endsWith("moxfield.com")) { + return "moxfield:" + getMoxfieldDeckId(normalizedUrl); + } + if (host.endsWith("archidekt.com")) { + return "archidekt:" + getArchidektDeckId(normalizedUrl); + } + return null; + } + + private static String normalizeUrl(final String deckUrl) { + final String trimmed = deckUrl == null ? "" : deckUrl.trim(); + if (trimmed.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*")) { + return trimmed; + } + return "https://" + trimmed; + } + + private static String getHost(final String deckUrl) throws IOException { + try { + final String host = new URI(deckUrl).getHost(); + if (host == null) { + throw new IOException(localizer.getMessage("lblInvalidDeckUrl")); + } + return host.toLowerCase(); + } catch (final URISyntaxException ex) { + throw new IOException(localizer.getMessage("lblInvalidDeckUrl"), ex); + } + } + + private static String readUrl(final String requestUrl, final String providerName) throws IOException { + final HttpURLConnection conn = (HttpURLConnection) new URL(requestUrl).openConnection(); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", "Forge Deck URL Loader"); + conn.setConnectTimeout(15000); + conn.setReadTimeout(30000); + + final int status = conn.getResponseCode(); + try (InputStream stream = status >= 400 ? conn.getErrorStream() : conn.getInputStream()) { + final String body = stream == null ? "" : readAll(stream); + if (status >= 400) { + throw new IOException(localizer.getMessage("lblDeckUrlHttpRequestFailed", providerName, status)); + } + return body; + } + } + + private static String readAll(final InputStream stream) throws IOException { + final StringBuilder out = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + out.append(line); + } + } + return out.toString(); + } + + private static Map readJsonObject(final String requestUrl, final String providerName, final String unexpectedResponseKey) throws IOException { + final Object parsed = new JsonParser(readUrl(requestUrl, providerName)).parse(); + if (parsed instanceof Map root) { + return root; + } + throw new IOException(localizer.getMessage(unexpectedResponseKey)); + } + + private static String getNestedString(final Map map, final String... keys) { + Object value = map; + for (final String key : keys) { + if (!(value instanceof Map current)) { + return null; + } + value = current.get(key); + } + return getString(value, null); + } + + private static String getString(final Object value, final String defaultValue) { + return value instanceof String str && !str.isBlank() ? str : defaultValue; + } + + private static int getInt(final Object value, final int defaultValue) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String str) { + try { + return Integer.parseInt(str); + } catch (final NumberFormatException ignored) { + } + } + return defaultValue; + } + + private static final class JsonParser { + private final String input; + private int pos; + + private JsonParser(final String input) { + this.input = input; + } + + private Object parse() throws IOException { + final Object value = parseValue(); + skipWhitespace(); + if (pos != input.length()) { + throw error("Unexpected trailing JSON content"); + } + return value; + } + + private Object parseValue() throws IOException { + skipWhitespace(); + if (pos >= input.length()) { + throw error("Unexpected end of JSON"); + } + return switch (input.charAt(pos)) { + case '{' -> parseObject(); + case '[' -> parseArray(); + case '"' -> parseString(); + case 't' -> parseLiteral("true", Boolean.TRUE); + case 'f' -> parseLiteral("false", Boolean.FALSE); + case 'n' -> parseLiteral("null", null); + default -> parseNumber(); + }; + } + + private Map parseObject() throws IOException { + expect('{'); + final Map map = new LinkedHashMap<>(); + skipWhitespace(); + if (peek('}')) { + pos++; + return map; + } + do { + skipWhitespace(); + final String key = parseString(); + skipWhitespace(); + expect(':'); + map.put(key, parseValue()); + skipWhitespace(); + } while (consume(',')); + expect('}'); + return map; + } + + private List parseArray() throws IOException { + expect('['); + final List list = new ArrayList<>(); + skipWhitespace(); + if (peek(']')) { + pos++; + return list; + } + do { + list.add(parseValue()); + skipWhitespace(); + } while (consume(',')); + expect(']'); + return list; + } + + private String parseString() throws IOException { + expect('"'); + final StringBuilder out = new StringBuilder(); + while (pos < input.length()) { + final char c = input.charAt(pos++); + if (c == '"') { + return out.toString(); + } + if (c != '\\') { + out.append(c); + continue; + } + if (pos >= input.length()) { + throw error("Unterminated escape sequence"); + } + final char escaped = input.charAt(pos++); + switch (escaped) { + case '"', '\\', '/' -> out.append(escaped); + case 'b' -> out.append('\b'); + case 'f' -> out.append('\f'); + case 'n' -> out.append('\n'); + case 'r' -> out.append('\r'); + case 't' -> out.append('\t'); + case 'u' -> out.append(parseUnicodeEscape()); + default -> throw error("Invalid escape sequence"); + } + } + throw error("Unterminated string"); + } + + private char parseUnicodeEscape() throws IOException { + if (pos + 4 > input.length()) { + throw error("Invalid unicode escape"); + } + try { + final char value = (char) Integer.parseInt(input.substring(pos, pos + 4), 16); + pos += 4; + return value; + } catch (final NumberFormatException ex) { + throw error("Invalid unicode escape"); + } + } + + private Object parseNumber() throws IOException { + final int start = pos; + if (peek('-')) { + pos++; + } + while (pos < input.length() && Character.isDigit(input.charAt(pos))) { + pos++; + } + if (peek('.')) { + pos++; + while (pos < input.length() && Character.isDigit(input.charAt(pos))) { + pos++; + } + } + if (peek('e') || peek('E')) { + pos++; + if (peek('+') || peek('-')) { + pos++; + } + while (pos < input.length() && Character.isDigit(input.charAt(pos))) { + pos++; + } + } + if (start == pos) { + throw error("Expected JSON value"); + } + final String number = input.substring(start, pos); + try { + return number.contains(".") || number.contains("e") || number.contains("E") + ? Double.parseDouble(number) + : Long.parseLong(number); + } catch (final NumberFormatException ex) { + throw error("Invalid number"); + } + } + + private Object parseLiteral(final String literal, final Object value) throws IOException { + if (!input.startsWith(literal, pos)) { + throw error("Invalid JSON literal"); + } + pos += literal.length(); + return value; + } + + private void skipWhitespace() { + while (pos < input.length() && Character.isWhitespace(input.charAt(pos))) { + pos++; + } + } + + private void expect(final char expected) throws IOException { + if (!peek(expected)) { + throw error("Expected '" + expected + "'"); + } + pos++; + } + + private boolean consume(final char expected) { + if (peek(expected)) { + pos++; + return true; + } + return false; + } + + private boolean peek(final char expected) { + return pos < input.length() && input.charAt(pos) == expected; + } + + private IOException error(final String message) { + return new IOException(message + " at position " + pos + "."); + } + } + + private DeckUrlLoader() { + } +}