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
27 changes: 8 additions & 19 deletions kardscm/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from __future__ import annotations

import json
import logging
import shutil
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
Expand All @@ -29,7 +31,7 @@
from kardscm.helpers import parse_int
from kardscm.importing import parse_deck_file
from kardscm.models import DeckCardEntry, DiffReport
from kardscm.scraping import scrape_cards
from kardscm.scraping import baseline, fetcher, probe, scrape_cards
from kardscm.storage import (
apply_extra_abilities_seed,
delete_all_decks,
Expand Down Expand Up @@ -270,22 +272,14 @@ def baseline_init(*, lang: str | None = None) -> None:
Always overwrites; use ``baseline_accept`` to promote a sync-generated
observed snapshot instead.
"""
from kardscm.scraping.baseline import (
BASELINE_PATH,
build_snapshot,
save_baseline,
)
from kardscm.scraping.fetcher import fetch_all_cards
from kardscm.scraping.probe import build_static_probe

lang_config = get_language_config(lang)
logger.info("Fetching cards from API to rebuild baseline...")
raw = fetch_all_cards(build_static_probe(language=lang_config.code))
snapshot = build_snapshot(raw)
save_baseline(snapshot)
raw = fetcher.fetch_all_cards(probe.build_static_probe(language=lang_config.code))
snapshot = baseline.build_snapshot(raw)
baseline.save_baseline(snapshot)
logger.info(
"Baseline written to %s (%d cards, %d enum value sets).",
BASELINE_PATH,
baseline.BASELINE_PATH,
snapshot["card_count"],
len(snapshot["enum_values"]),
)
Expand All @@ -302,11 +296,6 @@ def baseline_accept() -> None:
structural shape (must be a dict with all required snapshot keys of
the correct types), and copies it to the committed baseline location.
"""
import json
import shutil

from kardscm.scraping.baseline import BASELINE_PATH

candidates = sorted(Path.cwd().glob("sync-schema-observed-*.json"))
if not candidates:
raise SystemExit(
Expand All @@ -333,7 +322,7 @@ def baseline_accept() -> None:
if not isinstance(parsed["enum_values"], dict):
raise SystemExit(f"{latest}: enum_values must be a dict")

shutil.copy2(latest, BASELINE_PATH)
shutil.copy2(latest, baseline.BASELINE_PATH)
logger.info("Baseline updated from %s.", latest.name)


Expand Down
22 changes: 3 additions & 19 deletions kardscm/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@

from __future__ import annotations

import json
from collections.abc import Iterable

from kardscm.config import LanguageConfig
from kardscm.constants import KNOWN_ABILITIES
from kardscm.helpers import extract_locale
from kardscm.models import (
CardChange,
CardDict,
Expand Down Expand Up @@ -123,21 +123,8 @@ def _card_changes(old: dict, new: CardDict, locale_key: str) -> list[FieldChange
return changes


def _parse_json(value: object) -> object:
"""Parse a JSON string defensively. Returns None on any failure."""
if not isinstance(value, str) or not value:
return None
try:
return json.loads(value)
except (ValueError, TypeError):
return None


def _text_for_locale(value: object, locale_key: str) -> str:
parsed = _parse_json(value)
if isinstance(parsed, dict):
return str(parsed.get(locale_key, "") or "")
return ""
return extract_locale(value, locale_key)


def _faction_label(faction: str, lang_config: LanguageConfig) -> str:
Expand All @@ -146,10 +133,7 @@ def _faction_label(faction: str, lang_config: LanguageConfig) -> str:

def _title_for_locale(value: object, locale_key: str) -> str:
"""Best-effort localized title for display. Falls back to en-EN, then raw."""
parsed = _parse_json(value)
if isinstance(parsed, dict):
return str(parsed.get(locale_key) or parsed.get("en-EN") or "")
return str(value or "")
return extract_locale(value, locale_key, default=str(value or ""), en_fallback=True)


def _group_by_faction(cards: list[CardDict] | list[dict]) -> dict[str, list]:
Expand Down
51 changes: 12 additions & 39 deletions kardscm/export/exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@
from openpyxl.workbook import Workbook as WorkbookType

from kardscm.config import LanguageConfig
from kardscm.constants import DECK_COLUMN_WIDTHS, DECK_NATION_TO_DB, EXPORT_FIELD_NAMES
from kardscm.helpers import sanitize_text
from kardscm.constants import (
DECK_COLUMN_WIDTHS,
DECK_NATION_TO_DB,
EXPORT_FIELD_NAMES,
KNOWN_ABILITIES,
)
from kardscm.helpers import extract_locale, sanitize_text

logger = logging.getLogger(__name__)

Expand All @@ -33,27 +38,11 @@ def translate_card_for_export(card: dict, lang_config: LanguageConfig) -> dict:
"""
locale_key = lang_config.locale_key

# Extract localized title
title_raw = card.get("title", "")
try:
title_dict = json.loads(title_raw) if title_raw else {}
except (json.JSONDecodeError, TypeError):
title_dict = {}
if isinstance(title_dict, dict):
title = title_dict.get(locale_key, title_dict.get("en-EN", title_raw))
else:
title = str(title_raw)

# Extract localized text
title = extract_locale(title_raw, locale_key, default=str(title_raw), en_fallback=True)

text_raw = card.get("text", "")
try:
text_dict = json.loads(text_raw) if text_raw else {}
except (json.JSONDecodeError, TypeError):
text_dict = {}
if isinstance(text_dict, dict):
text = text_dict.get(locale_key, text_dict.get("en-EN", ""))
else:
text = str(text_raw) if text_raw else ""
text = extract_locale(text_raw, locale_key, en_fallback=True)

# Translate faction
faction_api = card.get("faction", "")
Expand All @@ -72,8 +61,6 @@ def translate_card_for_export(card: dict, lang_config: LanguageConfig) -> dict:
set_name = lang_config.set_names.get(set_api, set_api)

# Format abilities from binary columns
from kardscm.constants import KNOWN_ABILITIES

abilities = ", ".join(
lang_config.ability_names.get(a, a) for a in KNOWN_ABILITIES if card.get(f"ability_{a}", 0)
)
Expand Down Expand Up @@ -267,16 +254,8 @@ def add_deck_sheet(
current_row += 1

for card in faction_cards:
# Extract localized title
title_raw = card.get("title", "")
try:
title_dict = json.loads(title_raw) if title_raw else {}
except (json.JSONDecodeError, TypeError):
title_dict = {}
if isinstance(title_dict, dict):
title = title_dict.get(locale_key, title_dict.get("en-EN", title_raw))
else:
title = str(title_raw)
title = extract_locale(title_raw, locale_key, default=str(title_raw), en_fallback=True)

type_api = card.get("type", "")
type_name = lang_config.type_names.get(type_api, type_api)
Expand Down Expand Up @@ -313,13 +292,7 @@ def export_deck_to_json(

def extract_title(card: dict) -> str:
title_raw = card.get("title", "")
try:
title_dict = json.loads(title_raw) if title_raw else {}
except (json.JSONDecodeError, TypeError):
title_dict = {}
if isinstance(title_dict, dict):
return str(title_dict.get(locale_key, title_dict.get("en-EN", title_raw)))
return str(title_raw)
return extract_locale(title_raw, locale_key, default=str(title_raw), en_fallback=True)

output = {
"deck": {
Expand Down
47 changes: 47 additions & 0 deletions kardscm/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import re

ESCAPE_RE = re.compile(r"\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|r|n|t|\\|\"|')")
Expand Down Expand Up @@ -66,6 +67,52 @@ def replace_match(match: re.Match[str]) -> str:
return ESCAPE_RE.sub(replace_match, text)


def extract_locale(
raw: object,
locale_key: str,
*,
default: str = "",
en_fallback: bool = False,
) -> str:
"""Decode a locale-keyed JSON blob and return the value for ``locale_key``.

Args:
raw: The JSON-encoded locale dict, typically the value of a
locale-keyed DB column. Empty/None returns ``default``.
Non-string values are converted via ``str()``.
locale_key: Locale key (e.g. ``"ru-RU"``) to look up in the
decoded dict.
default: Returned on empty input, decode failure, non-dict
shape, or when neither ``locale_key`` nor (optionally)
``"en-EN"`` is present.
en_fallback: When True, fall back to ``"en-EN"`` if
``locale_key`` is missing or its value is empty. Default
False so accidental adoption at a new call site does not
silently change semantics.

Returns:
The locale-specific string, the en-EN fallback, or ``default``.
"""
if not raw:
return default
if not isinstance(raw, str):
return str(raw)
try:
decoded = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return default
if not isinstance(decoded, dict):
return default
value = decoded.get(locale_key)
if value:
return str(value)
if en_fallback:
en_value = decoded.get("en-EN")
if en_value:
return str(en_value)
return default


def sanitize_text(text: str) -> str:
"""Sanitize text by decoding escapes and normalizing whitespace.

Expand Down
3 changes: 1 addition & 2 deletions kardscm/scraping/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path
from typing import TYPE_CHECKING

from kardscm.locales import LANGUAGE_EN
from kardscm.models import CardDict
from kardscm.scraping.baseline import (
build_snapshot,
Expand Down Expand Up @@ -88,8 +89,6 @@ def scrape_cards(language: str = "en", lang_config: LanguageConfig | None = None
raw_cards = fetch_all_cards(probe)

if lang_config is None:
from kardscm.locales import LANGUAGE_EN

lang_config = LANGUAGE_EN
_check_api_drift(raw_cards, lang_config)

Expand Down
2 changes: 1 addition & 1 deletion kardscm/scraping/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _is_graphql_request(request: Request) -> bool:
try:
parsed = json.loads(body)
return "operationName" in parsed or "query" in parsed
except (json.JSONDecodeError, Exception):
except (json.JSONDecodeError, TypeError):
return False


Expand Down
2 changes: 2 additions & 0 deletions kardscm/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from kardscm.storage.backup import backup_database
from kardscm.storage.database import (
ADMIN_DB_COLUMNS,
ADMIN_EDITABLE_SCALARS,
apply_extra_abilities_seed,
delete_all_decks,
Expand All @@ -26,6 +27,7 @@
)

__all__ = [
"ADMIN_DB_COLUMNS",
"ADMIN_EDITABLE_SCALARS",
"apply_extra_abilities_seed",
"backup_database",
Expand Down
15 changes: 10 additions & 5 deletions kardscm/storage/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
"reserved",
)

# DB columns the admin UI can write directly. Title/text are NOT in this
# set — they are stored as locale-keyed JSON and merged in a separate
# branch in update_card_admin.
ADMIN_DB_COLUMNS: frozenset[str] = (
frozenset(ADMIN_EDITABLE_SCALARS)
| {f"ability_{a}" for a in KNOWN_ABILITIES}
| {f"extra_ability_{a}" for a in KNOWN_EXTRA_ABILITIES}
)

logger = logging.getLogger(__name__)

_EXTRA_ABILITIES_TOML = Path(__file__).parent.parent / "data" / "extra_abilities.toml"
Expand Down Expand Up @@ -562,10 +571,6 @@ def update_card_admin(
KeyError: If card_id does not exist.
ValueError: If fields contains an unsupported key.
"""
allowed = set(ADMIN_EDITABLE_SCALARS)
allowed.update(f"ability_{a}" for a in KNOWN_ABILITIES)
allowed.update(f"extra_ability_{a}" for a in KNOWN_EXTRA_ABILITIES)

row = conn.execute("SELECT title, text FROM cards WHERE cardId = ?", (card_id,)).fetchone()
if row is None:
raise KeyError(f"card not found: {card_id}")
Expand All @@ -574,7 +579,7 @@ def update_card_admin(
params: list[object] = []

for key, value in fields.items():
if key in allowed:
if key in ADMIN_DB_COLUMNS:
set_clauses.append(f'"{key}" = ?')
params.append(value)
elif key == "title":
Expand Down
Loading
Loading