Compare commits

...

4 commits

Author SHA1 Message Date
Lukas Brübach
8a97d23e20
[Windows] Scope running-instance handling to install dir and fix NSIS build
- Drop !include nsExec.nsh: nsExec is a plugin DLL, not a header, so
  makensis aborts before NSIS can build the installer.
- Match processes by image name and executable path under $INSTDIR via
  PowerShell, then close (WM_CLOSE) and force-stop only those PIDs, so an
  unrelated oracle.exe (Oracle DB) is never killed on a silent /R update.
- Gate the stale-runtime-DLL purge on $INSTDIR\cockatrice.exe existing, so
  a first-time install can't recursively delete an unrelated Plugins
  directory.
2026-09-21 09:13:19 +02:00
Lukas Brübach
933fb5d1f2
[Windows] Close running instances and purge stale runtime DLLs during update 2026-09-21 09:13:19 +02:00
BruebachL
ef68a7bdcc
[Card] Add a setting for the language used in card search (#7314)
* [Card] Add a setting for the language used in card search

Localized card names and texts can now be searched too, controlled by a
'Language used in card search' toggle (English, selected card language, or
both) on the general settings page. Untranslated cards always keep matching
in English.

Removed the redundant local copy of the URL templates list in the localized
picture loader while here.

* [Card] Bind search language per FilterString instance

The peg parser rules are set up once per process, so the GenericQuery and
OracleQuery rule actions could not capture per-instance state. Instead of
storing the search language in a process-global that FilterString instance
methods mutate, hand it to the rule actions through a thread-local parse
context and copy it into the filter closures they produce. Card evaluation
in FilterString::check no longer reads any process-global state, and each
instance keeps the language it was built with; constructing one instance no
longer changes what unrelated instances (deck filter, drop-to-hand, zone
views) match against.

The card database display model stores the raw query and rebuilds the
FilterString when the search language changes, since the language is now
bound at parse time.

Add tests for the English/Selected/Both search modes, the English fallback
for untranslated cards, and per-instance language independence.

* [Card] Pass the card search language to deck and zone card searches

Wire the two remaining FilterString consumers to the configured card search
language so card-name matches respect it everywhere:

- DeckFilterString now takes the search language and mode, exposes them to its
  [[card name]] rule action via a thread-local parse context (same pattern as
  FilterString), and the engine's card database uses them for content search.
- ZoneViewZone reads the card language from CardsDisplaySettings when applying
  its search filter, and the reveal-zone widget re-applies the active search
  when the language setting changes.
- The deck-storage search re-runs its filter against the current card language
  setting, including live re-application when the setting changes.

Game-action targeting (DlgMoveTopCardsUntil) intentionally keeps evaluating
against English card names.

* [Card] Rename CardSearchLanguage to SearchLanguageMode

* [Card] Restore displaced namespace doc in card_localization.h

* [Filters] Pass CardSearchLanguage as a single struct

* [CardSearchModel] Match English and localized names in Both mode

Card names are stored in both English and localized forms, so search for
matches in both during the 'Both' search mode instead of checking only
the localized name.

* [CreateTokenDialog] Fetch cardsDisplay settings inside the apply lambda

Avoid capturing the raw settings pointer in the lambda: resolve the card
language and card search language from the settings cache at call time so
the values are always current when the search language is re-applied.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 08:50:48 +02:00
BruebachL
12299abcc8
[Doxygen] More picture docs (#7220)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Doxygen] More picture docs

Took 12 minutes

Took 8 minutes

* [Doxygen] Move custom card pictures page into card_pictures subfolder

The actual cyclic dependency fix is converting the mutual @subpage
reference from custom_card_pictures to fixing_card_pictures into a
plain @ref, so the page hierarchy no longer loops back on itself.

* [Doxygen] Correct card-picture docs per review

* fix table layout

* [Doxygen] Deduplicate placeholder table and document image overrides

- Make custom_card_pictures.md the canonical home of the URL reference-point
  table; loading_card_pictures.md cross-references it through @ref instead of
  maintaining a second copy (unaddressed review comment).
- Switch the remaining @subpage custom_card_pictures to @ref in
  fixing_card_pictures.md so the page keeps its single parent under
  user_reference.
- Document the Image Overrides feature added in #7311/#7312 on the user page,
  loading_card_pictures.md and fixing_card_pictures.md: local override storage,
  the downloadedPics root lookup, exact file-name matching, and the set-folder
  vs flat export naming schemes.

* Update doc/doxygen/extra-pages/user_documentation/card_pictures/custom_card_pictures.md

Co-authored-by: tooomm <tooomm@users.noreply.github.com>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: tooomm <tooomm@users.noreply.github.com>
2026-09-20 23:30:28 +02:00
32 changed files with 995 additions and 75 deletions

View file

@ -131,6 +131,13 @@ ${EndIf}
; Now that $PortableMode reflects reality, commit InstDir into the correct slot
Call SetModeDestinationFromInstdir
; Make sure no application instance is still running (and holding file locks)
; before the previous version is uninstalled or new files are installed.
; On a silent update (/R /S) running processes are asked to close gracefully
; and waited for, then force-closed only on timeout. On interactive installs
; the user is prompted to close them instead.
Call EnsureAppsNotRunning
${If} $ReinstallMode = 1
${AndIf} $PortableMode = 0
Call AutoUninstallIfNeeded
@ -144,6 +151,9 @@ ${If} ${NSIS_IS_64_BIT} == 1
SetRegView 64
${EndIf}
; Ensure no application instance is still running before removing files.
Call un.EnsureAppsNotRunning
FunctionEnd
Function RequireAdmin
@ -199,6 +209,170 @@ ${EndIf}
FunctionEnd
; --- Running instance handling ---
; Cockatrice, Oracle and Servatrice must not be running while files are
; replaced or deleted. A still-running process holds locks on its .exe and
; Qt runtime DLLs, so a silent upgrade could otherwise end up with a mix of
; old and new Qt DLLs next to the new executable, failing with
; "The procedure entry point X could not be located in the dynamic link
; library ...Qt6Network.dll" on the next start.
;
; Processes are matched by image name AND by their executable path living
; under $INSTDIR, so unrelated processes that merely share an image name
; (e.g. the Oracle DB instance "oracle.exe") are never touched.
; usage: set $R2 to the base image name (without extension, e.g. "cockatrice",
; as accepted by Get-Process -Name), call this, result in $R0
; (1 = running from $INSTDIR, 0 = not running)
Function IsAppRunning
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"'
Pop $R0
${If} $R0 = 0
StrCpy $R0 1
${Else}
StrCpy $R0 0
${EndIf}
FunctionEnd
Function CloseMatchingApps
; gracefully ask every matching instance to close (sends WM_CLOSE)
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"'
Pop $R3
FunctionEnd
Function ForceCloseMatchingApps
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"'
Pop $R3
FunctionEnd
Function WaitForAppToClose
; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the
; base image name (e.g. "cockatrice")
Call IsAppRunning
${If} $R0 = 0
Return
${EndIf}
${If} ${Silent}
; ask the application to close gracefully (WM_CLOSE), then wait for it to exit
DetailPrint "Closing $R1 ..."
Call CloseMatchingApps
StrCpy $R8 0
ck_wait_loop:
Sleep 500
IntOp $R8 $R8 + 1
Call IsAppRunning
${If} $R0 = 0
DetailPrint "$R1 closed."
Return
${EndIf}
${If} $R8 < 60
Goto ck_wait_loop
${EndIf}
; give up waiting, force close
DetailPrint "Force closing $R1 ..."
Call ForceCloseMatchingApps
Sleep 500
${Else}
ck_wait_prompt:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
"$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \
IDCANCEL ck_abort_install
Call IsAppRunning
${If} $R0 = 0
Return
${EndIf}
Goto ck_wait_prompt
ck_abort_install:
Abort
${EndIf}
FunctionEnd
Function EnsureAppsNotRunning
StrCpy $R1 "cockatrice.exe"
StrCpy $R2 "cockatrice"
Call WaitForAppToClose
StrCpy $R1 "oracle.exe"
StrCpy $R2 "oracle"
Call WaitForAppToClose
StrCpy $R1 "servatrice.exe"
StrCpy $R2 "servatrice"
Call WaitForAppToClose
FunctionEnd
; Uninstaller copies of the same routines (the uninstaller gets its own
; function set compiled in, it cannot call the installer functions).
Function un.IsAppRunning
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"'
Pop $R0
${If} $R0 = 0
StrCpy $R0 1
${Else}
StrCpy $R0 0
${EndIf}
FunctionEnd
Function un.CloseMatchingApps
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"'
Pop $R3
FunctionEnd
Function un.ForceCloseMatchingApps
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"'
Pop $R3
FunctionEnd
Function un.WaitForAppToClose
Call un.IsAppRunning
${If} $R0 = 0
Return
${EndIf}
${If} ${Silent}
DetailPrint "Closing $R1 ..."
Call un.CloseMatchingApps
StrCpy $R8 0
un_ck_wait_loop:
Sleep 500
IntOp $R8 $R8 + 1
Call un.IsAppRunning
${If} $R0 = 0
DetailPrint "$R1 closed."
Return
${EndIf}
${If} $R8 < 60
Goto un_ck_wait_loop
${EndIf}
DetailPrint "Force closing $R1 ..."
Call un.ForceCloseMatchingApps
Sleep 500
${Else}
un_ck_wait_prompt:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
"$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \
IDCANCEL un_ck_abort_install
Call un.IsAppRunning
${If} $R0 = 0
Return
${EndIf}
Goto un_ck_wait_prompt
un_ck_abort_install:
Abort
${EndIf}
FunctionEnd
Function un.EnsureAppsNotRunning
StrCpy $R1 "cockatrice.exe"
StrCpy $R2 "cockatrice"
Call un.WaitForAppToClose
StrCpy $R1 "oracle.exe"
StrCpy $R2 "oracle"
Call un.WaitForAppToClose
StrCpy $R1 "servatrice.exe"
StrCpy $R2 "servatrice"
Call un.WaitForAppToClose
FunctionEnd
Function PortableModePageCreate
${If} $ReinstallMode = 1
@ -318,6 +492,26 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
RMDir "$INSTDIR"
${EndIf}
; Belt and braces: the old uninstaller may have already run in the /R path, so
; ensure no application instance is still holding file locks, then remove any
; runtime DLLs left over from older versions. A mismatched Qt/OpenSSL set next
; to the new executable is what causes "The procedure entry point X could not be
; located in the dynamic link library ...Qt6Network.dll" after an update.
Call EnsureAppsNotRunning
${If} $PortableMode = 0
${AndIf} ${FileExists} "$INSTDIR\cockatrice.exe"
RMDir /r "$INSTDIR\Plugins"
Delete "$INSTDIR\Qt*.dll"
Delete "$INSTDIR\libcrypto*.dll"
Delete "$INSTDIR\libssl*.dll"
Delete "$INSTDIR\zlib*.dll"
Delete "$INSTDIR\libmysql.dll"
Delete "$INSTDIR\icu*.dll"
Delete "$INSTDIR\libeay32.dll"
Delete "$INSTDIR\ssleay32.dll"
${EndIf}
@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@
@CPACK_NSIS_FULL_INSTALL@

View file

@ -43,6 +43,12 @@ NumericValue <- [0-9]+
static std::once_flag init;
// The peglib parser is a single permanent object, so the rule actions below cannot see
// per-instance state. The card language that the nested [[card name]] search matches
// against is passed through this thread-local context, which is live only while a
// DeckFilterString is being parsed, and copied into the nested FilterString closures.
thread_local CardSearchLanguage deckSearchLanguageContext;
static void setupParserRules()
{
// plumbing
@ -116,7 +122,7 @@ static void setupParserRules()
// actual functionality
search["DeckContentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto cardFilter = FilterString(std::any_cast<QString>(sv[0]));
auto cardFilter = FilterString(std::any_cast<QString>(sv[0]), deckSearchLanguageContext);
auto numberMatcher = sv.size() > 1 ? std::any_cast<NumberMatcher>(sv[1]) : [](int count) { return count > 0; };
return [=](const DeckSearchData &data) -> bool {
@ -186,7 +192,7 @@ DeckFilterString::DeckFilterString()
_error = "Not initialized";
}
DeckFilterString::DeckFilterString(const QString &expr)
DeckFilterString::DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage)
{
QByteArray ba = expr.simplified().toUtf8();
@ -199,6 +205,8 @@ DeckFilterString::DeckFilterString(const QString &expr)
return;
}
deckSearchLanguageContext = searchLanguage;
search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) {
_error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg));
});

View file

@ -12,6 +12,7 @@
#include <QLoggingCategory>
#include <QString>
#include <functional>
#include <libcockatrice/card/card_localization.h>
inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string");
@ -35,7 +36,7 @@ class DeckFilterString
{
public:
DeckFilterString();
explicit DeckFilterString(const QString &expr);
explicit DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage = {});
bool check(const DeckSearchData &data) const
{
return filter(data);

View file

@ -16,11 +16,13 @@
#include <QLineEdit>
#include <QRadioButton>
#include <QTreeView>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/models/database/card_database_model.h>
#include <libcockatrice/models/database/token/token_display_model.h>
#include <libcockatrice/settings/card_override_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/layouts_settings.h>
#include <libcockatrice/utility/string_limits.h>
@ -88,6 +90,17 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa
cardDatabaseDisplayModel = new TokenDisplayModel(this);
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
const auto applyCardSearchLanguage = [this]() {
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
cardDatabaseDisplayModel->setSearchLanguage(CardSearchLanguage{
cardsDisplay.getCardLang(), static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
};
applyCardSearchLanguage();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
applyCardSearchLanguage);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage);
chooseTokenFromAllRadioButton = new QRadioButton(tr("Show &all tokens"));
connect(chooseTokenFromAllRadioButton, &QRadioButton::toggled, this, &DlgCreateToken::actChooseTokenFromAll);
chooseTokenFromDeckRadioButton = new QRadioButton(tr("Show tokens from this &deck"));

View file

@ -1,5 +1,6 @@
#include "view_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h"
#include "../../game/zones/view_zone_logic.h"
@ -11,11 +12,13 @@
#include <QGraphicsSceneWheelEvent>
#include <QPainter>
#include <QtMath>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/protocol/pb/command_dump_zone.pb.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/protocol/pb/response_dump_zone.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/cards_display_settings.h>
/**
* @param parent the parent QGraphicsWidget containing the reveal zone
@ -253,7 +256,10 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
void ZoneViewZone::setFilterString(const QString &_filterString)
{
filterString = FilterString(_filterString);
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
filterString = FilterString(
_filterString, CardSearchLanguage{cardsDisplay.getCardLang(),
static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
reorganizeCards();
}

View file

@ -21,6 +21,7 @@
#include <QStyle>
#include <QStyleOption>
#include <libcockatrice/protocol/pb/command_shuffle.pb.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h>
namespace
@ -168,6 +169,12 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
}
connect(&searchEdit, &QLineEdit::textChanged, zone, &ZoneViewZone::setFilterString);
const auto applyCardSearchLanguage = [this] { zone->setFilterString(searchEdit.text()); };
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
applyCardSearchLanguage);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage);
}
setLayout(vbox);

View file

@ -94,8 +94,7 @@ void CardPictureToLoad::populateSetUrls()
}
}
const QStringList orderedTemplates = urlTemplates;
for (const QString &urlTemplate : orderedTemplates) {
for (const QString &urlTemplate : urlTemplates) {
QString transformedUrl = transformUrl(urlTemplate);
if (!transformedUrl.isEmpty()) {

View file

@ -11,8 +11,10 @@
#include <QHeaderView>
#include <QToolButton>
#include <QTreeView>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/relation/card_relation.h>
#include <libcockatrice/settings/cards_display_settings.h>
DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent, CardDatabaseModel *databaseModel)
: QWidget(parent)
@ -40,6 +42,17 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
databaseDisplayModel->setSourceModel(databaseModel);
databaseDisplayModel->setFilterKeyColumn(0);
const auto applyCardSearchLanguage = [this]() {
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
databaseDisplayModel->setSearchLanguage(CardSearchLanguage{
cardsDisplay.getCardLang(), static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
};
applyCardSearchLanguage();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
applyCardSearchLanguage);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage);
databaseView = new CardDatabaseView(this, databaseDisplayModel);
databaseView->setObjectName("databaseView");
databaseView->setFocusProxy(searchEdit);

View file

@ -63,13 +63,26 @@ GeneralSettingsPage::GeneralSettingsPage()
connect(&cardLanguageBox, qOverload<int>(&QComboBox::currentIndexChanged), this,
&GeneralSettingsPage::cardLanguageBoxChanged);
// card search language, independent of the card display language
cardSearchLanguageBox.addItem(""); // texts set in retranslateUi
cardSearchLanguageBox.addItem("");
cardSearchLanguageBox.addItem("");
const int cardSearchLanguageIndex = SettingsCache::instance().cardsDisplay().getCardSearchLanguage();
cardSearchLanguageBox.setCurrentIndex(cardSearchLanguageIndex < 0 ? static_cast<int>(SearchLanguageMode::English)
: cardSearchLanguageIndex);
connect(&cardSearchLanguageBox, qOverload<int>(&QComboBox::currentIndexChanged), this,
&GeneralSettingsPage::cardSearchLanguageBoxChanged);
auto *languageGrid = new QGridLayout;
languageGrid->addWidget(&languageLabel, 0, 0);
languageGrid->addWidget(&languageBox, 0, 1);
languageGrid->addWidget(&cardLanguageLabel, 1, 0);
languageGrid->addWidget(&cardLanguageBox, 1, 1);
languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1);
languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight);
languageGrid->addWidget(&cardSearchLanguageLabel, 3, 0);
languageGrid->addWidget(&cardSearchLanguageBox, 3, 1);
languageGrid->addWidget(&advertiseTranslationPageLabel, 4, 1, Qt::AlignRight);
cardLanguageNoteLabel.setWordWrap(true);
cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
@ -481,6 +494,11 @@ void GeneralSettingsPage::cardLanguageBoxChanged(int index)
}
}
void GeneralSettingsPage::cardSearchLanguageBoxChanged(int index)
{
SettingsCache::instance().cardsDisplay().setCardSearchLanguage(index);
}
void GeneralSettingsPage::updateStartupServerControlsVisibility()
{
const int index = startupTabSelector.currentIndex();
@ -502,6 +520,12 @@ void GeneralSettingsPage::retranslateUi()
cardLanguageLabel.setText(tr("Card text & images language:"));
cardLanguageNoteLabel.setText(
tr("Foreign card names, text and art apply after you update the card database (Oracle)."));
cardSearchLanguageLabel.setText(tr("Language used in card search:"));
cardSearchLanguageBox.setItemText(static_cast<int>(SearchLanguageMode::English), tr("English"));
cardSearchLanguageBox.setItemText(static_cast<int>(SearchLanguageMode::Selected),
tr("Selected card language (untranslated cards still match in English)"));
cardSearchLanguageBox.setItemText(static_cast<int>(SearchLanguageMode::Both),
tr("English and selected card language"));
advertiseTranslationPageLabel.setText(
QString("<a href='%1'>%2</a>").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));

View file

@ -38,6 +38,7 @@ private slots:
void resetAllPathsClicked();
void languageBoxChanged(int index);
void cardLanguageBoxChanged(int index);
void cardSearchLanguageBoxChanged(int index);
void updateStartupServerControlsVisibility();
private:
@ -55,6 +56,9 @@ private:
QComboBox cardLanguageBox;
QLabel cardLanguageNoteLabel;
QLabel cardSearchLanguageLabel;
QComboBox cardSearchLanguageBox;
QLabel updateReleaseChannelLabel;
QComboBox updateReleaseChannelBox;
QCheckBox startupUpdateCheckCheckBox;

View file

@ -1,5 +1,6 @@
#include "completer_utils.h"
#include "../../../client/settings/cache_settings.h"
#include "card_completer_styler.h"
#include <QCompleter>
@ -7,13 +8,26 @@
#include <QObject>
#include <QRegularExpression>
#include <QStringListModel>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/models/database/card/card_completer_proxy_model.h>
#include <libcockatrice/models/database/card/card_search_model.h>
#include <libcockatrice/models/database/card_database_display_model.h>
#include <libcockatrice/settings/cards_display_settings.h>
namespace
{
void applyCardSearchLanguage(CardSearchModel *searchModel)
{
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
searchModel->setSearchLanguage(CardSearchLanguage{
cardsDisplay.getCardLang(), static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
}
} // namespace
CardCompleterSetup createCardCompleter(CardDatabaseDisplayModel *displayModel, QObject *parent, int maxVisibleItems)
{
auto *searchModel = new CardSearchModel(displayModel, parent);
applyCardSearchLanguage(searchModel);
auto *proxyModel = new CardCompleterProxyModel(parent);
proxyModel->setSourceModel(searchModel);
@ -27,6 +41,12 @@ CardCompleterSetup createCardCompleter(CardDatabaseDisplayModel *displayModel, Q
completer->setMaxVisibleItems(maxVisibleItems);
CardCompleterStyler::apply(completer);
auto *cardsDisplay = &SettingsCache::instance().cardsDisplay();
QObject::connect(cardsDisplay, &CardsDisplaySettings::cardLangChanged, searchModel,
[searchModel] { applyCardSearchLanguage(searchModel); });
QObject::connect(cardsDisplay, &CardsDisplaySettings::cardSearchLanguageChanged, searchModel,
[searchModel] { applyCardSearchLanguage(searchModel); });
return {searchModel, proxyModel, completer};
}

View file

@ -41,6 +41,17 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
databaseDisplayModel->setSourceModel(database_model);
databaseDisplayModel->setFilterKeyColumn(0);
const auto applyCardSearchLanguage = [this]() {
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
databaseDisplayModel->setSearchLanguage(CardSearchLanguage{
cardsDisplay.getCardLang(), static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
};
applyCardSearchLanguage();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
applyCardSearchLanguage);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage);
cards = new QList<ExactCard>;
connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this,
&VisualDatabaseDisplayWidget::modelDirty);

View file

@ -1,9 +1,12 @@
#include "visual_deck_storage_sort_filter_proxy_model.h"
#include "../../../client/settings/cache_settings.h"
#include "../../filters/deck_filter_string.h"
#include <QFileInfo>
#include <algorithm>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/settings/cards_display_settings.h>
VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent)
: QSortFilterProxyModel(parent)
@ -215,7 +218,10 @@ void VisualDeckStorageSortFilterProxyModel::updateSearchMatches()
return;
}
DeckFilterString filterString(searchText);
const auto &cardsDisplay = SettingsCache::instance().cardsDisplay();
DeckFilterString filterString(
searchText, CardSearchLanguage{cardsDisplay.getCardLang(),
static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
for (int row = 0; row < count; ++row) {
const DeckPreviewData &data = source->dataForRow(row);

View file

@ -15,6 +15,7 @@
#include <QTimer>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
@ -118,6 +119,13 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this,
&VisualDeckStorageWidget::updateSearchFilter);
// The deck content search matches card names in the configured card language;
// re-run it whenever that setting changes so active searches follow immediately.
CardsDisplaySettings *cardsDisplay = &SettingsCache::instance().cardsDisplay();
const auto reapplySearchForLanguage = [this] { storageProxyModel->reapplyFilters(); };
connect(cardsDisplay, &CardsDisplaySettings::cardLangChanged, this, reapplySearchForLanguage);
connect(cardsDisplay, &CardsDisplaySettings::cardSearchLanguageChanged, this, reapplySearchForLanguage);
connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this,
&VisualDeckStorageWidget::createRootFolderWidget);

View file

@ -33,20 +33,178 @@ issue a load request, which will first look for local images on-disk and then co
found, use the stored binary data from the network cache to populate the in-memory pixmap cache under the card's cache
key. If it is not found, it will then proceed with issuing a network request.
The size of both of these caches can be configured by the user in the "Card Sources" settings page.
The size of both of these caches can be configured by the user on the "Storage" settings page.
# PixmapCacheKeys and ProviderIDs
TODO
Every card picture that is loaded ends up in the QPixmapCache under a key that identifies the exact printing it belongs
to. The key is produced by ExactCard::getPixmapCacheKey() and has the following shape:
```text
card_<card name>_<provider ID>
```
For example, the _Example Card_ printing with provider ID `0b23cdc8-d413-4fb1-8470-474221b10fe2` is stored
under `card_Example Card_0b23cdc8-d413-4fb1-8470-474221b10fe2`. If the printing has no provider ID, the key
drops the suffix and falls back to `card_<card name>`.
The **provider ID** is the Scryfall UUID of the printing. Oracle maps the `scryfallId` of every printing to the `uuid`
property when building the card database, and deck files persist it as the `uuid` attribute of each card entry. Because
the provider ID is part of the pixmap cache key, two different printings of the same card never share a cache entry.
This is exactly what allows the printing selector and exact-card lookups to display the picture of the precise printing
a card was added as.
The base key holds the full-size image. When a widget asks for a scaled version, the scaled pixmap is stored under an
additional key of the form `<key>_<width>x<height>`, with the size adjusted for the device pixel ratio of the screen,
so each widget size is only ever scaled once.
The cache key is also used for bookkeeping outside of the pixmap cache itself:
- CardPictureLoaderWorker keeps a set of keys that are currently being loaded so the same card is never queued twice.
- CardPictureLoader tracks, per key, the last time loading failed. A failed load stores a NULL pixmap under the key; as
long as that marker is present, subsequent requests for the card show the "failed" card back and are only re-queued
after the retry interval of 300 seconds has passed.
- When the CardInfo of a loaded card is destroyed, its cache entries and failure markers are removed.
# The Redirect Cache
TODO
Many picture URLs - in particular the Gatherer and Scryfall URLs from the default set of templates - redirect to a CDN
or to a different host. To avoid following the same redirect for every single card, CardPictureLoaderWorker remembers
redirects and applies them without an extra network round trip.
The redirect cache is a hash map from original URL to redirect URL plus timestamp. It is persisted to a `cache.ini`
file (Qt's INI format, under the `redirects` array) inside the redirect cache directory
(`SettingsCache::instance().paths().getRedirectCachePath()`, i.e. `<cache directory>/redirects/`). The cache is loaded when the worker
starts, pruned of entries older than the configured TTL, and written back to disk when the application quits.
Entries are added whenever a network reply reports a redirection (see below) and are consulted before any request is
made: both CardPictureLoaderWorker::queueRequest() and CardPictureLoaderWorker::makeRequest() check for a cached
redirect first and jump straight to the final URL.
The TTL is the "Redirect Cache TTL" setting on the "Storage" settings page and defaults to 30 days. Lowering it makes
Cockatrice re-resolve redirects sooner, which can help when a download URL changed its redirect target.
Because Cockatrice tracks redirects itself, the QNetworkAccessManager is configured with Qt's `ManualRedirectPolicy`.
Redirects found in a reply are handled manually:
- A recursive redirect (a URL redirecting to itself) is treated as a failed load.
- Otherwise the redirect is recorded in the redirect cache and the request is re-issued against the target URL.
- A successful reply with one of the redirect status codes 301, 302, 303, 305, 307 or 308 is handled the same way.
Clearing the network cache (CardPictureLoader::clearNetworkCache()) also clears the redirect cache.
# Local Image Loading
TODO
Before any network request is issued, CardPictureLoaderWorker hands the ExactCard to CardPictureLoaderLocal, which
tries to find a matching picture on disk. If a local picture is found, it is used and no network request is made.
CardPictureLoaderLocal searches three locations:
- The **CUSTOM folder** (`<pictures directory>/CUSTOM/`). Every file in it is indexed recursively by its base name
(both `baseName` and `completeBaseName`, so a file named `ExampleCard.jpg` is indexed as `ExampleCard`). The index is rebuilt
every 10 seconds, so new files are picked up without restarting the
client (changing the configured pictures directory only reassigns the search paths; the next timer tick rebuilds the index).
- The **set-named subfolders** of the pictures directory: `<pictures directory>/<set code>/` and
`<pictures directory>/downloadedPics/<set code>/`.
- The **root of the `downloadedPics` folder** (`<pictures directory>/downloadedPics/`). The export naming schemes without a
set-folder part write their files straight into `downloadedPics/`, so this is where flat-scheme downloads and the
local overrides described below are matched.
For each candidate folder, the loader generates file-name variants from the card's corrected name, set code, collector
number and provider ID using the import naming schemes (Card Name + Provider ID, Card Name + Set + Collector,
Set + Collector + Card Name, Card Name + Set, Card Name), each tried with both `_` and `-` as separator. A file is
accepted when its name without the extension *equals* the variant exactly - the extension itself is free - and the
first variant that yields a readable image wins. For example, the file `Example Card_EXM_43.png` in the `EXM` set
folder matches the card with corrected name `Example Card`, set code `EXM` and collector number `43`.
\attention The file-name variants use the *corrected* card name, so split cards are stored under their joined name: the
"Example // Card" card is matched by a file named `ExampleCard.*`.
The naming schemes are also documented in the user-facing page @ref custom_card_pictures, which additionally covers
the CUSTOM folder workflow, `picurl` and download URL templates.
When the filesystem cache method is selected on the "Storage" settings page, downloaded images are additionally written
into `<pictures directory>/downloadedPics/` using the configured export naming scheme (as `.png` files). The two export
schemes with a set-folder part (`Set Folder / Name + Provider ID` and `Set Folder / Name + Set Name + Collector`) write
into `downloadedPics/<set code>/`; the three flat schemes write directly into `downloadedPics/`. Automatic cache writes
never overwrite an existing file, so a provider outage can permanently leave an outdated image in that folder until it
is deleted manually - the user-facing troubleshooting guide @ref fixing_card_pictures covers how to do this. Explicit
image overrides (see below) are the exception and always overwrite.
# Local Image Overrides
Beyond the generic on-disk lookup above, individual printings can be given explicit artwork that wins over every other
source without touching the CUSTOM folder or any download URL. This is the "Image Overrides" submenu of the context menu
that opens when you right-click a card in the deck editor's printing selector.
- **Load Custom Image...** asks for a picture file and installs it for the card through
CardPictureLoader::saveCardImageToLocalStorage() with `allowOverwrite == true`.
- **One entry per alternate printing** (labeled `<set> <collector number>`): selecting one hands the card to
CardPictureLoader::installPrintingOverride(), which resolves that printing's artwork - enqueueing a load and waiting
for the `CardInfo::pixmapUpdated` signal if it is not cached yet - and persists it for the card.
- **Clear Custom Image** calls CardPictureLoader::deleteAllLocalOverrides() to remove every stored override image of the
card, after which normal resolution resumes. The entry is only enabled while CardPictureLoader::hasLocalOverrides()
reports at least one stored file.
Overrides are stored as `.png` files in `downloadedPics/` under the export naming scheme configured on the "Storage"
settings page - which is exactly why the local matcher also looks into the `downloadedPics/` root (see above). They are
written with `allowOverwrite == true`, so an override always replaces whatever the filesystem cache previously saved for
that spelling; only *automatic* cache writes are prevented from clobbering it. Overriding a card with its own current
printing is a no-op (the UI omits it from the menu), and an override whose artwork fails to resolve surfaces the
"failed" card back instead of a silent no-op while any override already on disk is left in place and re-displayed.
# URL Generation and Resolution
TODO
When no local image is available and downloading is enabled, the network loader starts working through a list of
candidate URLs. This list is managed by CardPictureToLoad and is built in two steps.
First, CardPictureToLoad::extractSetsSorted() collects all sets the card has printings in and sorts them by set
priority. Unless the user disabled per-printing art ("Override all card art with personal set preference (Pre-ProviderID
change behavior)"), the set that
matches the requested printing's provider ID is moved to the front, so the exact printing is always attempted first.
For each set, CardPictureToLoad::populateSetUrls() builds an ordered URL list:
1. A custom URL defined for that printing via the `picurl` property in the card database, if present.
2. The configured download URL templates, in priority order (Deck Editor → "URL Download Priority").
URL templates are transformed into concrete URLs by CardPictureToLoad::transformUrl(), which substitutes reference
points. `!name!`, `!setcode!` and friends substitute card and printing data, while the `!set:<property>!` and
`!prop:<property>!` reference points resolve a property of the printing or of the card respectively. The canonical list
of all reference points with examples, including the `_fill_with_` and `_substr_` modifiers, lives in
@ref custom_card_pictures.
The `!set:...!` and `!prop:...!` reference points also support two modifiers:
- `_fill_with_<text>` pads the value with the given text, right-aligned, e.g. `!set:num_fill_with_000!` turns collector
number `1` into `001`. If the value is longer than the fill text, the template is invalidated.
- `_substr_<start>_<length>` extracts a substring, e.g. `!set:num_substr_2_2!` takes two characters starting at the
third. If the substring would extend past the end of the value, the template is invalidated.
Substituted values are percent-encoded. If a template asks for a property the card or printing does not have (or one of
the modifiers invalidates it), the template yields no URL and is skipped; the next template is tried instead.
\attention Custom URLs should start with `http://` or `https://`. The scheme is not validated before the URL is handed
to QNetworkAccessManager, so a template without an absolute scheme may silently fail to download; prefer HTTPS where the
provider allows it.
The resolution order is: for the current set, try each URL in the list; when all URLs for a set are exhausted, move to
the next set; when every set is exhausted, the load fails. A failed load is reported through the NULL-pixmap mechanism
described in the PixmapCacheKeys and ProviderIDs section above.
Several mechanisms influence the resolution process:
- **Rate limiting.** The worker allows roughly 10 requests per second globally. A server that answers with HTTP 429
gets its per-host allowance halved; the first 429 for a host is waited out (honoring the `Retry-After` header if
present) and the same URL retried, while a second 429 makes the loader fall through to the other configured sources.
When all sources are exhausted the request is deferred with some random jitter and retried once the back-off expires.
- **Redirects.** Replies with a redirect status (301, 302, 303, 305, 307, 308) are followed and recorded in the
redirect cache as described in the Redirect Cache section above.
- **Blacklisted images.** Gatherer returns the card back image for cards it does not know. A few known MD5 hashes of
that image are blacklisted, so such a "successful" download is treated as not found instead of being shown.
- **WebP.** Images detected as WebP (RIFF/WEBP header) are decoded through QMovie instead of QImageReader.
- **Downloads disabled.** When "Download card pictures on the fly" is disabled and the network cache method is active,
requests use Qt's `AlwaysCache` policy so that only previously cached images are served.
A user-facing reference for writing download URL templates, including more worked examples, is available at
@ref custom_card_pictures.

View file

@ -0,0 +1,135 @@
@page custom_card_pictures Custom Card Pictures
There are four ways to make Cockatrice use custom artwork for your cards:
- Placing image files in the **CUSTOM pictures folder**.
- Providing a **custom card database** that points each printing at a picture URL via the `picurl` property.
- Writing your **own download URL templates**.
- Setting an **image override** for a single card from inside the deck editor.
Each of these is described below. If pictures are missing or wrong, see @ref fixing_card_pictures instead.
# Custom Pictures Folder (CUSTOM)
Any image file placed in the CUSTOM folder is used as the card picture, and no download is attempted for cards that
match a file there.
- The folder is `<pictures directory>/CUSTOM/`. The pictures directory is configured on the 'General' settings tab,
under 'Directories' → 'Pictures directory'.
- Any image format Qt can decode is accepted (PNG, JPG/JPEG, WebP, GIF, BMP, ...); the file extension is not filtered,
so even an extension-less file is picked up if the decoder recognizes its content.
- Files are indexed by their name, so you can organize them into subfolders freely.
- New or changed files are picked up automatically within a few seconds — no client restart is required.
The file name must match the card using one of the naming schemes below. Both `_` and `-` are accepted as separators,
and the file extension is ignored when matching:
| Scheme | Example file name |
| --------------------------- | ------------------------------------------------------- |
| Card Name | `Example Card.png` |
| Card Name + Set | `Example Card_DDL.png` |
| Card Name + Set + Collector | `Example Card_DDL_43.png` |
| Set + Collector + Card Name | `DDL_43_Example Card.png` |
| Card Name + Provider ID | `Example Card_0b23cdc8-d413-4fb1-8470-474221b10fe2.png` |
The name used for matching is the *corrected* card name. Correction removes the split-card separator ` // ` and the
characters reserved in Windows file names (`* < > : " \ ?` and control characters), and turns `/` into a space, so the
"Example // Card" card is matched by a file named `ExampleCard.png`, not `Example // Card.png`. Most other punctuation
(commas, apostrophes, `!`, ...) is left untouched.
\attention A file in the CUSTOM folder always wins over downloaded pictures, even if it is the wrong image. Delete the
file if you want to see the downloaded artwork again.
The naming conventions are the same as those recognized in the set-named subfolders and in `downloadedPics`, and are
documented for developers in @ref loading_card_pictures.
# Custom Card Database (picurl)
If you maintain your own card database (see the
[Custom Cards & Sets](https://github.com/Cockatrice/Cockatrice/wiki/Custom-Cards-&-Sets) wiki), each printing's `<set>`
tag can carry a `picurl` attribute containing a full URL for that printing's picture:
```xml
<set picurl="https://example.com/cards/example-card.jpg" ...>
```
Cockatrice tries this URL **before** the configured download URL templates, so it is the most direct way to provide
custom artwork for a specific printing.
- The URL should start with `http://` or `https://`; the scheme is not validated, so make sure it is absolute or the
download may silently fail.
- When you change a `picurl` for a card whose picture was already downloaded and cached, delete the stored images
(Storage tab → 'Delete Saved Images' / 'Delete Cached Images') so Cockatrice fetches the new URL.
# Custom Download URL Templates
The built-in download URLs are templates: Cockatrice replaces reference points in the URL with information about the
card and its printing. You can write your own templates in 'Cockatrice → Settings' (Ctrl + Shift + P by default), on
the 'Deck Editor' tab, in the 'URL Download Priority' section.
The following reference points are available:
| Reference point | Description | Example |
| ------------------------------- | ------------------------------- | -------------- |
| `!name!` | Card name | `Example Card` |
| `!name_lower!` | Card name, lower case | `example card` |
| `!corrected_name!` | Corrected card name | `ExampleCard` (instead of "Example // Card") |
| `!corrected_name_lower!` | Corrected card name, lower case | `examplecard` |
| `!sflang!` | Scryfall language code for the current client language; defaults to English when the language has no localized images | `en`, `zhs` |
| `!setcode!` / `!setcode_lower!` | Set code | `EXM` / `exm` |
| `!setname!` / `!setname_lower!` | Full set name | `Exemplary Set` / `exemplary set` |
| `!set:<property>!` | A property of this printing, e.g. `muid` (Gatherer multiverse ID), `uuid` (Scryfall UUID), `num` (collector number), `rarity` | `373549` |
| `!prop:<property>!` | A property of the card, e.g. `side` (front/back), `colors`, `cmc`, `coloridentity`, `type`, `pt`, and the format legality statuses | `front` |
The `!set:...!` and `!prop:...!` reference points support two modifiers:
- `_fill_with_<text>` pads the value with the given text, right-aligned, e.g. `!set:num_fill_with_000!` turns collector
number `1` into `001`. If the value is longer than the fill text, the template is skipped.
- `_substr_<start>_<length>` extracts a substring, e.g. `!set:num_substr_2_2!` takes two characters starting at the
third. If the substring would extend past the end of the value, the template is skipped.
Substituted values are URL-encoded. A template that asks for a property the card or printing does not have is skipped,
and the next template in the list is tried instead.
\attention Custom URLs should start with `http://` or `https://`. As with `picurl`, the scheme is not validated before
the URL is handed to QNetworkAccessManager, so use an absolute URL or the download may silently fail.
Some working examples:
```text
https://cards.scryfall.io/large/!prop:side!/!set:uuid_substr_0_1!/!set:uuid_substr_1_1!/!set:uuid!.jpg
https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!
https://api.scryfall.com/cards/multiverse/!set:muid!?format=image
https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card
https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card
```
See the [Custom Picture Download URLs](https://github.com/Cockatrice/Cockatrice/wiki/Custom-Picture-Download-URLs)
wiki for more examples and ideas.
\attention Keep in mind that templates using `!name!` or `!set:muid!` resolve by name or multiverse ID, not by the
exact printing. Only the Scryfall `!set:uuid!` templates always return the exact printing requested. See
@ref fixing_card_pictures for more on this.
# Image Overrides
The quickest way to give one card custom art is an image override: right-click the card in the deck editor's printing
selector and open the **Image Overrides** submenu of the context menu.
- **Load Custom Image...** — choose a picture file (the dialog suggests PNG, JPG/JPEG and WebP); it becomes that card's
artwork immediately.
- **One entry per alternate printing** of the card, labeled `<set> <collector number>` (hovering an entry previews that
printing's artwork). Selecting one makes the card use that exact printing's picture, so e.g. a basic land can be shown
with any of its artworks.
- **Clear Custom Image** — removes the stored override and returns the card to normal resolution. It is only available
while the card has a stored override.
Overrides are stored as `.png` files in `<pictures directory>/downloadedPics/`, under the "Naming scheme" configured on
the Storage settings page, and are matched the same way as downloaded images. Because local files are checked before any
URL is requested, an override always wins over downloaded artwork and `picurl` for that card. The override exists only on
the machine it was created on - it is not part of the deck file - so a card with a stored override shows normally on
another computer.
\attention If you also keep a matching file in the CUSTOM folder, that file is matched before the override. When you
change an override, use **Clear Custom Image** so the stored `.png` is replaced; manually deleting the file in
`downloadedPics/` has the same effect.

View file

@ -11,6 +11,10 @@
- @subpage beta_release
## Card Pictures
- @subpage custom_card_pictures
## Troubleshooting
- @subpage fixing_card_pictures

View file

@ -28,7 +28,8 @@ valid URLs. If you suspect the list has been modified or corrupted, press 'Reset
defaults.
For information on how to add your own custom URL templates, see the 'How to add a custom URL' link in the same
settings section.
settings section, or @ref custom_card_pictures for a full reference of the URL reference points, the CUSTOM
pictures folder, and custom card databases.
# Check Your Local Picture Folder
@ -41,8 +42,12 @@ Cockatrice checks the following locations, in order:
- The custom pictures folder (recursively indexed by file name).
- `<pictures directory>/<set code>/<card file name>`
- `<pictures directory>/downloadedPics/<set code>/<card file name>`
- `<pictures directory>/downloadedPics/<card file name>` (for export naming schemes without a set folder)
The following import naming schemes are recognized (using both `_` and `-` as separators):
A file only matches when its name without the extension equals one of the recognized scheme patterns exactly.
The following import naming schemes are recognized (using both `_` and `-` as separators). The canonical table with
concrete example file names is on @ref custom_card_pictures:
| Scheme | Pattern |
| --------------------------- | -------------------------- |
@ -56,6 +61,10 @@ If a picture you downloaded or placed manually is wrong, stale, or corrupted, de
attention to the `downloadedPics` subfolder: this is where the filesystem caching method writes downloaded images, and
after a provider outage it can permanently contain the wrong printing until you delete it manually.
If a card persistently shows artwork you assigned yourself, you may have an **image override** set for it. Right-click
the card in the deck editor's printing selector and use 'Image Overrides' → 'Clear Custom Image' to remove it (or
delete the stored `.png` in `downloadedPics/`). See @ref custom_card_pictures for details.
See @ref loading_card_pictures for details on how local images are loaded.
# Clear Caches

View file

@ -4,6 +4,42 @@
#include <QString>
#include <QStringList>
/**
* @brief The card languages card search should run against.
*/
enum class SearchLanguageMode
{
English, ///< Only search the English card names and texts.
Selected, ///< Search the selected card language (untranslated cards still match in English).
Both ///< Search both the English and the selected card language names and texts.
};
/**
* @brief The card language and matching mode searches run against.
*
* Bundles the card language code configured in the settings with the
* SearchLanguageMode, so entry points take one value instead of two related
* parameters.
*/
struct CardSearchLanguage
{
QString language; ///< Card language code (e.g. "de"); empty means the English fallback.
SearchLanguageMode mode = SearchLanguageMode::English; ///< How the language participates in the search.
/**
* @brief Whether only the English card data is searched.
*
* @return True when no card language is selected or English itself is selected.
*/
[[nodiscard]] bool isEnglishOnly() const
{
return language.isEmpty() || language == QLatin1String("en");
}
bool operator==(const CardSearchLanguage &) const = default;
bool operator!=(const CardSearchLanguage &) const = default;
};
/**
* @namespace CardLocalization
* @ingroup Cards

View file

@ -74,6 +74,36 @@ NumericValue <- [0-9]+
static std::once_flag init;
// The peglib parser rules (and therefore their rule actions) are set up once per
// process, so a rule action cannot capture per-instance state. The card language
// plain-text name and text queries search in is therefore handed to the GenericQuery
// and OracleQuery rule actions through this thread-local context, which is live only
// while a FilterString is being parsed. The rule actions copy it into the filter
// closures they produce, so card evaluation never reads process-global state.
thread_local CardSearchLanguage searchLanguageContext;
namespace
{
bool matchesInSearchLanguage(const QString &english,
const QString &localized,
const CardSearchLanguage &searchLanguage,
const StringMatcher &matcher)
{
if (searchLanguage.mode == SearchLanguageMode::English) {
return matcher(english);
}
if (searchLanguage.mode == SearchLanguageMode::Both) {
if (!searchLanguage.isEnglishOnly() && matcher(localized)) {
return true;
}
return matcher(english);
}
return searchLanguage.isEnglishOnly() ? matcher(english) : matcher(localized);
}
} // namespace
static void setupParserRules()
{
auto passthru = [](const peg::SemanticValues &sv) -> Filter {
@ -333,7 +363,11 @@ static void setupParserRules()
search["OracleQuery"] = [](const peg::SemanticValues &sv) -> Filter {
const auto matcher = std::any_cast<StringMatcher>(sv[0]);
return [=](const CardData &x) { return matcher(x->getText()); };
const CardSearchLanguage searchLanguage = searchLanguageContext;
return [=](const CardData &x) {
return matchesInSearchLanguage(x->getText(), x->getLocalizedText(searchLanguage.language), searchLanguage,
matcher);
};
};
search["ColorQuery"] = [](const peg::SemanticValues &sv) -> Filter {
@ -410,7 +444,11 @@ static void setupParserRules()
};
search["GenericQuery"] = [](const peg::SemanticValues &sv) -> Filter {
const auto matcher = std::any_cast<StringMatcher>(sv[0]);
return [=](const CardData &x) { return matcher(x->getName()); };
const CardSearchLanguage searchLanguage = searchLanguageContext;
return [=](const CardData &x) {
return matchesInSearchLanguage(x->getName(), x->getLocalizedName(searchLanguage.language), searchLanguage,
matcher);
};
};
search["Color"] = [](const peg::SemanticValues &sv) -> char { return "WUBRGU"[sv.choice()]; };
@ -425,7 +463,7 @@ FilterString::FilterString()
_error = "Not initialized";
}
FilterString::FilterString(const QString &expr)
FilterString::FilterString(const QString &expr, const CardSearchLanguage &searchLanguage)
{
QByteArray ba = expr.simplified().toUtf8();
@ -438,6 +476,8 @@ FilterString::FilterString(const QString &expr)
return;
}
searchLanguageContext = searchLanguage;
search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) {
_error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg));
});

View file

@ -14,6 +14,7 @@
#include <QString>
#include <functional>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/card_localization.h>
#include <utility>
inline Q_LOGGING_CATEGORY(FilterStringLog, "filter_string");
@ -35,7 +36,7 @@ class FilterString
{
public:
FilterString();
explicit FilterString(const QString &exp);
explicit FilterString(const QString &exp, const CardSearchLanguage &searchLanguage = {});
[[nodiscard]] bool check(const CardData &card) const
{
if (card.isNull()) {

View file

@ -100,14 +100,16 @@ FilterTreeNode *FilterItemList::termNode(const QString &term)
return childNodes.at(i);
}
bool FilterItemList::testTypeAnd(const CardInfoPtr info, CardFilter::Attr attr) const
bool FilterItemList::testTypeAnd(const CardInfoPtr info,
CardFilter::Attr attr,
const CardSearchLanguage &searchLanguage) const
{
for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) {
if (!(*i)->isEnabled()) {
continue;
}
if (!(*i)->acceptCardAttr(info, attr)) {
if (!(*i)->acceptCardAttr(info, attr, searchLanguage)) {
return false;
}
}
@ -115,13 +117,17 @@ bool FilterItemList::testTypeAnd(const CardInfoPtr info, CardFilter::Attr attr)
return true;
}
bool FilterItemList::testTypeAndNot(const CardInfoPtr info, CardFilter::Attr attr) const
bool FilterItemList::testTypeAndNot(const CardInfoPtr info,
CardFilter::Attr attr,
const CardSearchLanguage &searchLanguage) const
{
// if any one in the list is true, return false
return !testTypeOr(info, attr);
return !testTypeOr(info, attr, searchLanguage);
}
bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) const
bool FilterItemList::testTypeOr(const CardInfoPtr info,
CardFilter::Attr attr,
const CardSearchLanguage &searchLanguage) const
{
bool noChildEnabledChild = true;
@ -134,7 +140,7 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) c
noChildEnabledChild = false;
}
if ((*i)->acceptCardAttr(info, attr)) {
if ((*i)->acceptCardAttr(info, attr, searchLanguage)) {
return true;
}
}
@ -142,20 +148,58 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) c
return noChildEnabledChild;
}
bool FilterItemList::testTypeOrNot(const CardInfoPtr info, CardFilter::Attr attr) const
bool FilterItemList::testTypeOrNot(const CardInfoPtr info,
CardFilter::Attr attr,
const CardSearchLanguage &searchLanguage) const
{
// if any one in the list is false, return true
return !testTypeAnd(info, attr);
return !testTypeAnd(info, attr, searchLanguage);
}
bool FilterItem::acceptName(const CardInfoPtr info) const
bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const
{
return info->getName().contains(term, Qt::CaseInsensitive);
const QString &englishName = info->getName();
const QString &localizedName = info->getLocalizedName(searchLanguage.language);
switch (searchLanguage.mode) {
case SearchLanguageMode::English:
return englishName.contains(term, Qt::CaseInsensitive);
case SearchLanguageMode::Both:
if (englishName.contains(term, Qt::CaseInsensitive)) {
return true;
}
return !searchLanguage.isEnglishOnly() && localizedName.contains(term, Qt::CaseInsensitive);
case SearchLanguageMode::Selected:
if (searchLanguage.isEnglishOnly()) {
return englishName.contains(term, Qt::CaseInsensitive);
}
return localizedName.contains(term, Qt::CaseInsensitive);
}
return false;
}
bool FilterItem::acceptNameExact(const CardInfoPtr info) const
bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const
{
return info->getName() == term;
const QString &englishName = info->getName();
const QString &localizedName = info->getLocalizedName(searchLanguage.language);
switch (searchLanguage.mode) {
case SearchLanguageMode::English:
return englishName == term;
case SearchLanguageMode::Both:
if (englishName == term) {
return true;
}
return !searchLanguage.isEnglishOnly() && localizedName == term;
case SearchLanguageMode::Selected:
if (searchLanguage.isEnglishOnly()) {
return englishName == term;
}
return localizedName == term;
}
return false;
}
bool FilterItem::acceptType(const CardInfoPtr info) const
@ -213,9 +257,27 @@ bool FilterItem::acceptColor(const CardInfoPtr info) const
return match_count == converted_term.length();
}
bool FilterItem::acceptText(const CardInfoPtr info) const
bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const
{
return info->getText().contains(term, Qt::CaseInsensitive);
const QString &englishText = info->getText();
const QString &localizedText = info->getLocalizedText(searchLanguage.language);
switch (searchLanguage.mode) {
case SearchLanguageMode::English:
return englishText.contains(term, Qt::CaseInsensitive);
case SearchLanguageMode::Both:
if (englishText.contains(term, Qt::CaseInsensitive)) {
return true;
}
return !searchLanguage.isEnglishOnly() && localizedText.contains(term, Qt::CaseInsensitive);
case SearchLanguageMode::Selected:
if (searchLanguage.isEnglishOnly()) {
return englishText.contains(term, Qt::CaseInsensitive);
}
return localizedText.contains(term, Qt::CaseInsensitive);
}
return false;
}
bool FilterItem::acceptSet(const CardInfoPtr info) const
@ -402,19 +464,21 @@ bool FilterItem::relationCheck(int cardInfo) const
return result;
}
bool FilterItem::acceptCardAttr(const CardInfoPtr info, CardFilter::Attr attr) const
bool FilterItem::acceptCardAttr(const CardInfoPtr info,
CardFilter::Attr attr,
const CardSearchLanguage &searchLanguage) const
{
switch (attr) {
case CardFilter::AttrName:
return acceptName(info);
return acceptName(info, searchLanguage);
case CardFilter::AttrNameExact:
return acceptNameExact(info);
return acceptNameExact(info, searchLanguage);
case CardFilter::AttrType:
return acceptType(info);
case CardFilter::AttrColor:
return acceptColor(info);
case CardFilter::AttrText:
return acceptText(info);
return acceptText(info, searchLanguage);
case CardFilter::AttrSet:
return acceptSet(info);
case CardFilter::AttrManaCost:
@ -484,18 +548,18 @@ FilterTreeNode *FilterTree::termNode(const CardFilter *f)
return termNode(f->attr(), f->type(), f->term());
}
bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm) const
bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const
{
const FilterItemList *fil;
bool status = true;
fil = lm->findTypeList(CardFilter::TypeAnd);
if (fil && fil->isEnabled() && !fil->testTypeAnd(info, lm->attr)) {
if (fil && fil->isEnabled() && !fil->testTypeAnd(info, lm->attr, searchLanguage)) {
return false;
}
fil = lm->findTypeList(CardFilter::TypeAndNot);
if (fil && fil->isEnabled() && !fil->testTypeAndNot(info, lm->attr)) {
if (fil && fil->isEnabled() && !fil->testTypeAndNot(info, lm->attr, searchLanguage)) {
return false;
}
@ -504,23 +568,23 @@ bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm) const
status = false;
// if this is true we can return because it is OR'd with the OrNot list
if (fil->testTypeOr(info, lm->attr)) {
if (fil->testTypeOr(info, lm->attr, searchLanguage)) {
return true;
}
}
fil = lm->findTypeList(CardFilter::TypeOrNot);
if (fil && fil->isEnabled() && fil->testTypeOrNot(info, lm->attr)) {
if (fil && fil->isEnabled() && fil->testTypeOrNot(info, lm->attr, searchLanguage)) {
return true;
}
return status;
}
bool FilterTree::acceptsCard(const CardInfoPtr info) const
bool FilterTree::acceptsCard(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const
{
for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) {
if ((*i)->isEnabled() && !testAttr(info, *i)) {
if ((*i)->isEnabled() && !testAttr(info, *i, searchLanguage)) {
return false;
}
}

View file

@ -11,6 +11,7 @@
#include <QList>
#include <QObject>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/card/database/card_database.h>
#include <utility>
@ -167,10 +168,14 @@ public:
return CardFilter::typeName(type);
}
[[nodiscard]] bool testTypeAnd(CardInfoPtr info, CardFilter::Attr attr) const;
[[nodiscard]] bool testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr) const;
[[nodiscard]] bool testTypeOr(CardInfoPtr info, CardFilter::Attr attr) const;
[[nodiscard]] bool testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr) const;
[[nodiscard]] bool
testTypeAnd(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool
testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool
testTypeOr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool
testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const;
};
class FilterItem : public FilterTreeNode
@ -207,20 +212,21 @@ public:
return true;
}
[[nodiscard]] bool acceptName(CardInfoPtr info) const;
[[nodiscard]] bool acceptNameExact(CardInfoPtr info) const;
[[nodiscard]] bool acceptName(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool acceptNameExact(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool acceptType(CardInfoPtr info) const;
[[nodiscard]] bool acceptMainType(CardInfoPtr info) const;
[[nodiscard]] bool acceptSubType(CardInfoPtr info) const;
[[nodiscard]] bool acceptColor(CardInfoPtr info) const;
[[nodiscard]] bool acceptText(CardInfoPtr info) const;
[[nodiscard]] bool acceptText(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool acceptSet(CardInfoPtr info) const;
[[nodiscard]] bool acceptManaCost(CardInfoPtr info) const;
[[nodiscard]] bool acceptCmc(CardInfoPtr info) const;
[[nodiscard]] bool acceptPowerToughness(CardInfoPtr info, CardFilter::Attr attr) const;
[[nodiscard]] bool acceptLoyalty(CardInfoPtr info) const;
[[nodiscard]] bool acceptRarity(CardInfoPtr info) const;
[[nodiscard]] bool acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr) const;
[[nodiscard]] bool
acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool acceptFormat(CardInfoPtr info) const;
[[nodiscard]] bool relationCheck(int cardInfo) const;
};
@ -240,7 +246,7 @@ private:
LogicMap *attrLogicMap(CardFilter::Attr attr);
FilterItemList *attrTypeList(CardFilter::Attr attr, CardFilter::Type type);
bool testAttr(CardInfoPtr info, const LogicMap *lm) const;
bool testAttr(CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const;
void nodeChanged() const override
{
@ -279,7 +285,7 @@ public:
return 0;
}
[[nodiscard]] bool acceptsCard(CardInfoPtr info) const;
[[nodiscard]] bool acceptsCard(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const;
void removeFiltersByAttr(CardFilter::Attr filterType);
void removeFilter(const CardFilter *toRemove);
void clear();

View file

@ -29,6 +29,7 @@ public:
[[nodiscard]] virtual int getArchidektPreviewSize() const = 0;
[[nodiscard]] virtual int getSampleHandSize() const = 0;
[[nodiscard]] virtual QString getCardLang() const = 0;
[[nodiscard]] virtual int getCardSearchLanguage() const = 0;
};
#endif // COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H

View file

@ -65,25 +65,31 @@ void CardSearchModel::updateSearchResults(const QString &query)
continue;
}
const QString lowerName = card->getName().toLower();
if (!lowerName.contains(lowerQuery)) {
continue;
}
// The completer suggestions match against the same languages the card
// search uses, so typing a localized name finds the card. In Both mode
// either language can match.
for (const QString &matchName : searchableNames(card)) {
const QString lowerName = matchName.toLower();
if (!lowerName.contains(lowerQuery)) {
continue;
}
const int distance = levenshteinDistance(lowerQuery, lowerName);
const int distance = levenshteinDistance(lowerQuery, lowerName);
if (lowerName.startsWith(lowerQuery)) {
prefixMatches.append({card, distance});
} else {
containsMatches.append({card, distance});
if (lowerName.startsWith(lowerQuery)) {
prefixMatches.append({card, distance});
} else {
containsMatches.append({card, distance});
}
break;
}
}
auto sortByDistanceThenLength = [](const SearchResult &a, const SearchResult &b) {
auto sortByDistanceThenLength = [this](const SearchResult &a, const SearchResult &b) {
if (a.distance != b.distance) {
return a.distance < b.distance;
}
return a.card->getName().size() < b.card->getName().size();
return sortableName(a.card).size() < sortableName(b.card).size();
};
std::sort(prefixMatches.begin(), prefixMatches.end(), sortByDistanceThenLength);
@ -101,3 +107,25 @@ void CardSearchModel::updateSearchResults(const QString &query)
endResetModel();
}
QStringList CardSearchModel::searchableNames(const CardInfoPtr &card) const
{
if (searchLanguage.isEnglishOnly()) {
return {card->getName()};
}
const QString localizedName = card->getLocalizedName(searchLanguage.language);
if (searchLanguage.mode == SearchLanguageMode::Selected) {
return {localizedName};
}
return {card->getName(), localizedName};
}
QString CardSearchModel::sortableName(const CardInfoPtr &card) const
{
if (searchLanguage.isEnglishOnly()) {
return card->getName();
}
return card->getLocalizedName(searchLanguage.language);
}

View file

@ -27,6 +27,14 @@ public:
void updateSearchResults(const QString &query); // Update results based on input
void setSearchLanguage(const CardSearchLanguage &searchLang)
{
if (searchLanguage == searchLang) {
return;
}
searchLanguage = searchLang;
}
private:
struct SearchResult
{
@ -34,8 +42,15 @@ private:
int distance;
};
/** @brief The names a card is searched by with the current search language. */
[[nodiscard]] QStringList searchableNames(const CardInfoPtr &card) const;
/** @brief The name used to break distance ties when sorting suggestions. */
[[nodiscard]] QString sortableName(const CardInfoPtr &card) const;
CardDatabaseDisplayModel *sourceModel;
QList<SearchResult> searchResults;
CardSearchLanguage searchLanguage;
};
#endif // CARD_SEARCH_MODEL_H

