Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions forge-gui/src/main/java/forge/deck/DeckUrlLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -39,6 +39,7 @@ public static DeckProxy load(final String deckUrl) throws IOException {
final DeckUrlProvider.RemoteDeck remoteDeck = provider.load(normalizedUrl, storage);
final Deck deck = importDeck(remoteDeck);

deleteRenamedSourceDecks(storage, deck);
storage.add(deck);
return new DeckProxy(deck, localizer.getMessage("lblUrlDeck"), GameType.Constructed, storage);
}
Expand All @@ -60,6 +61,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));
}

Expand Down Expand Up @@ -119,10 +126,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<Deck> 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<Deck> 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;
Expand All @@ -131,6 +142,18 @@ static String getDeckName(final Map<?, ?> root, final String deckId, final Strin
return requestedName;
}

private static void deleteRenamedSourceDecks(final StorageImmediatelySerialized<Deck> storage, final Deck deck) throws IOException {
final List<String> oldNames = new ArrayList<>();
for (final Deck savedDeck : storage) {
if (!deck.getName().equals(savedDeck.getName()) && isSameSourceDeck(deck.getSourceUrl(), savedDeck.getSourceUrl())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you silently deleting decks user might have modified further locally?

oldNames.add(savedDeck.getName());
}
}
for (final String oldName : oldNames) {
storage.delete(oldName);
}
}

private static boolean isSameSourceDeck(final String sourceUrl, final String savedSourceUrl) throws IOException {
try {
return sourceUrl.equals(savedSourceUrl) || Objects.equals(getSourceDeckKey(sourceUrl), getSourceDeckKey(savedSourceUrl));
Expand Down Expand Up @@ -165,6 +188,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;
}

Expand All @@ -188,9 +217,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);
Expand All @@ -210,7 +247,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();
Expand Down
6 changes: 6 additions & 0 deletions forge-gui/src/main/java/forge/deck/DeckUrlProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
interface DeckUrlProvider {
RemoteDeck load(String normalizedUrl, Iterable<Deck> 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) {
}
}
130 changes: 130 additions & 0 deletions forge-gui/src/main/java/forge/deck/MtgGoldfishDeckUrlProvider.java
Original file line number Diff line number Diff line change
@@ -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)<title>\\s*(.*?)\\s*(?:-\\s*Original Deck)?\\s*</title>");
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<Deck> 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<String> 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<String> getCommanders(final String html) {
final Set<String> 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<String> 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)<input\\b[^>]*\\bid=\"" + Pattern.quote(inputId) + "\"[^>]*\\bvalue=\"([^\"]*)\"[^>]*>");
}
}
129 changes: 129 additions & 0 deletions forge-gui/src/main/java/forge/deck/TappedOutDeckUrlProvider.java
Original file line number Diff line number Diff line change
@@ -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)<title>\\s*(.*?)\\s*(?:\\([^<]*MTG Deck\\))?\\s*</title>");
private static final Pattern OG_TITLE = Pattern.compile("(?is)<meta\\s+property=\"og:title\"\\s+content=\"(?:MTG Deck:\\s*)?(.*?)\"\\s*/?>");
private static final Pattern MTGA_EXPORT = Pattern.compile("(?is)<textarea\\b[^>]*id=\"mtga-textarea\"[^>]*>(.*?)</textarea>");
private static final String PROVIDER_NAME = "TappedOut";
private static final Localizer localizer = Localizer.getInstance();

@Override
public RemoteDeck load(final String normalizedUrl, final Iterable<Deck> 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();
}
}