mtg_python_deckbuilder/code/web/services/custom_theme_manager.py

229 lines
6.8 KiB
Python

"""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