View file

@ -179,7 +179,7 @@ bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex
}
if (filterString != nullptr) {
if (filterTree != nullptr && !filterTree->acceptsCard(info)) {
if (filterTree != nullptr && !filterTree->acceptsCard(info, searchLanguage)) {
return false;
}
return filterString->check(info);
@ -190,8 +190,14 @@ bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex
bool CardDatabaseDisplayModel::rowMatchesCardName(CardInfoPtr info) const
{
if (!cardName.isEmpty() && !info->getName().contains(cardName, Qt::CaseInsensitive)) {
return false;
if (!cardName.isEmpty()) {
const bool matchesEnglish = info->getName().contains(cardName, Qt::CaseInsensitive);
const bool matchesLocalized =
!searchLanguage.isEnglishOnly() &&
info->getLocalizedName(searchLanguage.language).contains(cardName, Qt::CaseInsensitive);
if (!matchesEnglish && !matchesLocalized) {
return false;
}
}
if (!cardNameSet.isEmpty() && !cardNameSet.contains(info->getName())) {
@ -199,7 +205,7 @@ bool CardDatabaseDisplayModel::rowMatchesCardName(CardInfoPtr info) const
}
if (filterTree != nullptr) {
return filterTree->acceptsCard(info);
return filterTree->acceptsCard(info, searchLanguage);
}
return true;
@ -235,6 +241,28 @@ void CardDatabaseDisplayModel::setFilterTree(FilterTree *_filterTree)
invalidate();
}
void CardDatabaseDisplayModel::setStringFilter(const QString &_src)
{
searchText = _src;
delete filterString;
filterString = new FilterString(_src, searchLanguage);
dirty();
}
void CardDatabaseDisplayModel::setSearchLanguage(const CardSearchLanguage &searchLang)
{
if (searchLanguage == searchLang) {
return;
}
searchLanguage = searchLang;
if (filterString != nullptr) {
setStringFilter(searchText);
}
dirty();
}
void CardDatabaseDisplayModel::filterTreeChanged()
{
invalidate();

View file

@ -10,6 +10,7 @@
#include <QSortFilterProxyModel>
#include <QTimer>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/filters/filter_string.h>
class FilterTree;
@ -32,6 +33,8 @@ private:
FilterString *filterString;
int loadedRowCount;
QTimer dirtyTimer;
CardSearchLanguage searchLanguage;
QString searchText;
/** The translation table that will be used for sanitizeCardName. */
static QMap<wchar_t, wchar_t> characterTranslation;
@ -55,17 +58,13 @@ public:
cardName = sanitizeCardName(_cardName, characterTranslation);
dirty();
}
void setStringFilter(const QString &_src)
{
delete filterString;
filterString = new FilterString(_src);
dirty();
}
void setStringFilter(const QString &_src);
void setCardNameSet(const QSet<QString> &_cardNameSet)
{
cardNameSet = _cardNameSet;
dirty();
}
void setSearchLanguage(const CardSearchLanguage &searchLang);
void dirty()
{

View file

@ -110,6 +110,11 @@ QString CardsDisplaySettings::getCardLang() const
return getValue("cardLang", QString(), QString(), "en").toString();
}
int CardsDisplaySettings::getCardSearchLanguage() const
{
return getValue("cardSearchLanguage", QString(), QString(), static_cast<int>(SearchLanguageMode::English)).toInt();
}
void CardsDisplaySettings::setDisplayCardNames(bool _displayCardNames)
{
setValue(_displayCardNames, "displayCardNames");
@ -242,3 +247,12 @@ void CardsDisplaySettings::setCardLang(const QString &_cardLang)
sync();
emit cardLangChanged(_cardLang);
}
void CardsDisplaySettings::setCardSearchLanguage(int _cardSearchLanguage)
{
if (_cardSearchLanguage == getCardSearchLanguage()) {
return;
}
setValue(_cardSearchLanguage, "cardSearchLanguage");
emit cardSearchLanguageChanged(_cardSearchLanguage);
}

View file

@ -3,6 +3,7 @@
#include "settings_manager.h"
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/interfaces/interface_cards_display_settings_provider.h>
class CardsDisplaySettings : public SettingsManager, public ICardsDisplaySettingsProvider
@ -32,6 +33,7 @@ public:
[[nodiscard]] int getArchidektPreviewSize() const override;
[[nodiscard]] int getSampleHandSize() const override;
[[nodiscard]] QString getCardLang() const override;
[[nodiscard]] int getCardSearchLanguage() const override;
void setDisplayCardNames(bool _displayCardNames);
void setRoundCardCorners(bool _roundCardCorners);
@ -54,6 +56,7 @@ public:
void setArchidektPreviewCardSize(int _archidektPreviewCardSize);
void setSampleHandSize(int _sampleHandSize);
void setCardLang(const QString &_cardLang);
void setCardSearchLanguage(int _cardSearchLanguage);
signals:
void displayCardNamesChanged();
@ -71,6 +74,7 @@ signals:
void archidektPreviewSizeChanged();
void sampleHandSizeChanged(int amount);
void cardLangChanged(const QString &lang);
void cardSearchLanguageChanged(int cardSearchLanguage);
public:
explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr);

View file

@ -73,6 +73,57 @@ QUERY(Color4, cat, "c!gw", false)
QUERY(BracketNextToUnquotedString, cat, "(o:woof OR o:meow)", true)
CardInfoPtr localizedCat()
{
CardInfoPtr localized = CardInfo::newInstance("Cat", "Meow!", false, {}, {}, {}, {}, {});
localized->setLocalizedName("de", "Kater");
localized->setLocalizedText("de", "miaut");
return localized;
}
TEST_F(CardQuery, SearchLanguageEnglishMatchesOnlyEnglish)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::English}).check(localized));
ASSERT_FALSE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::English}).check(localized));
}
TEST_F(CardQuery, SearchLanguageSelectedMatchesLocalizedNameAndText)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized));
ASSERT_TRUE(FilterString("o:miaut", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized));
ASSERT_FALSE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized));
}
TEST_F(CardQuery, SearchLanguageSelectedFallsBackToEnglishForUntranslatedCards)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"fr", SearchLanguageMode::Selected}).check(localized));
ASSERT_FALSE(FilterString("Kater", CardSearchLanguage{"fr", SearchLanguageMode::Selected}).check(localized));
}
TEST_F(CardQuery, SearchLanguageBothMatchesEitherLanguage)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized));
ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized));
}
TEST_F(CardQuery, SearchLanguageIsBoundPerInstance)
{
const CardData localized = localizedCat();
FilterString germanQuery("Kater", CardSearchLanguage{"de", SearchLanguageMode::Selected});
ASSERT_TRUE(germanQuery.check(localized));
// Constructing an English-bound instance afterwards must not change the
// language the earlier instance searches in.
FilterString englishQuery("Kater", CardSearchLanguage{"", SearchLanguageMode::English});
ASSERT_FALSE(englishQuery.check(localized));
ASSERT_TRUE(germanQuery.check(localized));
}
} // namespace
int main(int argc, char **argv)

View file

@ -575,6 +575,19 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_SetAndGet)
ASSERT_EQ(s.getCardLang(), QString("de"));
}
TEST_F(SettingsDefaultsTest, CardsDisplay_CardSearchLanguage_Default)
{
CardsDisplaySettings s(settingsPath, nullptr);
ASSERT_EQ(s.getCardSearchLanguage(), static_cast<int>(SearchLanguageMode::English));
}
TEST_F(SettingsDefaultsTest, CardsDisplay_CardSearchLanguage_SetAndGet)
{
CardsDisplaySettings s(settingsPath, nullptr);
s.setCardSearchLanguage(static_cast<int>(SearchLanguageMode::Selected));
ASSERT_EQ(s.getCardSearchLanguage(), static_cast<int>(SearchLanguageMode::Selected));
}
// --- VisualDeckStorageSettings ---
TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default)