from __future__ import annotations import os import re from typing import Dict, Mapping from sqlalchemy.orm import Session from models.application_setting import ApplicationSetting from models.theme_setting import ThemeSetting # Import ThemeSetting model CSS_COLOR_CATEGORY = "theme" CSS_COLOR_VALUE_TYPE = "color" CSS_ENV_PREFIX = "CALMINER_THEME_" CSS_COLOR_DEFAULTS: Dict[str, str] = { "--color-background": "#f4f5f7", "--color-surface": "#ffffff", "--color-text-primary": "#2a1f33", "--color-text-secondary": "#624769", "--color-text-muted": "#64748b", "--color-text-subtle": "#94a3b8", "--color-text-invert": "#ffffff", "--color-text-dark": "#0f172a", "--color-text-strong": "#111827", "--color-primary": "#5f320d", "--color-primary-strong": "#7e4c13", "--color-primary-stronger": "#837c15", "--color-accent": "#bff838", "--color-border": "#e2e8f0", "--color-border-strong": "#cbd5e1", "--color-highlight": "#eef2ff", "--color-panel-shadow": "rgba(15, 23, 42, 0.08)", "--color-panel-shadow-deep": "rgba(15, 23, 42, 0.12)", "--color-surface-alt": "#f8fafc", "--color-success": "#047857", "--color-error": "#b91c1c", } _COLOR_VALUE_PATTERN = re.compile( r"^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\([^)]+\)|hsla?\([^)]+\))$", re.IGNORECASE, ) def ensure_css_color_settings(db: Session) -> Dict[str, ApplicationSetting]: """Ensure the CSS color defaults exist in the settings table.""" existing = ( db.query(ApplicationSetting) .filter(ApplicationSetting.key.in_(CSS_COLOR_DEFAULTS.keys())) .all() ) by_key = {setting.key: setting for setting in existing} created = False for key, default_value in CSS_COLOR_DEFAULTS.items(): if key in by_key: continue setting = ApplicationSetting( key=key, value=default_value, value_type=CSS_COLOR_VALUE_TYPE, category=CSS_COLOR_CATEGORY, description=f"CSS variable {key}", is_editable=True, ) db.add(setting) by_key[key] = setting created = True if created: db.commit() for key, setting in by_key.items(): db.refresh(setting) return by_key def get_css_color_settings(db: Session) -> Dict[str, str]: """Return CSS color variables, filling missing values with defaults.""" settings = ensure_css_color_settings(db) values: Dict[str, str] = { key: settings[key].value if key in settings else default for key, default in CSS_COLOR_DEFAULTS.items() } env_overrides = read_css_color_env_overrides(os.environ) if env_overrides: values.update(env_overrides) return values def update_css_color_settings( db: Session, updates: Mapping[str, str] ) -> Dict[str, str]: """Persist provided CSS color overrides and return the final values.""" if not updates: return get_css_color_settings(db) invalid_keys = sorted(set(updates.keys()) - set(CSS_COLOR_DEFAULTS.keys())) if invalid_keys: invalid_list = ", ".join(invalid_keys) raise ValueError(f"Unsupported CSS variables: {invalid_list}") normalized: Dict[str, str] = {} for key, value in updates.items(): normalized[key] = _normalize_color_value(value) settings = ensure_css_color_settings(db) changed = False for key, value in normalized.items(): setting = settings[key] if setting.value != value: setting.value = value changed = True if setting.value_type != CSS_COLOR_VALUE_TYPE: setting.value_type = CSS_COLOR_VALUE_TYPE changed = True if setting.category != CSS_COLOR_CATEGORY: setting.category = CSS_COLOR_CATEGORY changed = True if not setting.is_editable: setting.is_editable = True changed = True if changed: db.commit() for key in normalized.keys(): db.refresh(settings[key]) return get_css_color_settings(db) def read_css_color_env_overrides( env: Mapping[str, str] | None = None, ) -> Dict[str, str]: """Return validated CSS overrides sourced from environment variables.""" if env is None: env = os.environ overrides: Dict[str, str] = {} for css_key in CSS_COLOR_DEFAULTS.keys(): env_name = css_key_to_env_var(css_key) raw_value = env.get(env_name) if raw_value is None: continue overrides[css_key] = _normalize_color_value(raw_value) return overrides def _normalize_color_value(value: str) -> str: if not isinstance(value, str): raise ValueError("Color value must be a string") trimmed = value.strip() if not trimmed: raise ValueError("Color value cannot be empty") if not _COLOR_VALUE_PATTERN.match(trimmed): raise ValueError( "Color value must be a hex code or an rgb/rgba/hsl/hsla expression" ) _validate_functional_color(trimmed) return trimmed def _validate_functional_color(value: str) -> None: lowered = value.lower() if lowered.startswith("rgb(") or lowered.startswith("hsl("): _ensure_component_count(value, expected=3) elif lowered.startswith("rgba(") or lowered.startswith("hsla("): _ensure_component_count(value, expected=4) def _ensure_component_count(value: str, expected: int) -> None: if not value.endswith(")"): raise ValueError( "Color function expressions must end with a closing parenthesis" ) inner = value[value.index("(") + 1: -1] parts = [segment.strip() for segment in inner.split(",")] if len(parts) != expected: raise ValueError( "Color function expressions must provide the expected number of components" ) if any(not component for component in parts): raise ValueError("Color function components cannot be empty") def css_key_to_env_var(css_key: str) -> str: sanitized = css_key.lstrip("-").replace("-", "_").upper() return f"{CSS_ENV_PREFIX}{sanitized}" def list_css_env_override_rows( env: Mapping[str, str] | None = None, ) -> list[Dict[str, str]]: overrides = read_css_color_env_overrides(env) rows: list[Dict[str, str]] = [] for css_key, value in overrides.items(): rows.append( { "css_key": css_key, "env_var": css_key_to_env_var(css_key), "value": value, } ) return rows def save_theme_settings(db: Session, theme_data: dict): theme = db.query(ThemeSetting).first() or ThemeSetting() for key, value in theme_data.items(): setattr(theme, key, value) db.add(theme) db.commit() db.refresh(theme) return theme def get_theme_settings(db: Session): theme = db.query(ThemeSetting).first() if theme: return {c.name: getattr(theme, c.name) for c in theme.__table__.columns} return {}