diff --git a/forge-gui/src/main/java/forge/deck/DeckUrlLoader.java b/forge-gui/src/main/java/forge/deck/DeckUrlLoader.java index c5495665b4e6..86f0639157b9 100644 --- a/forge-gui/src/main/java/forge/deck/DeckUrlLoader.java +++ b/forge-gui/src/main/java/forge/deck/DeckUrlLoader.java @@ -29,7 +29,7 @@ public final class DeckUrlLoader { private static final Pattern PRINTING_HINT = Pattern.compile( "^(\\s*\\d+\\s+.+?)\\s+\\[[A-Z0-9_]{2,7}\\](?:\\s+\\*?[0-9A-Z]+(?:\\S[0-9A-Z]*)?)?\\s*$"); private static final String URL_DECK_DIR_NAME = "URL"; - private static final String SUPPORTED_PROVIDERS = "Moxfield, Archidekt"; + private static final String SUPPORTED_PROVIDERS = "Moxfield, Archidekt, TappedOut, MTGGoldfish"; private static final Localizer localizer = Localizer.getInstance(); public static DeckProxy load(final String deckUrl) throws IOException { @@ -60,6 +60,12 @@ private static DeckUrlProvider getProvider(final String normalizedUrl) throws IO if (host.endsWith("archidekt.com")) { return new ArchidektDeckUrlProvider(); } + if (host.endsWith("tappedout.net")) { + return new TappedOutDeckUrlProvider(); + } + if (host.endsWith("mtggoldfish.com")) { + return new MtgGoldfishDeckUrlProvider(); + } throw new IOException(localizer.getMessage("lblOnlySupportedDeckUrls", SUPPORTED_PROVIDERS)); } @@ -119,10 +125,14 @@ private static void requirePlayableCards(final Deck deck, final String providerN 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); + return getDeckName(getString(root.get("name"), defaultName), deckId, sourceUrl, savedDecks); + } + + static String getDeckName(final String requestedName, final String deckId, final String sourceUrl, + final Iterable savedDecks) throws IOException { for (final Deck deck : savedDecks) { if (isSameSourceDeck(sourceUrl, deck.getSourceUrl())) { - return deck.getName(); + continue; } if (requestedName.equals(deck.getName())) { return requestedName + " " + deckId; @@ -165,6 +175,12 @@ private static String getSourceDeckKey(final String deckUrl) throws IOException if (host.endsWith("archidekt.com")) { return "archidekt:" + ArchidektDeckUrlProvider.getDeckId(normalizedUrl); } + if (host.endsWith("tappedout.net")) { + return "tappedout:" + TappedOutDeckUrlProvider.getDeckSlug(normalizedUrl); + } + if (host.endsWith("mtggoldfish.com")) { + return "mtggoldfish:" + MtgGoldfishDeckUrlProvider.getDeckId(normalizedUrl); + } return null; } @@ -188,9 +204,17 @@ private static String getHost(final String deckUrl) throws IOException { } } + static String readText(final String requestUrl, final String providerName) throws IOException { + return readUrl(requestUrl, providerName, "text/plain, text/html, */*"); + } + private static String readUrl(final String requestUrl, final String providerName) throws IOException { + return readUrl(requestUrl, providerName, "application/json"); + } + + private static String readUrl(final String requestUrl, final String providerName, final String accept) throws IOException { final HttpURLConnection conn = (HttpURLConnection) new URL(requestUrl).openConnection(); - conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Accept", accept); conn.setRequestProperty("User-Agent", "Forge Deck URL Loader"); conn.setConnectTimeout(15000); conn.setReadTimeout(30000); @@ -210,7 +234,7 @@ private static String readAll(final InputStream stream) throws IOException { try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { - out.append(line); + out.append(line).append('\n'); } } return out.toString(); diff --git a/forge-gui/src/main/java/forge/deck/DeckUrlProvider.java b/forge-gui/src/main/java/forge/deck/DeckUrlProvider.java index 877d2837a99d..0a4ac95d97c0 100644 --- a/forge-gui/src/main/java/forge/deck/DeckUrlProvider.java +++ b/forge-gui/src/main/java/forge/deck/DeckUrlProvider.java @@ -5,6 +5,12 @@ interface DeckUrlProvider { RemoteDeck load(String normalizedUrl, Iterable savedDecks) throws IOException; + static void appendSection(final StringBuilder out, final DeckSection section, final StringBuilder cards) { + if (!cards.isEmpty()) { + out.append(section.name()).append('\n').append(cards); + } + } + record RemoteDeck(String name, DeckFormat format, String sourceUrl, String importText, String providerName) { } } diff --git a/forge-gui/src/main/java/forge/deck/MtgGoldfishDeckUrlProvider.java b/forge-gui/src/main/java/forge/deck/MtgGoldfishDeckUrlProvider.java new file mode 100644 index 000000000000..67f4cfbad1ce --- /dev/null +++ b/forge-gui/src/main/java/forge/deck/MtgGoldfishDeckUrlProvider.java @@ -0,0 +1,130 @@ +package forge.deck; + +import forge.util.Localizer; +import org.apache.commons.text.StringEscapeUtils; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class MtgGoldfishDeckUrlProvider implements DeckUrlProvider { + private static final Pattern DECK_URL = Pattern.compile("(?i)(?:^|/)deck/(\\d+)(?:[/?#]|$)"); + private static final Pattern CARD_LINE = Pattern.compile("^(\\d+)\\s+(.+)$"); + private static final Pattern TITLE = Pattern.compile("(?is)\\s*(.*?)\\s*(?:-\\s*Original Deck)?\\s*"); + private static final Pattern FORMAT = inputValuePattern("deck_input_format"); + private static final Pattern COMMANDER = inputValuePattern("deck_input_commander"); + private static final Pattern COMMANDER_ALT = inputValuePattern("deck_input_commander_alt"); + private static final String PROVIDER_NAME = "MTGGoldfish"; + private static final Localizer localizer = Localizer.getInstance(); + + @Override + public RemoteDeck load(final String normalizedUrl, final Iterable savedDecks) throws IOException { + final String deckId = getDeckId(normalizedUrl); + final String html = DeckUrlLoader.readText("https://www.mtggoldfish.com/deck/" + deckId, PROVIDER_NAME); + final String text = DeckUrlLoader.readText("https://www.mtggoldfish.com/deck/download/" + deckId, PROVIDER_NAME); + final String deckName = getDeckName(html); + + return new RemoteDeck( + DeckUrlLoader.getDeckName(deckName, deckId, normalizedUrl, savedDecks), + getDeckFormat(html), + normalizedUrl, + toSectionedImportText(text, getCommanders(html)), + PROVIDER_NAME); + } + + static String getDeckId(final String deckUrl) throws IOException { + final Matcher matcher = DECK_URL.matcher(deckUrl); + if (matcher.find()) { + return matcher.group(1); + } + throw new IOException(localizer.getMessage("lblCouldNotFindDeckUrlId", PROVIDER_NAME)); + } + + static String toSectionedImportText(final String text) { + return toSectionedImportText(text, Collections.emptySet()); + } + + static String toSectionedImportText(final String text, final Set commanders) { + final StringBuilder commander = new StringBuilder(); + final StringBuilder main = new StringBuilder(); + final StringBuilder sideboard = new StringBuilder(); + boolean wroteMain = false; + boolean afterBlankLine = false; + boolean inSideboard = false; + + for (final String line : text.split("\\R")) { + final String trimmed = line.trim(); + if (trimmed.isEmpty()) { + afterBlankLine = true; + continue; + } + final Matcher matcher = CARD_LINE.matcher(trimmed); + if (!matcher.matches()) { + afterBlankLine = false; + continue; + } + if (!inSideboard && afterBlankLine && wroteMain) { + inSideboard = true; + } + final String cardName = matcher.group(2).trim(); + if (!inSideboard && commanders.contains(cardName)) { + commander.append(matcher.group(1)).append(' ').append(cardName).append('\n'); + } else { + (inSideboard ? sideboard : main).append(trimmed).append('\n'); + } + wroteMain = true; + afterBlankLine = false; + } + + final StringBuilder out = new StringBuilder(); + DeckUrlProvider.appendSection(out, DeckSection.Commander, commander); + DeckUrlProvider.appendSection(out, DeckSection.Main, main); + DeckUrlProvider.appendSection(out, DeckSection.Sideboard, sideboard); + return out.toString(); + } + + static Set getCommanders(final String html) { + final Set commanders = new LinkedHashSet<>(); + addInputValue(commanders, html, COMMANDER); + addInputValue(commanders, html, COMMANDER_ALT); + return commanders; + } + + static String getDeckName(final String html) { + final Matcher matcher = TITLE.matcher(html); + if (!matcher.find()) { + return localizer.getMessage("lblDeckUrlDefaultDeckName", PROVIDER_NAME); + } + final String title = StringEscapeUtils.unescapeHtml4(matcher.group(1)).trim(); + return title.isBlank() ? localizer.getMessage("lblDeckUrlDefaultDeckName", PROVIDER_NAME) : title; + } + + private static DeckFormat getDeckFormat(final String html) { + final String format = getInputValue(html, FORMAT); + return "commander".equalsIgnoreCase(format) + ? DeckFormat.Commander + : DeckFormat.Constructed; + } + + private static void addInputValue(final Set values, final String html, final Pattern inputPattern) { + final String value = getInputValue(html, inputPattern); + if (value != null && !value.isBlank()) { + values.add(value); + } + } + + private static String getInputValue(final String html, final Pattern inputPattern) { + final Matcher matcher = inputPattern.matcher(html); + if (!matcher.find()) { + return null; + } + return StringEscapeUtils.unescapeHtml4(matcher.group(1)).trim(); + } + + private static Pattern inputValuePattern(final String inputId) { + return Pattern.compile("(?is)]*\\bid=\"" + Pattern.quote(inputId) + "\"[^>]*\\bvalue=\"([^\"]*)\"[^>]*>"); + } +} diff --git a/forge-gui/src/main/java/forge/deck/TappedOutDeckUrlProvider.java b/forge-gui/src/main/java/forge/deck/TappedOutDeckUrlProvider.java new file mode 100644 index 000000000000..ddb4130d77e7 --- /dev/null +++ b/forge-gui/src/main/java/forge/deck/TappedOutDeckUrlProvider.java @@ -0,0 +1,129 @@ +package forge.deck; + +import forge.util.Localizer; +import org.apache.commons.text.StringEscapeUtils; + +import java.io.IOException; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class TappedOutDeckUrlProvider implements DeckUrlProvider { + private static final Pattern DECK_URL = Pattern.compile("(?i)(?:^|/)mtg-decks/([^/?#]+)/?"); + private static final Pattern CARD_LINE = Pattern.compile("^(\\d+)x?\\s+(.+?)(?:\\s+\\(([A-Z0-9_]{2,7})\\)\\s+\\S+)?$"); + private static final Pattern TITLE = Pattern.compile("(?is)\\s*(.*?)\\s*(?:\\([^<]*MTG Deck\\))?\\s*"); + private static final Pattern OG_TITLE = Pattern.compile("(?is)"); + private static final Pattern MTGA_EXPORT = Pattern.compile("(?is)]*id=\"mtga-textarea\"[^>]*>(.*?)"); + private static final String PROVIDER_NAME = "TappedOut"; + private static final Localizer localizer = Localizer.getInstance(); + + @Override + public RemoteDeck load(final String normalizedUrl, final Iterable savedDecks) throws IOException { + final String deckSlug = getDeckSlug(normalizedUrl); + final String deckPage = "https://tappedout.net/mtg-decks/" + deckSlug + "/"; + final String html = DeckUrlLoader.readText(deckPage, PROVIDER_NAME); + final String deckName = getDeckName(html, deckSlug); + + return new RemoteDeck( + DeckUrlLoader.getDeckName(deckName, deckSlug, normalizedUrl, savedDecks), + isCommanderPage(html) ? DeckFormat.Commander : DeckFormat.Constructed, + normalizedUrl, + toImportText(html), + PROVIDER_NAME); + } + + static String getDeckSlug(final String deckUrl) throws IOException { + final Matcher matcher = DECK_URL.matcher(deckUrl); + if (matcher.find() && !matcher.group(1).isBlank()) { + return matcher.group(1); + } + throw new IOException(localizer.getMessage("lblCouldNotFindDeckUrlId", PROVIDER_NAME)); + } + + static String toImportText(final String html) throws IOException { + final Matcher matcher = MTGA_EXPORT.matcher(html); + if (!matcher.find()) { + throw new IOException(localizer.getMessage("lblDeckUrlUnexpectedResponse", PROVIDER_NAME)); + } + + final StringBuilder main = new StringBuilder(); + final StringBuilder commanders = new StringBuilder(); + final StringBuilder sideboard = new StringBuilder(); + StringBuilder currentSection = null; + for (final String line : StringEscapeUtils.unescapeHtml4(matcher.group(1)).split("\\R")) { + final String trimmed = line.trim(); + if ("Commander".equalsIgnoreCase(trimmed)) { + currentSection = commanders; + continue; + } + if ("Deck".equalsIgnoreCase(trimmed)) { + currentSection = main; + continue; + } + if ("Sideboard".equalsIgnoreCase(trimmed)) { + currentSection = sideboard; + continue; + } + if (trimmed.isBlank() || trimmed.equalsIgnoreCase("About") || trimmed.startsWith("Name ")) { + continue; + } + if (currentSection != null) { + appendCardLine(currentSection, trimmed); + } + } + if (commanders.isEmpty() && main.isEmpty() && sideboard.isEmpty()) { + throw new IOException(localizer.getMessage("lblNoPlayableCardsInDeckUrl", PROVIDER_NAME)); + } + final StringBuilder out = new StringBuilder(); + DeckUrlProvider.appendSection(out, DeckSection.Commander, commanders); + DeckUrlProvider.appendSection(out, DeckSection.Main, main); + DeckUrlProvider.appendSection(out, DeckSection.Sideboard, sideboard); + return out.toString(); + } + + static String getDeckName(final String html, final String deckSlug) { + final String title = getFirstMatch(OG_TITLE, html); + if (title != null) { + return title; + } + final String pageTitle = getFirstMatch(TITLE, html); + if (pageTitle != null) { + return pageTitle; + } + return deckSlug.replace('-', ' ').trim(); + } + + private static void appendCardLine(final StringBuilder out, final String line) { + final Matcher matcher = CARD_LINE.matcher(line.trim()); + if (!matcher.matches()) { + return; + } + if ("SUNF".equalsIgnoreCase(matcher.group(3))) { + return; + } + String cardName = matcher.group(2).trim(); + cardName = stripAfter(cardName, '#'); + cardName = stripAfter(cardName, '*'); + if (!cardName.isBlank()) { + out.append(matcher.group(1)).append(' ').append(cardName).append('\n'); + } + } + + private static String getFirstMatch(final Pattern pattern, final String html) { + final Matcher matcher = pattern.matcher(html); + if (!matcher.find()) { + return null; + } + final String value = StringEscapeUtils.unescapeHtml4(matcher.group(1)).trim(); + return value.isBlank() ? null : value; + } + + private static boolean isCommanderPage(final String html) { + return html.toLowerCase(Locale.ROOT).contains("commander / edh"); + } + + private static String stripAfter(final String value, final char marker) { + final int markerIndex = value.indexOf(marker); + return markerIndex < 0 ? value : value.substring(0, markerIndex).trim(); + } +}