diff --git a/kardscm/data/extra_abilities.toml b/kardscm/data/extra_abilities.toml index 031f409..5191ef4 100644 --- a/kardscm/data/extra_abilities.toml +++ b/kardscm/data/extra_abilities.toml @@ -12,7 +12,7 @@ # in-game deck-builder URL returns and serve as the source of truth. [abilities.pincer] -# q=клещи (ru) → 16 cards +# q=клещи (ru) / q=pincer (en) → 17 cards, confirmed 2026-05-07 (en authoritative; ru misses protect_the_pocket) cards = [ "128th_rifles", "56_jger_regiment", @@ -30,10 +30,11 @@ cards = [ "zis2", "tupolev_sb2", "222nd_guard_rifles", + "protect_the_pocket", ] [abilities.resistance] -# q=Сопротивл (ru) → 9 cards +# q=resistance (en) / q=сопротивл (ru) → 9 cards, confirmed 2026-05-07, both locales match cards = [ "resistance_b", "unexpected_resistance", @@ -47,7 +48,7 @@ cards = [ ] [abilities.legions] -# q=ЛЕГИОН (ru) → 9 cards +# q=легион (ru) / q=legions (en) → 9 cards, confirmed 2026-05-07, both locales match cards = [ "5th_legions_regiment", "first_to_fight", @@ -61,7 +62,7 @@ cards = [ ] [abilities.sissi] -# q=SISSI → 11 cards +# q=sissi (ru+en) → 11 cards, confirmed 2026-05-07, both locales match cards = [ "dark_deeds", "heartland_defense", @@ -77,7 +78,7 @@ cards = [ ] [abilities.destruction] -# q=уничтожение (ru) → 70 cards +# q=destruction (en) → 79 cards, confirmed 2026-05-07 (en authoritative; ru gives 82 due to translation noise) cards = [ "164th_infantry_regiment", "321st_rifle_regiment", @@ -149,6 +150,15 @@ cards = [ "ki49_storm_dragon", "ijn_yamato", "is_ii_1944_early", + "out_with_the_old", + "ita_regiment", + "patrol", + "fortunes_of_war", + "matsumoto_regiment", + "motherland_calls", + "114th_infantry_regiment", + "73rd_infantry_regiment", + "duty_is_a_mountain", ] [abilities.naval] diff --git a/kardscm/storage/database.py b/kardscm/storage/database.py index dac6a70..d45fb33 100644 --- a/kardscm/storage/database.py +++ b/kardscm/storage/database.py @@ -95,6 +95,8 @@ def get_connection(db_path: str | Path) -> sqlite3.Connection: conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA foreign_keys = ON") conn.create_function("sanitize_text", 1, sanitize_text, deterministic=True) + # Unicode-aware case folding for Cyrillic/non-ASCII text search + conn.create_function("uni_lower", 1, lambda s: s.casefold() if s else "", deterministic=True) return conn diff --git a/kardscm/web/queries.py b/kardscm/web/queries.py index 492415c..fa82add 100644 --- a/kardscm/web/queries.py +++ b/kardscm/web/queries.py @@ -65,7 +65,8 @@ def _build_where(filters: CardFilters, locale_key: str) -> tuple[list[str], list if not filters.include_reserved: where.append("reserved = 0") if not filters.include_spawnable: - where.append("(can_create IS NULL OR can_create = '[]')") + where.append('"set" != ?') + params.append("OnlySpawnable") if filters.factions: placeholders = ",".join("?" for _ in filters.factions) @@ -109,11 +110,15 @@ def _build_where(filters: CardFilters, locale_key: str) -> tuple[list[str], list where.append(f"({clauses})") if filters.text_query: + q_fold = filters.text_query.casefold() text_expr = f"json_extract(text, '$.\"{locale_key}\"')" + en_title_expr = "json_extract(title, '$.\"en-EN\"')" where.append( - f"(LOWER({_title_expr(locale_key)}) LIKE LOWER(?) OR LOWER({text_expr}) LIKE LOWER(?))" + f"(uni_lower({_title_expr(locale_key)}) LIKE ?" + f" OR uni_lower({en_title_expr}) LIKE ?" + f" OR uni_lower({text_expr}) LIKE ?)" ) - params.extend([f"%{filters.text_query}%", f"%{filters.text_query}%"]) + params.extend([f"%{q_fold}%", f"%{q_fold}%", f"%{q_fold}%"]) if filters.owned_only: where.append("quantity > 0") diff --git a/kardscm/web/templates/_table.html b/kardscm/web/templates/_table.html index 0bb0fff..89e9006 100644 --- a/kardscm/web/templates/_table.html +++ b/kardscm/web/templates/_table.html @@ -25,6 +25,7 @@ {{ sort_header('type', headers[2]) }} {{ sort_header('rarity', headers[3]) }} {{ headers[4] }} + {{ ui.filter_extra_abilities }} {{ sort_header('set', headers[5]) }} {{ sort_header('quantity', headers[6]) }} {{ sort_header('kredits', headers[7]) }} @@ -46,6 +47,7 @@ {{ card.type }} {{ card.rarity }} {{ card.attributes }} + {{ card.extra_attributes }} {{ card.set }} {% include "_qty_cell.html" %} {{ card.kredits }} diff --git a/kardscm/web/translate.py b/kardscm/web/translate.py index f42c540..6312b61 100644 --- a/kardscm/web/translate.py +++ b/kardscm/web/translate.py @@ -3,6 +3,7 @@ from __future__ import annotations from kardscm.config import LanguageConfig +from kardscm.constants import KNOWN_EXTRA_ABILITIES from kardscm.export.exporters import translate_card_for_export @@ -10,11 +11,17 @@ def to_view(card: dict, lang_config: LanguageConfig) -> dict: """Translate a raw DB card into a dict for Jinja templates. Reuses translate_card_for_export and adds the fields the webUI needs - that are not part of the xlsx export (cardId, image URLs, operationCost). + that are not part of the xlsx export (cardId, image URLs, operationCost, + extra_attributes). """ base = translate_card_for_export(card, lang_config) base["cardId"] = card.get("cardId", "") base["operationCost"] = card.get("operationCost") base["imageUrl"] = card.get("imageUrl") or "" base["thumbUrl"] = card.get("thumbUrl") or "" + base["extra_attributes"] = ", ".join( + lang_config.extra_ability_names.get(a, a) + for a in KNOWN_EXTRA_ABILITIES + if card.get(f"extra_ability_{a}", 0) + ) return base diff --git a/tests/web/test_queries.py b/tests/web/test_queries.py index 9c6aadb..eb62fdd 100644 --- a/tests/web/test_queries.py +++ b/tests/web/test_queries.py @@ -8,7 +8,7 @@ import pytest from kardscm.constants import KNOWN_ABILITIES -from kardscm.storage.database import initialize_schema +from kardscm.storage.database import get_connection, initialize_schema from kardscm.web.queries import ALLOWED_SORT_COLUMNS, CardFilters, query_cards _ABILITY_COLS = [f"ability_{a}" for a in KNOWN_ABILITIES] @@ -62,7 +62,7 @@ def _make_card( @pytest.fixture def conn() -> sqlite3.Connection: - conn = sqlite3.connect(":memory:") + conn = get_connection(":memory:") initialize_schema(conn) rows = [ _make_card( @@ -132,9 +132,9 @@ def conn() -> sqlite3.Connection: card_id="spawnable_card", faction="Soviet", card_type="tank", + card_set="OnlySpawnable", title_en="Spawned Card", title_ru="Спаунованная карта", - can_create='["sov_tank_1"]', quantity=0, ), ] @@ -233,6 +233,16 @@ def test_text_search_no_double_count(self, conn): result = query_cards(conn, CardFilters(text_query="rifles"), locale_key="en-EN") assert _ids(result).count("sov_inf_1") == 1 + def test_text_search_cyrillic_case_insensitive(self, conn): + # "Т-34" title in ru-RU; lowercase query "т-34" must match (SQLite LOWER is ASCII-only) + result = query_cards(conn, CardFilters(text_query="т-34"), locale_key="ru-RU") + assert "sov_tank_1" in _ids(result) + + def test_text_search_en_title_fallback_in_ru_locale(self, conn): + # sov_tank_1 title_en="T-34" — searching "T-34" with ru-RU locale must still find it + result = query_cards(conn, CardFilters(text_query="T-34"), locale_key="ru-RU") + assert "sov_tank_1" in _ids(result) + def test_owned_only(self, conn): result = query_cards(conn, CardFilters(owned_only=True)) # quantities: sov_inf_1=2, sov_tank_1=0, ger_inf_1=3, usa_order=1 @@ -246,6 +256,17 @@ def test_include_spawnable_adds_spawnable(self, conn): result = query_cards(conn, CardFilters(include_spawnable=True)) assert "spawnable_card" in _ids(result) + def test_card_with_can_create_not_filtered_as_spawnable(self, conn): + # sov_tank_1 has can_create=None in fixture; insert a card with can_create + # set but set != OnlySpawnable — it must still appear without include_spawnable + conn.execute( + "UPDATE cards SET can_create = ? WHERE cardId = ?", + ('["spawnable_card"]', "sov_tank_1"), + ) + conn.commit() + result = query_cards(conn, CardFilters()) + assert "sov_tank_1" in _ids(result) + def test_filters_combined_with_and(self, conn): result = query_cards( conn,