File size: 7,558 Bytes
ce847d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""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("=")