from __future__ import annotations import json import math import os from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING from ccma.domain.models import DEFAULT_OPTIONAL_MEMBER_FIELDS, normalize_optional_member_fields from ccma.storage.atomic import write_json_atomic if TYPE_CHECKING: from ccma.services.housekeeper import HousekeeperSettings @dataclass(slots=True) class AppConfig: store_path: str = "" gnucash_path: str = "" gnucash_last_accounts: dict[str, str] = field(default_factory=dict) theme_mode: str = "dark" run_housekeeper_on_startup: bool = True splash_minimum_seconds: float = 5.0 birthday_days_before: int = 7 birthday_days_after: int = 2 anniversary_days_before: int = 14 anniversary_days_after: int = 7 anniversary_intervals: str = "1Y;5Y;10Y;25Y;50Y" retroactive_claims: bool = False optional_member_fields: tuple[str, ...] = DEFAULT_OPTIONAL_MEMBER_FIELDS window_geometry: str = "" window_state: str = "normal" monitor_bounds: tuple[int, int, int, int] | None = None @property def path(self) -> Path: return config_directory() / "config.json" def save(self) -> None: write_json_atomic( self.path, { "schema_version": 1, "store_path": self.store_path, "gnucash_path": self.gnucash_path, "gnucash_last_accounts": self.gnucash_last_accounts, "theme_mode": self.theme_mode, "run_housekeeper_on_startup": self.run_housekeeper_on_startup, "splash_minimum_seconds": _non_negative_float(self.splash_minimum_seconds, 5.0), "birthday_days_before": self.birthday_days_before, "birthday_days_after": self.birthday_days_after, "anniversary_days_before": self.anniversary_days_before, "anniversary_days_after": self.anniversary_days_after, "anniversary_intervals": self.anniversary_intervals, "retroactive_claims": self.retroactive_claims, "optional_member_fields": list(normalize_optional_member_fields(self.optional_member_fields)), "window_geometry": self.window_geometry, "window_state": self.window_state, "monitor_bounds": list(self.monitor_bounds) if self.monitor_bounds else None, }, ) def housekeeper_settings(self) -> HousekeeperSettings: from ccma.services.housekeeper import HousekeeperSettings from ccma.services.intervals import IntervalValidationError try: return HousekeeperSettings.from_values( birthday_days_before=self.birthday_days_before, birthday_days_after=self.birthday_days_after, anniversary_days_before=self.anniversary_days_before, anniversary_days_after=self.anniversary_days_after, anniversary_intervals=self.anniversary_intervals, retroactive_claims=self.retroactive_claims, optional_member_fields=self.optional_member_fields, ) except IntervalValidationError: return HousekeeperSettings() def config_directory() -> Path: override = os.environ.get("CCMA_CONFIG_DIR") if override: return Path(override).expanduser() if os.name == "nt": return Path(os.environ.get("APPDATA", Path.home())) / "CCMA" return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "ccma" def load_config() -> AppConfig: path = config_directory() / "config.json" store_override = os.environ.get("CCMA_STORE", "") if not path.exists(): return AppConfig(store_path=store_override) try: data = json.loads(path.read_text(encoding="utf-8")) monitor_raw = data.get("monitor_bounds") monitor_bounds = None if isinstance(monitor_raw, list) and len(monitor_raw) == 4: monitor_bounds = tuple(int(value) for value in monitor_raw) last_accounts_raw = data.get("gnucash_last_accounts") gnucash_last_accounts = ( {str(key): str(value) for key, value in last_accounts_raw.items()} if isinstance(last_accounts_raw, dict) else {} ) return AppConfig( store_path=store_override or str(data.get("store_path", "")), gnucash_path=str(data.get("gnucash_path", "")), gnucash_last_accounts=gnucash_last_accounts, theme_mode=str(data.get("theme_mode", "dark")), run_housekeeper_on_startup=bool(data.get("run_housekeeper_on_startup", True)), splash_minimum_seconds=_non_negative_float(data.get("splash_minimum_seconds", 5.0), 5.0), birthday_days_before=int(data.get("birthday_days_before", 7)), birthday_days_after=int(data.get("birthday_days_after", 2)), anniversary_days_before=int(data.get("anniversary_days_before", 14)), anniversary_days_after=int(data.get("anniversary_days_after", 7)), anniversary_intervals=str(data.get("anniversary_intervals", "1Y;5Y;10Y;25Y;50Y")), retroactive_claims=bool(data.get("retroactive_claims", False)), optional_member_fields=normalize_optional_member_fields( data.get("optional_member_fields", DEFAULT_OPTIONAL_MEMBER_FIELDS) ), window_geometry=str(data.get("window_geometry", "")), window_state=str(data.get("window_state", "normal")), monitor_bounds=monitor_bounds, ) except (OSError, ValueError, TypeError): return AppConfig(store_path=store_override) def _non_negative_float(value: object, default: float) -> float: try: parsed = float(value) except (TypeError, ValueError): return default return max(0.0, parsed) if math.isfinite(parsed) else default