from __future__ import annotations import logging import re from typing import Any from unshackle.core.utilities import sanitize_filename log = logging.getLogger(__name__) class TemplateFormatter: """ Template formatter for custom filename patterns. Supports variable substitution and conditional variables. Example: '{title}.{year}.{quality?}.{source}-{tag}' """ def __init__(self, template: str): """Initialize the template formatter. Args: template: Template string with variables in {variable} format """ self.template = template self.variables = self._extract_variables() def _extract_variables(self) -> list[str]: """Extract all variables from the template.""" pattern = r"\{([^}]+)\}" matches = re.findall(pattern, self.template) return [match.strip() for match in matches] def format(self, context: dict[str, Any]) -> str: """Format the template with the provided context. Args: context: Dictionary containing variable values Returns: Formatted filename string Raises: ValueError: If required template variables are missing from context """ is_valid, missing_vars = self.validate(context) if not is_valid: error_msg = f"Missing required template variables: {', '.join(missing_vars)}" log.error(error_msg) raise ValueError(error_msg) try: result = self.template for variable in self.variables: placeholder = "{" + variable + "}" is_conditional = variable.endswith("?") if is_conditional: var_name = variable[:-1] value = context.get(var_name, "") if value: safe_value = str(value).strip() result = result.replace(placeholder, safe_value) else: # Remove the placeholder and consume the adjacent separator on one side # e.g. "{disc?}-{track}" → "{track}" when disc is empty # e.g. "{title}.{edition?}.{quality}" → "{title}.{quality}" when edition is empty def _remove_conditional(m: re.Match) -> str: s = m.group(0) has_left = s[0] in ".- " has_right = s[-1] in ".- " if has_left and has_right: return s[0] # keep left separator return "" result = re.sub( rf"[\.\s\-]?{re.escape(placeholder)}[\.\s\-]?", _remove_conditional, result, count=1, ) else: value = context.get(variable, "") if value is None: log.warning(f"Template variable '{variable}' is None, using empty string") value = "" safe_value = str(value).strip() result = result.replace(placeholder, safe_value) # Clean up multiple consecutive dots/separators and other artifacts result = re.sub(r"\.{2,}", ".", result) # Multiple dots -> single dot result = re.sub(r"\s{2,}", " ", result) # Multiple spaces -> single space result = re.sub(r"-{2,}", "-", result) # Multiple dashes -> single dash result = re.sub(r"^[\.\s\-]+|[\.\s\-]+$", "", result) # Remove leading/trailing dots, spaces, dashes result = re.sub(r"\.-", "-", result) # Remove dots before dashes (for dot-based templates) result = re.sub(r"[\.\s]+\)", ")", result) # Remove dots/spaces before closing parentheses result = re.sub(r"\(\s*\)", "", result) # Remove empty parentheses (empty conditional) # Determine the appropriate separator based on template style # Count separator characters between variables (between } and {) between_vars = re.findall(r"\}([^{]*)\{", self.template) separator_text = "".join(between_vars) dot_count = separator_text.count(".") space_count = separator_text.count(" ") if space_count > dot_count: result = sanitize_filename(result, spacer=" ") else: result = sanitize_filename(result, spacer=".") if not result or result.isspace(): log.warning("Template formatting resulted in empty filename, using fallback") return "untitled" log.debug(f"Template formatted successfully: '{self.template}' -> '{result}'") return result except (KeyError, ValueError, re.error) as e: log.error(f"Error formatting template '{self.template}': {e}") fallback = f"error_formatting_{hash(self.template) % 10000}" log.warning(f"Using fallback filename: {fallback}") return fallback def validate(self, context: dict[str, Any]) -> tuple[bool, list[str]]: """Validate that all required variables are present in context. Args: context: Dictionary containing variable values Returns: Tuple of (is_valid, missing_variables) """ missing = [] for variable in self.variables: is_conditional = variable.endswith("?") var_name = variable[:-1] if is_conditional else variable if not is_conditional and var_name not in context: missing.append(var_name) return len(missing) == 0, missing def get_required_variables(self) -> list[str]: """Get list of required (non-conditional) variables.""" required = [] for variable in self.variables: if not variable.endswith("?"): required.append(variable) return required def get_optional_variables(self) -> list[str]: """Get list of optional (conditional) variables.""" optional = [] for variable in self.variables: if variable.endswith("?"): optional.append(variable[:-1]) # Remove the ? return optional