LVPWB/lvpwb/cardfile.py

191 lines
10 KiB
Python

from __future__ import annotations
import json
import math
import re
from pathlib import Path
from typing import Any
import lvpwb as app
def default_card_path(show: str, show_date: str) -> Path:
app.CARD_DIR.mkdir(parents=True, exist_ok=True)
stem = f"{show_date}-{app.slugify(show)}"
path = app.CARD_DIR / f"{stem}.md"
suffix = 2
while path.exists():
path = app.CARD_DIR / f"{stem}-{suffix}.md"
suffix += 1
return path
def save_card(card: dict[str, Any]) -> None:
path = Path(card["path"])
path.parent.mkdir(parents=True, exist_ok=True)
card.setdefault("game_title", app.game_title(card["game_id"]))
card.setdefault("federation_name", app.federation_name(card["game_id"]))
card.setdefault("title_definitions", app.title_definitions_for_game(card["game_id"]))
ppv_rating = app.calculate_ppv_rating(card)
card["ppv_rating"] = ppv_rating
lines = [
f"# Show: {json.dumps(card['show'], ensure_ascii=False)}",
f"- City: {json.dumps(card.get('city', ''), ensure_ascii=False)}",
f"- Attendance: {json.dumps(card.get('attendance'))}",
f"- Game ID: {json.dumps(card['game_id'])}",
f"- Game: {json.dumps(card['game_title'], ensure_ascii=False)}",
f"- Federation: {json.dumps(card['federation_name'], ensure_ascii=False)}",
f"- Title Definitions: {json.dumps(card['title_definitions'], ensure_ascii=False)}",
f"- Date: {json.dumps(card['date'])}",
]
if app.is_ppv_card(card):
lines.append(f"- PPV Rating: {json.dumps(ppv_rating)}")
if card.get("season_id"):
lines.extend([
f"- Season ID: {json.dumps(card['season_id'])}",
f"- Season Show: {json.dumps(card.get('season_show', ''))}",
f"- Season Show Number: {json.dumps(card.get('season_show_number'))}",
])
lines.append("")
for number, match in enumerate(card["matches"], 1):
lines.append(f"## Match {number}")
lines.append(f"- Title: {json.dumps(match.get('title', ''), ensure_ascii=False)}")
lines.append(f"- Type: {json.dumps(match['type'], ensure_ascii=False)}")
lines.append(f"- Status: {json.dumps('Complete' if match.get('winner_side') is not None else 'Pending')}")
lines.append(f"- Sides: {json.dumps(match['sides'], ensure_ascii=False)}")
lines.append(f"- Winner side: {json.dumps(match.get('winner_side'))}")
lines.append(f"- Finish: {json.dumps(match.get('finish', ''), ensure_ascii=False)}")
lines.append(f"- Promo: {json.dumps(match.get('promo'), ensure_ascii=False)}")
lines.append(f"- Image: {json.dumps(match.get('image', ''), ensure_ascii=False)}")
lines.append(f"- Ranking Division: {json.dumps(match.get('ranking_division'))}")
lines.append(f"- Title Defense: {json.dumps(bool(match.get('title_defense', False)))}")
lines.append(f"- Rivalry Payoff: {json.dumps(bool(match.get('rivalry_payoff', False)))}")
lines.append(f"- Match Time Minutes: {json.dumps(match.get('match_time_minutes'))}")
lines.append(f"- Near Falls: {json.dumps(match.get('near_falls', 0))}")
lines.append(f"- Finishers: {json.dumps(match.get('finishers', 0))}")
lines.append(f"- Match Rating: {json.dumps(match.get('rating'))}")
lines.append(f"- Base Rating: {json.dumps(match.get('base_rating'))}")
lines.append(f"- Valid Outcomes: {json.dumps(match.get('valid_outcomes'), ensure_ascii=False)}")
lines.append("")
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
card["path"] = str(path)
def load_card(path: Path) -> dict[str, Any]:
"""Load a card written in the app's readable Markdown format."""
text = path.read_text(encoding="utf-8")
card: dict[str, Any] = {"path": str(path), "matches": []}
current: dict[str, Any] | None = None
for line in text.splitlines():
if line.startswith("# Show: "):
card["show"] = json.loads(line[len("# Show: "):])
continue
match_header = re.fullmatch(r"## Match (\d+)", line)
if match_header:
current = {"title": "", "type": "Singles", "sides": [], "winner_side": None,
"finish": "", "promo": None, "image": "", "ranking_division": None,
"title_defense": False, "rivalry_payoff": False,
"match_time_minutes": None, "near_falls": 0, "finishers": 0,
"rating": None, "base_rating": None, "valid_outcomes": None}
card["matches"].append(current)
continue
field = re.match(r"^- ([A-Za-z ]+): (.*)$", line)
if not field:
continue
key, raw = field.group(1).strip().lower(), field.group(2)
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid Markdown card field '{key}' in {path}") from exc
if key in ("game id", "game", "federation", "title definitions", "date", "city", "attendance",
"season id", "season show", "season show number", "ppv rating"):
metadata_key = {"game": "game_title", "game id": "game_id"}.get(
key, key.replace(" ", "_")
)
card[metadata_key] = value
elif current is not None:
mapping = {"title": "title", "type": "type", "sides": "sides",
"winner side": "winner_side", "finish": "finish", "promo": "promo",
"image": "image", "ranking division": "ranking_division",
"title defense": "title_defense", "rivalry payoff": "rivalry_payoff",
"match time minutes": "match_time_minutes", "near falls": "near_falls",
"finishers": "finishers", "match rating": "rating",
"base rating": "base_rating", "valid outcomes": "valid_outcomes"}
if key in mapping:
current[mapping[key]] = value
if card.get("game_id") not in app.GAMES:
raise ValueError(f"Unknown or missing game ID in {path}")
if len(card["matches"]) > 3:
raise ValueError(f"A card cannot contain more than three matches: {path}")
while len(card["matches"]) < 3:
card["matches"].append({"title": "", "type": "Singles", "sides": [],
"winner_side": None, "finish": "", "promo": None,
"image": "", "ranking_division": None,
"title_defense": False, "rivalry_payoff": False,
"match_time_minutes": None, "near_falls": 0,
"finishers": 0, "rating": None, "base_rating": None,
"valid_outcomes": None})
if not card.get("show") or not card.get("date"):
raise ValueError(f"Card is missing its show name or date: {path}")
card.setdefault("city", "")
card.setdefault("attendance", None)
if card["attendance"] is not None and (
not isinstance(card["attendance"], int) or card["attendance"] <= 0
):
raise ValueError(f"Card has an invalid attendance: {path}")
for key in ("season_show_number",):
value = card.get(key)
if value is not None and (not isinstance(value, int) or value < 1):
raise ValueError(f"Card has invalid season metadata: {path}")
for match in card["matches"]:
if any(not side for side in match.get("sides", [])):
raise ValueError(f"A match is missing participants: {path}")
winner = match.get("winner_side")
if winner is not None and (not isinstance(winner, int) or not 0 <= winner < len(match["sides"])):
raise ValueError(f"A match has an invalid winner side: {path}")
division = match.get("ranking_division")
card_titles = card.get("title_definitions") or app.title_definitions_for_game(card["game_id"])
if division is not None and division not in [title["name"] for title in card_titles]:
raise ValueError(f"A match has an unknown ranking division: {path}")
if match.get("title_defense") and division is None:
raise ValueError(f"A title defense must have a ranking division: {path}")
minutes = match.get("match_time_minutes")
if minutes is not None and (
not isinstance(minutes, (int, float)) or not math.isfinite(minutes) or minutes < 0
):
raise ValueError(f"A match has an invalid duration: {path}")
for counter in ("near_falls", "finishers"):
value = match.get(counter, 0)
if not isinstance(value, int) or value < 0:
raise ValueError(f"A match has an invalid {counter.replace('_', ' ')} count: {path}")
rating = match.get("rating")
if rating is not None and (
not isinstance(rating, (int, float)) or not math.isfinite(rating) or not 0 <= rating <= 100
):
raise ValueError(f"A match has an invalid rating: {path}")
base_rating = match.get("base_rating")
if base_rating is not None and (
not isinstance(base_rating, (int, float)) or not math.isfinite(base_rating)
or not 0 <= base_rating <= 100
):
raise ValueError(f"A match has an invalid base rating: {path}")
outcomes = match.get("valid_outcomes")
if outcomes is not None and (
not isinstance(outcomes, list) or not outcomes
or any(not isinstance(outcome, str) or not outcome.strip() for outcome in outcomes)
):
raise ValueError(f"A match has invalid outcome options: {path}")
card.setdefault("game_title", app.game_title(card["game_id"]))
card.setdefault("federation_name", app.federation_name(card["game_id"]))
card.setdefault("title_definitions", app.title_definitions_for_game(card["game_id"]))
card["ppv_rating"] = app.calculate_ppv_rating(card)
return card
def card_summary(path: Path) -> str:
"""One-line listing for a saved card; falls back to the file stem."""
try:
return app.show_location_text(app.load_card(path))
except (OSError, ValueError, json.JSONDecodeError):
return path.stem
__all__ = ['card_summary', 'default_card_path', 'load_card', 'save_card']