From 78b4bb464a751a06467302ef4e5ab957f6b13bcd Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Thu, 30 Apr 2026 12:29:41 -0700 Subject: [PATCH 1/2] Add deck URL loading for Moxfield and Archidekt --- forge-core/src/main/java/forge/deck/Deck.java | 20 + .../java/forge/deck/io/DeckFileHeader.java | 7 + .../java/forge/deck/io/DeckSerializer.java | 10 +- .../java/forge/deckchooser/FDeckChooser.java | 112 +++- .../main/java/forge/screens/home/VLobby.java | 2 +- forge-gui/res/languages/de-DE.properties | 23 +- forge-gui/res/languages/en-US.properties | 22 +- forge-gui/res/languages/es-ES.properties | 23 +- forge-gui/res/languages/fr-FR.properties | 23 +- forge-gui/res/languages/it-IT.properties | 23 +- forge-gui/res/languages/ja-JP.properties | 23 +- forge-gui/res/languages/ko-KR.properties | 23 +- forge-gui/res/languages/pt-BR.properties | 23 +- forge-gui/res/languages/zh-CN.properties | 21 + .../src/main/java/forge/deck/DeckProxy.java | 5 + .../src/main/java/forge/deck/DeckType.java | 15 +- .../main/java/forge/deck/DeckUrlLoader.java | 624 ++++++++++++++++++ 17 files changed, 983 insertions(+), 16 deletions(-) create mode 100644 forge-gui/src/main/java/forge/deck/DeckUrlLoader.java 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 11897b53557..d5685d1ee03 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,11 @@ 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 isAi; @@ -96,6 +103,7 @@ public FDeckChooser(final CDetailPicture cDetailPicture, final boolean forAi, Ga } }; lstDecks.setItemActivateCommand(cmdViewDeck); + lstDecks.setSelectCommand(this::handleDeckSelection); btnViewDeck.setCommand(cmdViewDeck); } @@ -117,6 +125,10 @@ 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); @@ -286,6 +298,23 @@ private void updateNetArchiveBlockDecks() { updateDecks(DeckProxy.getNetArchiveBlockDecks(NetDeckArchiveBlock), ItemManagerConfig.NET_DECKS); } + private void updateProvidedDeckUrl() { + lstDecks.setAllowMultipleSelections(false); + lstDecks.setPool(DeckUrlLoader.getUrlDecks()); + lstDecks.setup(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(); + } + public Deck getDeck() { final DeckProxy proxy = lstDecks.getSelectedItem(); if (proxy == null) { @@ -316,22 +345,99 @@ 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() { + 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(); + 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; } @@ -647,9 +753,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 3ec3b3ef7fd..f85c4ae72b0 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 @@ -813,7 +813,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 e94d2ea210b..46f6cbed1f7 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 @@ -3580,4 +3601,4 @@ lblRepair=Reparieren lblDataMigrationMsg=Datenmigration abgeschlossen!\nBitte überprüfen Sie Ihr Inventar und Ihre Ausrüstung.\nBitte erstellen Sie an dieser Stelle eine Sicherungskopie Ihrer Spielstände, da der aktuelle Spielstand noch nicht überschrieben wird, wenn Sie im Menü „Szene“ den Punkt „Daten“ -> „Sicherungskopie“ verwenden. #AdventureDeckEditor.java lblRemoveUnsupportedCard=Verwijder niet-ondersteunde kaart -lblRemoveAllUnsupportedCards=Nicht unterstützte Karten wurden aus Ihrem Inventar entfernt. \ No newline at end of file +lblRemoveAllUnsupportedCards=Nicht unterstützte Karten wurden aus Ihrem Inventar entfernt. diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index d7660d94a7e..c9186ba2420 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -740,6 +740,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 @@ -3432,4 +3453,3 @@ lblRemoveUnsupportedCard=Remove unsupported card lblRemoveAllUnsupportedCards=Unsupported cards have been removed from your inventory. lbldisableCrackedItems=Disable the possibility of your items breaking after losing a boss fight. lblusepricelist=Use the (currently experimental) price generation based on a set cardlist. - diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index 1439903e722..003f309bbf6 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 @@ -3561,4 +3582,4 @@ lblRepair=Reparar lblDataMigrationMsg=¡Migración de datos completada!\nPor favor revise su inventario y equipos.\nPor favor, haz una copia de seguridad de tus partidas guardadas en este punto, ya que la partida guardada real aún no se sobrescribe al usar Datos -> Copia de seguridad en la Escena del menú. #AdventureDeckEditor.java lblRemoveUnsupportedCard=Quitar tarjeta incompatible -lblRemoveAllUnsupportedCards=Las tarjetas no compatibles se han eliminado de tu inventario. \ No newline at end of file +lblRemoveAllUnsupportedCards=Las tarjetas no compatibles se han eliminado de tu inventario. diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index caebb0cabd4..3769174e204 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 @@ -3562,4 +3583,4 @@ lblRepair=Réparation lblDataMigrationMsg=Migration des données terminée!\nVeuillez vérifier votre inventaire et vos équipements.\nVeuillez effectuer une sauvegarde de vos sauvegardes à ce stade, car la sauvegarde réelle n'est pas encore écrasée en utilisant Données -> Sauvegarde dans le menu Scène. #AdventureDeckEditor.java lblRemoveUnsupportedCard=Supprimer la carte non prise en charge -lblRemoveAllUnsupportedCards=Les cartes non prises en charge ont été supprimées de votre inventaire. \ No newline at end of file +lblRemoveAllUnsupportedCards=Les cartes non prises en charge ont été supprimées de votre inventaire. diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index de43c1786e4..e7f39090b77 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 @@ -3560,4 +3581,4 @@ lblRepair=Riparazione lblDataMigrationMsg=Migrazione dati completata!\nControlla il tuo inventario e le tue attrezzature.\nA questo punto, esegui un backup dei tuoi salvataggi, poiché il salvataggio effettivo non è ancora stato sovrascritto, utilizzando Dati -> Backup nel menu Scena. #AdventureDeckEditor.java lblRemoveUnsupportedCard=Rimuovi la carta non supportata -lblRemoveAllUnsupportedCards=Le carte non supportate sono state rimosse dal tuo inventario. \ No newline at end of file +lblRemoveAllUnsupportedCards=Le carte non supportate sono state rimosse dal tuo inventario. diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index b586b42e17e..963f0c10377 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=チュートリアル @@ -3556,4 +3577,4 @@ lblRepair=修理 lblDataMigrationMsg=データ移行が完了しました!\nインベントリと装備を確認してください。\n実際の保存はまだメニューシーンの「データ」->「バックアップ」を使用して上書きされていないため、この時点で保存のバックアップを作成してください。 #AdventureDeckEditor.java lblRemoveUnsupportedCard=サポートされていないカードを削除する -lblRemoveAllUnsupportedCards=サポートされていないカードはインベントリから削除されました。 \ No newline at end of file +lblRemoveAllUnsupportedCards=サポートされていないカードはインベントリから削除されました。 diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index 485f2b78bd1..67c2b3a04d4 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=튜토리얼 @@ -3342,4 +3363,4 @@ lblRepair=수리 lblDataMigrationMsg=데이터 마이그레이션이 완료되었습니다!\n인벤토리와 장비를 확인하세요.\n실제 저장 데이터는 아직 메뉴 화면의 ‘데이터’ -> '백업'을 사용하여 덮어쓰지 않았으므로, 이 시점에서 저장 백업을 생성하세요. #AdventureDeckEditor.java lblRemoveUnsupportedCard=지원되지 않는 카드 제거 -lblRemoveAllUnsupportedCards=지원되지 않는 카드가 인벤토리에서 제거되었습니다. \ No newline at end of file +lblRemoveAllUnsupportedCards=지원되지 않는 카드가 인벤토리에서 제거되었습니다. diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index 714cbc2b491..6f62ee12636 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 @@ -3645,4 +3666,4 @@ lblRepair=Reparar lblDataMigrationMsg=Migração de dados concluída!\nVerifique seu inventário e equipamentos.\nPor favor, faça um backup dos seus arquivos salvos neste momento, já que o arquivo salvo atual ainda não foi sobrescrito usando Dados -> Backup na Cena do Menu. #AdventureDeckEditor.java lblRemoveUnsupportedCard=Remover cartão não suportado -lblRemoveAllUnsupportedCards=Cartas não suportadas foram removidas do seu inventário. \ No newline at end of file +lblRemoveAllUnsupportedCards=Cartas não suportadas foram removidas do seu inventário. diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index 42b0a123335..52fcfe110ce 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() { + } +} From 91616c7d269112a993409cde20502a7c187008f3 Mon Sep 17 00:00:00 2001 From: Madwand99 Date: Tue, 12 May 2026 07:29:05 -0700 Subject: [PATCH 2/2] Fix deck chooser source switching for URL decks --- .../java/forge/deckchooser/FDeckChooser.java | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) 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 d5685d1ee03..c2df12c6f05 100644 --- a/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java +++ b/forge-gui-desktop/src/main/java/forge/deckchooser/FDeckChooser.java @@ -61,6 +61,7 @@ public class FDeckChooser extends JPanel implements IDecksComboBoxListener { private FLabel btnReloadUrl; private String lastLoadedUrlDeckName; private UiCommand deckSelectionCommand; + private boolean updatingDeckPool; private boolean isAi; @@ -132,8 +133,7 @@ public void setDeckSelectionCommand(final UiCommand 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)); @@ -165,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)); @@ -178,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)); @@ -195,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)); @@ -212,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)); @@ -300,8 +296,7 @@ private void updateNetArchiveBlockDecks() { private void updateProvidedDeckUrl() { lstDecks.setAllowMultipleSelections(false); - lstDecks.setPool(DeckUrlLoader.getUrlDecks()); - lstDecks.setup(ItemManagerConfig.NET_DECKS); + setDeckPoolWithConfig(DeckUrlLoader.getUrlDecks(), ItemManagerConfig.NET_DECKS); btnRandom.setText(localizer.getMessage("lblReload")); btnRandom.setCommand(this::loadDeckFromUrl); @@ -315,6 +310,17 @@ private void updateProvidedDeckUrl() { 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) { @@ -394,6 +400,9 @@ private void syncUrlFieldWithSelectedDeck() { } private void handleDeckSelection() { + if (updatingDeckPool) { + return; + } syncUrlFieldWithSelectedDeck(); if (deckSelectionCommand != null) { deckSelectionCommand.run(); @@ -415,7 +424,9 @@ private void loadDeckFromUrl() { final DeckProxy deck = DeckUrlLoader.load(deckUrl); FThreads.invokeInEdtLater(() -> { lastLoadedUrlDeckName = deck.toString(); - refreshDecksList(DeckType.PROVIDED_DECK_URL, true, null); + if (selectedDeckType == DeckType.PROVIDED_DECK_URL) { + refreshDecksList(DeckType.PROVIDED_DECK_URL, true, null); + } setDeckUrlLoading(false); }); } catch (final IOException ex) {