""" Character and persona management utilities. Handles character switching, creation, and image management. """ from __future__ import annotations import os import re import json import shutil import time import logging from pathlib import Path from io import BytesIO from typing import Any, Dict, Optional from urllib.parse import urlparse, parse_qs import io import cgi from PIL import Image from logger import Logger logger = Logger(name="character_utils.py", level=logging.DEBUG) class CharacterUtils: """Utilities for character/persona management.""" def __init__(self, shared_data): self.shared_data = shared_data self.logger = logger # --------- helpers --------- def _send_error_response(self, handler, message: str, status_code: int = 500): handler.send_response(status_code) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'error', 'message': message}).encode('utf-8')) def _to_bmp_bytes(self, raw: bytes, width: int | None = None, height: int | None = None) -> bytes: """Convert any image bytes to BMP (optionally resize).""" with Image.open(BytesIO(raw)) as im: if im.mode != 'RGB': im = im.convert('RGB') if width and height: try: resample = Image.Resampling.LANCZOS except AttributeError: resample = Image.LANCZOS im = im.resize((width, height), resample) out = BytesIO() im.save(out, format='BMP') return out.getvalue() def get_existing_character_numbers(self, action_dir: str | Path, action_name: str) -> set[int]: """ Retourne l'ensemble des numéros déjà utilisés pour les images characters (p. ex. 1.bmp, 2.bmp, ...). """ d = Path(action_dir) if not d.exists(): return set() nums: set[int] = set() pat = re.compile(rf"^{re.escape(action_name)}(\d+)\.bmp$", re.IGNORECASE) for p in d.glob("*.bmp"): m = pat.match(p.name) if m: try: nums.add(int(m.group(1))) except ValueError: pass return nums # --------- endpoints --------- def get_current_character(self): """Lit le personnage courant depuis la config (DB).""" try: return self.shared_data.config.get('current_character', 'BJORN') or 'BJORN' except Exception: return 'BJORN' def serve_bjorn_say(self, handler): try: bjorn_says_data = {"text": self.shared_data.bjorn_says} handler.send_response(200) handler.send_header("Content-Type", "application/json") handler.end_headers() handler.wfile.write(json.dumps(bjorn_says_data).encode('utf-8')) except Exception as e: handler.send_response(500) handler.send_header("Content-Type", "application/json") handler.end_headers() handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8')) def serve_bjorn_character(self, handler): try: img_byte_arr = io.BytesIO() self.shared_data.bjorn_character.save(img_byte_arr, format='PNG') img_byte_arr = img_byte_arr.getvalue() handler.send_response(200) handler.send_header('Content-Type', 'image/png') handler.send_header('Cache-Control', 'no-cache') handler.end_headers() handler.wfile.write(img_byte_arr) except BrokenPipeError: pass except Exception as e: self.logger.error(f"Error serving status image: {e}") def list_characters(self, handler): """List all available characters with metadata.""" try: characters_dir = self.shared_data.settings_dir characters = [] for entry in os.scandir(characters_dir): if entry.is_dir(): character_name = entry.name idle_image_path = os.path.join(entry.path, 'IDLE', 'IDLE1.bmp') # legacy path? has_idle_image = os.path.exists(idle_image_path) characters.append({'name': character_name, 'has_idle_image': has_idle_image}) current_character = self.get_current_character() handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() resp = {'status': 'success', 'characters': characters, 'current_character': current_character} handler.wfile.write(json.dumps(resp).encode('utf-8')) except Exception as e: self.logger.error(f"Error in list_characters: {e}") self._send_error_response(handler, str(e)) def get_character_icon(self, handler): """Serve character icon (IDLE1.bmp).""" try: query_components = parse_qs(urlparse(handler.path).query) character = (query_components.get('character', [None])[0] or '').strip() if not character: raise ValueError('Character parameter is required') current_character = self.get_current_character() if character == current_character: # Quand le perso est actif, ses images sont dans status_images_dir/IDLE/IDLE1.bmp idle_image_path = os.path.join(self.shared_data.status_images_dir, 'IDLE', 'IDLE1.bmp') else: idle_image_path = os.path.join(self.shared_data.settings_dir, character, 'status', 'IDLE', 'IDLE1.bmp') if not os.path.exists(idle_image_path): raise FileNotFoundError(f"IDLE1.bmp for character '{character}' not found") with open(idle_image_path, 'rb') as f: image_data = f.read() handler.send_response(200) handler.send_header('Content-Type', 'image/bmp') handler.end_headers() handler.wfile.write(image_data) except Exception as e: self.logger.error(f"Error in get_character_icon: {e}") handler.send_error(404) def create_character(self, handler): """Create a new character by copying current character's images.""" try: content_length = int(handler.headers['Content-Length']) post_data = handler.rfile.read(content_length).decode('utf-8') data = json.loads(post_data) new_character_name = (data.get('character_name') or '').strip() if not new_character_name: raise ValueError('Character name is required') new_character_dir = os.path.join(self.shared_data.settings_dir, new_character_name) if os.path.exists(new_character_dir): raise FileExistsError(f"Character '{new_character_name}' already exists") self.save_current_character_images(new_character_dir) handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character created successfully'}).encode('utf-8')) except Exception as e: self.logger.error(f"Error in create_character: {e}") self._send_error_response(handler, str(e)) def switch_character(self, handler): """Switch to a different character, saving current modifications first.""" try: content_length = int(handler.headers['Content-Length']) post_data = handler.rfile.read(content_length).decode('utf-8') data = json.loads(post_data) selected_character_name = (data.get('character_name') or '').strip() if not selected_character_name: raise ValueError('Character name is required') current_character = self.get_current_character() if selected_character_name == current_character: handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character already selected'}).encode('utf-8')) return # Save current character's images current_character_dir = os.path.join(self.shared_data.settings_dir, current_character) self.save_current_character_images(current_character_dir) # Check new character exists selected_character_dir = os.path.join(self.shared_data.settings_dir, selected_character_name) if not os.path.exists(selected_character_dir): raise FileNotFoundError(f"Character '{selected_character_name}' does not exist") # Activate self.copy_character_images( selected_character_dir, self.shared_data.status_images_dir, self.shared_data.static_images_dir ) # Update config self.shared_data.config['bjorn_name'] = selected_character_name self.shared_data.config['current_character'] = selected_character_name self.shared_data.save_config() self.shared_data.load_config() time.sleep(1) self.shared_data.load_images() handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character switched successfully'}).encode('utf-8')) except Exception as e: self.logger.error(f"Error in switch_character: {e}") self._send_error_response(handler, str(e)) def delete_character(self, handler): """Delete a character, handling current character case.""" try: content_length = int(handler.headers['Content-Length']) post_data = handler.rfile.read(content_length).decode('utf-8') data = json.loads(post_data) character_name = (data.get('character_name') or '').strip() if not character_name: raise ValueError('Character name is required') if character_name == 'BJORN': raise ValueError("Cannot delete the default 'BJORN' character") character_dir = os.path.join(self.shared_data.settings_dir, character_name) if not os.path.exists(character_dir): raise FileNotFoundError(f"Character '{character_name}' does not exist") current_character = self.get_current_character() if character_name == current_character: bjorn_dir = os.path.join(self.shared_data.settings_dir, 'BJORN') if not os.path.exists(bjorn_dir): raise FileNotFoundError("Default 'BJORN' character does not exist") self.copy_character_images( bjorn_dir, self.shared_data.status_images_dir, self.shared_data.static_images_dir ) self.shared_data.config['bjorn_name'] = 'BJORN' self.shared_data.config['current_character'] = 'BJORN' self.shared_data.save_config() self.shared_data.load_config() self.shared_data.load_images() shutil.rmtree(character_dir) handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character deleted successfully'}).encode('utf-8')) except Exception as e: self.logger.error(f"Error in delete_character: {e}") self._send_error_response(handler, str(e)) def save_current_character_images(self, character_dir): """Save current character's status and static images.""" try: if not os.path.exists(character_dir): os.makedirs(character_dir) dest_status_dir = os.path.join(character_dir, 'status') if os.path.exists(dest_status_dir): shutil.rmtree(dest_status_dir) shutil.copytree(self.shared_data.status_images_dir, dest_status_dir) dest_static_dir = os.path.join(character_dir, 'static') if os.path.exists(dest_static_dir): shutil.rmtree(dest_static_dir) shutil.copytree(self.shared_data.static_images_dir, dest_static_dir) except Exception as e: self.logger.error(f"Error in save_current_character_images: {e}") def copy_character_images(self, source_dir, dest_status_dir, dest_static_dir): """Copy character images from source to destination directories.""" try: source_status_dir = os.path.join(source_dir, 'status') if os.path.exists(source_status_dir): if os.path.exists(dest_status_dir): shutil.rmtree(dest_status_dir) shutil.copytree(source_status_dir, dest_status_dir) source_static_dir = os.path.join(source_dir, 'static') if os.path.exists(source_static_dir): if os.path.exists(dest_static_dir): shutil.rmtree(dest_static_dir) shutil.copytree(source_static_dir, dest_static_dir) except Exception as e: self.logger.error(f"Error in copy_character_images: {e}") def upload_character_images(self, handler): """Ajoute des images de characters pour une action existante (toujours BMP + numérotation).""" try: ctype, pdict = cgi.parse_header(handler.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') pdict['boundary'] = bytes(pdict['boundary'], "utf-8") pdict['CONTENT-LENGTH'] = int(handler.headers.get('Content-Length')) form = cgi.FieldStorage( fp=io.BytesIO(handler.rfile.read(pdict['CONTENT-LENGTH'])), headers=handler.headers, environ={'REQUEST_METHOD': 'POST'}, keep_blank_values=True ) if 'action_name' not in form: raise ValueError("Le nom de l'action est requis") action_name = (form.getvalue('action_name') or '').strip() if not action_name: raise ValueError("Le nom de l'action est requis") if 'character_images' not in form: raise ValueError('Aucun fichier image fourni') action_dir = os.path.join(self.shared_data.status_images_dir, action_name) if not os.path.exists(action_dir): raise FileNotFoundError(f"L'action '{action_name}' n'existe pas") existing_numbers = self.get_existing_character_numbers(action_dir, action_name) next_number = max(existing_numbers, default=0) + 1 file_items = form['character_images'] if not isinstance(file_items, list): file_items = [file_items] for file_item in file_items: if not getattr(file_item, 'filename', ''): continue raw = file_item.file.read() bmp = self._to_bmp_bytes(raw) out_path = os.path.join(action_dir, f"{action_name}{next_number}.bmp") with open(out_path, 'wb') as f: f.write(bmp) next_number += 1 handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images de characters ajoutées avec succès'}).encode('utf-8')) except Exception as e: self.logger.error(f"Erreur dans upload_character_images: {e}") import traceback self.logger.error(traceback.format_exc()) self._send_error_response(handler, str(e)) def reload_fonts(self, handler): """Recharge les fonts en exécutant load_fonts.""" try: self.shared_data.load_fonts() handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'success', 'message': 'Fonts loaded successfully.'}).encode('utf-8')) except Exception as e: self.logger.error(f"Error in load_fonts: {e}") handler.send_response(500) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8')) def reload_images(self, handler): """Recharge les images en exécutant load_images.""" try: self.shared_data.load_images() handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images rechargées avec succès.'}).encode('utf-8')) except Exception as e: self.logger.error(f"Error in reload_images: {e}") handler.send_response(500) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8'))