Files
mvd_tcg_deck/wizards/mvd_tcg_deck_text_transfer.py
2026-04-03 23:08:57 +02:00

336 lines
12 KiB
Python

"""Import and export decklists as plain text."""
import re
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class MvdTcgDeckTextTransfer(models.TransientModel):
"""Round-trip decklists through a text-based import/export wizard."""
_name = "mvd.tcg.deck.text.transfer"
_description = "TCG Deck Text Transfer"
_LINE_PATTERN = re.compile(
r"^(?:(?P<qty>\d+)\s*x?\s+)?(?P<name>.+?)"
r"(?:\s+\[(?P<bracket_ref>[^\]]+)\]"
r"|\s+\((?P<print_set>[A-Za-z0-9]+)\s+(?P<print_collector>[^)]+)\))?$"
)
_BOARD_ALIASES = {
"mainboard": "mainboard",
"main": "mainboard",
"command zone": "command_zone",
"commandzone": "command_zone",
"command": "command_zone",
"commander": "command_zone",
"sideboard": "sideboard",
"side": "sideboard",
"maybeboard": "maybeboard",
"maybe": "maybeboard",
}
deck_id = fields.Many2one(
"mvd.tcg.deck",
required=True,
readonly=True,
)
game_id = fields.Many2one(
"mvd.tcg.game",
related="deck_id.game_id",
readonly=True,
)
operation = fields.Selection(
[
("import", "Import"),
("export", "Export"),
],
required=True,
default="import",
readonly=True,
)
replace_existing = fields.Boolean(
string="Replace Existing Deck Lines",
help="Clear all current deck entries before importing the provided deck list.",
)
line_text = fields.Text(
string="Deck List",
required=True,
)
@api.model
def default_get(self, field_names):
"""Initialize the wizard from the current deck context.
Args:
field_names: Requested wizard fields.
Returns:
dict: Prefilled wizard values.
"""
defaults = super().default_get(field_names)
deck_id = defaults.get("deck_id") or self.env.context.get("default_deck_id")
if not deck_id and self.env.context.get("active_model") == "mvd.tcg.deck":
deck_id = self.env.context.get("active_id")
if deck_id:
deck = self.env["mvd.tcg.deck"].browse(deck_id).exists()
if deck:
defaults["deck_id"] = deck.id
if (
defaults.get("operation") == "export"
and defaults.get("deck_id")
and "line_text" in field_names
):
deck = self.env["mvd.tcg.deck"].browse(defaults["deck_id"]).exists()
if deck:
defaults["line_text"] = self._build_export_text(deck)
return defaults
def _normalize_board_heading(self, raw_heading):
"""Resolve one free-form board heading to a canonical code.
Args:
raw_heading: Raw heading text from the import payload.
Returns:
str | bool: Canonical board code or ``False`` when unmatched.
"""
heading = (raw_heading or "").strip().lstrip("#").strip().lower()
heading = re.sub(r"[^a-z\s_]", "", heading).replace("_", " ")
heading = re.sub(r"\s+", " ", heading)
return self.env["mvd.tcg.deck"]._mvd_tcg_normalize_board_code(
self._BOARD_ALIASES.get(heading) or heading
)
def _build_export_text(self, deck):
"""Render one deck as a deterministic plain-text list.
Args:
deck: Deck record to serialize.
Returns:
str: Plain-text export payload with board headings.
"""
exported_sections = []
for board in deck.board_ids.sorted(key=lambda current_board: (current_board.sequence, current_board.id)):
if not board.line_ids:
continue
exported_sections.append(
f"# {deck._mvd_tcg_get_board_export_heading(board)}"
)
for line in board.line_ids.sorted(
key=lambda current_line: (
current_line.primary_role_sequence,
current_line.board_sequence,
current_line.sequence,
current_line.id,
)
):
card_name = line.card_id.display_name
print_hint = self._get_export_print_hint(line.card_id)
exported_sections.append(
f"{line.quantity} {card_name}{print_hint}"
)
exported_sections.append("")
return "\n".join(exported_sections).strip()
@classmethod
def _normalize_import_reference(cls, match):
"""Convert one parsed optional print reference into the canonical key.
Args:
match: Regex match produced by ``_LINE_PATTERN``.
Returns:
str: Canonical import reference such as ``tdm:101``.
"""
bracket_reference = (match.group("bracket_ref") or "").strip().lower()
if bracket_reference:
return bracket_reference
print_set = (match.group("print_set") or "").strip().lower()
print_collector = (match.group("print_collector") or "").strip().lower()
if print_set and print_collector:
return f"{print_set}:{print_collector}"
return ""
@classmethod
def _get_export_print_hint(cls, card):
"""Return one human-readable print hint for export when available.
Args:
card: Card record being exported.
Returns:
str: Optional print hint such as `` (TDM 101)``.
"""
external_ref = (card.external_ref or "").strip()
if ":" not in external_ref:
return ""
set_code, collector_number = external_ref.split(":", 1)
set_code = set_code.strip().upper()
collector_number = collector_number.strip()
if not set_code or not collector_number:
return ""
return f" ({set_code} {collector_number})"
def _find_card_for_import(self, deck, raw_name, raw_ref=False):
"""Resolve one import line to one unique reference card.
Args:
deck: Target deck record.
raw_name: Human-readable imported card name.
raw_ref: Optional explicit reference such as ``tdm:101``.
Returns:
mvd.tcg.card: Unique matching card record.
Raises:
UserError: If no unique card could be resolved.
"""
Card = self.env["mvd.tcg.card"]
base_domain = [
("game_id", "=", deck.game_id.id),
("active", "=", True),
]
external_ref = (raw_ref or "").strip().lower()
card_name = (raw_name or "").strip()
if external_ref:
exact_by_ref = Card.search(base_domain + [("external_ref", "=", external_ref)])
if len(exact_by_ref) == 1:
return exact_by_ref
if len(exact_by_ref) > 1:
raise UserError(
_(
"The explicit reference '%(reference)s' matches multiple cards. Narrow the import source first."
)
% {"reference": external_ref}
)
exact_current_lang = Card.search(base_domain + [("name", "=", card_name)])
exact_english = Card.with_context(lang="en_US").search(
base_domain + [("name", "=", card_name)]
)
exact_matches = (exact_current_lang | exact_english)
if len(exact_matches) == 1:
return exact_matches
if len(exact_matches) > 1:
raise UserError(
_(
"The card '%(name)s' matches multiple printings. Add a print hint such as '(TDM 101)' or pick the card directly in the deck builder."
)
% {"name": card_name}
)
fuzzy_current_lang = Card.search(base_domain + [("name", "=ilike", card_name)])
fuzzy_english = Card.with_context(lang="en_US").search(
base_domain + [("name", "=ilike", card_name)]
)
fuzzy_matches = (fuzzy_current_lang | fuzzy_english)
if len(fuzzy_matches) == 1:
return fuzzy_matches
if len(fuzzy_matches) > 1:
raise UserError(
_(
"The card '%(name)s' matches multiple printings. Add a print hint such as '(TDM 101)' or use a more specific card name."
)
% {"name": card_name}
)
raise UserError(_("No active card named '%s' exists in the current game.") % card_name)
def _parse_import_payload(self):
"""Parse the current text payload into structured import commands.
Returns:
list[dict]: Parsed commands with board, quantity and card lookup data.
Raises:
UserError: If the text payload contains invalid lines.
"""
self.ensure_one()
default_board = self.deck_id._mvd_tcg_get_board_by_code("mainboard") or self.deck_id.board_ids[:1]
current_board_code = default_board.code if default_board else False
parsed_lines = []
parsing_errors = []
for line_number, raw_line in enumerate((self.line_text or "").splitlines(), start=1):
stripped_line = raw_line.strip()
if not stripped_line:
continue
if stripped_line.startswith("//"):
continue
normalized_heading = self._normalize_board_heading(stripped_line)
if normalized_heading:
current_board_code = normalized_heading
continue
if stripped_line.startswith("#"):
normalized_heading = self._normalize_board_heading(stripped_line[1:])
if normalized_heading:
current_board_code = normalized_heading
continue
match = self._LINE_PATTERN.match(stripped_line)
if not match or not current_board_code:
parsing_errors.append(_("Line %(line)s could not be parsed: %(value)s") % {
"line": line_number,
"value": stripped_line,
})
continue
parsed_lines.append(
{
"line_number": line_number,
"board_code": current_board_code,
"quantity": int(match.group("qty") or 1),
"name": (match.group("name") or "").strip(),
"external_ref": self._normalize_import_reference(match),
}
)
if parsing_errors:
raise UserError("\n".join(parsing_errors))
return parsed_lines
def action_apply_text_transfer(self):
"""Run the selected import or export operation.
Returns:
dict: Window action returning to the current deck.
"""
self.ensure_one()
if self.operation == "export":
return {"type": "ir.actions.act_window_close"}
deck = self.deck_id
deck._mvd_tcg_seed_default_boards()
board_map = {board.code: board for board in deck.board_ids}
parsed_lines = self._parse_import_payload()
if self.replace_existing:
deck.line_ids.unlink()
for parsed_line in parsed_lines:
target_board = board_map.get(parsed_line["board_code"])
if not target_board:
raise UserError(
_("No board exists for code '%s'.") % parsed_line["board_code"]
)
card = self._find_card_for_import(
deck,
parsed_line["name"],
raw_ref=parsed_line["external_ref"],
)
deck._mvd_tcg_add_card_to_board(
card,
target_board,
quantity=parsed_line["quantity"],
)
return {
"type": "ir.actions.act_window",
"name": deck.display_name,
"res_model": "mvd.tcg.deck",
"view_mode": "form",
"res_id": deck.id,
"target": "current",
}