diff --git a/forge-core/src/main/java/forge/StaticData.java b/forge-core/src/main/java/forge/StaticData.java index 818d355ebf8a..cf471551a1d4 100644 --- a/forge-core/src/main/java/forge/StaticData.java +++ b/forge-core/src/main/java/forge/StaticData.java @@ -15,6 +15,7 @@ import java.io.File; import java.util.*; +import java.util.function.BiPredicate; import java.util.function.Predicate; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; @@ -64,9 +65,12 @@ public class StaticData { private static StaticData lastInstance = null; public StaticData(CardStorageReader cardReader, CardStorageReader customCardReader, String editionFolder, String customEditionsFolder, String blockDataFolder, String cardArtPreference, boolean enableUnknownCards, boolean loadNonLegalCards) { - this(cardReader, null, customCardReader, null, editionFolder, customEditionsFolder, blockDataFolder, "", cardArtPreference, enableUnknownCards, loadNonLegalCards, false, false); + this(cardReader, null, customCardReader, null, editionFolder, customEditionsFolder, blockDataFolder, "", cardArtPreference, enableUnknownCards, loadNonLegalCards, false, false, null); } public StaticData(CardStorageReader cardReader, CardStorageReader tokenReader, CardStorageReader customCardReader, CardStorageReader customTokenReader, String editionFolder, String customEditionsFolder, String blockDataFolder, String setLookupFolder, String cardArtPreference, boolean enableUnknownCards, boolean loadNonLegalCards, boolean allowCustomCardsInDecksConformance, boolean enableSmartCardArtSelection) { + this(cardReader, tokenReader, customCardReader, customTokenReader, editionFolder, customEditionsFolder, blockDataFolder, setLookupFolder, cardArtPreference, enableUnknownCards, loadNonLegalCards, allowCustomCardsInDecksConformance, enableSmartCardArtSelection, null); + } + public StaticData(CardStorageReader cardReader, CardStorageReader tokenReader, CardStorageReader customCardReader, CardStorageReader customTokenReader, String editionFolder, String customEditionsFolder, String blockDataFolder, String setLookupFolder, String cardArtPreference, boolean enableUnknownCards, boolean loadNonLegalCards, boolean allowCustomCardsInDecksConformance, boolean enableSmartCardArtSelection, BiPredicate preferredLanguageAvailability) { this.cardReader = cardReader; this.tokenReader = tokenReader; this.editions = new CardEdition.Collection(new CardEdition.Reader(new File(editionFolder))); @@ -131,6 +135,8 @@ public StaticData(CardStorageReader cardReader, CardStorageReader tokenReader, C commonCards.setCardArtPreference(cardArtPreference); variantCards.setCardArtPreference(cardArtPreference); + commonCards.setPreferredLanguageAvailability(preferredLanguageAvailability); + variantCards.setPreferredLanguageAvailability(preferredLanguageAvailability); //must initialize after establish field values for the sake of card image logic commonCards.initialize(false, false, enableUnknownCards); @@ -956,6 +962,11 @@ public void setCardArtPreference(String artPreference) { this.variantCards.setCardArtPreference(artPreference); } + public void setPreferredLanguageAvailability(BiPredicate availability) { + this.commonCards.setPreferredLanguageAvailability(availability); + this.variantCards.setPreferredLanguageAvailability(availability); + } + public boolean isEnabledCardArtSmartSelection() { return this.enableSmartCardArtSelection; } diff --git a/forge-core/src/main/java/forge/card/CardDb.java b/forge-core/src/main/java/forge/card/CardDb.java index 2a615dcecffc..d19c50726c45 100644 --- a/forge-core/src/main/java/forge/card/CardDb.java +++ b/forge-core/src/main/java/forge/card/CardDb.java @@ -32,6 +32,7 @@ import java.util.*; import java.util.Map.Entry; +import java.util.function.BiPredicate; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -98,6 +99,8 @@ public int compare(CardEdition o1, CardEdition o2) { // Placeholder to setup default art Preference - to be moved from Static Data! private CardArtPreference defaultCardArtPreference; + private BiPredicate preferredLanguageAvailability; + private boolean initialized; public static class CardRequest { public String cardName; @@ -536,6 +539,7 @@ public void initialize(boolean logMissingPerEdition, boolean logMissingSummary, } } + initialized = true; reIndex(); } @@ -583,11 +587,23 @@ private void reIndex() { private PaperCard getBestUniquePrint(final Collection cards) { return cards.stream() .filter(pc -> !pc.getRarity().equals(CardRarity.Special)) - .min(Comparator.comparing((PaperCard pc) -> editions.get(pc.getEdition()), defaultCardArtPreference) + .min(Comparator.comparing((PaperCard pc) -> isPreferredLanguagePrint(pc) ? 0 : 1) + .thenComparing((PaperCard pc) -> editions.get(pc.getEdition()), defaultCardArtPreference) .thenComparing(PaperCard::getCollectorNumber)) .orElseGet(() -> cards.iterator().next()); } + private boolean isPreferredLanguagePrint(PaperCard pc) { + if (preferredLanguageAvailability == null) { + return false; + } + CardEdition edition = editions.get(pc.getEdition()); + if (edition == null) { + return false; + } + return preferredLanguageAvailability.test(edition.getScryfallCode(), pc.getCollectorNumber()); + } + public boolean setPreferredArt(String cardName, String setCode, int artIndex) { String cardRequestForPreferredArt = CardRequest.compose(cardName, setCode, artIndex); PaperCard pc = this.getCard(cardRequestForPreferredArt); @@ -613,6 +629,13 @@ public void setCardArtPreference(boolean latestArt, boolean coreExpansionOnly){ } } + public void setPreferredLanguageAvailability(BiPredicate availability) { + this.preferredLanguageAvailability = availability; + if (initialized) { + reIndex(); + } + } + /** * Retrieves a CardRules matching the provided name. * @param allowAltNames If false, the name must be the exact name of the card in its default state. If true, flavor diff --git a/forge-gui-desktop/src/main/java/forge/download/GuiDownloader.java b/forge-gui-desktop/src/main/java/forge/download/GuiDownloader.java index 04671d2e01ee..c3855442d424 100644 --- a/forge-gui-desktop/src/main/java/forge/download/GuiDownloader.java +++ b/forge-gui-desktop/src/main/java/forge/download/GuiDownloader.java @@ -115,7 +115,10 @@ public void show() { pnl.add(pnlDialog, "w 400px!, h 350px!, ax center, ay center"); SOverlayUtils.showOverlay(); - service.initialize(txtAddress, txtPort, progressBar, btnStart, cmdClose, null, this::fireStateChanged); + progressBar.setIndeterminate(true); + + service.initialize(txtAddress, txtPort, progressBar, btnStart, cmdClose, + () -> progressBar.setIndeterminate(false), this::fireStateChanged); } private class ProxyHandler implements ChangeListener { diff --git a/forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java b/forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java index 78e4b86a5df8..e10f2559f6de 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java @@ -9,6 +9,7 @@ import forge.gamemodes.quest.QuestWorld; import forge.gamemodes.quest.data.QuestPreferences; import forge.gui.GuiUtils; +import forge.gui.download.CdnUuidCache; import forge.item.PaperCard; import forge.itemmanager.filters.*; import forge.localinstance.properties.ForgePreferences; @@ -62,6 +63,14 @@ protected Iterable> getUnique(Iterable cardsMap = new HashMap<>(); for (String cardName : entriesByName.keySet()) { @@ -85,7 +94,7 @@ protected Iterable> getUnique(Iterable cardEntry = getCardEntryToAdd(entriesByEdition, acceptedEditions); + Entry cardEntry = getCardEntryToAdd(entriesByEdition, acceptedEditions, preferredLang); if (cardEntry != null) cardsMap.put(cardEntry.getKey(), cardEntry.getValue()); } @@ -95,7 +104,7 @@ protected Iterable> getUnique(Iterable getCardEntryToAdd(ListMultimap> entriesByEdition, - List acceptedEditions) { + List acceptedEditions, String preferredLang) { // Use standard sort + index, for better performance! Collections.sort(acceptedEditions); if (StaticData.instance().cardArtPreferenceIsLatest()) @@ -107,6 +116,20 @@ private Entry getCardEntryToAdd(ListMultimap> cardEntries = entriesByEdition.get(cardEdition); + + if (preferredLang != null) { + for (Entry entry : cardEntries) { + if (isPreferredLanguagePrint(entry.getKey(), preferredLang)) { + if (firstCandidateEntryFound == null) + firstCandidateEntryFound = entry; + candidateEntry = entry; + break; + } + } + if (candidateEntry != null) + break; + } + Iterator> entriesIterator = cardEntries.iterator(); candidateEntry = entriesIterator.hasNext() ? entriesIterator.next() : null; if (candidateEntry != null && firstCandidateEntryFound == null) @@ -123,6 +146,14 @@ private Entry getCardEntryToAdd(ListMultimap itemManager) { diff --git a/forge-gui-desktop/src/main/java/forge/itemmanager/ItemManager.java b/forge-gui-desktop/src/main/java/forge/itemmanager/ItemManager.java index 5d6b80f25651..890accb91be3 100644 --- a/forge-gui-desktop/src/main/java/forge/itemmanager/ItemManager.java +++ b/forge-gui-desktop/src/main/java/forge/itemmanager/ItemManager.java @@ -18,11 +18,17 @@ package forge.itemmanager; import com.google.common.collect.Lists; +import forge.StaticData; +import forge.card.CardEdition; import forge.gui.GuiUtils; import forge.gui.UiCommand; +import forge.gui.download.CdnUuidCache; +import forge.item.IPaperCard; import forge.item.InventoryItem; import forge.item.PaperCard; import forge.itemmanager.filters.ItemFilter; +import forge.localinstance.properties.ForgePreferences.FPref; +import forge.model.FModel; import forge.itemmanager.views.ImageView; import forge.itemmanager.views.ItemListView; import forge.itemmanager.views.ItemTableColumn; @@ -998,7 +1004,35 @@ public void refresh() { } protected Iterable> getUnique(final Iterable> items) { - return Aggregates.uniqueByLast(items, from -> from.getKey().getName()); + if (!FModel.getPreferences().getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)) { + return Aggregates.uniqueByLast(items, from -> from.getKey().getName()); + } + final String preferredLang = FModel.getPreferences().getPref(FPref.UI_CARD_DOWNLOAD_LANG); + if (preferredLang == null || preferredLang.isEmpty() || "en".equalsIgnoreCase(preferredLang)) { + return Aggregates.uniqueByLast(items, from -> from.getKey().getName()); + } + + final Map> uniques = new LinkedHashMap<>(); + for (final Entry entry : items) { + final String key = entry.getKey().getName(); + final Entry existing = uniques.get(key); + if (existing == null || isPreferredLanguagePrint(entry.getKey(), preferredLang)) { + uniques.put(key, entry); + } + } + return uniques.values(); + } + + private boolean isPreferredLanguagePrint(final T item, final String preferredLang) { + if (!(item instanceof IPaperCard)) { + return false; + } + final IPaperCard card = (IPaperCard) item; + final CardEdition edition = StaticData.instance().getEditions().get(card.getEdition()); + if (edition == null) { + return false; + } + return CdnUuidCache.isAvailableInLanguage(edition.getScryfallCode(), card.getCollectorNumber(), preferredLang); } /** diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuDownloaders.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuDownloaders.java index 3a015bee71fc..1b9378a2e5b7 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuDownloaders.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuDownloaders.java @@ -35,6 +35,7 @@ public enum CSubmenuDownloaders implements ICDoc { private final UiCommand cmdLicensing = VSubmenuDownloaders.SINGLETON_INSTANCE::showLicensing; private final UiCommand cmdCheckForUpdates = () -> new AutoUpdater(false).attemptToUpdate(CompletableFuture.supplyAsync(() -> RSSReader.getCommitLog(GITHUB_COMMITS_ATOM, BuildInfo.getTimestamp(), FControl.instance.getSnapsTimestamp()))); + private final UiCommand cmdDownloadCardImages = () -> new DialogDownloadCardImages().show(); private final UiCommand cmdPicDownload = () -> new GuiDownloader(new GuiDownloadPicturesLQ()).show(); private final UiCommand cmdPicDownloadHQ = () -> new GuiDownloader(new GuiDownloadPicturesHQ()).show(); @@ -59,6 +60,7 @@ public void register() { public void initialize() { final VSubmenuDownloaders view = VSubmenuDownloaders.SINGLETON_INSTANCE; view.setCheckForUpdatesCommand(cmdCheckForUpdates); + view.setDownloadCardImagesCommand(cmdDownloadCardImages); view.setDownloadPicsCommand(cmdPicDownload); view.setDownloadPicsHQCommand(cmdPicDownloadHQ); view.setDownloadSetPicsCommand(cmdSetDownload); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java index 16f05906123d..429effb2fe4d 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java @@ -10,6 +10,7 @@ import forge.gamemodes.net.server.FServerManager; import forge.gui.GuiBase; import forge.gui.UiCommand; +import forge.gui.download.CdnUuidCache; import forge.gui.framework.FScreen; import forge.gui.framework.ICDoc; import forge.localinstance.properties.ForgeConstants; @@ -205,6 +206,7 @@ public void initialize() { initializeDefaultFontSizeComboBox(); initializeCardArtFormatComboBox(); initializeCardArtPreference(); + initializeCardDownloadLanguageComboBox(); initializeAutoUpdaterComboBox(); initializeServerUPnPComboBox(); initializeMulliganRuleComboBox(); @@ -418,6 +420,46 @@ private void initializeAutoUpdaterComboBox() { panel.setComboBox(comboBox, selectedItem); } + private void initializeCardDownloadLanguageComboBox() { + final Map cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping(); + final String[] localizedOptions = cardLangMapping.keySet().toArray(new String[0]); + + final FPref cardLangPreference = FPref.UI_CARD_DOWNLOAD_LANG; + + final FComboBoxPanel panel = this.view.getCbpCardDownloadLangComboBoxPanel(); + final FComboBox comboBox = createLocalizedComboBox(localizedOptions, cardLangPreference, cardLangMapping); + comboBox.addItemListener(e -> applyPreferredLanguageAvailability()); + + final String savedCode = this.prefs.getPref(cardLangPreference); + final String selectedDisplayName = cardLangMapping.entrySet().stream() + .filter(entry -> entry.getValue().equals(savedCode)) + .map(Map.Entry::getKey) + .findFirst() + .orElse("English"); + + panel.setComboBox(comboBox, selectedDisplayName); + + final JCheckBox cbPreferLang = this.view.getCbPreferLangForUniqueCards(); + cbPreferLang.setSelected(this.prefs.getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)); + cbPreferLang.addItemListener(e -> { + this.prefs.setPref(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS, String.valueOf(cbPreferLang.isSelected())); + this.prefs.save(); + applyPreferredLanguageAvailability(); + }); + + applyPreferredLanguageAvailability(); + } + + private void applyPreferredLanguageAvailability() { + String langCode = this.prefs.getPref(FPref.UI_CARD_DOWNLOAD_LANG); + boolean preferForUnique = this.prefs.getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS); + if (!preferForUnique || langCode == null || langCode.isEmpty() || "en".equalsIgnoreCase(langCode)) { + FModel.getMagicDb().setPreferredLanguageAvailability(null); + } else { + FModel.getMagicDb().setPreferredLanguageAvailability((setCode, cn) -> CdnUuidCache.isAvailableInLanguage(setCode, cn, langCode)); + } + } + private void initializeServerUPnPComboBox() { // Step 1: Define the localized strings and mappings final Map upnpPreferenceMapping = ForgeConstants.getUPnPPreferenceMapping(); diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java new file mode 100644 index 000000000000..8a17e3a9806b --- /dev/null +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/DialogDownloadCardImages.java @@ -0,0 +1,348 @@ +package forge.screens.home.settings; + +import com.google.common.collect.Iterables; +import forge.card.CardEdition; +import forge.download.GuiDownloader; +import forge.game.GameFormat; +import forge.gui.FThreads; +import forge.gui.SOverlayUtils; +import forge.gui.download.CdnUuidCache; +import forge.gui.download.GuiDownloadFilteredCardImages; +import forge.gui.download.ScryfallBulkDataSync; +import forge.gui.util.SOptionPane; +import forge.item.PaperCard; +import forge.itemmanager.SFilterUtil; +import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; +import forge.localinstance.skin.FSkinProp; +import forge.model.FModel; +import forge.toolbox.*; +import forge.toolbox.FCheckBoxTree.FTreeNodeData; +import forge.util.Localizer; +import net.miginfocom.swing.MigLayout; + +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.Timer; +import java.awt.Font; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; + +/** + * Desktop equivalent of the mobile {@code CardImageBrowserScreen}: a Scryfall-syntax search box, + * a format/"Choose Sets" filter, live total/downloaded/missing stats, and Download / Clear CDN + * Cache buttons, all driving the shared {@link GuiDownloadFilteredCardImages}. + */ +public class DialogDownloadCardImages { + private static final Localizer localizer = Localizer.getInstance(); + + private final FTextField txtSearch = new FTextField.Builder() + .ghostText(localizer.getMessage("lblSearch") + " " + localizer.getMessage("lblCards") + "...") + .build(); + private final FComboBox cbxFormats = new FComboBox<>(); + private final FLabel lblTotal = new FLabel.Builder().text("--").fontSize(14).fontAlign(SwingConstants.CENTER).build(); + private final FLabel lblDownloaded = new FLabel.Builder().text("--").fontSize(14).fontAlign(SwingConstants.CENTER).build(); + private final FLabel lblMissing = new FLabel.Builder().text("--").fontSize(14).fontAlign(SwingConstants.CENTER).build(); + private final FProgressBar bulkSyncProgress = new FProgressBar(); + + private final Timer searchDebounce = new Timer(200, e -> scheduleStatsUpdate()); + private final AtomicInteger generation = new AtomicInteger(0); + + private GameFormat selectedFormat = null; + private FPanel mainPanel; + private FButton btnDownload; + private FButton btnSyncBulkData; + private FComboBox cbxIndexLang; + private FButton btnSyncBulkDataLang; + private FCheckBox cbPreferLangForUnique; + private FButton btnClearCdnCache; + + public void show() { + searchDebounce.setRepeats(false); + buildMainPanel(); + showMainOverlay(); + scheduleStatsUpdate(); + } + + // ========================================================================= + // Main dialog + // ========================================================================= + + private void buildMainPanel() { + txtSearch.addChangeListener(new FTextField.ChangeListener() { + @Override public void textChanged() { searchDebounce.restart(); } + }); + + cbxFormats.addItem(localizer.getMessage("lblAllSetsFormats")); + for (GameFormat fmt : FModel.getFormats().getFilterList()) { + cbxFormats.addItem(fmt); + } + cbxFormats.addItem(localizer.getMessage("lblChooseSets")); + + cbxFormats.addActionListener(e -> { + int idx = cbxFormats.getSelectedIndex(); + if (idx < 0) { return; } + if (idx == cbxFormats.getItemCount() - 1) { + openChooseSets(); + return; + } + selectedFormat = idx == 0 ? null : (GameFormat) cbxFormats.getSelectedItem(); + scheduleStatsUpdate(); + }); + + btnDownload = new FButton(localizer.getMessage("btnDownloadCardImages")); + btnDownload.addActionListener(e -> startDownload()); + + btnSyncBulkData = new FButton(localizer.getMessage("btnSyncBulkCardData")); + btnSyncBulkData.addActionListener(e -> startBulkSync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, "English")); + + final Map cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping(); + cbxIndexLang = new FComboBox<>(); + for (Map.Entry entry : cardLangMapping.entrySet()) { + if (!"en".equalsIgnoreCase(entry.getValue())) { + cbxIndexLang.addItem(entry.getKey()); + } + } + final String savedLangCode = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + cardLangMapping.entrySet().stream() + .filter(entry -> entry.getValue().equals(savedLangCode)) + .map(Map.Entry::getKey) + .findFirst() + .ifPresent(cbxIndexLang::setSelectedItem); + + cbPreferLangForUnique = new FCheckBox(localizer.getMessage("cbPreferLangForUniqueCards"), + FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)); + cbPreferLangForUnique.setToolTipText(localizer.getMessage("nlPreferLangForUniqueCards")); + + btnSyncBulkDataLang = new FButton(localizer.getMessage("btnSyncBulkCardDataLang")); + btnSyncBulkDataLang.addActionListener(e -> { + String selectedLangName = (String) cbxIndexLang.getSelectedItem(); + String selectedLangCode = cardLangMapping.get(selectedLangName); + FModel.getPreferences().setPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG, selectedLangCode); + FModel.getPreferences().setPref(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS, + String.valueOf(cbPreferLangForUnique.isSelected())); + FModel.getPreferences().save(); + applyPreferredLanguageAvailability(selectedLangCode); + startBulkSync(ScryfallBulkDataSync.BULK_TYPE_ALL_CARDS, + new java.util.HashSet<>(java.util.Arrays.asList("en", selectedLangCode)), selectedLangName); + }); + + btnClearCdnCache = new FButton(localizer.getMessage("btnClearCdnImageCache")); + btnClearCdnCache.addActionListener(e -> clearCdnCache()); + + final FButton btnClose = new FButton(localizer.getMessage("lblClose")); + btnClose.addActionListener(e -> SOverlayUtils.hideOverlay()); + + mainPanel = new FPanel(new MigLayout("insets 15, gap 5, wrap, center")); + mainPanel.setOpaque(false); + mainPanel.setBackgroundTexture(FSkin.getIcon(FSkinProp.BG_TEXTURE)); + + mainPanel.add(new FLabel.Builder().text(localizer.getMessage("btnDownloadCardImages")) + .fontSize(18).fontStyle(Font.BOLD).fontAlign(SwingConstants.CENTER).build(), "center, w 480!, gaptop 5"); + mainPanel.add(txtSearch, "w 480!, h 28!, gaptop 10"); + mainPanel.add(cbxFormats, "w 480!, h 28!, gaptop 8"); + mainPanel.add(lblTotal, "w 480!, gaptop 15, center"); + mainPanel.add(lblDownloaded, "w 480!, center"); + mainPanel.add(lblMissing, "w 480!, center, gapbottom 10"); + mainPanel.add(btnDownload, "w 460!, h 32!, center, gaptop 10"); + mainPanel.add(btnSyncBulkData, "w 460!, h 32!, center, gaptop 8"); + mainPanel.add(cbxIndexLang, "w 300!, h 32!, split 2, gaptop 8"); + mainPanel.add(btnSyncBulkDataLang, "w 154!, h 32!"); + mainPanel.add(cbPreferLangForUnique, "w 460!, center, gaptop 2"); + mainPanel.add(bulkSyncProgress, "w 460!, h 26!, center, gaptop 6"); + mainPanel.add(btnClearCdnCache, "w 460!, h 32!, center, gaptop 8"); + mainPanel.add(btnClose, "w 460!, h 32!, center, gaptop 8, gapbottom 15"); + } + + private void showMainOverlay() { + final JPanel overlay = FOverlay.SINGLETON_INSTANCE.getPanel(); + overlay.removeAll(); + overlay.setLayout(new MigLayout("insets 0, gap 0, wrap, ax center, ay center")); + overlay.add(mainPanel); + SOverlayUtils.showOverlay(); + } + + // ========================================================================= + // Stats computation (non-blocking, discards stale results) + // ========================================================================= + + private void scheduleStatsUpdate() { + final int gen = generation.incrementAndGet(); + + lblTotal.setText(localizer.getMessage("lblTotalCards") + ": ..."); + lblDownloaded.setText(localizer.getMessage("lblDownloaded") + ": ..."); + lblMissing.setText(localizer.getMessage("lblMissing") + ": ..."); + + final Predicate combined = buildCurrentFilter(); + + FThreads.invokeInBackgroundThread(() -> { + int total = 0, downloaded = 0, missing = 0; + for (PaperCard card : Iterables.concat( + FModel.getMagicDb().getCommonCards().getAllCards(), + FModel.getMagicDb().getVariantCards().getAllCards())) { + if (combined.test(card)) { + total++; + if (card.hasImage()) { downloaded++; } else { missing++; } + } + } + + if (generation.get() != gen) { return; } + final int fTotal = total, fDownloaded = downloaded, fMissing = missing; + FThreads.invokeInEdtLater(() -> { + if (generation.get() != gen) { return; } + lblTotal.setText(localizer.getMessage("lblTotalCards") + ": " + fTotal); + lblDownloaded.setText(localizer.getMessage("lblDownloaded") + ": " + fDownloaded); + lblMissing.setText(localizer.getMessage("lblMissing") + ": " + fMissing); + }); + }); + } + + /** Builds the combined card predicate from the current search field and format selection. */ + private Predicate buildCurrentFilter() { + Predicate textPred = SFilterUtil.buildTextFilter( + txtSearch.getText(), false, true, true, true, false); + Predicate fmtPred = selectedFormat == null ? x -> true + : selectedFormat.getName() == null ? selectedFormat.getFilterPrinted() + : selectedFormat.getFilterRules(); + return textPred.and(fmtPred); + } + + // ========================================================================= + // Download / cache actions + // ========================================================================= + + private void startDownload() { + new GuiDownloader(new GuiDownloadFilteredCardImages(buildCurrentFilter())).show(); + } + + private void clearCdnCache() { + if (!SOptionPane.showConfirmDialog(localizer.getMessage("lblClearCdnImageCacheConfirm"))) { + return; + } + CdnUuidCache.clearCache(); + SOptionPane.showMessageDialog(localizer.getMessage("lblCdnImageCacheCleared")); + } + + /** Called after {@link #show()} to open straight into a confirmed bulk sync (e.g. the first-run prompt). */ + public void showAndAutoStartBulkSync() { + show(); + runBulkSync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, "English"); + } + + private void applyPreferredLanguageAvailability(String langCode) { + boolean preferForUnique = cbPreferLangForUnique.isSelected(); + if (!preferForUnique || langCode == null || langCode.isEmpty() || "en".equalsIgnoreCase(langCode)) { + FModel.getMagicDb().setPreferredLanguageAvailability(null); + } else { + FModel.getMagicDb().setPreferredLanguageAvailability((setCode, cn) -> CdnUuidCache.isAvailableInLanguage(setCode, cn, langCode)); + } + } + + /** Resolves CDN links for every set at once from an online bulk index, instead of one set at a time. */ + private void startBulkSync(String bulkDataType, java.util.Set allowedLangs, String langLabel) { + if (!SOptionPane.showConfirmDialog(localizer.getMessage("lblSyncBulkCardDataConfirm", ScryfallBulkDataSync.approxSizeLabel(bulkDataType)))) { + return; + } + runBulkSync(bulkDataType, allowedLangs, langLabel); + } + + private void runBulkSync(String bulkDataType, java.util.Set allowedLangs, String langLabel) { + btnDownload.setEnabled(false); + btnSyncBulkData.setEnabled(false); + btnSyncBulkDataLang.setEnabled(false); + btnClearCdnCache.setEnabled(false); + + bulkSyncProgress.reset(); + bulkSyncProgress.setMaximum(100); + bulkSyncProgress.setShowETA(false); + bulkSyncProgress.setShowCount(false); + bulkSyncProgress.setIndeterminate(true); + bulkSyncProgress.setDescription("Starting..."); + + FThreads.invokeInBackgroundThread(() -> { + int setCount = ScryfallBulkDataSync.sync(bulkDataType, allowedLangs, + (message, fraction) -> FThreads.invokeInEdtLater(() -> { + bulkSyncProgress.setDescription(message); + if (fraction >= 0) { + bulkSyncProgress.setIndeterminate(false); + bulkSyncProgress.setValue((int) Math.round(fraction * 100)); + } else { + bulkSyncProgress.setIndeterminate(true); + } + }), + () -> false); + FThreads.invokeInEdtLater(() -> { + btnDownload.setEnabled(true); + btnSyncBulkData.setEnabled(true); + btnSyncBulkDataLang.setEnabled(true); + btnClearCdnCache.setEnabled(true); + bulkSyncProgress.setIndeterminate(false); + if (setCount >= 0) { + bulkSyncProgress.setValue(100); + bulkSyncProgress.setDescription(localizer.getMessage("lblBulkCardDataSynced") + " (" + setCount + " sets) - " + langLabel); + scheduleStatsUpdate(); + } else { + bulkSyncProgress.setDescription("Bulk sync failed -- see log for details."); + } + }); + }); + } + + // ========================================================================= + // "Choose Sets" picker (mirrors DialogChooseSets, minus its randomizer/format extras) + // ========================================================================= + + private void openChooseSets() { + final FCheckBoxTree checkBoxTree = new FCheckBoxTree(); + checkBoxTree.setOpaque(false); + + final TreeMap> treeData = new TreeMap<>(); + for (Map.Entry> entry : FModel.getMagicDb().getEditionsTypeMap().entrySet()) { + if (entry.getValue().isEmpty()) { continue; } + final List nodes = new ArrayList<>(); + for (CardEdition ce : entry.getValue()) { + nodes.add(new FTreeNodeData(ce, ce.getName(), ce.getCode())); + } + treeData.put(new FTreeNodeData(entry.getKey()), nodes); + } + checkBoxTree.setTreeData(treeData); + + final FPanel panel = new FPanel(new MigLayout("insets 10, gap 5, wrap, center")); + panel.setOpaque(false); + panel.setBackgroundTexture(FSkin.getIcon(FSkinProp.BG_TEXTURE)); + panel.add(new FLabel.Builder().text(localizer.getMessage("lblChooseSets")) + .fontSize(18).fontStyle(Font.BOLD).fontAlign(SwingConstants.CENTER).build(), "center, w 500!, gaptop 5"); + panel.add(new FScrollPane(checkBoxTree, true), "w 500!, h 400!, gaptop 10"); + + final FButton btnOk = new FButton(localizer.getMessage("lblOK")); + final FButton btnCancel = new FButton(localizer.getMessage("lblCancel")); + btnOk.addActionListener(e -> { + final List codes = new ArrayList<>(); + for (Object v : checkBoxTree.getCheckedValues(true)) { + codes.add(((CardEdition) v).getCode()); + } + showMainOverlay(); + if (!codes.isEmpty()) { + selectedFormat = new GameFormat(null, codes, null); + scheduleStatsUpdate(); + } + }); + btnCancel.addActionListener(e -> showMainOverlay()); + + final JPanel southPanel = new JPanel(new MigLayout("insets 10, gap 30, ax center")); + southPanel.setOpaque(false); + southPanel.add(btnOk, "w 200!, h 30!"); + southPanel.add(btnCancel, "w 200!, h 30!"); + panel.add(southPanel, "gaptop 10, gapbottom 5"); + + final JPanel overlay = FOverlay.SINGLETON_INSTANCE.getPanel(); + overlay.removeAll(); + overlay.setLayout(new MigLayout("insets 0, gap 0, wrap, ax center, ay center")); + overlay.add(panel); + SOverlayUtils.showOverlay(); + } +} diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuDownloaders.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuDownloaders.java index efbc86de486e..f8e1fc92a3f4 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuDownloaders.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuDownloaders.java @@ -55,6 +55,7 @@ public enum VSubmenuDownloaders implements IVSubmenu { private final FScrollPane scrContent = new FScrollPane(pnlContent, false); private final FLabel btnCheckForUpdates = _makeButton(localizer.getMessage("btnCheckForUpdates")); + private final FLabel btnDownloadCardImages = _makeButton(localizer.getMessage("btnDownloadCardImages")); private final FLabel btnDownloadSetPics = _makeButton(localizer.getMessage("btnDownloadSetPics")); private final FLabel btnDownloadPics = _makeButton(localizer.getMessage("btnDownloadPics")); private final FLabel btnDownloadPicsHQ = _makeButton(localizer.getMessage("btnDownloadPicsHQ")); @@ -85,6 +86,9 @@ public enum VSubmenuDownloaders implements IVSubmenu { pnlContent.add(btnCheckForUpdates, constraintsBTN); pnlContent.add(_makeLabel(localizer.getMessage("lblCheckForUpdates")), constraintsLBL); + pnlContent.add(btnDownloadCardImages, constraintsBTN); + pnlContent.add(_makeLabel(localizer.getMessage("btnDownloadCardImages")), constraintsLBL); + // pnlContent.add(btnDownloadPics, constraintsBTN); // pnlContent.add(_makeLabel(localizer.getMessage("lblDownloadPics")), constraintsLBL); // @@ -145,6 +149,7 @@ public EMenuGroup getGroupEnum() { } public void setCheckForUpdatesCommand(UiCommand command) { btnCheckForUpdates.setCommand(command); } + public void setDownloadCardImagesCommand(UiCommand command) { btnDownloadCardImages.setCommand(command); } public void setDownloadPicsCommand(UiCommand command) { btnDownloadPics.setCommand(command); } public void setDownloadPicsHQCommand(UiCommand command) { btnDownloadPicsHQ.setCommand(command); } public void setDownloadSetPicsCommand(UiCommand command) { btnDownloadSetPics.setCommand(command); } diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java index f2db42391ad8..4fbc963e2a58 100644 --- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java +++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/VSubmenuPreferences.java @@ -87,6 +87,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final JCheckBox cbSROptimize = new OptionsCheckBox(localizer.getMessage("cbSROptimize")); private final JCheckBox cbFilteredHands = new OptionsCheckBox(localizer.getMessage("cbFilteredHands")); private final JCheckBox cbImageFetcher = new OptionsCheckBox(localizer.getMessage("cbImageFetcher")); + private final JCheckBox cbPreferLangForUniqueCards = new OptionsCheckBox(localizer.getMessage("cbPreferLangForUniqueCards")); private final JCheckBox cbDisableCardImages = new OptionsCheckBox(localizer.getMessage("lblDisableCardImages")); private final JCheckBox cbCloneImgSource = new OptionsCheckBox(localizer.getMessage("cbCloneImgSource")); private final JCheckBox cbScaleLarger = new OptionsCheckBox(localizer.getMessage("cbScaleLarger")); @@ -146,6 +147,7 @@ public enum VSubmenuPreferences implements IVSubmenu { private final FComboBoxPanel cbpDefaultFontSize = new FComboBoxPanel<>(localizer.getMessage("cbpDefaultFontSize")+":"); private final FComboBoxPanel cbpCardArtFormat = new FComboBoxPanel<>(localizer.getMessage("cbpCardArtFormat")+":"); private final FComboBoxPanel cbpCardArtPreference = new FComboBoxPanel<>(localizer.getMessage("lblPreferredArt")+":"); + private final FComboBoxPanel cbpCardDownloadLang = new FComboBoxPanel<>(localizer.getMessage("cbpCardDownloadLang")+":"); private final FComboBoxPanel cbpMulliganRule = new FComboBoxPanel<>(localizer.getMessage("cbpMulliganRule")+":"); private final FComboBoxPanel cbpSoundSets = new FComboBoxPanel<>(localizer.getMessage("cbpSoundSets")+":"); private final FComboBoxPanel cbpMusicSets = new FComboBoxPanel<>(localizer.getMessage("cbpMusicSets")+":"); @@ -397,6 +399,12 @@ public enum VSubmenuPreferences implements IVSubmenu { pnlPrefs.add(cbImageFetcher, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlImageFetcher")), descriptionConstraints); + pnlPrefs.add(cbpCardDownloadLang, comboBoxConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlCardDownloadLang")), descriptionConstraints); + + pnlPrefs.add(cbPreferLangForUniqueCards, titleConstraints); + pnlPrefs.add(new NoteLabel(localizer.getMessage("nlPreferLangForUniqueCards")), descriptionConstraints); + pnlPrefs.add(cbDisableCardImages, titleConstraints); pnlPrefs.add(new NoteLabel(localizer.getMessage("nlDisableCardImages")), descriptionConstraints); @@ -783,6 +791,14 @@ public JCheckBox getCbImageFetcher() { return cbImageFetcher; } + public JCheckBox getCbPreferLangForUniqueCards() { + return cbPreferLangForUniqueCards; + } + + public FComboBoxPanel getCbpCardDownloadLangComboBoxPanel() { + return cbpCardDownloadLang; + } + /** @return {@link javax.swing.JCheckBox} */ public JCheckBox getCbDisableCardImages() { return cbDisableCardImages; diff --git a/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java b/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java index 39d78c41bed7..38dc099106ce 100644 --- a/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java +++ b/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java @@ -36,17 +36,19 @@ private boolean doFetch(String urlToDownload) throws IOException { return false; } - if (inScryfallCooldown(urlToDownload)) { + if (ScryfallRateLimiter.shouldSkip(urlToDownload)) { return false; } - String newdespath = urlToDownload.contains(".fullborder.jpg") || urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) ? + boolean isScryfallUrl = urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) + || urlToDownload.startsWith(ForgeConstants.URL_SCRYFALL_CDN); + String newdespath = urlToDownload.contains(".fullborder.jpg") || isScryfallUrl ? TextUtil.fastReplace(destPath, ".full.jpg", ".fullborder.jpg") : destPath; - if (!newdespath.contains(".full") && !newdespath.contains(".artcrop") && urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) && !destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR)) + if (!newdespath.contains(".full") && !newdespath.contains(".artcrop") && isScryfallUrl && !destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR)) newdespath = newdespath.replace(".jpg", ".fullborder.jpg"); //fix planes/phenomenon for round border options URL url = new URL(urlToDownload); System.out.println("Attempting to fetch: " + url); - paceScryfall(urlToDownload); + ScryfallRateLimiter.acquire(urlToDownload); // Read through a connection rather than ImageIO.read(URL), which discards the response // code - without it a 429 is indistinguishable from any other failure and we keep asking. @@ -58,10 +60,7 @@ private boolean doFetch(String urlToDownload) throws IOException { if (responseCode != HttpURLConnection.HTTP_OK) { System.err.println("Failed to fetch image. HTTP code: " + responseCode + " (" + httpConnection.getResponseMessage() + ") for URL: " + urlToDownload); - if (responseCode == 429 && isScryfall(urlToDownload)) { - System.err.println("Rate limited by scryfall. Pausing image downloads."); - noteScryfallRateLimited(); - } + ScryfallRateLimiter.noteIfRateLimited(responseCode, urlToDownload, httpConnection.getHeaderField("Retry-After")); httpConnection.disconnect(); return false; } diff --git a/forge-gui-desktop/src/main/java/forge/view/FView.java b/forge-gui-desktop/src/main/java/forge/view/FView.java index 843d78aa3e24..270dca7c52a1 100644 --- a/forge-gui-desktop/src/main/java/forge/view/FView.java +++ b/forge-gui-desktop/src/main/java/forge/view/FView.java @@ -29,6 +29,8 @@ import forge.Singletons; import forge.gui.ImportDialog; import forge.gui.SOverlayUtils; +import forge.gui.download.CdnUuidCache; +import forge.gui.util.SOptionPane; import forge.gui.framework.DragCell; import forge.gui.framework.EDocID; import forge.gui.framework.FScreen; @@ -41,6 +43,7 @@ import forge.screens.bazaar.VBazaarUI; import forge.screens.deckeditor.VDeckEditorUI; import forge.screens.home.VHomeUI; +import forge.screens.home.settings.DialogDownloadCardImages; import forge.sound.MusicPlaylist; import forge.sound.SoundSystem; import forge.toolbox.CardFaceSymbols; @@ -53,6 +56,7 @@ import forge.toolbox.FScrollPane; import forge.toolbox.FSkin; import forge.toolbox.FSkin.SkinnedLayeredPane; +import forge.util.Localizer; import forge.util.BuildInfo; import net.miginfocom.swing.MigLayout; @@ -233,6 +237,15 @@ public void initialize() { SwingUtilities.invokeLater(btnOk::requestFocusInWindow); }).show(); + } else if (!CdnUuidCache.hasAnyCachedSets()) { + // First run (or cache cleared): offer the one-time bulk CDN sync so later card + // image downloads resolve instantly instead of one Scryfall set at a time. Skipped + // alongside the (rare) legacy-migration prompt above to avoid stacking dialogs. + if (SOptionPane.showConfirmDialog( + Localizer.getInstance().getMessage("lblFirstRunBulkCdnPrompt"), + "Forge", "Download Now", "Not Now", true)) { + new DialogDownloadCardImages().showAndAutoStartBulkSync(); + } } } diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java new file mode 100644 index 000000000000..eb299d7b7eed --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java @@ -0,0 +1,248 @@ +package forge.gui.download; + +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * UUID data is pre-seeded into the local cache dir (gzip, as {@link CdnUuidCache} writes it). + * {@link ScryfallSetSync#searchBaseUrlOverride} points at an unreachable address so an + * unseeded lookup fails fast; on-demand generation is covered by {@link ScryfallSetSyncTest}. + */ +@Test(groups = {"UnitTest"}) +public class CdnUuidCacheTest { + + private static final String SET = "tst"; + private static final String SET_DFC = "dfc"; + private static final String SET_ABSENT = "xyz"; + + private static final String UUID_EN = "aaaaaaaa-bbbb-cccc-dddd-000000000001"; + private static final String UUID_JA = "aaaaaaaa-bbbb-cccc-dddd-000000000002"; + private static final String UUID_FRONT = "aaaaaaaa-bbbb-cccc-dddd-000000000003"; + private static final String UUID_BACK = "aaaaaaaa-bbbb-cccc-dddd-000000000004"; + + private File localCacheDir; + + @BeforeClass + public void setUp() throws IOException { + localCacheDir = Files.createTempDirectory("cdn_local").toFile(); + + // tst.json.gz — single-faced cards, multiple languages + writeGzip(new File(localCacheDir, SET + ".json.gz"), + "{" + + "\"1\":{\"en\":\"" + UUID_EN + "\",\"ja\":\"" + UUID_JA + "\"}," + + "\"2\":{\"en\":\"" + UUID_EN + "\"}" + + "}"); + + // dfc.json.gz — double-faced cards + writeGzip(new File(localCacheDir, SET_DFC + ".json.gz"), + "{" + + "\"1\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_BACK + "\"]}," + + "\"2\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_FRONT + "\"]}" + + "}"); + + // SET_ABSENT has no local file; the Scryfall override is unreachable. + + CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator; + CdnUuidCache.autoSyncEnabled = false; // drive misses synchronously, not via the pool + ScryfallSetSync.searchBaseUrlOverride = "http://127.0.0.1:1/unreachable"; + CdnUuidCache.clearCacheForTesting(); + } + + @AfterClass + public void tearDown() { + CdnUuidCache.localCacheDirOverride = null; + CdnUuidCache.autoSyncEnabled = true; + ScryfallSetSync.searchBaseUrlOverride = null; + CdnUuidCache.clearCacheForTesting(); + deleteDir(localCacheDir); + } + + // --- CDN URL formula --- + + @Test + public void cdnUrl_matchesScryfallFormula() { + String uuid = "4e7a547f-d1b0-4f4e-9a99-3c44fc89c048"; + Assert.assertEquals( + CdnUuidCache.cdnUrl(uuid, "front", "normal"), + "https://cards.scryfall.io/normal/front/4/e/" + uuid + ".jpg"); + Assert.assertEquals( + CdnUuidCache.cdnUrl(uuid, "back", "art_crop"), + "https://cards.scryfall.io/art_crop/back/4/e/" + uuid + ".jpg"); + } + + // --- happy path --- + + @Test + public void englishFront_returnsCorrectCdnUrl() { + String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_EN, "front", "normal")); + } + + @Test + public void artCropSize_reflectedInUrl() { + String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "art_crop"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_EN, "front", "art_crop")); + } + + @Test + public void japaneseLang_returnsJaUuid() { + String url = CdnUuidCache.getCdnUrl(SET, "1", "ja", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_JA, "front", "normal")); + } + + // --- language fallback --- + + @Test + public void unknownLang_fallsBackToEnglish() { + String url = CdnUuidCache.getCdnUrl(SET, "1", "zz", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_EN, "front", "normal")); + } + + @Test + public void cardWithOnlyEn_jaRequestFallsBack() { + String url = CdnUuidCache.getCdnUrl(SET, "2", "ja", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_EN, "front", "normal")); + } + + // --- DFC (double-faced cards) --- + + @Test + public void dfcDistinctFaces_frontUuid() { + String url = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_FRONT, "front", "normal")); + } + + @Test + public void dfcDistinctFaces_backUuid() { + String url = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "back", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_BACK, "back", "normal")); + } + + @Test + public void dfcSameUuid_backRequestStillUsesSharedUuid() { + // When both faces share the same UUID, back is stored as null internally. + String url = CdnUuidCache.getCdnUrl(SET_DFC, "2", "en", "back", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_FRONT, "back", "normal")); + } + + @Test + public void dfcEmptyFaceString_treatedAsFront() { + // ImageFetcher passes "" for the front face. + String urlEmpty = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "", "normal"); + String urlFront = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "front", "normal"); + Assert.assertEquals(urlEmpty, urlFront); + } + + // --- set code normalisation --- + + @Test + public void uppercaseSetCode_lowercasedBeforeLookup() { + String url = CdnUuidCache.getCdnUrl(SET.toUpperCase(), "1", "en", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl(UUID_EN, "front", "normal")); + } + + // --- null / missing inputs --- + + @Test + public void nullScryfallCode_returnsNull() { + Assert.assertNull(CdnUuidCache.getCdnUrl(null, "1", "en", "front", "normal")); + } + + @Test + public void nullCollectorNumber_returnsNull() { + Assert.assertNull(CdnUuidCache.getCdnUrl(SET, null, "en", "front", "normal")); + } + + @Test + public void absentSet_returnsNull() { + // No local file for SET_ABSENT, and the Scryfall search override is unreachable. + Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "1", "en", "front", "normal")); + } + + @Test(dependsOnMethods = "absentSet_returnsNull") + public void absentSetCachedAsMissing_secondCallAlsoNull() { + // MISSING_SET sentinel must be in cache; second lookup must not retry Scryfall. + Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "99", "en", "front", "normal")); + } + + @Test + public void unknownCollectorNumber_returnsNull() { + Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "9999", "en", "front", "normal")); + } + + // --- miss tracking --- + + @Test(dependsOnMethods = "unknownCollectorNumber_returnsNull") + public void missingEntry_isPersistedAsTimestampedMiss() throws IOException { + // The prior lookup for cn 9999 should have recorded a miss marker on disk. + String raw = readGunzipped(new File(localCacheDir, SET + ".json.gz")); + Assert.assertTrue(raw.contains("\"9999\""), "miss should be recorded under its collector number: " + raw); + Assert.assertTrue(raw.contains("\"miss\""), "miss should be recorded with the miss marker: " + raw); + + // Real entries already in the file must be untouched. + Assert.assertEquals(CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal"), + CdnUuidCache.cdnUrl(UUID_EN, "front", "normal")); + } + + @Test(dependsOnMethods = "missingEntry_isPersistedAsTimestampedMiss") + public void freshMiss_stillReturnsNullOnRepeatLookup() { + // The miss recorded above is fresh, well under the retry window. + Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "9999", "en", "front", "normal")); + } + + @Test + public void recordedMiss_upgradesToRealEntryOnceMerged() { + // A real entry found later (e.g. via a resync) must overwrite the miss marker. + Map cn7 = new HashMap<>(); + cn7.put("en", new String[]{UUID_EN, null}); + Map> found = new HashMap<>(); + found.put("7", cn7); + + Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "7", "en", "front", "normal")); + CdnUuidCache.mergeSetEntriesWithFaces(SET, found); + + Assert.assertEquals(CdnUuidCache.getCdnUrl(SET, "7", "en", "front", "normal"), + CdnUuidCache.cdnUrl(UUID_EN, "front", "normal")); + } + + // --- helpers --- + + private static void writeGzip(File f, String content) throws IOException { + try (GZIPOutputStream gz = new GZIPOutputStream(new FileOutputStream(f))) { + gz.write(content.getBytes(StandardCharsets.UTF_8)); + } + } + + private static String readGunzipped(File f) throws IOException { + try (GZIPInputStream gz = new GZIPInputStream(new FileInputStream(f))) { + return new String(gz.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void deleteDir(File dir) { + if (dir == null) return; + File[] children = dir.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isDirectory()) deleteDir(child); + else //noinspection ResultOfMethodCallIgnored + child.delete(); + } + } + //noinspection ResultOfMethodCallIgnored + dir.delete(); + } +} diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java new file mode 100644 index 000000000000..38c7f13145ba --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/gui/download/ScryfallSetSyncTest.java @@ -0,0 +1,287 @@ +package forge.gui.download; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.GZIPOutputStream; + +/** + * Exercises {@link ScryfallSetSync} against an embedded HTTP server standing in for + * {@code api.scryfall.com/cards/search}, offline. {@code autoSyncEnabled} is disabled so + * tests drive {@link CdnUuidCache#syncPendingSets} synchronously instead of racing a real + * background submission. + */ +@Test(groups = {"UnitTest"}) +public class ScryfallSetSyncTest { + + private HttpServer server; + private String baseUrl; + private File localCacheDir; + private final AtomicInteger requestCount = new AtomicInteger(); + private volatile String lastQuery; + + /** Pages served for the current test, keyed by 1-based page number. */ + private final Map pages = new HashMap<>(); + + @BeforeClass + public void setUp() throws IOException { + localCacheDir = Files.createTempDirectory("setsync_local").toFile(); + CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator; + CdnUuidCache.autoSyncEnabled = false; + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + ScryfallSetSync.searchBaseUrlOverride = baseUrl; + } + + @AfterClass + public void tearDown() { + server.stop(0); + CdnUuidCache.localCacheDirOverride = null; + CdnUuidCache.autoSyncEnabled = true; + ScryfallSetSync.searchBaseUrlOverride = null; + } + + @AfterMethod + public void resetBetweenTests() { + CdnUuidCache.clearCacheForTesting(); + pages.clear(); + requestCount.set(0); + lastQuery = null; + for (File f : localCacheDir.listFiles()) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + } + } + + private void handle(HttpExchange ex) throws IOException { + requestCount.incrementAndGet(); + lastQuery = ex.getRequestURI().getRawQuery(); + Map params = queryParams(lastQuery); + int page = Integer.parseInt(params.getOrDefault("page", "1")); + String body = pages.get(page); + byte[] bytes; + int status; + if (body == null) { + bytes = "{\"object\":\"error\",\"status\":404,\"details\":\"no cards found\"}" + .getBytes(StandardCharsets.UTF_8); + status = 404; + } else { + bytes = body.getBytes(StandardCharsets.UTF_8); + status = 200; + } + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(status, bytes.length); + try (var os = ex.getResponseBody()) { + os.write(bytes); + } + } + + private static Map queryParams(String rawQuery) { + Map out = new HashMap<>(); + if (rawQuery == null) return out; + for (String pair : rawQuery.split("&")) { + int eq = pair.indexOf('='); + out.put(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8)); + } + return out; + } + + private static String cdnUrl(String uuid) { + return "https://cards.scryfall.io/normal/front/" + uuid.charAt(0) + "/" + uuid.charAt(1) + "/" + uuid + ".jpg"; + } + + private static String singleFaced(String id, String cn, String lang) { + return String.format( + "{\"id\":\"%s\",\"collector_number\":\"%s\",\"lang\":\"%s\",\"image_uris\":{\"normal\":\"%s\"}}", + id, cn, lang, cdnUrl(id)); + } + + private static String doubleFaced(String id, String cn, String lang, String frontUuid, String backUuid) { + return String.format( + "{\"id\":\"%s\",\"collector_number\":\"%s\",\"lang\":\"%s\",\"card_faces\":[" + + "{\"image_uris\":{\"normal\":\"%s\"}}," + + "{\"image_uris\":{\"normal\":\"%s\"}}]}", + id, cn, lang, cdnUrl(frontUuid), cdnUrl(backUuid)); + } + + private String page(boolean hasMore, int pageNumber, String... entries) { + String nextPage = hasMore ? ",\"next_page\":\"" + baseUrl + "/?page=" + (pageNumber + 1) + "\"" : ""; + return "{\"object\":\"list\",\"has_more\":" + hasMore + nextPage + + ",\"data\":[" + String.join(",", entries) + "]}"; + } + + /** Triggers a cold lookup, (cdn) syncs (operation) synchronously, then re-queries for the result. */ + private static String resolveAfterSync(String set, String cn, String lang, String face, String size) { + CdnUuidCache.getCdnUrl(set, cn, lang, face, size); + CdnUuidCache.syncPendingSets(); + return CdnUuidCache.getCdnUrl(set, cn, lang, face, size); + } + + // ------------------------------------------------------------------------- + + @Test + public void singleFacedCard_resolvesViaCdnUuidCache() { + pages.put(1, page(false, 1, + singleFaced("11111111-1111-1111-1111-111111111111", "1", "en"))); + + String url = resolveAfterSync("neo", "1", "en", "front", "normal"); + + Assert.assertEquals(url, + CdnUuidCache.cdnUrl("11111111-1111-1111-1111-111111111111", "front", "normal")); + Assert.assertTrue(new File(localCacheDir, "neo.json.gz").exists()); + } + + @Test + public void dfcWithDistinctArtworkUuids_bothFacesResolveIndependently() { + pages.put(1, page(false, 1, + doubleFaced("22222222-2222-2222-2222-222222222222", "5", "en", + "aaaaaaaa-0000-0000-0000-000000000001", + "bbbbbbbb-0000-0000-0000-000000000002"))); + + String front = resolveAfterSync("sld", "5", "en", "front", "normal"); + String back = CdnUuidCache.getCdnUrl("sld", "5", "en", "back", "normal"); // set already synced by now + + Assert.assertEquals(front, CdnUuidCache.cdnUrl("aaaaaaaa-0000-0000-0000-000000000001", "front", "normal"), + "front face UUID should come from card_faces[0].image_uris, not the card id"); + Assert.assertEquals(back, CdnUuidCache.cdnUrl("bbbbbbbb-0000-0000-0000-000000000002", "back", "normal"), + "back face UUID should come from card_faces[1].image_uris, distinct from front"); + } + + @Test + public void dfcWithSharedArtworkUuid_backResolvesToSameUuid() { + pages.put(1, page(false, 1, + doubleFaced("33333333-3333-3333-3333-333333333333", "6", "en", + "cccccccc-0000-0000-0000-000000000003", + "cccccccc-0000-0000-0000-000000000003"))); + + String back = resolveAfterSync("neo", "6", "en", "back", "normal"); + + Assert.assertEquals(back, CdnUuidCache.cdnUrl("cccccccc-0000-0000-0000-000000000003", "back", "normal")); + } + + @Test + public void multiplePages_paginatesViaNextPageAndMergesAll() { + pages.put(1, page(true, 1, singleFaced("dddddddd-0000-0000-0000-000000000001", "1", "en"))); + pages.put(2, page(false, 2, singleFaced("eeeeeeee-0000-0000-0000-000000000002", "2", "en"))); + + String url1 = resolveAfterSync("neo", "1", "en", "front", "normal"); + String url2 = CdnUuidCache.getCdnUrl("neo", "2", "en", "front", "normal"); // set already synced by now + + Assert.assertNotNull(url1); + Assert.assertNotNull(url2); + Assert.assertEquals(requestCount.get(), 2, "should follow next_page across both pages"); + } + + @Test + public void unknownSet_returns404_lookupIsNullWithoutThrowing() { + // No page registered for "xyz" -> handle() serves a 404, same as Scryfall for no matches. + String url = resolveAfterSync("xyz", "1", "en", "front", "normal"); + + Assert.assertNull(url); + Assert.assertFalse(new File(localCacheDir, "xyz.json.gz").exists()); + } + + @Test + public void sync_neverOverwritesExistingPrecomputedEntry() { + // Pre-existing, precise data (e.g. from a prior manifest sync). + Map> existing = new HashMap<>(); + Map cn1 = new HashMap<>(); + cn1.put("en", new String[]{"ffffffff-0000-0000-0000-000000000001", null}); + existing.put("1", cn1); + CdnUuidCache.mergeSetEntriesWithFaces("neo", existing); + + // Scryfall search sees a *different* id for the same card/lang -- must not clobber it. + pages.put(1, page(false, 1, singleFaced("99999999-0000-0000-0000-000000000009", "1", "en"))); + ScryfallSetSync.sync("neo"); // direct call, bypassing CdnUuidCache's backgrounding entirely + + String url = CdnUuidCache.getCdnUrl("neo", "1", "en", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl("ffffffff-0000-0000-0000-000000000001", "front", "normal"), + "pre-existing entries must win over a freshly-synced one"); + } + + @Test + public void searchQuery_scopesToSetAndEnglish() { + pages.put(1, page(false, 1, singleFaced("11111111-2222-3333-4444-555555555555", "1", "en"))); + + resolveAfterSync("ltr", "1", "en", "front", "normal"); + + Assert.assertNotNull(lastQuery); + String decoded = URLDecoder.decode(lastQuery, StandardCharsets.UTF_8); + Assert.assertTrue(decoded.contains("set:ltr"), "query should scope to the requested set: " + decoded); + // Scoped to English only -- CdnUuidCache.getCdnUrl() always falls back to "en" and + // virtually every edition is English, so fetching every printed language (lang:any) + // multiplied API calls ~10x and tripped Scryfall's rate limit before a set finished syncing. + Assert.assertTrue(decoded.contains("lang:en"), "query should scope to English: " + decoded); + } + + // ------------------------------------------------------------------------- + // Miss-record retry + + private static void writeGzip(File f, String content) throws IOException { + try (GZIPOutputStream gz = new GZIPOutputStream(new FileOutputStream(f))) { + gz.write(content.getBytes(StandardCharsets.UTF_8)); + } + } + + @Test + public void freshMiss_doesNotRetryScryfall() throws IOException { + // "neo" already has local data, but cn 9 was checked moments ago and came back empty. + writeGzip(new File(localCacheDir, "neo.json.gz"), + "{\"1\":{\"en\":\"11111111-0000-0000-0000-000000000001\"}," + + "\"9\":{\"en\":{\"miss\":\"" + java.time.Instant.now() + "\"}}}"); + + String url = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); + + Assert.assertNull(url); + Assert.assertEquals(requestCount.get(), 0, "a fresh miss must not hit Scryfall again"); + } + + @Test + public void staleMiss_retriesAndResolvesIfNowFound() throws IOException { + writeGzip(new File(localCacheDir, "neo.json.gz"), + "{\"9\":{\"en\":{\"miss\":\"2020-01-01T00:00:00Z\"}}}"); + pages.put(1, page(false, 1, singleFaced("99999999-0000-0000-0000-000000000009", "9", "en"))); + + String immediate = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); + Assert.assertNull(immediate, "the retry is only queued, not run synchronously"); + CdnUuidCache.syncPendingSets(); + + String url = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); + Assert.assertEquals(url, CdnUuidCache.cdnUrl("99999999-0000-0000-0000-000000000009", "front", "normal")); + Assert.assertEquals(requestCount.get(), 1, "a stale miss should trigger exactly one retry"); + } + + @Test + public void staleMiss_stillMissing_refreshesTimestampAndStopsRetrying() throws IOException { + writeGzip(new File(localCacheDir, "neo.json.gz"), + "{\"9\":{\"en\":{\"miss\":\"2020-01-01T00:00:00Z\"}}}"); + // No page registered -> 404, same as Scryfall finding nothing for this set. + + CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); // queues the retry + CdnUuidCache.syncPendingSets(); + Assert.assertEquals(requestCount.get(), 1, "the stale miss should have triggered one retry"); + + String second = CdnUuidCache.getCdnUrl("neo", "9", "en", "front", "normal"); + Assert.assertNull(second); + Assert.assertEquals(requestCount.get(), 1, "the refreshed miss timestamp should prevent an immediate second retry"); + } +} diff --git a/forge-gui-mobile/src/forge/Forge.java b/forge-gui-mobile/src/forge/Forge.java index 3cccbdd6f210..0df75b61d658 100644 --- a/forge-gui-mobile/src/forge/Forge.java +++ b/forge-gui-mobile/src/forge/Forge.java @@ -28,7 +28,9 @@ import forge.gamemodes.limited.BoosterDraft; import forge.gui.FThreads; import forge.gui.GuiBase; +import forge.gui.download.CdnUuidCache; import forge.gui.error.BugReporter; +import forge.gui.util.SOptionPane; import forge.interfaces.IDeviceAdapter; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgePreferences; @@ -43,6 +45,7 @@ import forge.screens.home.NewGameMenu; import forge.screens.match.MatchController; import forge.screens.match.MatchScreen; +import forge.screens.settings.CardImageBrowserScreen; import forge.sound.MusicPlaylist; import forge.sound.SoundSystem; import forge.toolbox.*; @@ -360,6 +363,25 @@ public static void openHomeScreen(int index, FScreen lastMatch) { System.out.println(fScreen.toString());*/ } + /** + * First run (or cache cleared): offer the one-time bulk CDN sync so later card image + * downloads resolve instantly instead of one Scryfall set at a time. Call only once a real + * home/adventure screen is open -- not while the mode-selector splash is still showing. + */ + public static void maybePromptForBulkCdnSync() { + if (CdnUuidCache.hasAnyCachedSets()) { + return; + } + FThreads.invokeInBackgroundThread(() -> { + boolean confirmed = SOptionPane.showConfirmDialog( + getLocalizer().getMessage("lblFirstRunBulkCdnPrompt"), + "Forge", "Download Now", "Not Now", true); + if (confirmed) { + CardImageBrowserScreen.openAndAutoStartBulkSync(); + } + }); + } + public static void openHomeDefault() { //default to English only if CJK is missing getLocalizer().setEnglish(forcedEnglishonCJKMissing); @@ -451,25 +473,34 @@ protected void afterDbLoaded() { } //selection transition setTransitionScreen(new TransitionScreen(() -> { + boolean openedRealScreen; if (createNewAdventureMap) { openAdventure(); clearSplashScreen(); + openedRealScreen = true; } else { if (selector.equals("Classic")) { openHomeDefault(); clearSplashScreen(); + openedRealScreen = true; } else if (selector.equals("Adventure")) { openAdventure(); clearSplashScreen(); + openedRealScreen = true; } else if (splashScreen != null) { splashScreen.setShowModeSelector(true); + openedRealScreen = false; } else {//default mode in case splashscreen is null at some point as seen on resume.. openHomeDefault(); clearSplashScreen(); + openedRealScreen = true; } } safeToClose = true; clearTransitionScreen(); + if (openedRealScreen) { + maybePromptForBulkCdnSync(); + } if (GuiBase.isIOS()) { // POST-LOAD memory reclaim (iOS): booting parses ~32k card rules + // builds ~100k PaperCards + loads skin assets — a large transient diff --git a/forge-gui-mobile/src/forge/screens/SplashScreen.java b/forge-gui-mobile/src/forge/screens/SplashScreen.java index bd44347f2865..09b7768d9824 100644 --- a/forge-gui-mobile/src/forge/screens/SplashScreen.java +++ b/forge-gui-mobile/src/forge/screens/SplashScreen.java @@ -226,6 +226,7 @@ protected void onEnd(boolean endingAll) { else Forge.openHomeDefault(); Forge.clearSplashScreen(); + Forge.maybePromptForBulkCdnSync(); } } } diff --git a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java index 9ee4e4263b98..5d54fcbfb18f 100644 --- a/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java +++ b/forge-gui-mobile/src/forge/screens/settings/CardImageBrowserScreen.java @@ -9,10 +9,15 @@ import forge.card.CardEdition; import forge.game.GameFormat; import forge.gui.FThreads; +import forge.gui.download.CdnUuidCache; import forge.gui.download.GuiDownloadFilteredCardImages; +import forge.gui.download.ScryfallBulkDataSync; +import forge.gui.util.SOptionPane; import forge.item.PaperCard; import forge.itemmanager.SFilterUtil; import forge.itemmanager.filters.ArchivedFormatSelect; +import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; import forge.model.FModel; import forge.screens.FScreen; import forge.toolbox.*; @@ -31,12 +36,19 @@ public class CardImageBrowserScreen extends FScreen { private static final float BTN_HEIGHT = Math.round(Utils.AVG_FINGER_HEIGHT * 0.9f); private static final FSkinFont STAT_FONT = FSkinFont.get(16); + private final FScrollPane scroller; private final FTextField txtSearch; private final FComboBox cbxFormats; private final FLabel lblTotal; private final FLabel lblDownloaded; private final FLabel lblMissing; private final FButton btnDownload; + private final FButton btnSyncBulkData; + private final FComboBox cbxIndexLang; + private final FButton btnSyncBulkDataLang; + private final FCheckBox cbPreferLangForUnique; + private final FProgressBar bulkSyncProgress; + private final FButton btnClearCdnCache; private GameFormat selectedFormat = null; private String selectedFormatText; @@ -49,8 +61,57 @@ public class CardImageBrowserScreen extends FScreen { public CardImageBrowserScreen() { super(Forge.getLocalizer().getMessage("btnDownloadCardImages")); + scroller = add(new FScrollPane() { + @Override + protected ScrollBounds layoutAndGetScrollBounds(float visibleWidth, float visibleHeight) { + float x = PADDING; + float y = PADDING; + float w = visibleWidth - 2 * PADDING; + + txtSearch.setBounds(x, y, w, FIELD_HEIGHT); + y += FIELD_HEIGHT + PADDING; + + cbxFormats.setBounds(x, y, w, FIELD_HEIGHT); + y += FIELD_HEIGHT + PADDING * 5; + + lblTotal.setBounds(x, y, w, STAT_HEIGHT); + y += STAT_HEIGHT + PADDING; + + lblDownloaded.setBounds(x, y, w, STAT_HEIGHT); + y += STAT_HEIGHT + PADDING; + + lblMissing.setBounds(x, y, w, STAT_HEIGHT); + y += STAT_HEIGHT + PADDING * 5; + + float btnW = Math.min(w * 0.6f, Utils.AVG_FINGER_HEIGHT * 4); + btnDownload.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + btnSyncBulkData.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + cbxIndexLang.setBounds(x, y, w, FIELD_HEIGHT); + y += FIELD_HEIGHT + PADDING; + + btnSyncBulkDataLang.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + float checkboxHeight = Math.round(Utils.AVG_FINGER_HEIGHT * 0.6f); + cbPreferLangForUnique.setBounds(x, y, w, checkboxHeight); + y += checkboxHeight + PADDING; + + bulkSyncProgress.setBounds(x, y, w, STAT_HEIGHT * 0.8f); + y += STAT_HEIGHT * 0.8f + PADDING; + + btnClearCdnCache.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + y += BTN_HEIGHT + PADDING; + + return new ScrollBounds(visibleWidth, y); + } + }); + // ── Search text field ──────────────────────────────────────────────── - txtSearch = add(new FTextField()); + txtSearch = scroller.add(new FTextField()); txtSearch.setFont(FSkinFont.get(12)); txtSearch.setGhostText(Forge.getLocalizer().getMessage("lblSearch") + " " + Forge.getLocalizer().getMessage("lblCards") + "..."); @@ -59,7 +120,7 @@ public CardImageBrowserScreen() { txtSearch.setChangedHandler(e -> scheduleStatsUpdate()); // ── Format / Sets combo — same options as the deck-browser filter ──── - cbxFormats = add(new FComboBox<>()); + cbxFormats = scroller.add(new FComboBox<>()); cbxFormats.setFont(FSkinFont.get(12)); cbxFormats.addItem(Forge.getLocalizer().getMessage("lblAllSetsFormats")); for (GameFormat fmt : FModel.getFormats().getFilterList()) { @@ -107,14 +168,53 @@ public CardImageBrowserScreen() { }); // ── Stats labels ───────────────────────────────────────────────────── - lblTotal = add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); - lblDownloaded = add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); - lblMissing = add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); + lblTotal = scroller.add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); + lblDownloaded = scroller.add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); + lblMissing = scroller.add(new FLabel.Builder().text("--").font(STAT_FONT).align(Align.center).build()); // ── Download button ────────────────────────────────────────────────── - btnDownload = add(new FButton(Forge.getLocalizer().getMessage("btnDownloadCardImages"))); + btnDownload = scroller.add(new FButton(Forge.getLocalizer().getMessage("btnDownloadCardImages"))); btnDownload.setCommand(e -> startDownload()); + // ── Bulk data sync buttons ─────────────────────────────────────────── + btnSyncBulkData = scroller.add(new FButton(Forge.getLocalizer().getMessage("btnSyncBulkCardData"))); + btnSyncBulkData.setCommand(e -> startBulkSync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, "English")); + + final Map cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping(); + cbxIndexLang = scroller.add(new FComboBox<>()); + cbxIndexLang.setFont(FSkinFont.get(12)); + for (Map.Entry entry : cardLangMapping.entrySet()) { + if (!"en".equalsIgnoreCase(entry.getValue())) { + cbxIndexLang.addItem(entry.getKey()); + } + } + final String savedLangCode = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + cardLangMapping.entrySet().stream() + .filter(entry -> entry.getValue().equals(savedLangCode)) + .map(Map.Entry::getKey) + .findFirst() + .ifPresent(cbxIndexLang::setSelectedItem); + + cbPreferLangForUnique = scroller.add(new FCheckBox(Forge.getLocalizer().getMessage("cbPreferLangForUniqueCards"), + FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS))); + + btnSyncBulkDataLang = scroller.add(new FButton(Forge.getLocalizer().getMessage("btnSyncBulkCardDataLang"))); + btnSyncBulkDataLang.setCommand(e -> { + String selectedLangName = cbxIndexLang.getSelectedItem(); + String selectedLangCode = cardLangMapping.get(selectedLangName); + FModel.getPreferences().setPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG, selectedLangCode); + FModel.getPreferences().setPref(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS, + String.valueOf(cbPreferLangForUnique.isSelected())); + FModel.getPreferences().save(); + applyPreferredLanguageAvailability(selectedLangCode); + startBulkSync(ScryfallBulkDataSync.BULK_TYPE_ALL_CARDS, new HashSet<>(Arrays.asList("en", selectedLangCode)), selectedLangName); + }); + bulkSyncProgress = scroller.add(new FProgressBar()); + + // ── Clear CDN cache button ───────────────────────────────────────────── + btnClearCdnCache = scroller.add(new FButton(Forge.getLocalizer().getMessage("btnClearCdnImageCache"))); + btnClearCdnCache.setCommand(e -> clearCdnCache()); + // Run initial stats for "All cards, all sets" as soon as the screen opens scheduleStatsUpdate(); } @@ -166,6 +266,100 @@ private void startDownload() { }).show(); } + // ========================================================================= + // Bulk data sync: resolve CDN links for every set at once + // ========================================================================= + + /** Opens the screen and immediately starts a bulk sync that's already been confirmed elsewhere (e.g. the first-run prompt). Safe to call from any thread. */ + public static void openAndAutoStartBulkSync() { + FThreads.invokeInEdtLater(() -> { + CardImageBrowserScreen screen = new CardImageBrowserScreen(); + Forge.openScreen(screen); + screen.runBulkSync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, "English"); + }); + } + + private void applyPreferredLanguageAvailability(String langCode) { + boolean preferForUnique = cbPreferLangForUnique.isSelected(); + if (!preferForUnique || langCode == null || langCode.isEmpty() || "en".equalsIgnoreCase(langCode)) { + FModel.getMagicDb().setPreferredLanguageAvailability(null); + } else { + FModel.getMagicDb().setPreferredLanguageAvailability((setCode, cn) -> CdnUuidCache.isAvailableInLanguage(setCode, cn, langCode)); + } + } + + private void startBulkSync(String bulkDataType, Set allowedLangs, String langLabel) { + // SOptionPane.showConfirmDialog() blocks its caller while the dialog renders on the EDT, + // so it must never be called directly from a tap handler (which runs on the EDT itself) + // -- that throws immediately and the whole method aborts before any UI update happens. + FThreads.invokeInBackgroundThread(() -> { + if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblSyncBulkCardDataConfirm", ScryfallBulkDataSync.approxSizeLabel(bulkDataType)))) { + return; + } + runBulkSync(bulkDataType, allowedLangs, langLabel); + }); + } + + /** Runs the sync itself; always hops onto its own background thread, so it's safe to call from the EDT or not. */ + private void runBulkSync(String bulkDataType, Set allowedLangs, String langLabel) { + FThreads.invokeInBackgroundThread(() -> { + FThreads.invokeInEdtLater(() -> { + btnDownload.setEnabled(false); + btnSyncBulkData.setEnabled(false); + btnSyncBulkDataLang.setEnabled(false); + btnClearCdnCache.setEnabled(false); + bulkSyncProgress.reset(); + bulkSyncProgress.setMaximum(100); + bulkSyncProgress.setShowETA(false); + bulkSyncProgress.setShowCount(false); + bulkSyncProgress.setDescription("Starting..."); + bulkSyncProgress.setShowProgressTrail(true); + }); + + int setCount = ScryfallBulkDataSync.sync(bulkDataType, allowedLangs, + (message, fraction) -> FThreads.invokeInEdtLater(() -> { + bulkSyncProgress.setDescription(message); + if (fraction >= 0) { + bulkSyncProgress.setShowProgressTrail(false); + bulkSyncProgress.setValue((int) Math.round(fraction * 100)); + } else { + bulkSyncProgress.setShowProgressTrail(true); + } + }), + () -> false); + FThreads.invokeInEdtLater(() -> { + btnDownload.setEnabled(true); + btnSyncBulkData.setEnabled(true); + btnSyncBulkDataLang.setEnabled(true); + btnClearCdnCache.setEnabled(true); + bulkSyncProgress.setShowProgressTrail(false); + if (setCount >= 0) { + bulkSyncProgress.setValue(100); + bulkSyncProgress.setDescription(Forge.getLocalizer().getMessage("lblBulkCardDataSynced") + " (" + setCount + " sets) - " + langLabel); + scheduleStatsUpdate(); + } else { + bulkSyncProgress.setDescription("Bulk sync failed -- see log for details."); + } + }); + }); + } + + // ========================================================================= + // CDN image lookup cache: clear + // ========================================================================= + + private void clearCdnCache() { + // Same EDT restriction as startBulkSync() -- must not call SOptionPane directly from a + // tap handler. + FThreads.invokeInBackgroundThread(() -> { + if (!SOptionPane.showConfirmDialog(Forge.getLocalizer().getMessage("lblClearCdnImageCacheConfirm"))) { + return; + } + CdnUuidCache.clearCache(); + SOptionPane.showMessageDialog(Forge.getLocalizer().getMessage("lblCdnImageCacheCleared")); + }); + } + // ========================================================================= // Helpers // ========================================================================= @@ -186,27 +380,7 @@ private Predicate buildCurrentFilter() { @Override protected void doLayout(float startY, float width, float height) { - float x = PADDING; - float y = startY + PADDING; - float w = width - 2 * PADDING; - - txtSearch.setBounds(x, y, w, FIELD_HEIGHT); - y += FIELD_HEIGHT + PADDING; - - cbxFormats.setBounds(x, y, w, FIELD_HEIGHT); - y += FIELD_HEIGHT + PADDING * 5; - - lblTotal.setBounds(x, y, w, STAT_HEIGHT); - y += STAT_HEIGHT + PADDING; - - lblDownloaded.setBounds(x, y, w, STAT_HEIGHT); - y += STAT_HEIGHT + PADDING; - - lblMissing.setBounds(x, y, w, STAT_HEIGHT); - y += STAT_HEIGHT + PADDING * 5; - - float btnW = Math.min(w * 0.6f, Utils.AVG_FINGER_HEIGHT * 4); - btnDownload.setBounds(x + (w - btnW) / 2f, y, btnW, BTN_HEIGHT); + scroller.setBounds(0, startY, width, height - startY); } // ========================================================================= diff --git a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java index e92702c4ac8a..5791f1c07281 100644 --- a/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java +++ b/forge-gui-mobile/src/forge/screens/settings/SettingsPage.java @@ -12,6 +12,7 @@ import forge.game.GameLogEntryType; import forge.game.GameLogVerbosity; import forge.gui.GuiBase; +import forge.gui.download.CdnUuidCache; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgeNetPreferences; import forge.localinstance.properties.ForgePreferences; @@ -507,6 +508,25 @@ public void select() { lstSettings.addItem(new BooleanSetting(FPref.UI_ENABLE_ONLINE_IMAGE_FETCHER, Forge.getLocalizer().getMessage("cbImageFetcher"), Forge.getLocalizer().getMessage("nlImageFetcher")), 4); + final Map cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping(); + lstSettings.addItem(new CustomSelectSetting(FPref.UI_CARD_DOWNLOAD_LANG, "Card art language", + "Preferred language for downloaded card images", + cardLangMapping.values()) { + @Override + public void valueChanged(String newValue) { + super.valueChanged(newValue); + applyPreferredLanguageAvailability(); + } + }, 4); + lstSettings.addItem(new BooleanSetting(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS, + "Prefer language for unique cards", + "When enabled, prioritizes cards available in the selected language for unique art") { + @Override + public void select() { + super.select(); + applyPreferredLanguageAvailability(); + } + }, 4); lstSettings.addItem(new CustomSelectSetting(FPref.UI_PREFERRED_ART, Forge.getLocalizer().getMessage("lblPreferredArt"), Forge.getLocalizer().getMessage("nlPreferredArt"), @@ -748,6 +768,16 @@ public void refreshCJKFontsList() { settingCJKFonts.updateOptions(FSkinFont.getAllCJKFonts()); } + private void applyPreferredLanguageAvailability() { + String langCode = FModel.getPreferences().getPref(FPref.UI_CARD_DOWNLOAD_LANG); + boolean preferForUnique = FModel.getPreferences().getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS); + if (!preferForUnique || langCode == null || langCode.isEmpty() || "en".equalsIgnoreCase(langCode)) { + FModel.getMagicDb().setPreferredLanguageAvailability(null); + } else { + FModel.getMagicDb().setPreferredLanguageAvailability((setCode, cn) -> CdnUuidCache.isAvailableInLanguage(setCode, cn, langCode)); + } + } + private void applySearch() { final String query = txtSearch.getText().toLowerCase().trim(); if (query.isEmpty()) { diff --git a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java index 82ccc7352a50..dde0d27ad09d 100644 --- a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java +++ b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java @@ -55,17 +55,20 @@ private boolean doFetch(String urlToDownload) throws IOException { return false; } - if (inScryfallCooldown(urlToDownload)) { + if (ScryfallRateLimiter.shouldSkip(urlToDownload)) { return false; } - String newdespath = urlToDownload.contains(".fullborder.") || urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) ? + boolean isScryfallUrl = urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) + || urlToDownload.startsWith(ForgeConstants.URL_SCRYFALL_CDN); + String newdespath = urlToDownload.contains(".fullborder.") || isScryfallUrl ? TextUtil.fastReplace(destPath, ".full.", ".fullborder.") : destPath; - if (!newdespath.contains(".full") && urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) && + if (!newdespath.contains(".full") && isScryfallUrl && !destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR) && !destPath.startsWith(ForgeConstants.CACHE_PLANECHASE_PICS_DIR)) newdespath = newdespath.replace(".jpg", ".fullborder.jpg"); //fix planes/phenomenon for round border options URL url = new URL(urlToDownload); System.out.println("Attempting to fetch: " + url); + ScryfallRateLimiter.acquire(urlToDownload); HttpURLConnection c = (HttpURLConnection) url.openConnection(); c.setRequestProperty("Accept", "*/*"); c.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); @@ -78,14 +81,13 @@ private boolean doFetch(String urlToDownload) throws IOException { System.out.println("HTTP Response: " + responseCode + " " + responseMessage + " for URL: " + urlToDownload); if (responseCode != HttpURLConnection.HTTP_OK) { System.err.println("Failed to fetch image. HTTP code: " + responseCode + " (" + responseMessage + ") for URL: " + urlToDownload); - c.disconnect(); - if (responseCode == 429) { - System.err.println("Device has been rate limited. Adding reduction of download attempts for this device."); + if (responseCode == 429 && ScryfallRateLimiter.isApiUrl(urlToDownload)) { Sentry.captureMessage("Device has been rate limited. Adding reduction of download attempts for this device. " + urlToDownload); - noteScryfallRateLimited(); + ScryfallRateLimiter.noteIfRateLimited(responseCode, urlToDownload, c.getHeaderField("Retry-After")); } + c.disconnect(); return false; } @@ -174,12 +176,6 @@ public void run() { } } } - } finally { - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } } } if (!success) { diff --git a/forge-gui/pom.xml b/forge-gui/pom.xml index ed727b73384c..42b126c52d78 100644 --- a/forge-gui/pom.xml +++ b/forge-gui/pom.xml @@ -82,6 +82,11 @@ jetty-servlet ${jetty.version} + + com.google.code.gson + gson + 2.13.2 + diff --git a/forge-gui/res/languages/de-DE.properties b/forge-gui/res/languages/de-DE.properties index ff30424a8a2a..23596b85e045 100644 --- a/forge-gui/res/languages/de-DE.properties +++ b/forge-gui/res/languages/de-DE.properties @@ -107,7 +107,8 @@ cbPerformanceMode=Performance-Modus cbOrderHand=Keep Hand ordered cbFilteredHands=Alternative Starthand cbImageFetcher=Lade automatisch fehlende Kartenbilder -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Kartenbildsprache +cbPreferLangForUniqueCards=Meine Sprache für einzigartige Karten bevorzugen cbCloneImgSource=Klone zeigen eigenes Kartenbild cbScaleLarger=Skaliere Bilder größer cbRenderBlackCardBorders=Zeige schwarzen Rand @@ -246,7 +247,8 @@ nlDefaultFontSize=Die Standardschriftgröße. Alle Schriftelemente werden relati nlCardArtFormat=Format der Kartenbilder (Full: Bilder ganzen Karte; Crop: nur Bildteil der Karte) cbpMulliganRule=Mulligan Regel nlImageFetcher=Ermöglicht bei bestehender Onlineverbindung das automatisches Nachladen fehlender Kartenbilder. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=Die bevorzugte Sprache zum Herunterladen von Kartenbildern, sofern verfügbar. Fällt auf Englisch zurück, wenn die gewählte Sprache für eine bestimmte Karte nicht gedruckt wurde. +nlPreferLangForUniqueCards=Bevorzugt bei der Anzeige nur einzigartiger Karten einen Druck, der nachweislich in deiner Kartenbildsprache existiert, gegenüber der Standard-Motivauswahl. nlDisplayFoil=Zeige FOIL-Karten mit einem optischen FOIL-Effekt. nlRandomFoil=Zeige den FOIL-Effekt bei zufälligen Karten. nlScaleLarger=Erlaubt Kartenbilder größer als ihre originale Größe zu zeigen. @@ -333,6 +335,15 @@ lblDownloadSkins=Lade verfügbare Skins herunter, um die Erscheinung von Forge a lblDownloadCardImages=Kartenbilder vorab herunterladen oder prüfen, wie viele Bilder fehlen. lblDownloaded=Heruntergeladen lblMissing=Fehlend +btnClearCdnImageCache=CDN-Bildzuordnung leeren +lblClearCdnImageCache=Löscht den lokalen CDN-Bildzuordnungs-Cache. Nützlich, wenn manche Karten weiterhin über den langsameren Ausweichpfad geladen werden, obwohl ihre Bilder eigentlich verfügbar sein sollten -- Forge ruft die Daten danach bei Bedarf erneut ab. +lblClearCdnImageCacheConfirm=Dies löscht den lokalen CDN-Bildzuordnungs-Cache. Forge ruft ihn bei Bedarf erneut ab. Fortfahren? +lblCdnImageCacheCleared=CDN-Bildzuordnungs-Cache geleert. +btnSyncBulkCardData=Index herunterladen - Nur Englisch (~75 MB) +btnSyncBulkCardDataLang=Index herunterladen +lblSyncBulkCardDataConfirm=Dies lädt einen vollständigen Kartenbild-Index von einer Online-Quelle herunter, um den Bildlink jeder Karte auf einmal aufzulösen, statt Set für Set. Fortfahren? +lblBulkCardDataSynced=Kartendaten synchronisiert. +lblFirstRunBulkCdnPrompt=Forge kann jetzt einen vollständigen Kartenbild-Index (einmalig, nur Englisch) von einer Online-Quelle herunterladen, damit künftige Kartenbild-Downloads sofort statt Set für Set aufgelöst werden. Es gibt auch einen mehrsprachigen Index -- dafür unter "Inhalte herunterladen" > "Kartenbilder herunterladen" gehen. Jetzt herunterladen? lblDownloadCJKFonts=Lade Schriftarten zur lokalen Nutzung von CJK-Zeichen herunter. lblDownloadCJKFontPrompt=Wähle eine Schriftart zum Herunterladen aus. lblYourVersionOfJavaIsTooOld=Deine Java-Version ist leider zu alt. diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index ce80bca02508..a6199c83bb50 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -105,6 +105,8 @@ cbPerformanceMode=Performance Mode cbOrderHand=Keep Hand ordered cbFilteredHands=Filtered Hands cbImageFetcher=Automatically Download Missing Card Art +cbpCardDownloadLang=Card Image Language +cbPreferLangForUniqueCards=Prefer My Language for Unique Cards cbCloneImgSource=Clones Use Original Card Art cbScaleLarger=Scale Image Larger cbRenderBlackCardBorders=Render Black Card Borders @@ -242,6 +244,8 @@ nlCardArtFormat=The format of card art images. (Full: image of entire card. Crop nlDefaultFontSize=The default font size within the UI. All font elements are scaled relative to this. (REQUIRES RESTART) cbpMulliganRule=Mulligan Rule nlImageFetcher=Enables live fetching of missing card images from an online resource. +nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlPreferLangForUniqueCards=When showing only unique cards, prefer a printing confirmed to exist in your card image language over the default art selection. nlDisplayFoil=Displays foil cards with the visual foil overlay effect. nlRandomFoil=Adds foil effect to random cards. nlScaleLarger=Allows card pictures to be expanded larger than their original size. @@ -329,6 +333,15 @@ lblDownloadSkins=Download available skins used in customizing forge appearance. lblDownloadCardImages=Pre-download cards or check how many images are missing. lblDownloaded=Downloaded lblMissing=Missing +btnClearCdnImageCache=Clear CDN Image Lookup Cache +lblClearCdnImageCache=Delete the local CDN image lookup cache. +lblClearCdnImageCacheConfirm=This deletes the local CDN image lookup cache. Forge will re-fetch it as needed. Continue? +lblCdnImageCacheCleared=CDN image lookup cache cleared. +btnSyncBulkCardData=Download Index - English Only (~75MB) +btnSyncBulkCardDataLang=Download Index +lblSyncBulkCardDataConfirm=This downloads a full card image index (~{0}) from an online source to resolve every card''s image link at once, instead of one set at a time. Continue? +lblBulkCardDataSynced=Card data synced. +lblFirstRunBulkCdnPrompt=Forge can download a full card image index now (one time, English only) from an online source, so that future card image downloads resolve instantly instead of one set at a time. A multi-language index is also available -- for that, go to Download Content > Download Card Images. Download it now? lblDownloadCJKFonts=Download fonts for locales using CJK glyphs. lblDownloadCJKFontPrompt=Select a font file to download lblYourVersionOfJavaIsTooOld=Your version of Java is too old to use the content downloaders. diff --git a/forge-gui/res/languages/es-ES.properties b/forge-gui/res/languages/es-ES.properties index 4b629f7be9b8..47f35100c3d9 100644 --- a/forge-gui/res/languages/es-ES.properties +++ b/forge-gui/res/languages/es-ES.properties @@ -83,6 +83,7 @@ cbOrderHand=Keep Hand ordered cbFilteredHands=Manos filtradas cbImageFetcher=Descargar automáticamente el arte de la carta si no existe cbpCardDownloadLang=Idioma de la imagen de carta +cbPreferLangForUniqueCards=Preferir mi idioma en cartas únicas cbCloneImgSource=Clones usan el arte original de la carta cbScaleLarger=Imagen de escala más grande cbRenderBlackCardBorders=Renderizar bordes de cartas negras @@ -222,6 +223,7 @@ nlCardArtFormat=El formato de las imágenes del arte de la carta. (Full: imagen cbpMulliganRule=Regla de mulligan nlImageFetcher=Permite la descarga al vuelo de las imágenes de cartas que falten. nlCardDownloadLang=El idioma preferido para descargar las imágenes de las cartas cuando esté disponible. Si la carta no se imprimió en el idioma seleccionado, se usará el inglés como alternativa. +nlPreferLangForUniqueCards=Al mostrar solo cartas únicas, prioriza una impresión confirmada en tu idioma de imagen de carta sobre la selección de arte por defecto. nlDisplayFoil=Mostrar las cartas foil con un capa sobre la carta que da efecto foil nlRandomFoil=Agrega efecto de foil a cartas aleatorias. nlScaleLarger=Permite que las imágenes de las cartas se amplíen más que su tamaño original. @@ -308,6 +310,15 @@ lblDownloadSkins=Descarga los temas disponibles para personalizar el aspecto de lblDownloadCardImages=Descarga imágenes de cartas por adelantado o comprueba cuántas faltan. lblDownloaded=Descargado lblMissing=Faltante +btnClearCdnImageCache=Borrar caché de búsqueda de imágenes CDN +lblClearCdnImageCache=Elimina la caché local de búsqueda de imágenes CDN. Úsalo si algunas cartas siguen resolviéndose por la vía alternativa más lenta aunque sus imágenes deberían estar disponibles -- Forge la volverá a obtener cuando sea necesario. +lblClearCdnImageCacheConfirm=Esto elimina la caché local de búsqueda de imágenes CDN. Forge la volverá a obtener cuando sea necesario. ¿Continuar? +lblCdnImageCacheCleared=Caché de búsqueda de imágenes CDN borrada. +btnSyncBulkCardData=Descargar índice - Solo inglés (~75 MB) +btnSyncBulkCardDataLang=Descargar índice +lblSyncBulkCardDataConfirm=Esto descarga un índice completo de imágenes de cartas (~{0}) desde una fuente online para resolver el enlace de imagen de todas las cartas a la vez, en lugar de una edición a la vez. ¿Continuar? +lblBulkCardDataSynced=Datos de cartas sincronizados. +lblFirstRunBulkCdnPrompt=Forge puede descargar ahora un índice completo de imágenes de cartas (solo una vez, solo inglés) desde una fuente online, para que las futuras descargas de imágenes de cartas se resuelvan al instante en lugar de una edición a la vez. También existe un índice multiidioma -- para eso, ve a Descargar contenido > Descargar imágenes de cartas. ¿Descargarlo ahora? lblDownloadCJKFonts=Descargar fuentes para idiomas que utilicen glifos CJK. lblDownloadCJKFontPrompt=Selecciona un archivo de fuente para descargar lblYourVersionOfJavaIsTooOld=Tu versión de Java es demasiado antigua para usar los descargadores de contenido. diff --git a/forge-gui/res/languages/fr-FR.properties b/forge-gui/res/languages/fr-FR.properties index cae5a019355a..ece084b7e7de 100644 --- a/forge-gui/res/languages/fr-FR.properties +++ b/forge-gui/res/languages/fr-FR.properties @@ -82,7 +82,8 @@ cbPerformanceMode=Mode Performances cbOrderHand=Keep Hand ordered cbFilteredHands=Mains filtrées cbImageFetcher=Télécharger automatiquement l'illustration de la carte manquante -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Langue des images de cartes +cbPreferLangForUniqueCards=Préférer ma langue pour les cartes uniques cbCloneImgSource=Les clones utilisent l'illustration originale de la carte cbScaleLarger=Agrandir l'image cbRenderBlackCardBorders=Rendre les bordures noires de la carte @@ -221,7 +222,8 @@ nlCardArtFormat=Le format des images d'art de la carte. (Plein : image de la car nlDefaultFontSize=La taille de police par défaut dans l'interface utilisateur. Tous les éléments de police sont mis à l'échelle par rapport à cela. (NÉCESSITE UN REDÉMARRAGE) cbpMulliganRule=règle de Mulligan nlImageFetcher=Active la récupération en direct des images de cartes manquantes à partir d'une ressource en ligne. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=La langue préférée pour télécharger les images de cartes, lorsqu''elle est disponible. Revient à l''anglais si la langue sélectionnée n''a pas été imprimée pour une carte donnée. +nlPreferLangForUniqueCards=Lors de l''affichage des cartes uniques uniquement, privilégie une impression confirmée dans votre langue d''image de carte plutôt que la sélection d''art par défaut. nlDisplayFoil=Affiche les cartes brillantes avec l'effet visuel de superposition de feuille. nlRandomFoil=Ajoute un effet foil aux cartes aléatoires. nlScaleLarger=Permet aux images de la carte d'être agrandies au-delà de leur taille d'origine. @@ -308,6 +310,15 @@ lblDownloadSkins=T\u00e9l\u00e9charger les skins disponibles utilis\u00e9s pour lblDownloadCardImages=Pré-téléchargez des images de cartes ou vérifiez combien d'images manquent. lblDownloaded=Téléchargé lblMissing=Manquant +btnClearCdnImageCache=Vider le cache de correspondance CDN +lblClearCdnImageCache=Supprime le cache local de correspondance CDN. À utiliser si certaines cartes continuent de passer par la solution de repli plus lente alors que leurs images devraient être disponibles -- Forge le régénérera au besoin. +lblClearCdnImageCacheConfirm=Cette action supprime le cache local de correspondance CDN. Forge le régénérera au besoin. Continuer ? +lblCdnImageCacheCleared=Cache de correspondance CDN vidé. +btnSyncBulkCardData=Télécharger l''index - Anglais uniquement (~75 Mo) +btnSyncBulkCardDataLang=Télécharger l''index +lblSyncBulkCardDataConfirm=Ceci télécharge un index complet d''images de cartes (~{0}) depuis une source en ligne afin de résoudre le lien d''image de chaque carte en une seule fois, plutôt qu''un jeu de cartes à la fois. Continuer ? +lblBulkCardDataSynced=Données des cartes synchronisées. +lblFirstRunBulkCdnPrompt=Forge peut télécharger dès maintenant un index complet d''images de cartes (une seule fois, anglais uniquement) depuis une source en ligne, afin que les prochains téléchargements d''images de cartes se résolvent instantanément plutôt qu''un jeu de cartes à la fois. Un index multilingue est également disponible -- pour cela, allez dans Télécharger du contenu > Télécharger des images de cartes. Télécharger maintenant ? lblDownloadCJKFonts=T\u00e9l\u00e9charger les polices pour les locales utilisant les glyphes CJK. lblDownloadCJKFontPrompt=Sélectionnez un fichier de police à télécharger lblYourVersionOfJavaIsTooOld=Votre version de Java est trop ancienne pour utiliser les téléchargeurs de contenu. diff --git a/forge-gui/res/languages/it-IT.properties b/forge-gui/res/languages/it-IT.properties index e23a19018d70..8f8f3c3c085e 100644 --- a/forge-gui/res/languages/it-IT.properties +++ b/forge-gui/res/languages/it-IT.properties @@ -81,7 +81,8 @@ cbPerformanceMode=Modalità alte prestazioni cbOrderHand=Keep Hand ordered cbFilteredHands=Mani Filtrate cbImageFetcher=Scarica automaticamente l'arte delle carte mancanti -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Lingua immagine carta +cbPreferLangForUniqueCards=Preferisci la mia lingua per le carte uniche cbCloneImgSource=I cloni usano l'arte della carta originale cbScaleLarger=Ingrandisci l'immagine cbRenderBlackCardBorders=Disegna i bordi neri delle carte @@ -220,7 +221,8 @@ nlDefaultFontSize=La dimensione predefinita dei caratteri nell'interfaccia utent nlCardArtFormat=Formato delle Illustrazioni delle carte. (Full: Immagine per intero della carta. Crop: Solo illustrazione) cbpMulliganRule=Tipo di Mulligan nlImageFetcher=Consente il recupero in tempo reale delle immagini mancanti da una risorsa online. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=La lingua preferita per scaricare le immagini delle carte, quando disponibile. Torna all''inglese se la lingua selezionata non è stata stampata per una determinata carta. +nlPreferLangForUniqueCards=Quando vengono mostrate solo carte uniche, preferisce una stampa confermata esistente nella lingua immagine impostata rispetto alla selezione dell''illustrazione predefinita. nlDisplayFoil=Visualizza le carte foil con l'effetto di sovrapposizione foil visivo. nlRandomFoil=Aggiunge l'effetto foil alle carte casuali. nlScaleLarger=Consente di espandere le immagini delle carte più grandi delle loro dimensioni originali. @@ -307,6 +309,15 @@ lblDownloadSkins=Scarica i temi disponibili per personalizzare la grafica di For lblDownloadCardImages=Scarica in anticipo le immagini delle carte o controlla quante mancano. lblDownloaded=Scaricato lblMissing=Mancante +btnClearCdnImageCache=Svuota cache mappatura immagini CDN +lblClearCdnImageCache=Elimina la cache locale della mappatura immagini CDN. Usalo se alcune carte continuano a essere risolte tramite il percorso alternativo più lento anche se le loro immagini dovrebbero essere disponibili -- Forge la recupererà nuovamente quando necessario. +lblClearCdnImageCacheConfirm=Questa operazione elimina la cache locale della mappatura immagini CDN. Forge la recupererà nuovamente quando necessario. Continuare? +lblCdnImageCacheCleared=Cache della mappatura immagini CDN svuotata. +btnSyncBulkCardData=Scarica indice - Solo inglese (~75 MB) +btnSyncBulkCardDataLang=Scarica indice +lblSyncBulkCardDataConfirm=Questa operazione scarica un indice completo di immagini delle carte da una fonte online per risolvere il collegamento immagine di tutte le carte in una sola volta, invece che un''espansione alla volta. Continuare? +lblBulkCardDataSynced=Dati delle carte sincronizzati. +lblFirstRunBulkCdnPrompt=Forge può scaricare subito un indice completo di immagini delle carte (una tantum, solo inglese) da una fonte online, in modo che i futuri download delle immagini delle carte vengano risolti istantaneamente invece che un''espansione alla volta. È disponibile anche un indice multilingue -- per ottenerlo, vai su Scarica contenuti > Scarica immagini delle carte. Scaricarlo ora? lblDownloadCJKFonts=Scarica i font per le lingue che utilizzano caratteri Cinesi-Giapponesi-Coreani lblDownloadCJKFontPrompt=Seleziona un font da scaricare lblYourVersionOfJavaIsTooOld=La tua versione di Java è troppo vecchia per scaricare contenuti aggiuntivi. diff --git a/forge-gui/res/languages/ja-JP.properties b/forge-gui/res/languages/ja-JP.properties index 51c4d1dd3de1..6bf243404ae1 100644 --- a/forge-gui/res/languages/ja-JP.properties +++ b/forge-gui/res/languages/ja-JP.properties @@ -82,7 +82,8 @@ cbPerformanceMode=パフォーマンスモード cbOrderHand=Keep Hand ordered cbFilteredHands=初期ハンド補正 cbImageFetcher=欠落したカード画像を自動的にダウンロードする -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=カード画像の言語 +cbPreferLangForUniqueCards=固有カードで自分の言語を優先する cbCloneImgSource=コピーカードは元のカード画像を使用します cbScaleLarger=画像を拡大する cbRenderBlackCardBorders=黒枠のレンダリング @@ -221,7 +222,8 @@ nlDefaultFontSize=UI 内のデフォルトのフォントサイズ。 すべて nlCardArtFormat=カード描画時使うアートのフォーマット(Full: カード全体の画像を使う。Crop: カードのアートの部分の画像だけを使う。) cbpMulliganRule =マリガンルール nlImageFetcher=オンラインサーバーから欠落しているカード画像の動的にフェッチを有効にします。 -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=利用可能な場合にカード画像をダウンロードする優先言語です。選択した言語でそのカードが印刷されていない場合は英語にフォールバックします。 +nlPreferLangForUniqueCards=固有カードのみを表示する際、デフォルトのアート選択よりも、設定した画像言語で存在が確認された印刷を優先します。 nlDisplayFoil=視覚的フォイルオーバーレイ効果のあるフォイルカードを表示します。 nlRandomFoil=ランダムなカードにフォイル効果を追加します。 nlScaleLarger=カード画像を元のサイズより大きく拡大できます。 @@ -308,6 +310,15 @@ lblDownloadSkins=Forge のカスタマイズ用のテーマをダウンロード lblDownloadCardImages=カード画像を事前にダウンロードするか、不足している画像数を確認します。 lblDownloaded=ダウンロード済み lblMissing=未取得 +btnClearCdnImageCache=CDN画像索引キャッシュを削除 +lblClearCdnImageCache=ローカルのCDN画像索引キャッシュを削除します。画像が利用可能なはずなのに一部のカードが遅いフォールバック経由で解決され続ける場合に使用してください -- 必要に応じてForgeが再取得します。 +lblClearCdnImageCacheConfirm=ローカルのCDN画像索引キャッシュを削除します。必要に応じてForgeが再取得します。続行しますか? +lblCdnImageCacheCleared=CDN画像索引キャッシュを削除しました。 +btnSyncBulkCardData=インデックスをダウンロード - 英語のみ (~75MB) +btnSyncBulkCardDataLang=インデックスをダウンロード +lblSyncBulkCardDataConfirm=オンラインの提供元から完全なカード画像インデックスをダウンロードし、セットごとではなく全カードの画像リンクを一度に解決します。続行しますか? +lblBulkCardDataSynced=カードデータを同期しました。 +lblFirstRunBulkCdnPrompt=Forgeは今すぐ完全なカード画像インデックス(一度限り、英語のみ)をオンラインの提供元からダウンロードできます。これにより、今後のカード画像のダウンロードがセットごとではなく即座に解決されるようになります。多言語対応のインデックスも利用可能です。その場合は「コンテンツをダウンロード」>「カード画像をダウンロード」に移動してください。今すぐダウンロードしますか? lblDownloadCJKFonts=日中韓文字を使う言語用のフォントをダウンロードします。 lblDownloadCJKFontPrompt=ダウンロードするフォントファイルを選択してください。 lblYourVersionOfJavaIsTooOld=Java のバージョンが古すぎるため、コンテンツダウンローダーを使用できません。 diff --git a/forge-gui/res/languages/ko-KR.properties b/forge-gui/res/languages/ko-KR.properties index 975abc27ff15..52da60ac6b89 100644 --- a/forge-gui/res/languages/ko-KR.properties +++ b/forge-gui/res/languages/ko-KR.properties @@ -107,7 +107,8 @@ cbPerformanceMode=성능 모드 cbOrderHand=핸드 정렬 유지 cbFilteredHands=초기 핸드 보정 cbImageFetcher=누락된 카드 이미지를 자동 다운로드 -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=카드 이미지 언어 +cbPreferLangForUniqueCards=고유 카드에 내 언어 우선 사용 cbCloneImgSource=복제 카드는 원본 카드 이미지를 사용 cbScaleLarger=이미지 확대 cbRenderBlackCardBorders=검은 테두리 렌더링 @@ -246,7 +247,8 @@ nlCardArtFormat=카드 그릴 때 사용할 아트 포맷 (Full: 카드 전체 nlDefaultFontSize=UI 내부 기본 글꼴 크기. 모든 글꼴 요소가 이에 비례해 확대/축소됩니다(재시작 필요). cbpMulliganRule =멀리건 규칙 nlImageFetcher=온라인 서버에서 누락된 카드 이미지를 동적으로 가져오기를 활성화합니다. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=사용 가능한 경우 카드 이미지를 다운로드할 선호 언어입니다. 선택한 언어로 해당 카드가 인쇄되지 않은 경우 영어로 대체됩니다. +nlPreferLangForUniqueCards=고유 카드만 표시할 때, 기본 아트 선택 대신 설정한 카드 이미지 언어로 존재가 확인된 인쇄본을 우선합니다. nlDisplayFoil=시각적인 포일 오버레이 효과가 있는 포일 카드를 표시합니다. nlRandomFoil=랜덤 카드에 포일 효과를 추가합니다. nlScaleLarger=카드 이미지를 원본보다 크게 확대할 수 있습니다. @@ -333,6 +335,15 @@ lblDownloadSkins=Forge 맞춤 테마 다운로드. lblDownloadCardImages=Pre-download cards or check how many images are missing. lblDownloaded=Downloaded lblMissing=Missing +btnClearCdnImageCache=CDN 이미지 조회 캐시 지우기 +lblClearCdnImageCache=로컬 CDN 이미지 조회 캐시를 삭제합니다. 일부 카드의 이미지를 사용할 수 있어야 하는데도 계속 느린 대체 경로로 해결되는 경우 사용하세요 -- 이후 필요에 따라 Forge가 다시 가져옵니다. +lblClearCdnImageCacheConfirm=로컬 CDN 이미지 조회 캐시를 삭제합니다. 필요에 따라 Forge가 다시 가져옵니다. 계속하시겠습니까? +lblCdnImageCacheCleared=CDN 이미지 조회 캐시를 지웠습니다. +btnSyncBulkCardData=인덱스 다운로드 - 영어만 (~75MB) +btnSyncBulkCardDataLang=인덱스 다운로드 +lblSyncBulkCardDataConfirm=이 작업은 온라인 소스에서 전체 카드 이미지 인덱스를 다운로드하여, 세트별로 확인하는 대신 모든 카드의 이미지 링크를 한 번에 확인합니다. 계속하시겠습니까? +lblBulkCardDataSynced=카드 데이터가 동기화되었습니다. +lblFirstRunBulkCdnPrompt=Forge는 지금 온라인 소스에서 전체 카드 이미지 인덱스(1회성, 영어만)를 다운로드하여 이후 카드 이미지 다운로드가 세트별이 아닌 즉시 확인되도록 할 수 있습니다. 다국어 인덱스도 사용할 수 있습니다 -- 이를 위해서는 콘텐츠 다운로드 > 카드 이미지 다운로드로 이동하세요. 지금 다운로드하시겠습니까? lblDownloadCJKFonts=한중일 문자 사용하는 언어용 폰트 다운로드. lblDownloadCJKFontPrompt=다운로드할 폰트 파일을 선택하세요. lblYourVersionOfJavaIsTooOld=Java 버전이 너무 오래되어 콘텐츠 다운로더를 사용할 수 없습니다. diff --git a/forge-gui/res/languages/pt-BR.properties b/forge-gui/res/languages/pt-BR.properties index 2adca113bf80..c74056899782 100644 --- a/forge-gui/res/languages/pt-BR.properties +++ b/forge-gui/res/languages/pt-BR.properties @@ -83,7 +83,8 @@ cbPerformanceMode=Modo de Desempenho cbOrderHand=Keep Hand ordered cbFilteredHands=Mãos Filtradas cbImageFetcher=Baixar Automaticamente a Arte de Cartas Ausentes -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Idioma da imagem da carta +cbPreferLangForUniqueCards=Preferir meu idioma em cartas únicas cbCloneImgSource=Clones Usam a Arte da Carta Origem cbScaleLarger=Expandir Imagem cbRenderBlackCardBorders=Renderizar Bordas Pretas de Carta @@ -222,7 +223,8 @@ nlCardArtFormat=O formato das imagens de arte da carta. (Total\: imagem inteira nlDefaultFontSize=O tamanho padrão da fonte dentro da interface do usuário. Todos os elementos de fonte são dimensionados em relação a isso. (REQUER REINÍCIO) cbpMulliganRule = Regra de Mulligan nlImageFetcher=Habilita a busca sob demanda das imagens das cartas ausentes a partir de um repositório online. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=O idioma preferido para baixar as imagens das cartas quando disponível. Usa o inglês como alternativa se a carta não foi impressa no idioma selecionado. +nlPreferLangForUniqueCards=Ao mostrar apenas cartas únicas, prioriza uma impressão confirmada no seu idioma de imagem de carta em vez da seleção de arte padrão. nlDisplayFoil=Exibe cartas cromadas com o efeito de camada cromada. nlRandomFoil=Adiciona o efeito cromado a cartas aleatórias. nlScaleLarger=Permite que as imagens das cartas sejam expandidas maior que o tamanho original. @@ -309,6 +311,15 @@ lblDownloadSkins=Baixar as skins disponíveis usadas na customização da aparê lblDownloadCardImages=Pré-baixe imagens de cartas ou verifique quantas imagens ainda faltam. lblDownloaded=Baixado lblMissing=Faltando +btnClearCdnImageCache=Limpar cache de busca de imagens CDN +lblClearCdnImageCache=Exclui o cache local de busca de imagens CDN. Use isso se algumas cartas continuarem sendo resolvidas pelo caminho alternativo mais lento mesmo que suas imagens devessem estar disponíveis -- o Forge buscará novamente conforme necessário. +lblClearCdnImageCacheConfirm=Isso exclui o cache local de busca de imagens CDN. O Forge buscará novamente conforme necessário. Continuar? +lblCdnImageCacheCleared=Cache de busca de imagens CDN limpo. +btnSyncBulkCardData=Baixar índice - Somente inglês (~75MB) +btnSyncBulkCardDataLang=Baixar índice +lblSyncBulkCardDataConfirm=Isso baixa um índice completo de imagens de cartas de uma fonte online para resolver o link de imagem de todas as cartas de uma só vez, em vez de uma coleção por vez. Continuar? +lblBulkCardDataSynced=Dados das cartas sincronizados. +lblFirstRunBulkCdnPrompt=O Forge pode baixar agora um índice completo de imagens de cartas (uma única vez, somente inglês) de uma fonte online, para que os futuros downloads de imagens de cartas sejam resolvidos instantaneamente, em vez de uma coleção por vez. Também existe um índice multilíngue -- para isso, vá em Baixar conteúdo > Baixar imagens de cartas. Baixar agora? lblDownloadCJKFonts=Baixe fontes para locais usando CJK glyphs. lblDownloadCJKFontPrompt=Selecione um arquivo de fonte para download lblYourVersionOfJavaIsTooOld=Sua versão do Java é muito antiga para usar os baixadores de conteúdo. diff --git a/forge-gui/res/languages/ru-RU.properties b/forge-gui/res/languages/ru-RU.properties index 4cd584a99b5c..6c1907f14a7a 100644 --- a/forge-gui/res/languages/ru-RU.properties +++ b/forge-gui/res/languages/ru-RU.properties @@ -105,7 +105,8 @@ cbPerformanceMode=Режим производительности cbOrderHand=Упорядочивать карты в руке cbFilteredHands=Фильтрация стартовых рук cbImageFetcher=Автоматически загружать недостающие изображения карт -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=Язык изображений карт +cbPreferLangForUniqueCards=Предпочитать мой язык для уникальных карт cbCloneImgSource=Клоны используют оригинальное изображение карты cbScaleLarger=Увеличивать изображение cbRenderBlackCardBorders=Отображать чёрные рамки карт @@ -242,7 +243,8 @@ nlCardArtFormat=Формат изображений карт. (Полное: и nlDefaultFontSize=Размер шрифта по умолчанию в интерфейсе. Все элементы шрифта масштабируются относительно этого значения. (ТРЕБУЕТСЯ ПЕРЕЗАПУСК) cbpMulliganRule=Правило маллигана nlImageFetcher=Включает загрузку недостающих изображений карт из онлайн-источника в реальном времени. -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=Предпочитаемый язык для загрузки изображений карт, если доступен. Возвращается к английскому, если карта не была напечатана на выбранном языке. +nlPreferLangForUniqueCards=При отображении только уникальных карт отдаёт предпочтение изданию, подтверждённо существующему на выбранном языке изображений карт, а не выбору иллюстрации по умолчанию. nlDisplayFoil=Отображает фольгированные карты с визуальным эффектом фольги. nlRandomFoil=Добавляет эффект фольги случайным картам. nlScaleLarger=Позволяет увеличивать изображения карт больше их исходного размера. @@ -329,6 +331,15 @@ lblDownloadSkins=Скачать доступные темы оформления lblDownloadCardImages=Заранее скачать карты или проверить, сколько изображений отсутствует. lblDownloaded=Скачано lblMissing=Отсутствует +btnClearCdnImageCache=Очистить кэш CDN-индекса изображений +lblClearCdnImageCache=Удаляет локальный кэш CDN-индекса изображений. Используйте, если некоторые карты по-прежнему загружаются через более медленный резервный путь, хотя их изображения должны быть доступны -- Forge при необходимости заново получит данные кэша. +lblClearCdnImageCacheConfirm=Это удалит локальный кэш CDN-индекса изображений. Forge при необходимости заново его получит. Продолжить? +lblCdnImageCacheCleared=Кэш CDN-индекса изображений очищен. +btnSyncBulkCardData=Загрузить индекс - Только английский (~75 МБ) +btnSyncBulkCardDataLang=Загрузить индекс +lblSyncBulkCardDataConfirm=Это загрузит полный индекс изображений карт из онлайн-источника, чтобы разрешить ссылку на изображение сразу для всех карт, а не по одному выпуску за раз. Продолжить? +lblBulkCardDataSynced=Данные карт синхронизированы. +lblFirstRunBulkCdnPrompt=Forge может сейчас загрузить полный индекс изображений карт (единоразово, только на английском) из онлайн-источника, чтобы последующие загрузки изображений карт разрешались мгновенно, а не по одному выпуску за раз. Также доступен многоязычный индекс -- для этого перейдите в Раздел Загрузить контент > Загрузить изображения карт. Загрузить сейчас? lblDownloadCJKFonts=Скачать шрифты для локалей, использующих иероглифы CJK. lblDownloadCJKFontPrompt=Выберите файл шрифта для загрузки lblYourVersionOfJavaIsTooOld=Ваша версия Java слишком старая для использования загрузчиков контента. diff --git a/forge-gui/res/languages/zh-CN.properties b/forge-gui/res/languages/zh-CN.properties index 5ffa5bf2a073..ac153cfe80f3 100644 --- a/forge-gui/res/languages/zh-CN.properties +++ b/forge-gui/res/languages/zh-CN.properties @@ -82,7 +82,8 @@ cbPerformanceMode=性能模式 cbOrderHand=Keep Hand ordered cbFilteredHands=手牌过滤 cbImageFetcher=自动下载缺失的图片 -cbpCardDownloadLang=Card Image Language +cbpCardDownloadLang=卡牌图片语言 +cbPreferLangForUniqueCards=唯一卡牌优先使用我的语言 cbCloneImgSource=复制使用原始的图片 cbScaleLarger=将图像缩放的更大 cbRenderBlackCardBorders=渲染黑卡边框 @@ -221,7 +222,8 @@ nlDefaultFontSize=UI中字体的默认大小。所有字体元素都相对于此 nlCardArtFormat=牌图的格式。(Full表示使用完整的牌张图片,Crop表示只使用牌图的插画部分) cbpMulliganRule = 调度规则 nlImageFetcher=允许从在线资源中实时获取缺失的图片 -nlCardDownloadLang=The preferred language to download card images in when available. Falls back to English if the selected language wasn't printed for a given card. +nlCardDownloadLang=在可用的情况下下载卡牌图片时使用的首选语言。如果该卡牌没有以所选语言印刷,则回退为英语。 +nlPreferLangForUniqueCards=仅显示唯一卡牌时,优先选择已确认存在于你所设图片语言中的印刷版本,而不是默认的插画选择。 nlDisplayFoil=显示闪卡 nlRandomFoil=随机将牌设置为闪卡 nlScaleLarger=允许卡牌图片缩放为初始大小 @@ -308,6 +310,15 @@ lblDownloadSkins=下载可用于自定义forge外观的主题。 lblDownloadCardImages=预下载卡牌图片,或检查有多少图片缺失。 lblDownloaded=已下载 lblMissing=缺失 +btnClearCdnImageCache=清除CDN图片索引缓存 +lblClearCdnImageCache=删除本地CDN图片索引缓存。如果某些卡牌的图片明明应该可用,却一直通过较慢的备用方式解析,可以使用此项——之后Forge会按需重新获取缓存数据。 +lblClearCdnImageCacheConfirm=这将删除本地CDN图片索引缓存,Forge会按需重新获取。是否继续? +lblCdnImageCacheCleared=CDN图片索引缓存已清除。 +btnSyncBulkCardData=下载索引 - 仅英语 (~75MB) +btnSyncBulkCardDataLang=下载索引 +lblSyncBulkCardDataConfirm=这将从在线来源下载完整的卡牌图片索引,一次性解析所有卡牌的图片链接,而不是逐个系列进行。是否继续? +lblBulkCardDataSynced=卡牌数据已同步。 +lblFirstRunBulkCdnPrompt=Forge现在可以从在线来源下载完整的卡牌图片索引(仅需一次,仅英语),这样以后下载卡牌图片时可以立即解析,而不必逐个系列进行。还有多语言索引可用 -- 前往“下载内容”>“下载卡牌图片”获取。是否现在下载? lblDownloadCJKFonts=下载可用于显示当前区域设置所使用的CJK图形的字体。 lblDownloadCJKFontPrompt=选择要下载的字体文件 lblYourVersionOfJavaIsTooOld=你的Java版本太旧无法开始下载内容 diff --git a/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java new file mode 100644 index 000000000000..d96b4bf555b1 --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/CdnUuidCache.java @@ -0,0 +1,481 @@ +package forge.gui.download; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import forge.localinstance.properties.ForgeConstants; +import forge.util.ThreadUtil; +import org.tinylog.Logger; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * Thread-safe cache of Scryfall CDN image URLs, built on demand via {@link ScryfallSetSync} + * and stored at {@code {cacheDir}/cdn_uuid/{setCode}.json.gz}. Never blocks on network I/O: + * a miss queues the set for {@link #syncPendingSets} and returns {@code null}. An unresolved + * (cn, lang) is recorded as a timestamped miss so repeat lookups skip until it's stale + * (see {@link #trackMiss}). + * + *

Set JSON: {@code {"cn": {"lang": "uuid" | ["front","back"] | {"miss": timestamp}}}} + */ +public final class CdnUuidCache { + + private static final String FALLBACK_LANG = "en"; + private static final Duration MISS_RETRY_AFTER = Duration.ofDays(1); + + /** + * Every Scryfall language code observed + */ + public static final String[] LANGUAGE_CODES = { + "ar", // Arabic + "de", // German + "dw", // Dwarvish (joke/funny-set language) + "en", // English + "es", // Spanish + "fr", // French + "grc", // Ancient Greek + "he", // Hebrew + "it", // Italian + "ja", // Japanese + "ko", // Korean + "la", // Latin + "ph", // Phyrexian + "pt", // Portuguese + "qya", // Quenya + "ru", // Russian + "sa", // Sanskrit + "zhs", // Chinese Simplified + "zht" // Chinese Traditional + }; + + /** Used when set was looked up and no data exists. */ + private static final Map> MISSING_SET = Collections.emptyMap(); + + private static final class LangUuids { + final String front; // null when this is a miss record + final String back; // null → same UUID for both faces (only meaningful for a real entry) + final Instant missedAt; // non-null → negative-cache record; front/back are unused + + private LangUuids(String front, String back, Instant missedAt) { + this.front = front; + this.back = back; + this.missedAt = missedAt; + } + + static LangUuids found(String front, String back) { return new LangUuids(front, back, null); } + static LangUuids miss(Instant at) { return new LangUuids(null, null, at); } + boolean isMiss() { return missedAt != null; } + } + + /** Cache: setCode → (collectorNumber → (lang → LangUuids)) */ + private static final ConcurrentHashMap>> setCache = + new ConcurrentHashMap<>(); + + /** Set codes a lookup couldn't answer locally, waiting for {@link #syncPendingSets}. */ + private static final Set pendingSyncs = ConcurrentHashMap.newKeySet(); + + /** Submits {@link #syncPendingSets} to the shared pool; tests disable this. */ + static volatile boolean autoSyncEnabled = true; + + /** Test override for the local cache directory. */ + static volatile String localCacheDirOverride = null; + + private CdnUuidCache() {} + + /** Test helper */ + static void clearCacheForTesting() { setCache.clear(); } + + /** Local set-cache directory, honoring the test override. */ + public static String cacheDir() { + return localCacheDirOverride != null ? localCacheDirOverride : ForgeConstants.CACHE_CDN_UUID_DIR; + } + + /** Whether {@code scryfallCode} already has a local cache file (per-set or bulk-data sync). */ + public static boolean isSetCached(String scryfallCode) { + return scryfallCode != null && localCacheFile(scryfallCode.toLowerCase()).exists(); + } + + /** Whether any set has ever been synced locally. Used to offer a one-time bulk warm-up on first run. */ + public static boolean hasAnyCachedSets() { + File[] files = new File(cacheDir()).listFiles((dir, name) -> name.endsWith(".json.gz")); + return files != null && files.length > 0; + } + + /** Deletes every local cache file and clears the in-memory cache. */ + public static void clearCache() { + setCache.clear(); + File dir = new File(cacheDir()); + File[] files = dir.listFiles(); + if (files == null) return; + for (File f : files) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + } + } + + /** + * Merges (cn → lang → [front, back]) entries for {@code setCode} into its cache file. + * Never overwrites a real entry, but a miss record doesn't count as "already there" -- + * real data always upgrades a miss. + */ + static synchronized void mergeSetEntriesWithFaces(String setCode, Map> newEntries) { + File file = localCacheFile(setCode); + JsonObject merged = readLocalJson(file); + + for (Map.Entry> cnEntry : newEntries.entrySet()) { + String cn = cnEntry.getKey(); + JsonObject langObj = merged.has(cn) && merged.get(cn).isJsonObject() + ? merged.getAsJsonObject(cn) : new JsonObject(); + for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { + String lang = langEntry.getKey(); + if (isRealEntry(langObj.get(lang))) continue; // never overwrite real data + String front = langEntry.getValue()[0]; + String back = langEntry.getValue()[1]; + if (back != null && !back.equals(front)) { + JsonArray arr = new JsonArray(); + arr.add(front); + arr.add(back); + langObj.add(lang, arr); + } else { + langObj.addProperty(lang, front); + } + } + if (langObj.size() > 0) merged.add(cn, langObj); + } + + writeLocalCache(file, merged.toString()); + setCache.remove(setCode); // force a re-read of the freshly-written file on next lookup + } + + /** Records {@code (cn, lang)} as missing as of now, so lookups skip retrying until {@link #MISS_RETRY_AFTER} passes. */ + private static synchronized void recordMiss(String setCode, String cn, String lang) { + File file = localCacheFile(setCode); + JsonObject setObj = readLocalJson(file); + JsonObject langObj = setObj.has(cn) && setObj.get(cn).isJsonObject() + ? setObj.getAsJsonObject(cn) : new JsonObject(); + if (isRealEntry(langObj.get(lang))) return; // real data already recorded; never clobber + + JsonObject missObj = new JsonObject(); + missObj.addProperty("miss", Instant.now().toString()); + langObj.add(lang, missObj); + setObj.add(cn, langObj); + + writeLocalCache(file, setObj.toString()); + setCache.remove(setCode); + } + + public static boolean isAvailableInLanguage(String scryfallCode, String collectorNum, String lang) { + if (scryfallCode == null || collectorNum == null || lang == null) return false; + String setCode = scryfallCode.toLowerCase(); + + Map> cardMap = ensureSetLoadedReadOnly(setCode); + if (cardMap == MISSING_SET) return false; + + Map langMap = cardMap.get(collectorNum); + if (langMap == null) return false; + + LangUuids uuids = langMap.get(lang.toLowerCase()); + return uuids != null && !uuids.isMiss(); + } + + public static String resolvePreferredLangCode(String preferredLang, String scryfallCode, + String collectorNum, String defaultLangCode) { + if (preferredLang == null || preferredLang.isEmpty() + || FALLBACK_LANG.equalsIgnoreCase(preferredLang) + || preferredLang.equalsIgnoreCase(defaultLangCode)) { + return defaultLangCode; + } + return isAvailableInLanguage(scryfallCode, collectorNum, preferredLang) ? preferredLang : defaultLangCode; + } + + /** + * Read-only counterpart to {@link #getCdnUrl}: never queues a sync or records a miss. Use + * from the gameplay image-fetch path, which must not trigger background Scryfall traffic. + */ + public static String getCdnUrlIfCached(String scryfallCode, String collectorNum, + String lang, String face, String size) { + if (scryfallCode == null || collectorNum == null) return null; + String setCode = scryfallCode.toLowerCase(); + + Map> cardMap = ensureSetLoadedReadOnly(setCode); + if (cardMap == MISSING_SET) return null; + + LangUuids uuids = resolveWithFallback(cardMap, collectorNum, lang); + if (uuids == null) return null; + + boolean wantBack = "back".equals(face); + String uuid = (wantBack && uuids.back != null) ? uuids.back : uuids.front; + String side = wantBack ? "back" : "front"; + return cdnUrl(uuid, side, size); + } + + /** Like {@link #ensureSetLoaded}, but never queues a sync -- an unsynced set stays retryable rather than triggering work or being cached as permanently absent. */ + private static Map> ensureSetLoadedReadOnly(String setCode) { + Map> cached = setCache.get(setCode); + if (cached != null) return cached; + + Map> onDisk = readSetFromDisk(setCode); + if (onDisk == MISSING_SET) return MISSING_SET; + + Map> existing = setCache.putIfAbsent(setCode, onDisk); + return existing != null ? existing : onDisk; + } + + /** A JSON string or array value is real data; an object (miss record) or absent value is not. */ + private static boolean isRealEntry(JsonElement value) { + return value != null && !value.isJsonObject(); + } + + private static JsonObject readLocalJson(File file) { + if (!file.exists()) return new JsonObject(); + try { + return readGzipJson(file); + } catch (Exception e) { + Logger.warn("CdnUuidCache: corrupt local cache {}, rebuilding: {}", file, e.getMessage()); + return new JsonObject(); + } + } + + private static JsonObject readGzipJson(File file) throws Exception { + try (InputStream is = new GZIPInputStream(new FileInputStream(file)); + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader).getAsJsonObject(); + } + } + + /** + * Returns the Scryfall CDN URL for a card face, or {@code null} if unavailable. + * + * @param scryfallCode lowercase Scryfall set code + * @param collectorNum collector number + * @param lang preferred language code + * @param face {@code ""}/{@code "front"} or {@code "back"} + * @param size {@code "normal"} or {@code "art_crop"} + */ + public static String getCdnUrl(String scryfallCode, String collectorNum, + String lang, String face, String size) { + if (scryfallCode == null || collectorNum == null) return null; + String setCode = scryfallCode.toLowerCase(); + boolean wantBack = "back".equals(face); + + Map> cardMap = ensureSetLoaded(setCode); + if (cardMap == MISSING_SET) return null; + + LangUuids uuids = resolveWithFallback(cardMap, collectorNum, lang); + if (uuids == null) { + trackMiss(setCode, cardMap, collectorNum, lang); + return null; + } + + String uuid = (wantBack && uuids.back != null) ? uuids.back : uuids.front; + String side = wantBack ? "back" : "front"; + return cdnUrl(uuid, side, size); + } + + /** The real (non-miss) entry for {@code lang}, or its English fallback, if either has real data. */ + private static LangUuids resolveWithFallback(Map> cardMap, String cn, String lang) { + Map langMap = cardMap.get(cn); + if (langMap == null) return null; + + LangUuids direct = langMap.get(lang); + if (direct != null && !direct.isMiss()) return direct; + + if (!FALLBACK_LANG.equals(lang)) { + LangUuids fallback = langMap.get(FALLBACK_LANG); + if (fallback != null && !fallback.isMiss()) return fallback; + } + return null; + } + + /** + * Records a first-time miss immediately. A miss younger than {@link #MISS_RETRY_AFTER} + * is left alone; an older one queues the set for {@link #syncPendingSets}. + */ + private static void trackMiss(String setCode, Map> cardMap, + String cn, String lang) { + Map langMap = cardMap.get(cn); + LangUuids existing = langMap != null ? langMap.get(lang) : null; + + if (existing != null && existing.isMiss()) { + if (isStale(existing.missedAt)) queueSync(setCode); + return; + } + + recordMiss(setCode, cn, lang); + } + + private static boolean isStale(Instant missedAt) { + return Duration.between(missedAt, Instant.now()).compareTo(MISS_RETRY_AFTER) >= 0; + } + + /** Queues {@code setCode} and, unless a test disabled it, submits {@link #syncPendingSets} to the shared pool. */ + private static void queueSync(String setCode) { + if (pendingSyncs.add(setCode) && autoSyncEnabled) { + ThreadUtil.getServicePool().submit(CdnUuidCache::syncPendingSets); + } + } + + /** Syncs every queued set from Scryfall. Meant to run off the EDT/render thread. */ + public static void syncPendingSets() { + for (String setCode : pendingSyncs) { + if (!pendingSyncs.remove(setCode)) continue; // another thread already claimed it + ScryfallSetSync.sync(setCode); + refreshStaleMisses(setCode); + } + } + + /** Refreshes the timestamp of any miss still unresolved after a resync. */ + private static void refreshStaleMisses(String setCode) { + for (Map.Entry> cnEntry : readSetFromDisk(setCode).entrySet()) { + for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { + LangUuids v = langEntry.getValue(); + if (v.isMiss() && isStale(v.missedAt)) recordMiss(setCode, cnEntry.getKey(), langEntry.getKey()); + } + } + } + + /** + * Builds the deterministic Scryfall CDN URL for a card UUID. + * + * @param uuid Scryfall card UUID + * @param side {@code "front"} or {@code "back"} + * @param size {@code "normal"} or {@code "art_crop"} + */ + public static String cdnUrl(String uuid, String side, String size) { + return ForgeConstants.URL_SCRYFALL_CDN + size + "/" + side + + "/" + uuid.charAt(0) + "/" + uuid.charAt(1) + "/" + uuid + ".jpg"; + } + + // ------------------------------------------------------------------------- + + private static Map> ensureSetLoaded(String setCode) { + Map> cached = setCache.get(setCode); + if (cached != null) return cached; + + Map> loaded = loadSet(setCode); + // putIfAbsent: if another thread raced and loaded first, use its result + Map> existing = setCache.putIfAbsent(setCode, loaded); + return existing != null ? existing : loaded; + } + + private static Map> loadSet(String setCode) { + Map> onDisk = readSetFromDisk(setCode); + if (onDisk != MISSING_SET) return onDisk; + + // Nothing local yet -- queue instead of blocking; the next lookup picks up whatever + // syncPendingSets finds. + queueSync(setCode); + return MISSING_SET; + } + + /** Pure disk read: {@link #MISSING_SET} if there's no local file yet, without queuing a sync. */ + private static Map> readSetFromDisk(String setCode) { + File localFile = localCacheFile(setCode); + if (!localFile.exists()) return MISSING_SET; + try { + return parseSetFile(localFile); + } catch (Exception e) { + Logger.warn("CdnUuidCache: corrupt local cache {}: {}", localFile, e.getMessage()); + //noinspection ResultOfMethodCallIgnored + localFile.delete(); + return MISSING_SET; + } + } + + private static File localCacheFile(String setCode) { + return new File(cacheDir(), safeFileStem(setCode) + ".json.gz"); + } + + /** + * Windows reserves CON, PRN, AUX, NUL, COM1-9 and LPT1-9 as device names -- a file called + * {@code con.json.gz} can never be created there, extension or not. This bites us for real: + * Conflux's Scryfall set code is "con". Prefix the handful of set codes that collide so their + * cache files can actually be written; every other set code is untouched. + */ + private static final java.util.Set WINDOWS_RESERVED_NAMES = new java.util.HashSet<>(java.util.Arrays.asList( + "con", "prn", "aux", "nul", + "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", + "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9")); + + private static String safeFileStem(String setCode) { + return WINDOWS_RESERVED_NAMES.contains(setCode.toLowerCase()) ? "set-" + setCode : setCode; + } + + private static void writeLocalCache(File file, String json) { + try { + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Path tmp = Files.createTempFile(file.getParentFile().toPath(), "cdn-", ".tmp"); + try { + try (GZIPOutputStream gz = new GZIPOutputStream(Files.newOutputStream(tmp))) { + gz.write(json.getBytes(StandardCharsets.UTF_8)); + } + Files.move(tmp, file.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + Files.deleteIfExists(tmp); + throw e; + } + } catch (Exception e) { + Logger.warn("CdnUuidCache: could not write local cache {}: {}", file, e.getMessage()); + } + } + + private static Map> parseSetFile(File file) throws Exception { + return parseSetObject(readGzipJson(file)); + } + + /** Format: {@code {"cn": {"lang": "uuid"|["front","back"]|{"miss": timestamp}}}} */ + private static Map> parseSetObject(JsonObject setObj) { + Map> cardMap = new HashMap<>(setObj.size() * 2); + for (Map.Entry cnEntry : setObj.entrySet()) { + if (!cnEntry.getValue().isJsonObject()) continue; + JsonObject langObj = cnEntry.getValue().getAsJsonObject(); + Map langMap = new HashMap<>(langObj.size() * 2); + for (Map.Entry langEntry : langObj.entrySet()) { + JsonElement val = langEntry.getValue(); + if (val.isJsonPrimitive()) { + langMap.put(langEntry.getKey(), LangUuids.found(val.getAsString(), null)); + } else if (val.isJsonArray()) { + JsonArray arr = val.getAsJsonArray(); + if (arr.size() >= 2) { + String front = arr.get(0).getAsString(); + String back = arr.get(1).getAsString(); + langMap.put(langEntry.getKey(), + LangUuids.found(front, back.equals(front) ? null : back)); + } else if (arr.size() == 1) { + langMap.put(langEntry.getKey(), + LangUuids.found(arr.get(0).getAsString(), null)); + } + } else if (val.isJsonObject() && val.getAsJsonObject().has("miss")) { + try { + Instant missedAt = Instant.parse(val.getAsJsonObject().get("miss").getAsString()); + langMap.put(langEntry.getKey(), LangUuids.miss(missedAt)); + } catch (Exception ignored) { + // corrupt timestamp -- treat as though this (cn, lang) was never checked + } + } + } + if (!langMap.isEmpty()) + cardMap.put(cnEntry.getKey(), Collections.unmodifiableMap(langMap)); + } + return Collections.unmodifiableMap(cardMap); + } +} diff --git a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java index 0ba812f9948a..8dedeb1cbe87 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadFilteredCardImages.java @@ -6,21 +6,24 @@ import forge.item.IPaperCard; import forge.item.PaperCard; import forge.localinstance.properties.ForgeConstants; +import forge.localinstance.properties.ForgePreferences; import forge.model.FModel; import forge.util.ImageUtil; +import forge.util.ScryfallRateLimiter; import forge.util.TextUtil; import org.apache.commons.lang3.StringUtils; import java.io.File; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.TreeSet; import java.util.function.Predicate; /** - * Downloads card images for all cards that match the supplied predicate. - * Uses Scryfall as the primary source (matching the auto-downloader path) so - * that images are actually available; falls back to the cardforge hosted server - * for cards that lack a collector number. + * Downloads card images for all cards matching the predicate. + * Per face: CDN via {@link CdnUuidCache}, then the rate-limited Scryfall API, then cardforge. */ public class GuiDownloadFilteredCardImages extends GuiDownloadService { @@ -39,6 +42,8 @@ public String getTitle() { protected Map getNeededFiles() { final Map downloads = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + final List matches = new ArrayList<>(); + final TreeSet scryfallSetCodes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); for (final PaperCard c : Iterables.concat( FModel.getMagicDb().getCommonCards().getAllCards(), FModel.getMagicDb().getVariantCards().getAllCards())) { @@ -48,6 +53,50 @@ protected Map getNeededFiles() { final String setCode3 = c.getEdition(); if (StringUtils.isBlank(setCode3) || CardEdition.UNKNOWN_CODE.equals(setCode3)) { continue; } + matches.add(c); + CardEdition edition = StaticData.instance().getEditions().get(setCode3); + if (edition != null && !StringUtils.isBlank(edition.getScryfallCode())) { + scryfallSetCodes.add(edition.getScryfallCode()); + } + } + + // Warm the CDN UUID cache for every needed set before resolving URLs below, so this run + // prefers the unthrottled CDN path over the rate-limited Scryfall API. Skip sets already + // cached (e.g. by a prior bulk-data sync). + final List needSync = new ArrayList<>(); + for (String setCode : scryfallSetCodes) { + if (!CdnUuidCache.isSetCached(setCode)) { + needSync.add(setCode); + } + } + if (needSync.isEmpty() && !scryfallSetCodes.isEmpty()) { + reportStatus("All " + scryfallSetCodes.size() + " needed sets already cached."); + } else if (!needSync.isEmpty()) { + int alreadyCached = scryfallSetCodes.size() - needSync.size(); + if (alreadyCached > 0) { + reportStatus(alreadyCached + "/" + scryfallSetCodes.size() + " sets already cached; syncing the remaining " + needSync.size() + "..."); + } + } + + // Syncing sets one at a time against the rate-limited Scryfall API is fine for a handful + // of sets, but with a cold cache (dozens/hundreds of missing sets) it can take minutes of + // sequential requests while barely updating the UI. Past a threshold, do it in one shot + // via the bulk-data export instead -- the same mechanism as the "Descarga masiva" button. + final int BULK_SYNC_THRESHOLD = 15; + if (needSync.size() > BULK_SYNC_THRESHOLD) { + reportStatus(needSync.size() + " sets need syncing; using bulk data sync instead of " + + "one-by-one requests..."); + int setCount = ScryfallBulkDataSync.sync(ScryfallBulkDataSync.BULK_TYPE_DEFAULT_CARDS, null, + (message, fraction) -> reportStatus(message), () -> cancel); + if (setCount < 0 && !cancel) { + reportStatus("Bulk sync failed; falling back to per-set sync..."); + syncSetsOneByOne(needSync); + } + } else { + syncSetsOneByOne(needSync); + } + + for (final PaperCard c : matches) { addIfMissing(c, "", downloads); if (c.hasBackFace()) { addIfMissing(c, "back", downloads); @@ -56,54 +105,71 @@ protected Map getNeededFiles() { return downloads; } + private void syncSetsOneByOne(List needSync) { + int setIndex = 0; + for (String setCode : needSync) { + if (cancel) break; + setIndex++; + reportStatus("Syncing card data from an online source: set " + setIndex + "/" + needSync.size() + + " (" + setCode.toUpperCase() + ")..."); + ScryfallRateLimiter.awaitCooldownCleared(() -> cancel, this::reportStatus); + if (cancel) break; + ScryfallSetSync.sync(setCode); + } + } + // ------------------------------------------------------------------------- private static void addIfMissing(PaperCard c, String face, Map downloads) { final String imageKey = ImageUtil.getImageKey(c, face, true); - if (imageKey == null) { return; } + if (imageKey == null) return; - // Destination path for this card face in the local cache - final File destFull = new File(ForgeConstants.CACHE_CARD_PICS_DIR, imageKey + ".jpg"); - // Also check for the fullborder variant that LibGDXImageFetcher produces from Scryfall - final String fbKey = TextUtil.fastReplace(imageKey, ".full", ".fullborder") + - (!imageKey.contains(".full") ? ".fullborder" : "") ; - final File destFb = new File(ForgeConstants.CACHE_CARD_PICS_DIR, fbKey + ".jpg"); + final File destFull = new File(ForgeConstants.CACHE_CARD_PICS_DIR, imageKey + ".jpg"); + final String fbKey = TextUtil.fastReplace(imageKey, ".full", ".fullborder") + + (!imageKey.contains(".full") ? ".fullborder" : ""); + final File destFb = new File(ForgeConstants.CACHE_CARD_PICS_DIR, fbKey + ".jpg"); - if (destFull.exists() || destFb.exists()) { return; } - if (downloads.containsKey(destFull.getAbsolutePath())) { return; } + if (destFull.exists() || destFb.exists()) return; + if (downloads.containsKey(destFull.getAbsolutePath())) return; final String url = buildUrl(c, face); - if (url == null) { return; } + if (url == null) return; downloads.put(destFull.getAbsolutePath(), url); } - /** - * Builds the download URL for one card face. - * Prefers Scryfall (which works) for cards that have a collector number; - * falls back to the cardforge hosted server otherwise. - */ + /** Best available download URL for one face: CDN, then Scryfall API, then cardforge. */ private static String buildUrl(PaperCard c, String face) { final String collectorNum = c.getCollectorNumber(); final boolean hasCollectorNum = !IPaperCard.NO_COLLECTOR_NUMBER.equals(collectorNum) && !"0".equals(collectorNum) && !StringUtils.isBlank(collectorNum); - if (hasCollectorNum) { - CardEdition edition = StaticData.instance().getEditions().get(c.getEdition()); - if (edition != null) { - String scryfallCode = edition.getScryfallCode(); - if (!StringUtils.isBlank(scryfallCode)) { - String langCode = edition.getCardsLangCode(); - String path = ImageUtil.getScryfallDownloadUrl(c, face, scryfallCode, langCode, false); - if (path != null) { - return ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + path; - } - } - } + CardEdition edition = hasCollectorNum + ? StaticData.instance().getEditions().get(c.getEdition()) : null; + String scryfallCode = (edition != null) ? edition.getScryfallCode() : null; + boolean hasScryfallCode = !StringUtils.isBlank(scryfallCode); + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = (edition != null && hasScryfallCode) + ? CdnUuidCache.resolvePreferredLangCode(preferredLang, scryfallCode, collectorNum, edition.getCardsLangCode()) + : null; + + // 1. CDN -- read-only (see CdnUuidCache.getCdnUrlIfCached()); falls through to the API + // below if the warm-up loop above didn't resolve this set. + if (edition != null && hasCollectorNum && hasScryfallCode) { + String cdnUrl = CdnUuidCache.getCdnUrlIfCached( + scryfallCode, collectorNum, langCode, face, "normal"); + if (cdnUrl != null) return cdnUrl; + } + + // 2. Scryfall API + if (hasCollectorNum && edition != null && hasScryfallCode) { + String apiPath = ImageUtil.getScryfallDownloadUrl( + c, face, scryfallCode, langCode, false); + if (apiPath != null) return ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + apiPath; } - // Fallback: cardforge hosted server + // 3. Cardforge String cardforgeUrl = ImageUtil.getDownloadUrl(c, face); return cardforgeUrl != null ? ForgeConstants.URL_PIC_DOWNLOAD + cardforgeUrl : null; } diff --git a/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java b/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java index 7164a3e923d5..df0f1187dd4a 100644 --- a/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java +++ b/forge-gui/src/main/java/forge/gui/download/GuiDownloadService.java @@ -27,8 +27,10 @@ import forge.gui.interfaces.IProgressBar; import forge.gui.interfaces.ITextField; import forge.localinstance.properties.ForgeConstants; +import forge.util.BuildInfo; import forge.util.FileUtil; import forge.util.HttpUtil; +import forge.util.ScryfallRateLimiter; import forge.util.TextUtil; import org.apache.commons.lang3.tuple.Pair; @@ -37,7 +39,6 @@ import java.util.HashSet; import java.util.Map; import java.util.Map.Entry; -import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -122,6 +123,11 @@ protected String getStartOverrideDesc() { return null; } + /** Updates the visible progress description from a background thread. */ + protected void reportStatus(String message) { + FThreads.invokeInEdtLater(() -> progressBar.setDescription(message)); + } + private void readyToStart() { if (files == null) { progressBar.setDescription("Connection error?"); @@ -251,14 +257,22 @@ public void run() { FileOutputStream fos = null; try { final File base = fileDest.getParentFile(); - if (FileUtil.ensureDirectoryExists(base)) { //ensure destination directory exists + if (ScryfallRateLimiter.isApiUrl(url)) { + // Wait out an active cooldown rather than skipping -- this is a background, + // cancelable bulk run, so it's worth pausing to actually finish downloading + // instead of racing through the rest of a large queue in skip-only mode. + ScryfallRateLimiter.awaitCooldownCleared(() -> cancel, this::reportStatus); + } + if (!cancel && FileUtil.ensureDirectoryExists(base)) { //ensure destination directory exists URL imageUrl = new URL(url); HttpURLConnection conn = (HttpURLConnection) imageUrl.openConnection(p); + // Scryfall asks for a descriptive User-Agent and rate-limits harder without one. + conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); // don't allow redirections here -- they indicate 'file not found' on the server // only allow redirections to consume Scryfall API - if(url.contains("api.scryfall.com")) { + if (ScryfallRateLimiter.isApiUrl(url)) { conn.setInstanceFollowRedirects(true); - TimeUnit.MILLISECONDS.sleep(100); + ScryfallRateLimiter.acquire(url); } else { conn.setInstanceFollowRedirects(false); } @@ -310,12 +324,16 @@ public void run() { if(url.contains("/images/") && !isJPG && !isLogged) System.out.println("File not found: .." + url.substring(url.lastIndexOf("/images/")+1)); break; + case 429: + ScryfallRateLimiter.noteIfRateLimited(429, url, conn.getHeaderField("Retry-After")); + conn.disconnect(); + break; default: conn.disconnect(); System.out.println(" Connection failed for url: " + url); break; } - } else { + } else if (!cancel) { System.out.println(" Can't create folder: " + base.getAbsolutePath()); } } diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java new file mode 100644 index 000000000000..343031593277 --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallBulkDataSync.java @@ -0,0 +1,237 @@ +package forge.gui.download; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import forge.util.BuildInfo; +import forge.util.ScryfallRateLimiter; +import org.tinylog.Logger; + +import java.io.BufferedReader; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.BooleanSupplier; +import java.util.zip.GZIPInputStream; + +/** + * Warms {@link CdnUuidCache} for every set in one pass via Scryfall's Bulk Data API + * (https://scryfall.com/docs/api/bulk-data) + */ +public final class ScryfallBulkDataSync { + private static final String BULK_DATA_LISTING_URL = "https://api.scryfall.com/bulk-data"; + public static final String BULK_TYPE_DEFAULT_CARDS = "default_cards"; + public static final String BULK_TYPE_ALL_CARDS = "all_cards"; + private static final int CONNECT_TIMEOUT_MS = 15_000; + private static final int READ_TIMEOUT_MS = 60_000; + + /** Override for tests; must be a full URL to a bulk-data listing JSON document. */ + static volatile String listingUrlOverride = null; + + private ScryfallBulkDataSync() {} + + /** Approximate compressed download size for the given bulk type, measured against the live API. */ + public static String approxSizeLabel(String bulkDataType) { + return BULK_TYPE_ALL_CARDS.equals(bulkDataType) ? "375 MB" : "75 MB"; + } + + public interface ProgressListener { + /** @param fractionDone 0.0-1.0, or -1 if not yet known (e.g. before a Content-Length is available) */ + void onProgress(String message, double fractionDone); + } + + /** + * Downloads and parses the given Scryfall bulk file type ({@link #BULK_TYPE_DEFAULT_CARDS} or + * {@link #BULK_TYPE_ALL_CARDS}), merging every print into {@link CdnUuidCache}. If + * {@code allowedLangs} is non-null, only prints in one of those languages are kept -- lets a + * single-language sync of {@code all_cards} skip writing the other 16+ languages to disk. + * Returns the number of sets written, or -1 on failure/cancellation before any data was merged. + */ + public static int sync(String bulkDataType, Set allowedLangs, ProgressListener progress, BooleanSupplier cancelled) { + String downloadUrl = findBulkDataUrl(bulkDataType, progress); + if (downloadUrl == null || cancelled.getAsBoolean()) { + return -1; + } + + final Map>> bySet = new HashMap<>(); + try { + streamAndAccumulate(downloadUrl, bySet, allowedLangs, progress, cancelled); + } catch (IOException e) { + Logger.error(e, "ScryfallBulkDataSync: failed to download/parse bulk data"); + if (progress != null) { + progress.onProgress("Bulk data download failed: " + e.getMessage(), -1); + } + return -1; + } + + if (cancelled.getAsBoolean()) { + return -1; + } + + if (progress != null) { + progress.onProgress("Writing " + bySet.size() + " sets to local cache...", -1); + } + int setCount = 0; + for (Map.Entry>> e : bySet.entrySet()) { + if (cancelled.getAsBoolean()) { + break; + } + CdnUuidCache.mergeSetEntriesWithFaces(e.getKey(), e.getValue()); + setCount++; + } + if (progress != null) { + progress.onProgress("Synced " + setCount + " sets from the online card index.", 1.0); + } + return setCount; + } + + // ------------------------------------------------------------------------- + + private static String findBulkDataUrl(String bulkDataType, ProgressListener progress) { + String url = listingUrlOverride != null ? listingUrlOverride : BULK_DATA_LISTING_URL; + if (progress != null) { + progress.onProgress("Looking up available card index files...", -1); + } + try { + ScryfallRateLimiter.acquire(url); + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(CONNECT_TIMEOUT_MS); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); + conn.connect(); + + int status = conn.getResponseCode(); + if (status == 429) { + ScryfallRateLimiter.noteIfRateLimited(429, url, conn.getHeaderField("Retry-After")); + return null; + } + if (status != 200) { + Logger.error("ScryfallBulkDataSync: bulk-data listing returned HTTP {}", status); + return null; + } + + JsonObject root; + try (InputStream is = conn.getInputStream(); + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + root = JsonParser.parseReader(reader).getAsJsonObject(); + } + JsonArray data = root.has("data") ? root.getAsJsonArray("data") : new JsonArray(); + for (var el : data) { + JsonObject obj = el.getAsJsonObject(); + if (!obj.has("type") || !bulkDataType.equals(obj.get("type").getAsString())) { + continue; + } + if (obj.has("jsonl_download_uri")) { + return obj.get("jsonl_download_uri").getAsString(); + } + if (obj.has("download_uri")) { + return obj.get("download_uri").getAsString(); + } + } + Logger.error("ScryfallBulkDataSync: no '{}' entry found in bulk-data listing", bulkDataType); + return null; + } catch (IOException e) { + Logger.error(e, "ScryfallBulkDataSync: failed to fetch bulk-data listing"); + return null; + } + } + + private static void streamAndAccumulate(String downloadUrl, Map>> bySet, + Set allowedLangs, ProgressListener progress, BooleanSupplier cancelled) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(downloadUrl).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); + // downloadUrl is a *.scryfall.io file origin, not api.scryfall.com -- unthrottled, no acquire() needed. + conn.connect(); + + int status = conn.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + throw new IOException("HTTP " + status + " for " + downloadUrl); + } + final long totalBytes = conn.getContentLengthLong(); + final long[] bytesRead = {0}; + final long[] cardsSeen = {0}; + + InputStream counting = new FilterInputStream(conn.getInputStream()) { + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n > 0) { + bytesRead[0] += n; + } + return n; + } + + @Override + public int read() throws IOException { + int n = super.read(); + if (n >= 0) { + bytesRead[0]++; + } + return n; + } + }; + + try (InputStream gz = new GZIPInputStream(counting); + BufferedReader reader = new BufferedReader(new InputStreamReader(gz, StandardCharsets.UTF_8))) { + String line; + long lastReportAt = 0; + while ((line = reader.readLine()) != null) { + if (cancelled.getAsBoolean()) { + return; + } + if (line.isBlank()) { + continue; + } + + JsonObject card; + try { + card = JsonParser.parseString(line).getAsJsonObject(); + } catch (Exception e) { + continue; // skip a malformed line rather than aborting the whole sync + } + addCard(bySet, card, allowedLangs); + cardsSeen[0]++; + + long now = System.currentTimeMillis(); + if (progress != null && now - lastReportAt >= 500) { + lastReportAt = now; + double fraction = totalBytes > 0 ? Math.min(1.0, bytesRead[0] / (double) totalBytes) : -1; + String pct = totalBytes > 0 ? Math.round(fraction * 100) + "%" : (bytesRead[0] / 1_000_000) + " MB"; + progress.onProgress("Downloading card index: " + pct + + " (" + cardsSeen[0] + " cards processed)...", fraction); + } + } + } + } + + private static void addCard(Map>> bySet, JsonObject card, Set allowedLangs) { + String setCode = ScryfallSetSync.str(card, "set"); + String cn = ScryfallSetSync.str(card, "collector_number"); + String lang = ScryfallSetSync.str(card, "lang"); + if (setCode == null || cn == null || lang == null) { + return; + } + if (allowedLangs != null && !allowedLangs.contains(lang)) { + return; + } + + String[] frontBack = ScryfallSetSync.frontBackUuids(card); + if (frontBack == null) { + return; + } + + bySet.computeIfAbsent(setCode, k -> new HashMap<>()) + .computeIfAbsent(cn, k -> new HashMap<>()) + .put(lang, frontBack); + } +} diff --git a/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java new file mode 100644 index 000000000000..cfa47fafd4dc --- /dev/null +++ b/forge-gui/src/main/java/forge/gui/download/ScryfallSetSync.java @@ -0,0 +1,175 @@ +package forge.gui.download; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import forge.localinstance.properties.ForgePreferences; +import forge.model.FModel; +import forge.util.BuildInfo; +import forge.util.ScryfallRateLimiter; +import org.tinylog.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +/** + * Builds one set's CDN UUID mapping from Scryfall's card search API, on demand. + * Reads each face's own image URL rather than assuming from the card's {@code id}, + * so double-faced cards with distinct front/back art resolve correctly. + * A card not found here is left for {@link CdnUuidCache} to record as a retryable miss. + */ +final class ScryfallSetSync { + + private static final String DEFAULT_SEARCH_URL = "https://api.scryfall.com/cards/search"; + + private static final int CONNECT_TIMEOUT_MS = 15_000; + + /** Override for tests; must be a full base URL with no trailing query string. */ + static volatile String searchBaseUrlOverride = null; + + private ScryfallSetSync() {} + + /** + * Fetches every print of {@code setCode} from Scryfall and merges it into the cache. + * @return true if anything was found and cached + */ + static boolean sync(String setCode) { + Map> byCn = new HashMap<>(); + String url = searchUrl(setCode); + try { + while (url != null) { + if (ScryfallRateLimiter.isCoolingDown()) break; // back off; a later sync retries + + JsonObject page = fetchJson(url); + if (page == null) break; // 404: no such set / no matching cards + + JsonArray data = page.has("data") ? page.getAsJsonArray("data") : new JsonArray(); + for (JsonElement el : data) { + addCard(byCn, el.getAsJsonObject()); + } + + boolean hasMore = page.has("has_more") && page.get("has_more").getAsBoolean(); + url = hasMore && page.has("next_page") ? page.get("next_page").getAsString() : null; + } + } catch (Exception e) { + Logger.debug("ScryfallSetSync: could not build set '{}' from Scryfall: {}", setCode, e.getMessage()); + } + + if (byCn.isEmpty()) return false; + CdnUuidCache.mergeSetEntriesWithFaces(setCode, byCn); + return true; + } + + // ------------------------------------------------------------------------- + + private static void addCard(Map> byCn, JsonObject card) { + String cn = str(card, "collector_number"); + String lang = str(card, "lang"); + if (cn == null || lang == null) return; + + String[] frontBack = frontBackUuids(card); + if (frontBack == null) return; // no image data yet, or a placeholder URL + + byCn.computeIfAbsent(cn, k -> new HashMap<>()).put(lang, frontBack); + } + + /** Per-face CDN UUIDs for one card, or {@code null} if it has no usable image data yet. Shared with {@link ScryfallBulkDataSync}. */ + static String[] frontBackUuids(JsonObject card) { + String imageStatus = str(card, "image_status"); + if ("missing".equals(imageStatus) || "placeholder".equals(imageStatus)) return null; + + String front; + String back = null; + if (card.has("image_uris") && card.get("image_uris").isJsonObject()) { + front = uuidFromUrl(normalUrl(card.getAsJsonObject("image_uris"))); + } else if (card.has("card_faces") && card.get("card_faces").isJsonArray()) { + JsonArray faces = card.getAsJsonArray("card_faces"); + if (faces.size() == 0) return null; + JsonObject face0 = faces.get(0).getAsJsonObject(); + if (!face0.has("image_uris") || !face0.get("image_uris").isJsonObject()) return null; + front = uuidFromUrl(normalUrl(face0.getAsJsonObject("image_uris"))); + if (faces.size() > 1) { + JsonObject face1 = faces.get(1).getAsJsonObject(); + if (face1.has("image_uris") && face1.get("image_uris").isJsonObject()) { + back = uuidFromUrl(normalUrl(face1.getAsJsonObject("image_uris"))); + } + } + } else { + return null; // no image data for this card + } + return front == null ? null : new String[]{front, back}; + } + + /** Reads an {@code image_uris} object and returns the value of the {@code normal} key. */ + private static String normalUrl(JsonObject imageUris) { + return imageUris.has("normal") ? imageUris.get("normal").getAsString() : null; + } + + /** UUID segment of a Scryfall CDN URL, or {@code null} for a non-CDN placeholder URL. */ + private static String uuidFromUrl(String url) { + if (url == null || !url.contains("cards.scryfall.io")) return null; + int qmark = url.indexOf('?'); + String path = qmark >= 0 ? url.substring(0, qmark) : url; + int slash = path.lastIndexOf('/'); + String filename = slash >= 0 ? path.substring(slash + 1) : path; + int dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.substring(0, dot) : filename; + } + + private static String searchUrl(String setCode) { + String base = searchBaseUrlOverride != null ? searchBaseUrlOverride : DEFAULT_SEARCH_URL; + // Always fetch English: CdnUuidCache.getCdnUrl() falls back to it, and virtually every + // edition's cards are English. "lang:any" fetched ~10x more pages (every printed + // language) than needed and was tripping Scryfall's rate limit before finishing a set -- + // so if the user has a non-English language preference, add just that one language too, + // instead of every language. + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String query = (preferredLang != null && !preferredLang.isEmpty() && !"en".equalsIgnoreCase(preferredLang)) + ? "set:" + setCode + " (lang:en or lang:" + preferredLang + ")" + : "set:" + setCode + " lang:en"; + return base + "?unique=prints&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8); + } + + /** Shared with {@link ScryfallBulkDataSync}. */ + static String str(JsonObject obj, String key) { + return obj.has(key) && !obj.get(key).isJsonNull() ? obj.get(key).getAsString() : null; + } + + /** Fetches {@code urlStr} and parses it as JSON, or returns {@code null} for HTTP 404. */ + private static JsonObject fetchJson(String urlStr) throws IOException { + ScryfallRateLimiter.acquire(urlStr); + URLConnection conn = new URL(urlStr).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(CONNECT_TIMEOUT_MS); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Accept-Encoding", "gzip"); + // Scryfall asks for a descriptive User-Agent and rate-limits harder without one. + conn.setRequestProperty("User-Agent", BuildInfo.getUserAgent()); + conn.connect(); + if (conn instanceof HttpURLConnection) { + int status = ((HttpURLConnection) conn).getResponseCode(); + if (status == 404) return null; + if (status == 429) { + ScryfallRateLimiter.noteIfRateLimited(429, urlStr, conn.getHeaderField("Retry-After")); + throw new IOException("HTTP 429 (rate limited) for " + urlStr); + } + if (status != 200) throw new IOException("HTTP " + status + " for " + urlStr); + } + boolean gzipped = "gzip".equalsIgnoreCase(conn.getContentEncoding()); + try (InputStream raw = conn.getInputStream(); + InputStream is = gzipped ? new GZIPInputStream(raw) : raw; + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader).getAsJsonObject(); + } + } +} diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java index 9178328faa15..0a1c1961a667 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java @@ -240,6 +240,7 @@ public final class ForgeConstants { } // data that is only in the profile dirs + public static final String CACHE_CDN_UUID_DIR = CACHE_DIR + "cdn_uuid" + PATH_SEPARATOR; public static final String USER_QUEST_DIR = USER_DIR + "quest" + PATH_SEPARATOR; public static final String USER_QUEST_WORLD_DIR = USER_QUEST_DIR + "world" + PATH_SEPARATOR; public static final String USER_CONQUEST_DIR = USER_DIR + "conquest" + PATH_SEPARATOR; @@ -346,6 +347,7 @@ public final class ForgeConstants { public static final String URL_PRICE_DOWNLOAD = GITHUB_ASSETS_BASE + "all-prices.txt"; private static final String URL_SCRYFALL = "https://api.scryfall.com"; public static final String URL_PIC_SCRYFALL_DOWNLOAD = URL_SCRYFALL + "/cards/"; + public static final String URL_SCRYFALL_CDN = "https://cards.scryfall.io/"; // Constants for Display Card Identity game setting public static final String DISP_CURRENT_COLORS_ALWAYS = "Always"; @@ -395,6 +397,22 @@ public static Map getUPnPPreferenceMapping() { ); } + public static Map getScryfallCardLanguageMapping() { + final java.util.LinkedHashMap map = new java.util.LinkedHashMap<>(); + map.put("English", "en"); + map.put("Spanish", "es"); + map.put("French", "fr"); + map.put("German", "de"); + map.put("Italian", "it"); + map.put("Portuguese", "pt"); + map.put("Japanese", "ja"); + map.put("Korean", "ko"); + map.put("Russian", "ru"); + map.put("Chinese Simplified", "zhs"); + map.put("Chinese Traditional", "zht"); + return map; + } + public enum CounterDisplayLocation { TOP("Top of Card"), BOTTOM("Bottom of Card"); diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index 50ae6b67d032..a1c8cd9fad52 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -78,6 +78,8 @@ public enum FPref implements AbstractPreferences.IPref { UI_OVERLAY_ABILITY_ICONS("true"), UI_OVERLAY_DRAFT_RANKING("true"), UI_ENABLE_ONLINE_IMAGE_FETCHER ("true"), + UI_CARD_DOWNLOAD_LANG ("en"), + UI_PREFER_LANG_FOR_UNIQUE_CARDS ("false"), UI_PREFERRED_ART("LATEST_ART_ALL_EDITIONS"), UI_SMART_CARD_ART("false"), UI_AUTO_AIDECK_SELECTION("true"), diff --git a/forge-gui/src/main/java/forge/model/FModel.java b/forge-gui/src/main/java/forge/model/FModel.java index 922d78e12047..8f24ea2dfe0d 100644 --- a/forge-gui/src/main/java/forge/model/FModel.java +++ b/forge-gui/src/main/java/forge/model/FModel.java @@ -34,6 +34,7 @@ import forge.game.card.CardUtil; import forge.game.spellability.Spell; import forge.gamemodes.gauntlet.GauntletData; +import forge.gui.download.CdnUuidCache; import forge.gamemodes.limited.GauntletMini; import forge.gamemodes.limited.ThemedChaosDraft; import forge.gamemodes.planarconquest.ConquestController; @@ -63,6 +64,7 @@ import java.io.File; import java.util.List; import java.util.Map; +import java.util.function.BiPredicate; import java.util.function.Function; /** @@ -85,7 +87,20 @@ private FModel() { } //don't allow creating instance getPreferences().getPrefBoolean(FPref.UI_LOAD_UNKNOWN_CARDS), getPreferences().getPrefBoolean(FPref.UI_LOAD_NONLEGAL_CARDS), getPreferences().getPrefBoolean(FPref.ALLOW_CUSTOM_CARDS_IN_DECKS_CONFORMANCE), - getPreferences().getPrefBoolean(FPref.UI_SMART_CARD_ART))); + getPreferences().getPrefBoolean(FPref.UI_SMART_CARD_ART), + buildPreferredLanguageAvailability())); + + private static BiPredicate buildPreferredLanguageAvailability() { + if (!getPreferences().getPrefBoolean(FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)) { + return null; + } + String preferredLang = getPreferences().getPref(FPref.UI_CARD_DOWNLOAD_LANG); + if (preferredLang == null || preferredLang.isEmpty() || "en".equalsIgnoreCase(preferredLang)) { + return null; + } + return (setCode, collectorNumber) -> CdnUuidCache.isAvailableInLanguage(setCode, collectorNumber, preferredLang); + } + private static final Supplier questPreferences = Suppliers.memoize(QuestPreferences::new); private static final Supplier conquestPreferences = Suppliers.memoize(() -> { final ConquestPreferences cp = new ConquestPreferences(); diff --git a/forge-gui/src/main/java/forge/util/ImageFetcher.java b/forge-gui/src/main/java/forge/util/ImageFetcher.java index 63c99624f25b..9ca66d56cc4b 100644 --- a/forge-gui/src/main/java/forge/util/ImageFetcher.java +++ b/forge-gui/src/main/java/forge/util/ImageFetcher.java @@ -4,16 +4,17 @@ import forge.StaticData; import forge.card.CardEdition; import forge.gui.FThreads; +import forge.gui.download.CdnUuidCache; import forge.item.IPaperCard; import forge.item.PaperCard; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgePreferences; import forge.model.FModel; +import org.apache.commons.lang3.StringUtils; import java.io.File; import java.util.*; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; public abstract class ImageFetcher { @@ -21,72 +22,8 @@ public abstract class ImageFetcher { // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes private static final HashMap langCodeMap = new HashMap<>(); protected static final boolean disableHostedDownload = true; - protected static volatile Date scryfallCooldownTime = null; private static final HashSet fetching = new HashSet<>(); - /** Minimum gap between Scryfall requests, matching what GuiDownloadService already keeps. */ - private static final long SCRYFALL_MIN_INTERVAL_MS = 100; - private static final long SCRYFALL_COOLDOWN_MINUTES = 5; - private static final Object scryfallPacing = new Object(); - private static long lastScryfallRequest = 0; - - protected static boolean isScryfall(final String url) { - return url != null && url.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD); - } - - /** Whether we are still backing off after Scryfall rate limited us. Clears an expired cooldown. */ - protected static boolean scryfallCoolingDown() { - final Date cooldown = scryfallCooldownTime; - if (cooldown == null) { - return false; - } - if (cooldown.after(new Date())) { - return true; - } - scryfallCooldownTime = null; - return false; - } - - /** - * Whether this particular download should be skipped because we are backing off Scryfall, so - * callers can simply skip the fetch while this returns true. - */ - protected static boolean inScryfallCooldown(final String url) { - if (!isScryfall(url) || !scryfallCoolingDown()) { - return false; - } - System.err.println("Currently in cooldown period for scryfall downloads. Skipping download attempt for: " + url); - return true; - } - - /** Record that Scryfall returned 429, so we stop asking for a while. */ - protected static void noteScryfallRateLimited() { - scryfallCooldownTime = new Date(System.currentTimeMillis() - + TimeUnit.MINUTES.toMillis(SCRYFALL_COOLDOWN_MINUTES)); - } - - /** - * Space Scryfall requests out. Downloads run on a work stealing pool, so without this a screen - * full of missing images asks for all of them at once. Called from the download task, never the - * EDT. - */ - protected static void paceScryfall(final String url) { - if (!isScryfall(url)) { - return; - } - synchronized (scryfallPacing) { - final long wait = lastScryfallRequest + SCRYFALL_MIN_INTERVAL_MS - System.currentTimeMillis(); - if (wait > 0) { - try { - Thread.sleep(wait); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - lastScryfallRequest = System.currentTimeMillis(); - } - } - static { langCodeMap.put("en-US", "en"); langCodeMap.put("es-ES", "es"); @@ -135,16 +72,22 @@ private String getScryfallDownloadURL(PaperCard c, String face, boolean useArtCr private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, ArrayList downloadUrls) { CardEdition edition = StaticData.instance().getEditions().get(card.getEdition()); - if (edition == null) { - return; - } + if (edition == null) return; String setCode = edition.getScryfallCode(); - String langCode = edition.getCardsLangCode(); - String primaryUrl = ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallDownloadUrl(card, face, setCode, langCode, useArtCrop); - if (!downloadUrls.contains(primaryUrl)) { - downloadUrls.add(primaryUrl); + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = CdnUuidCache.resolvePreferredLangCode(preferredLang, setCode, card.getCollectorNumber(), edition.getCardsLangCode()); + + // Prefer CDN (no rate limit) if this set was already synced; read-only, see getCdnUrlIfCached(). + if (!StringUtils.isBlank(setCode)) { + String size = useArtCrop ? "art_crop" : "normal"; + String cdnUrl = forge.gui.download.CdnUuidCache.getCdnUrlIfCached( + setCode, card.getCollectorNumber(), langCode, face, size); + if (cdnUrl != null && !downloadUrls.contains(cdnUrl)) downloadUrls.add(cdnUrl); } + + String primaryUrl = ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallDownloadUrl(card, face, setCode, langCode, useArtCrop); + if (!downloadUrls.contains(primaryUrl)) downloadUrls.add(primaryUrl); } protected boolean shouldTryScryfallSetLookupCandidate(PaperCard requestedCard, PaperCard candidate) { @@ -214,7 +157,8 @@ public void fetchImage(final String imageKey, final Callback callback) { CardEdition ed = StaticData.instance().getEditions().get(pc.getEdition()); if (ed != null) { String setCode = ed.getScryfallCode(); - String langCode = ed.getCardsLangCode(); + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = CdnUuidCache.resolvePreferredLangCode(preferredLang, setCode, pc.getCollectorNumber(), ed.getCardsLangCode()); downloadUrls.add("PLANECHASEBG:" + ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallDownloadUrl(pc, "", setCode, langCode, true)); FileUtil.ensureDirectoryExists(ForgeConstants.CACHE_PLANECHASE_PICS_DIR); File destFile = new File(ForgeConstants.CACHE_PLANECHASE_PICS_DIR, getPlanechaseFilename(cardName)); @@ -389,7 +333,8 @@ public void fetchImage(final String imageKey, final Callback callback) { if (tempdata.length > 2) { String tokenCode = edition.getTokensCode(); - String langCode = edition.getCardsLangCode(); + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = CdnUuidCache.resolvePreferredLangCode(preferredLang, tokenCode, tempdata[2], edition.getCardsLangCode()); // Just assume the CNr from the token image is valid downloadUrls.add(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallTokenDownloadUrl(tempdata[2], tokenCode, langCode, face)); } else if (!allTokens.isEmpty()) { @@ -404,10 +349,11 @@ public void fetchImage(final String imageKey, final Callback callback) { while (it.hasNext()) { tis = it.next(); String tokenCode = edition.getTokensCode(); - String langCode = edition.getCardsLangCode(); if (tis.collectorNumber() == null || tis.collectorNumber().isEmpty()) { continue; } + String preferredLang = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG); + String langCode = CdnUuidCache.resolvePreferredLangCode(preferredLang, tokenCode, tis.collectorNumber(), edition.getCardsLangCode()); downloadUrls.add(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD + ImageUtil.getScryfallTokenDownloadUrl(tis.collectorNumber(), tokenCode, langCode, face)); } @@ -478,7 +424,7 @@ private void setupObserver(final String destPath, final Callback callback, final // the in-flight set below, so a fetch registered during the cooldown would never be retried // once the cooldown lifts. Only when every candidate is Scryfall - otherwise another source // may still serve it. - if (scryfallCoolingDown() && downloadUrls.stream().allMatch(ImageFetcher::isScryfall)) { + if (ScryfallRateLimiter.isCoolingDown() && downloadUrls.stream().allMatch(ScryfallRateLimiter::isApiUrl)) { return; } // Note: No synchronization is needed here because this is executed on diff --git a/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java b/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java new file mode 100644 index 000000000000..61496747539b --- /dev/null +++ b/forge-gui/src/main/java/forge/util/ScryfallRateLimiter.java @@ -0,0 +1,163 @@ +package forge.util; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +/** + * Global pacing/cooldown gate for api.scryfall.com endpoints. Never applies to cards.scryfall.io + * CDN URLs -- those are unthrottled; see {@link forge.gui.download.CdnUuidCache}. + * + *

Scryfall caps {@code /cards/search} and {@code /cards/named} at 2/sec (500ms); everything + * else at 10/sec (100ms). + */ +public final class ScryfallRateLimiter { + private static final String API_HOST_PREFIX = "https://api.scryfall.com/"; + private static final long SEARCH_INTERVAL_MS = 500; // /cards/search, /cards/named -- 2/sec + private static final long DEFAULT_INTERVAL_MS = 100; // everything else -- 10/sec + /** Fallback only -- Scryfall's 429 response almost always carries a Retry-After header, which takes priority. */ + private static final long DEFAULT_COOLDOWN_SECONDS = 30; + + private static final Object searchPacingLock = new Object(); + private static long lastSearchRequestAt = 0; + private static final Object defaultPacingLock = new Object(); + private static long lastDefaultRequestAt = 0; + private static volatile Date cooldownUntil = null; + + private ScryfallRateLimiter() {} + + /** True for any api.scryfall.com request -- card lookup, search, named, and bulk-data listing. */ + public static boolean isApiUrl(String url) { + return url != null && url.startsWith(API_HOST_PREFIX); + } + + /** Whether {@code url} is in Scryfall's 2/sec-limited bucket (search, named). */ + private static boolean isSlowEndpoint(String url) { + return url != null && (url.contains("/cards/search") || url.contains("/cards/named")); + } + + /** Whether we are still backing off after Scryfall rate limited us. Clears an expired cooldown. */ + public static boolean isCoolingDown() { + final Date until = cooldownUntil; + if (until == null) { + return false; + } + if (until.after(new Date())) { + return true; + } + cooldownUntil = null; + return false; + } + + /** + * Record that Scryfall returned 429, so we stop asking for a while. Always logged (not + * debug-only): this is the one place that explains every subsequent "in cooldown" skip. + * + * @param retryAfterSeconds value of the response's {@code Retry-After} header, or {@code <= 0} + * if absent/unparseable, in which case a short default is used. + */ + public static void noteRateLimited(String triggerUrl, long retryAfterSeconds) { + long seconds = retryAfterSeconds > 0 ? retryAfterSeconds : DEFAULT_COOLDOWN_SECONDS; + cooldownUntil = new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(seconds)); + System.err.println("==== Scryfall returned 429 for: " + triggerUrl + + " -- backing off all api.scryfall.com requests until " + cooldownUntil + " ===="); + } + + /** Parses a {@code Retry-After} header value (seconds) into seconds, or {@code -1} if absent/unparseable. */ + public static long parseRetryAfterSeconds(String headerValue) { + if (headerValue == null) { + return -1; + } + try { + return Long.parseLong(headerValue.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + /** Records a cooldown if {@code responseCode} is 429 for a Scryfall API url; no-op otherwise. */ + public static void noteIfRateLimited(int responseCode, String url, String retryAfterHeader) { + if (responseCode == 429 && isApiUrl(url)) { + noteRateLimited(url, parseRetryAfterSeconds(retryAfterHeader)); + } + } + + /** True (and logs) if url is a Scryfall API URL and we're currently backing off. */ + public static boolean shouldSkip(String url) { + if (!isApiUrl(url) || !isCoolingDown()) { + return false; + } + System.err.println("Currently in cooldown period for scryfall downloads. Skipping download attempt for: " + url); + return true; + } + + /** + * Blocks the calling thread until any active cooldown clears, polling {@code cancelled}. + * No-op if not cooling down. Only call from a cancelable background job (e.g. the bulk + * downloader) -- never from a gameplay fire-and-forget path, where failing fast is correct. + */ + public static void awaitCooldownCleared(java.util.function.BooleanSupplier cancelled) { + awaitCooldownCleared(cancelled, null); + } + + /** Same as {@link #awaitCooldownCleared(java.util.function.BooleanSupplier)}, but reports a status string (throttled to ~5s) via {@code onWaiting} for a visible progress UI. */ + public static void awaitCooldownCleared(java.util.function.BooleanSupplier cancelled, + java.util.function.Consumer onWaiting) { + if (!isCoolingDown()) { + return; + } + long lastLoggedAt = 0; + while (isCoolingDown()) { + if (cancelled.getAsBoolean()) { + return; + } + long remainingSec = Math.max(0, cooldownUntil.getTime() - System.currentTimeMillis()) / 1000 + 1; + String message = "Rate limited by Scryfall -- waiting " + remainingSec + "s before continuing..."; + if (onWaiting != null) { + onWaiting.accept(message); + } + long now = System.currentTimeMillis(); + if (now - lastLoggedAt >= 5000) { + System.out.println(" " + message); + lastLoggedAt = now; + } + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + /** Blocks the calling thread (never the EDT) until it's this caller's turn. No-op for non-API URLs. */ + public static void acquire(String url) { + if (!isApiUrl(url)) { + return; + } + if (isSlowEndpoint(url)) { + synchronized (searchPacingLock) { + final long wait = lastSearchRequestAt + SEARCH_INTERVAL_MS - System.currentTimeMillis(); + if (wait > 0) { + try { + Thread.sleep(wait); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + lastSearchRequestAt = System.currentTimeMillis(); + } + } else { + synchronized (defaultPacingLock) { + final long wait = lastDefaultRequestAt + DEFAULT_INTERVAL_MS - System.currentTimeMillis(); + if (wait > 0) { + try { + Thread.sleep(wait); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + lastDefaultRequestAt = System.currentTimeMillis(); + } + } + } +} diff --git a/forge-gui/tools/java/ForgeMatrixWriter.java b/forge-gui/tools/java/ForgeMatrixWriter.java index a9b0a2ad5501..83b90b518721 100644 --- a/forge-gui/tools/java/ForgeMatrixWriter.java +++ b/forge-gui/tools/java/ForgeMatrixWriter.java @@ -71,7 +71,8 @@ public static void main(String[] args) throws Exception { true, false, false, - false); + false, + null); HashMap>> matrix = new HashMap<>(); int rows = 0;