rommapp_romm/backend/utils/structure_parser.py
2025-10-14 09:53:35 -04:00

211 lines
7.1 KiB
Python

import re
from dataclasses import dataclass
from enum import StrEnum
from config import LIBRARY_BASE_PATH
class StructureMacro(StrEnum):
"""Built-in macros for library structure patterns."""
PLATFORM = "platform"
GAME = "game"
CUSTOM = "custom"
@dataclass
class StructureSegment:
"""Represents a single segment in a library structure path."""
name: str
is_macro: bool
macro_type: StructureMacro | None = None
is_required: bool = False
class LibraryStructure:
"""Parses and manages custom library structure patterns."""
# Regex to match macro patterns like {library}, {platform}, etc.
MACRO_PATTERN = re.compile(r"\{([^}]+)\}")
# Required macros for different structure types
REQUIRED_ROM_MACROS = {StructureMacro.PLATFORM, StructureMacro.GAME}
REQUIRED_FIRMWARE_MACROS = {StructureMacro.PLATFORM}
def __init__(self, structure_string: str, structure_type: str = "roms"):
"""
Initialize structure parser.
Args:
structure_string: Structure pattern like "{roms}/{platform}/{game}"
structure_type: Type of structure ("roms" or "firmware")
"""
self.structure_string = structure_string
self.structure_type = structure_type
self.segments = self._parse_structure()
self._validate_structure()
def _parse_structure(self) -> list[StructureSegment]:
"""Parse the structure string into segments."""
segments = []
parts = self.structure_string.split("/")
for part in parts:
if not part:
continue
macro_match = self.MACRO_PATTERN.match(part)
if macro_match:
macro_name = macro_match.group(1)
macro_type = self._get_macro_type(macro_name)
is_required = (
macro_type in self.REQUIRED_ROM_MACROS
if self.structure_type == "roms"
else macro_type in self.REQUIRED_FIRMWARE_MACROS
)
segments.append(
StructureSegment(
name=macro_name,
is_macro=True,
macro_type=macro_type,
is_required=is_required,
)
)
else:
segments.append(
StructureSegment(name=part, is_macro=False, is_required=False)
)
return segments
def _get_macro_type(self, macro_name: str) -> StructureMacro:
"""Determine the type of macro based on its name."""
macro_map = {
"platform": StructureMacro.PLATFORM,
"game": StructureMacro.GAME,
}
return macro_map.get(macro_name, StructureMacro.CUSTOM)
def _validate_structure(self) -> None:
"""Validate that the structure contains required macros."""
macro_types = {
segment.macro_type for segment in self.segments if segment.is_macro
}
# Check for required macros based on structure type
if self.structure_type == "roms":
required_macros = self.REQUIRED_ROM_MACROS
else: # firmware
required_macros = self.REQUIRED_FIRMWARE_MACROS
if not required_macros.issubset(macro_types):
missing = required_macros - macro_types
raise ValueError(
f"{self.structure_type.title()} structure must contain required macros: {missing}"
)
def get_custom_macros(self) -> list[str]:
"""Get list of custom macro names in the structure."""
return [
segment.name
for segment in self.segments
if segment.is_macro and segment.macro_type == StructureMacro.CUSTOM
]
def resolve_path(self, **kwargs: str) -> str:
"""
Build a concrete path from the structure using provided values.
Args:
**kwargs: Values for macros (e.g., platform="gba", game="pokemon.gba")
Returns:
Resolved path string
"""
path_parts = []
print(f"DEBUG: Resolving path with kwargs: {kwargs}")
print(f"DEBUG: Segments: {self.segments}")
for segment in self.segments:
if segment.is_macro:
# For platform, game, and custom macros
value = kwargs.get(segment.name)
if value is None:
raise ValueError(f"Missing value for macro '{segment.name}'")
path_parts.append(value)
else:
path_parts.append(segment.name)
return "/".join(path_parts)
def extract_metadata(self, file_path: str) -> list[str]:
"""
Extract custom macro values from a file path as tags.
Args:
file_path: File path to extract metadata from
Returns:
List of tags in format "macro_name:value"
"""
# Remove library base path if present
if file_path.startswith(LIBRARY_BASE_PATH):
file_path = file_path[len(LIBRARY_BASE_PATH) :].lstrip("/")
path_parts = file_path.split("/")
tags = []
# Create a mapping of position to macro name
macro_positions = {}
for i, segment in enumerate(self.segments):
if segment.is_macro and segment.macro_type == StructureMacro.CUSTOM:
macro_positions[i] = segment.name
# Extract values for custom macros as tags
for position, macro_name in macro_positions.items():
if position < len(path_parts):
tags.append(f"{macro_name}:{path_parts[position]}")
return tags
def get_platform_position(self) -> int:
"""Get the position of the platform segment in the structure."""
for i, segment in enumerate(self.segments):
if segment.macro_type == StructureMacro.PLATFORM:
return i
raise ValueError("Platform macro not found in structure")
def get_platforms_directory(self) -> str:
"""Get the directory where platforms are located."""
# For structures like {roms}/{platform}/{region}/{game},
# platforms are at the first level after the base directory
# So we need to find the first non-macro segment before platform
path_parts = []
for segment in self.segments:
if segment.macro_type == StructureMacro.PLATFORM:
break
if not segment.is_macro:
path_parts.append(segment.name)
return "/".join(path_parts) if path_parts else ""
def supports_mixed_games(self) -> bool:
"""Check if structure supports both single-file and multi-file games."""
# Only ROM structures support mixed games
if self.structure_type != "roms":
return False
# With the unified {game} macro, all ROM structures support mixed games
return any(
segment.macro_type == StructureMacro.GAME for segment in self.segments
)
def __str__(self) -> str:
return self.structure_string
def __repr__(self) -> str:
return f"LibraryStructure('{self.structure_string}')"