"""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\d+)\s*x?\s+)?(?P.+?)" r"(?:\s+\[(?P[^\]]+)\]" r"|\s+\((?P[A-Za-z0-9]+)\s+(?P[^)]+)\))?$" ) _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", }