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,