"""Capture profile management — save/load screen region coordinates per game. Profiles are stored as JSON files in working_space/temp/profiles/. Each profile holds a name, regions, and a flat settings dict with ALL UI state. Usage: from src.services.ocr.profiles import ProfileManager, CaptureProfile, CaptureRegion mgr = ProfileManager() profile = CaptureProfile( name="Elden Ring", regions=[CaptureRegion(x=100, y=900, width=800, height=200, label="subtitles")], settings={"target_lang": "pl", "tts_engine": "edge", "tts_voice": "pl-PL-ZofiaNeural"}, ) mgr.save_profile(profile) loaded = mgr.load_profile("Elden Ring") """ from __future__ import annotations import base64 import json import re from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path from typing import Any from src.utils.logger import logger _PROFILES_DIR = ( Path(__file__).resolve().parent.parent.parent.parent / "working_space" / "temp" / "profiles" ) @dataclass class CaptureRegion: """A single screen capture region (rectangle).""" x: int y: int width: int height: int label: str = "default" enabled: bool = True @property def as_tuple(self) -> tuple[int, int, int, int]: return (self.x, self.y, self.width, self.height) def __str__(self) -> str: return f"{self.label}: ({self.x}, {self.y}, {self.width}x{self.height})" @dataclass class CaptureProfile: """A named capture profile. Attributes: name: Profile display name (also used to derive filename). regions: List of capture regions. settings: Flat dict holding **every** UI setting (target_lang, tts_engine, tts_voice, capture_interval, …). created_at: ISO timestamp of creation. updated_at: ISO timestamp of last update. """ name: str regions: list[CaptureRegion] = field(default_factory=list) settings: dict[str, Any] = field(default_factory=dict) created_at: str = "" updated_at: str = "" def __post_init__(self) -> None: now = datetime.now().isoformat() if not self.created_at: self.created_at = now if not self.updated_at: self.updated_at = now # ── serialization ──────────────────────────────────────────── def to_dict(self) -> dict[str, Any]: return { "name": self.name, "regions": [asdict(r) for r in self.regions], "settings": self.settings, "created_at": self.created_at, "updated_at": self.updated_at, } @classmethod def from_dict(cls, data: dict[str, Any]) -> CaptureProfile: regions = [CaptureRegion(**r) for r in data.get("regions", [])] settings = data.get("settings", {}) # ── backward compat: migrate old flat fields into settings ── _old_keys = { "source_lang", "target_lang", "capture_mode", "monitor_index", "interval", "tts_backend", "tts_voice", "description", } for key in _old_keys: if key in data and key not in settings: settings[key] = data[key] return cls( name=data.get("name", ""), regions=regions, settings=settings, created_at=data.get("created_at", ""), updated_at=data.get("updated_at", ""), ) @property def active_regions(self) -> list[CaptureRegion]: return [r for r in self.regions if r.enabled] class ProfileManager: """Manage capture profiles (save/load/list/delete).""" def __init__(self, profiles_dir: str | Path | None = None) -> None: self._dir = Path(profiles_dir) if profiles_dir else _PROFILES_DIR self._dir.mkdir(parents=True, exist_ok=True) # ── CRUD ───────────────────────────────────────────────────── def save_profile(self, profile: CaptureProfile) -> Path: """Save profile to JSON file.""" profile.updated_at = datetime.now().isoformat() filepath = self._dir / (self._sanitize_filename(profile.name) + ".json") filepath.write_text( json.dumps(profile.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8", ) logger.info(f"Profile saved: {profile.name} → {filepath}") return filepath def load_profile(self, name: str) -> CaptureProfile | None: """Load profile by name.""" filepath = self._find_profile_file(name) if filepath is None: logger.warning(f"Profile not found: {name}") return None try: data = json.loads(filepath.read_text(encoding="utf-8")) return CaptureProfile.from_dict(data) except Exception as e: logger.error(f"Failed to load profile {name}: {e}") return None def list_profiles(self) -> list[str]: """List all saved profile names.""" profiles: list[str] = [] for f in sorted(self._dir.glob("*.json")): try: encoded = f.stem padding = (4 - len(encoded) % 4) % 4 name = base64.urlsafe_b64decode(encoded + "=" * padding).decode("utf-8") profiles.append(name) except Exception: profiles.append(f.stem) return profiles def delete_profile(self, name: str) -> bool: """Delete a profile by name.""" filepath = self._find_profile_file(name) if filepath and filepath.exists(): filepath.unlink() logger.info(f"Profile deleted: {name}") return True return False def update_profile_settings(self, name: str, settings: dict[str, Any]) -> bool: """Merge *settings* into an existing profile and re-save.""" profile = self.load_profile(name) if profile is None: return False profile.settings.update(settings) self.save_profile(profile) return True def update_profile_regions(self, name: str, regions: list[dict[str, Any]]) -> bool: """Replace regions in an existing profile and re-save.""" profile = self.load_profile(name) if profile is None: return False profile.regions = [CaptureRegion(**r) for r in regions] self.save_profile(profile) return True # ── helpers ─────────────────────────────────────────────────── def _find_profile_file(self, name: str) -> Path | None: # Try new base64 encoding first filepath = self._dir / (self._sanitize_filename(name) + ".json") if filepath.exists(): return filepath # Try old simple sanitization for backward compatibility old = re.sub(r'[<>:"/\\|?*]', "_", name.strip()).rstrip(".") + ".json" old_path = self._dir / old if old_path.exists(): return old_path return None @staticmethod def _sanitize_filename(name: str) -> str: """Base64url encode the profile name so case is preserved on NTFS.""" return base64.urlsafe_b64encode(name.strip().encode("utf-8")).decode("ascii").rstrip("=")