Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions kardscm/data/extra_abilities.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions kardscm/storage/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
11 changes: 8 additions & 3 deletions kardscm/web/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Comment thread
AABur marked this conversation as resolved.
if filters.factions:
placeholders = ",".join("?" for _ in filters.factions)
Expand Down Expand Up @@ -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}%"])
Comment thread
AABur marked this conversation as resolved.

if filters.owned_only:
where.append("quantity > 0")
Expand Down
2 changes: 2 additions & 0 deletions kardscm/web/templates/_table.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
{{ sort_header('type', headers[2]) }}
{{ sort_header('rarity', headers[3]) }}
<th>{{ headers[4] }}</th>
<th>{{ ui.filter_extra_abilities }}</th>
{{ sort_header('set', headers[5]) }}
{{ sort_header('quantity', headers[6]) }}
{{ sort_header('kredits', headers[7]) }}
Expand All @@ -46,6 +47,7 @@
<td>{{ card.type }}</td>
<td>{{ card.rarity }}</td>
<td class="abilities">{{ card.attributes }}</td>
<td class="abilities">{{ card.extra_attributes }}</td>
<td>{{ card.set }}</td>
{% include "_qty_cell.html" %}
<td class="num">{{ card.kredits }}</td>
Expand Down
9 changes: 8 additions & 1 deletion kardscm/web/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@
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


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
27 changes: 24 additions & 3 deletions tests/web/test_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
),
]
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading