336 lines
12 KiB
Python
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",
|
|
}
|