mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
feat: add supplemental theme catalog tooling, additional theme selection, and custom theme selection
This commit is contained in:
parent
3a1b011dbc
commit
9428e09cef
39 changed files with 3643 additions and 198 deletions
229
code/web/services/custom_theme_manager.py
Normal file
229
code/web/services/custom_theme_manager.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Session helpers for managing supplemental user themes in the web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from deck_builder.theme_resolution import (
|
||||
ThemeResolutionInfo,
|
||||
clean_theme_inputs,
|
||||
normalize_theme_match_mode,
|
||||
resolve_additional_theme_inputs,
|
||||
)
|
||||
|
||||
DEFAULT_THEME_LIMIT = 8
|
||||
ADDITION_COOLDOWN_SECONDS = 0.75
|
||||
|
||||
_INPUTS_KEY = "custom_theme_inputs"
|
||||
_RESOLUTION_KEY = "user_theme_resolution"
|
||||
_MODE_KEY = "theme_match_mode"
|
||||
_LAST_ADD_KEY = "custom_theme_last_add_ts"
|
||||
_CATALOG_VERSION_KEY = "theme_catalog_version"
|
||||
|
||||
|
||||
def _sanitize_single(value: str | None) -> str | None:
|
||||
for item in clean_theme_inputs([value] if value is not None else []):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _store_inputs(sess: Dict[str, Any], inputs: List[str]) -> None:
|
||||
sess[_INPUTS_KEY] = list(inputs)
|
||||
|
||||
|
||||
def _current_inputs(sess: Dict[str, Any]) -> List[str]:
|
||||
values = sess.get(_INPUTS_KEY)
|
||||
if isinstance(values, list):
|
||||
return [str(v) for v in values if isinstance(v, str)]
|
||||
return []
|
||||
|
||||
|
||||
def _store_resolution(sess: Dict[str, Any], info: ThemeResolutionInfo) -> None:
|
||||
info_dict = asdict(info)
|
||||
sess[_RESOLUTION_KEY] = info_dict
|
||||
sess[_CATALOG_VERSION_KEY] = info.catalog_version
|
||||
sess[_MODE_KEY] = info.mode
|
||||
sess["additional_themes"] = list(info.resolved)
|
||||
|
||||
|
||||
def _default_resolution(mode: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"requested": [],
|
||||
"mode": normalize_theme_match_mode(mode),
|
||||
"catalog_version": "unknown",
|
||||
"resolved": [],
|
||||
"matches": [],
|
||||
"unresolved": [],
|
||||
"fuzzy_corrections": {},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_and_store(
|
||||
sess: Dict[str, Any],
|
||||
inputs: List[str],
|
||||
mode: str,
|
||||
commander_tags: Iterable[str],
|
||||
) -> ThemeResolutionInfo:
|
||||
info = resolve_additional_theme_inputs(inputs, mode, commander_tags=commander_tags)
|
||||
_store_inputs(sess, inputs)
|
||||
_store_resolution(sess, info)
|
||||
return info
|
||||
|
||||
|
||||
def get_view_state(sess: Dict[str, Any], *, default_mode: str) -> Dict[str, Any]:
|
||||
inputs = _current_inputs(sess)
|
||||
mode = sess.get(_MODE_KEY, default_mode)
|
||||
resolution = sess.get(_RESOLUTION_KEY)
|
||||
if not isinstance(resolution, dict):
|
||||
resolution = _default_resolution(mode)
|
||||
remaining = max(0, int(sess.get("custom_theme_limit", DEFAULT_THEME_LIMIT)) - len(inputs))
|
||||
return {
|
||||
"inputs": inputs,
|
||||
"mode": normalize_theme_match_mode(mode),
|
||||
"resolution": resolution,
|
||||
"limit": int(sess.get("custom_theme_limit", DEFAULT_THEME_LIMIT)),
|
||||
"remaining": remaining,
|
||||
}
|
||||
|
||||
|
||||
def set_limit(sess: Dict[str, Any], limit: int) -> None:
|
||||
sess["custom_theme_limit"] = max(1, int(limit))
|
||||
|
||||
|
||||
def add_theme(
|
||||
sess: Dict[str, Any],
|
||||
value: str | None,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
limit: int = DEFAULT_THEME_LIMIT,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
sanitized = _sanitize_single(value)
|
||||
if not sanitized:
|
||||
return None, "Enter a theme to add.", "error"
|
||||
lower_inputs = {item.casefold() for item in inputs}
|
||||
if sanitized.casefold() in lower_inputs:
|
||||
return None, "That theme is already listed.", "info"
|
||||
if len(inputs) >= limit:
|
||||
return None, f"You can only add up to {limit} themes.", "warning"
|
||||
last_ts = float(sess.get(_LAST_ADD_KEY, 0.0) or 0.0)
|
||||
now = time.time()
|
||||
if now - last_ts < ADDITION_COOLDOWN_SECONDS:
|
||||
return None, "Please wait a moment before adding another theme.", "warning"
|
||||
proposed = inputs + [sanitized]
|
||||
try:
|
||||
info = _resolve_and_store(sess, proposed, normalized_mode, commander_tags)
|
||||
sess[_LAST_ADD_KEY] = now
|
||||
return info, f"Added theme '{sanitized}'.", "success"
|
||||
except ValueError as exc:
|
||||
# Revert when strict mode rejects unresolved entries.
|
||||
_resolve_and_store(sess, inputs, normalized_mode, commander_tags)
|
||||
return None, str(exc), "error"
|
||||
|
||||
|
||||
def remove_theme(
|
||||
sess: Dict[str, Any],
|
||||
value: str | None,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
if not inputs:
|
||||
return None, "No themes to remove.", "info"
|
||||
key = (value or "").strip().casefold()
|
||||
if not key:
|
||||
return None, "Select a theme to remove.", "error"
|
||||
filtered = [item for item in inputs if item.casefold() != key]
|
||||
if len(filtered) == len(inputs):
|
||||
return None, "Theme not found in your list.", "warning"
|
||||
info = _resolve_and_store(sess, filtered, normalized_mode, commander_tags)
|
||||
return info, "Theme removed.", "success"
|
||||
|
||||
|
||||
def choose_suggestion(
|
||||
sess: Dict[str, Any],
|
||||
original: str,
|
||||
selection: str,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
orig_key = (original or "").strip().casefold()
|
||||
if not orig_key:
|
||||
return None, "Original theme missing.", "error"
|
||||
sanitized = _sanitize_single(selection)
|
||||
if not sanitized:
|
||||
return None, "Select a suggestion to apply.", "error"
|
||||
try:
|
||||
index = next(i for i, item in enumerate(inputs) if item.casefold() == orig_key)
|
||||
except StopIteration:
|
||||
return None, "Original theme not found.", "warning"
|
||||
replacement_key = sanitized.casefold()
|
||||
if replacement_key in {item.casefold() for i, item in enumerate(inputs) if i != index}:
|
||||
# Duplicate suggestion: simply drop the original.
|
||||
updated = [item for i, item in enumerate(inputs) if i != index]
|
||||
message = f"Removed duplicate theme '{original}'."
|
||||
else:
|
||||
updated = list(inputs)
|
||||
updated[index] = sanitized
|
||||
message = f"Updated '{original}' to '{sanitized}'."
|
||||
info = _resolve_and_store(sess, updated, normalized_mode, commander_tags)
|
||||
return info, message, "success"
|
||||
|
||||
|
||||
def set_mode(
|
||||
sess: Dict[str, Any],
|
||||
mode: str,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
new_mode = normalize_theme_match_mode(mode)
|
||||
current_inputs = _current_inputs(sess)
|
||||
previous_mode = sess.get(_MODE_KEY)
|
||||
try:
|
||||
info = _resolve_and_store(sess, current_inputs, new_mode, commander_tags)
|
||||
return info, f"Theme matching set to {new_mode} mode.", "success"
|
||||
except ValueError as exc:
|
||||
if previous_mode is not None:
|
||||
sess[_MODE_KEY] = previous_mode
|
||||
return None, str(exc), "error"
|
||||
|
||||
|
||||
def clear_all(sess: Dict[str, Any]) -> None:
|
||||
for key in (_INPUTS_KEY, _RESOLUTION_KEY, "additional_themes", _LAST_ADD_KEY):
|
||||
if key in sess:
|
||||
del sess[key]
|
||||
|
||||
|
||||
def refresh_resolution(
|
||||
sess: Dict[str, Any],
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None = None,
|
||||
) -> ThemeResolutionInfo | None:
|
||||
inputs = _current_inputs(sess)
|
||||
normalized_mode = normalize_theme_match_mode(mode or sess.get(_MODE_KEY))
|
||||
if not inputs:
|
||||
empty = ThemeResolutionInfo(
|
||||
requested=[],
|
||||
mode=normalized_mode,
|
||||
catalog_version=sess.get(_CATALOG_VERSION_KEY, "unknown"),
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=[],
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
_store_inputs(sess, [])
|
||||
_store_resolution(sess, empty)
|
||||
return empty
|
||||
info = _resolve_and_store(sess, inputs, normalized_mode, commander_tags)
|
||||
return info
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue