Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6430183
Add Scryfall CDN image lookup via cdn_uuid asset files
Jun 9, 2026
7972005
Add unit tests for CdnUuidCache; make base dir injectable for tests
Jun 9, 2026
d5f2a08
Fix CDN image URLs not being saved as .fullborder.jpg
Jun 9, 2026
bf04c67
Fetch CDN UUID data from forge-extras; cache per-set JSON files locally
Jun 10, 2026
51304c6
Merge branch 'master' into fix-10413-card-image-downloader
Jul 28, 2026
9df74cd
Compress cdn_uuid data over the wire and in local cache
Jul 28, 2026
9d6ff6f
Let users build their own CDN image lookup data from Scryfall's manif…
Jul 29, 2026
211c58c
Fold ScryfallBulkData's one-liner into CdnUuidCache
Jul 29, 2026
79766c1
Drop forge-extras as a data source; generate CDN UUID cache client-side
Aug 11, 2026
271bb50
Merge remote-tracking branch 'upstream/master' into fix-10413-card-im…
Aug 11, 2026
ba00cb0
Update comments and structure
Aug 11, 2026
d9a1a19
Update locale
Aug 11, 2026
9c4fbdb
Resolve to client-only cacher
Aug 16, 2026
f73a49f
Reduce refactor and resolve cache off hotpath
Aug 16, 2026
e98cefa
Merge remote-tracking branch 'upstream/master' into fix-10413-card-im…
Aug 16, 2026
c0ebfe5
Trim doc comments across the CDN cache work
Aug 17, 2026
4846ac1
Merge remote-tracking branch 'upstream/master' into fix-10413-card-im…
Aug 17, 2026
daf2c7d
Fix Scryfall rate limiting and add bulk CDN link resolution
Aug 18, 2026
a1d12a3
Add bulk download button+prompt, improve desktop batch image download
Aug 18, 2026
5e4ce7f
Add language codes
Aug 18, 2026
77a8f62
Simplify
Aug 18, 2026
d88b6be
CDN Multi-language card image downloads
churrufli Aug 27, 2026
587f8b3
Merge branch 'master' into fix-10413-card-image-downloader-multilang
churrufli Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion forge-core/src/main/java/forge/StaticData.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> preferredLanguageAvailability) {
this.cardReader = cardReader;
this.tokenReader = tokenReader;
this.editions = new CardEdition.Collection(new CardEdition.Reader(new File(editionFolder)));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -956,6 +962,11 @@ public void setCardArtPreference(String artPreference) {
this.variantCards.setCardArtPreference(artPreference);
}

public void setPreferredLanguageAvailability(BiPredicate<String, String> availability) {
this.commonCards.setPreferredLanguageAvailability(availability);
this.variantCards.setPreferredLanguageAvailability(availability);
}

public boolean isEnabledCardArtSmartSelection() {
return this.enableSmartCardArtSelection;
}
Expand Down
25 changes: 24 additions & 1 deletion forge-core/src/main/java/forge/card/CardDb.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> preferredLanguageAvailability;
private boolean initialized;

public static class CardRequest {
public String cardName;
Expand Down Expand Up @@ -536,6 +539,7 @@ public void initialize(boolean logMissingPerEdition, boolean logMissingSummary,
}
}

initialized = true;
reIndex();
}

Expand Down Expand Up @@ -583,11 +587,23 @@ private void reIndex() {
private PaperCard getBestUniquePrint(final Collection<PaperCard> 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);
Expand All @@ -613,6 +629,13 @@ public void setCardArtPreference(boolean latestArt, boolean coreExpansionOnly){
}
}

public void setPreferredLanguageAvailability(BiPredicate<String, String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 33 additions & 2 deletions forge-gui-desktop/src/main/java/forge/itemmanager/CardManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,6 +63,14 @@ protected Iterable<Entry<PaperCard, Integer>> getUnique(Iterable<Entry<PaperCard
entriesByName.put(cardName, item);
}

String preferredLang = null;
if (FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.UI_PREFER_LANG_FOR_UNIQUE_CARDS)) {
String pref = FModel.getPreferences().getPref(ForgePreferences.FPref.UI_CARD_DOWNLOAD_LANG);
if (pref != null && !pref.isEmpty() && !"en".equalsIgnoreCase(pref)) {
preferredLang = pref;
}
}

// Now we're ready to go on with retrieving cards to be returned
Map<PaperCard, Integer> cardsMap = new HashMap<>();
for (String cardName : entriesByName.keySet()) {
Expand All @@ -85,7 +94,7 @@ protected Iterable<Entry<PaperCard, Integer>> getUnique(Iterable<Entry<PaperCard
// Policy is too strict for current PaperCard in Entry. Remove any filter
acceptedEditions.addAll(entriesByEdition.keySet());

Entry<PaperCard, Integer> cardEntry = getCardEntryToAdd(entriesByEdition, acceptedEditions);
Entry<PaperCard, Integer> cardEntry = getCardEntryToAdd(entriesByEdition, acceptedEditions, preferredLang);
if (cardEntry != null)
cardsMap.put(cardEntry.getKey(), cardEntry.getValue());
}
Expand All @@ -95,7 +104,7 @@ protected Iterable<Entry<PaperCard, Integer>> getUnique(Iterable<Entry<PaperCard
// Select the Card Art Entry to add, based on current Card Art Preference Order.
// This method will prefer the entry currently having an image. If that's not the case,
private Entry<PaperCard, Integer> getCardEntryToAdd(ListMultimap<CardEdition, Entry<PaperCard, Integer>> entriesByEdition,
List<CardEdition> acceptedEditions) {
List<CardEdition> acceptedEditions, String preferredLang) {
// Use standard sort + index, for better performance!
Collections.sort(acceptedEditions);
if (StaticData.instance().cardArtPreferenceIsLatest())
Expand All @@ -107,6 +116,20 @@ private Entry<PaperCard, Integer> getCardEntryToAdd(ListMultimap<CardEdition, En
CardEdition cardEdition = editionIterator.next();
// These are now the entries to add to Cards Map
List<Entry<PaperCard, Integer>> cardEntries = entriesByEdition.get(cardEdition);

if (preferredLang != null) {
for (Entry<PaperCard, Integer> entry : cardEntries) {
if (isPreferredLanguagePrint(entry.getKey(), preferredLang)) {
if (firstCandidateEntryFound == null)
firstCandidateEntryFound = entry;
candidateEntry = entry;
break;
}
}
if (candidateEntry != null)
break;
}

Iterator<Entry<PaperCard, Integer>> entriesIterator = cardEntries.iterator();
candidateEntry = entriesIterator.hasNext() ? entriesIterator.next() : null;
if (candidateEntry != null && firstCandidateEntryFound == null)
Expand All @@ -123,6 +146,14 @@ private Entry<PaperCard, Integer> getCardEntryToAdd(ListMultimap<CardEdition, En
return candidateEntry != null ? candidateEntry : firstCandidateEntryFound;
}

private boolean isPreferredLanguagePrint(PaperCard card, String preferredLang) {
CardEdition edition = StaticData.instance().getEditions().get(card.getEdition());
if (edition == null) {
return false;
}
return CdnUuidCache.isAvailableInLanguage(edition.getScryfallCode(), card.getCollectorNumber(), preferredLang);
}

/* Static overrides shared with SpellShopManager*/

public static void addDefaultFilters(final ItemManager<? super PaperCard> itemManager) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -998,7 +1004,35 @@ public void refresh() {
}

protected Iterable<Entry<T, Integer>> getUnique(final Iterable<Entry<T, Integer>> 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<String, Entry<T, Integer>> uniques = new LinkedHashMap<>();
for (final Entry<T, Integer> entry : items) {
final String key = entry.getKey().getName();
final Entry<T, Integer> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -205,6 +206,7 @@ public void initialize() {
initializeDefaultFontSizeComboBox();
initializeCardArtFormatComboBox();
initializeCardArtPreference();
initializeCardDownloadLanguageComboBox();
initializeAutoUpdaterComboBox();
initializeServerUPnPComboBox();
initializeMulliganRuleComboBox();
Expand Down Expand Up @@ -418,6 +420,46 @@ private void initializeAutoUpdaterComboBox() {
panel.setComboBox(comboBox, selectedItem);
}

private void initializeCardDownloadLanguageComboBox() {
final Map<String, String> cardLangMapping = ForgeConstants.getScryfallCardLanguageMapping();
final String[] localizedOptions = cardLangMapping.keySet().toArray(new String[0]);

final FPref cardLangPreference = FPref.UI_CARD_DOWNLOAD_LANG;

final FComboBoxPanel<String> panel = this.view.getCbpCardDownloadLangComboBoxPanel();
final FComboBox<String> 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<String, String> upnpPreferenceMapping = ForgeConstants.getUPnPPreferenceMapping();
Expand Down
Loading
Loading