# system_utils.py import tkinter as tk from tkinter import messagebox, simpledialog, filedialog, ttk import datetime import webbrowser import subprocess import csv import threading import time import psutil import os import platform import uuid import pygame import json import random # Importaciones directas de módulos (Acceso con el prefijo del módulo) import config import monitor_manager # Inicializar pygame mixer try: pygame.mixer.init() except Exception as e: print(f"ADVERTENCIA: No se pudo iniciar pygame.mixer: {e}") # --- Constante para la comunicación de progreso --- PROGRESS_FILE = os.path.join(config.BASE_DIR, "task_progress.txt") # =============================================== # Log y Soporte # =============================================== def log_event(message): """Escribe un mensaje en el log del sistema.""" if config.system_log and config.system_log.winfo_exists(): timestamp = datetime.datetime.now().strftime("[%H:%M:%S] ") config.system_log.config(state=tk.NORMAL) config.system_log.insert(tk.END, timestamp + message + "\n") config.system_log.see(tk.END) config.system_log.config(state=tk.DISABLED) def bytes_a_human_readable(n): """Convierte bytes a KB, MB, GB, etc.""" symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') prefix = {} for i, s in enumerate(symbols): prefix[s] = 1 << (i + 1) * 10 for s in reversed(symbols): if n >= prefix[s]: value = float(n) / prefix[s] return f'{value:.2f} {s}iB' return f'{n} B' # =============================================== # Lógica de Persistencia de Alarmas # =============================================== def guardar_alarmas(): """Guarda la lista de alarmas en un archivo JSON.""" # 1. Preparar datos para serializar: Convertir objetos datetime a strings ISO data_to_save = {} # Encontramos el último ID usado last_id = 0 for uid, data in config.alarmas_programadas.items(): data_to_save[uid] = { 'time_str': data['time'].isoformat(), # Convertir datetime a string 'active': data['active'], 'message': data['message'], 'sound_file': data['sound_file'] } last_id = max(last_id, uid) final_data = { "last_id": last_id, "alarms": data_to_save } try: os.makedirs(os.path.dirname(config.ALARM_SAVE_FILE), exist_ok=True) with open(config.ALARM_SAVE_FILE, 'w', encoding='utf-8') as f: json.dump(final_data, f, indent=4) log_event("Datos de alarma guardados con éxito.") except Exception as e: log_event(f"ERROR: No se pudieron guardar las alarmas: {e}") def cargar_alarmas(treeview_alarmas=None, root=None): """Carga las alarmas desde el archivo JSON y las añade al modelo y al Treeview.""" if not os.path.exists(config.ALARM_SAVE_FILE): return try: with open(config.ALARM_SAVE_FILE, 'r', encoding='utf-8') as f: data = json.load(f) config.alarma_counter = data.get("last_id", 0) alarm_data = data.get("alarms", {}) for uid_str, data in alarm_data.items(): uid = int(uid_str) # 1. Convertir string ISO de vuelta a objeto datetime target_time = datetime.datetime.fromisoformat(data['time_str']) # 2. Re-agregar al modelo config.alarmas_programadas[uid] = { 'time': target_time, 'active': data['active'], 'message': data['message'], 'sound_file': data['sound_file'] } # 3. Si se proporciona Treeview, actualizar la interfaz if treeview_alarmas: status = "ACTIVA" if data['active'] else "INACTIVA" treeview_alarmas.insert('', tk.END, values=( uid, target_time.strftime('%H:%M'), data['message'], status, target_time.strftime('%Y-%m-%d') ), tags=('active',) if data['active'] else ('inactive',)) # Si la alarma está activa, aseguramos que el hilo de verificación inicie if data['active'] and root and not hasattr(agregar_alarma, 'hilo_activo'): threading.Thread(target=lambda: verificar_alarma(root, treeview_alarmas), daemon=True).start() setattr(agregar_alarma, 'hilo_activo', True) log_event(f"Se cargaron {len(alarm_data)} alarmas.") except Exception as e: log_event(f"ERROR: Fallo al cargar el archivo de alarmas: {e}") config.alarmas_programadas = {} # Limpiar modelo en caso de fallo # =============================================== # Funcionalidades de Alarma (Modificadas para Pygame) # =============================================== def verificar_alarma(root, treeview_alarmas): """ Función del hilo secundario que verifica la hora actual contra la hora objetivo. """ while config.monitor_running: alarmas_a_chequear = list(config.alarmas_programadas.items()) now = datetime.datetime.now() for alarma_id, data in alarmas_a_chequear: if data['active']: target = data['time'] # Comparamos hora y minuto if now.hour == target.hour and now.minute == target.minute and now.second < 2: # Alarma alcanzada! root.after(0, lambda: notificar_alarma_alcanzada(alarma_id, data, treeview_alarmas)) # Desactivamos la alarma en el modelo para que no se repita data['active'] = False guardar_alarmas() # Guardar después de desactivar time.sleep(1) # Chequeamos cada segundo def notificar_alarma_alcanzada(alarma_id, data, treeview_alarmas): """Muestra la notificación visual, reproduce el sonido, actualiza el Treeview y registra el evento.""" # Detenemos música antes de empezar a sonar la alarma detener_mp3() # 1. Reproducir Sonido (Usando pygame) sound_file = data['sound_file'] # Usamos el archivo almacenado en el modelo if sound_file and os.path.exists(sound_file): try: if pygame.mixer.music.get_busy(): pygame.mixer.music.stop() pygame.mixer.music.load(sound_file) pygame.mixer.music.set_volume(config.alarma_volumen) pygame.mixer.music.play(-1) # Reproducir en bucle (-1) config.alarma_sonando = True log_event(f"Sonido de alarma '{os.path.basename(sound_file)}' iniciado.") except Exception as e: log_event(f"ERROR al reproducir sonido con pygame: {e}") else: log_event("ADVERTENCIA: Archivo de sonido no encontrado o no válido.") # 2. Notificación visual msg = f"¡Alarma programada alcanzada!\nTarea: {data['message']}" log_event(f"ALARMA ACTIVADA: '{data['message']}' a las {data['time'].strftime('%H:%M:%S')}") messagebox.showinfo("ALARMA", msg) # 3. Actualizar el Treeview para mostrar 'INACTIVA' for item in treeview_alarmas.get_children(): values = treeview_alarmas.item(item, 'values') if values and values[0] == str(alarma_id): treeview_alarmas.item(item, values=(alarma_id, values[1], values[2], "INACTIVA", values[4]), tags=('inactive',)) break guardar_alarmas() # Guardar después de actualizar la interfaz def detener_sonido_alarma(): """Detiene la reproducción de sonido de la alarma usando pygame.""" if config.alarma_sonando and pygame.mixer.music.get_busy(): pygame.mixer.music.stop() config.alarma_sonando = False log_event("Reproducción de alarma detenida.") elif config.alarma_sonando: config.alarma_sonando = False log_event("Estado de alarma limpiado. No estaba sonando.") def ajustar_volumen_alarma(nuevo_volumen_str): """Ajusta el volumen de la alarma (rango 0.0 a 1.0) desde el slider (0-100).""" try: vol_int = int(float(nuevo_volumen_str)) vol = vol_int / 100.0 # Convertir de 0-100 a 0.0-1.0 # 1. Actualizar la variable de configuración para futuras alarmas/música config.alarma_volumen = vol # 2. Si hay algo sonando, ajustar inmediatamente el volumen if pygame.mixer.music.get_busy(): pygame.mixer.music.set_volume(vol) log_event(f"Volumen de reproducción ajustado a {vol:.2f}.") except Exception as e: log_event(f"Error al ajustar volumen: {e}") def agregar_alarma(root, hora_str, minuto_str, tarea, treeview_alarmas, window_to_close=None, sound_file=None): """ Añade una nueva alarma a la lista (usando ID autonumérico) y actualiza el Treeview. Acepta el argumento 'sound_file' para la ruta del sonido seleccionado. """ try: hora = int(hora_str) minuto = int(minuto_str) tarea = tarea.strip() if not tarea: tarea = "Alarma sin descripción" # Cálculo de la hora objetivo now = datetime.datetime.now() target_time = datetime.datetime(now.year, now.month, now.day, hora, minuto, 0) if target_time < now: target_time += datetime.timedelta(days=1) # 1. Generar ID autonumérico y único config.alarma_counter += 1 alarma_id = config.alarma_counter # 2. Añadir al modelo config.alarmas_programadas[alarma_id] = { 'time': target_time, 'active': True, 'message': tarea, 'sound_file': sound_file if sound_file else config.ALERTA_SOUND_FILE # Usar el seleccionado } # 3. Añadir al Treeview treeview_alarmas.insert('', tk.END, values=( alarma_id, target_time.strftime('%H:%M'), tarea, "ACTIVA", target_time.strftime('%Y-%m-%d') ), tags=('active',)) # Configuramos los tags visuales treeview_alarmas.tag_configure('active', background='#FFCCCC') # Fondo rojo claro si está activa treeview_alarmas.tag_configure('inactive', background='#DDDDDD') log_event(f"Nueva alarma ({alarma_id}) programada: {tarea}") # Guardar inmediatamente después de agregar guardar_alarmas() # 4. Iniciar el hilo de verificación si no está activo if not hasattr(agregar_alarma, 'hilo_activo'): threading.Thread(target=lambda: verificar_alarma(root, treeview_alarmas), daemon=True).start() setattr(agregar_alarma, 'hilo_activo', True) # 5. Cerrar la ventana flotante si existe if window_to_close: window_to_close.destroy() except ValueError: messagebox.showerror("Error de entrada", "Asegúrate de seleccionar una hora y minuto válidos (00-23, 00-59).") def toggle_alarma(treeview_alarmas): """Activa o desactiva la alarma seleccionada.""" selected_item = treeview_alarmas.focus() if not selected_item: messagebox.showwarning("Advertencia", "Selecciona una alarma de la lista.") return alarma_id = int(treeview_alarmas.item(selected_item, 'values')[0]) data = config.alarmas_programadas.get(alarma_id) if data: data['active'] = not data['active'] new_status = "ACTIVA" if data['active'] else "INACTIVA" # Actualizar Treeview y tags visuales treeview_alarmas.item(selected_item, values=(alarma_id, data['time'].strftime('%H:%M'), data['message'], new_status, data['time'].strftime('%Y-%m-%d')), tags=('active',) if data['active'] else ('inactive',)) log_event(f"Alarma {alarma_id} toggled a {new_status}.") guardar_alarmas() # Guardar después del toggle def eliminar_alarma(treeview_alarmas): """Elimina la alarma seleccionada del modelo y del Treeview.""" selected_item = treeview_alarmas.focus() if not selected_item: messagebox.showwarning("Advertencia", "Selecciona una alarma para eliminar.") return alarma_id = int(treeview_alarmas.item(selected_item, 'values')[0]) if messagebox.askyesno("Confirmar Eliminación", f"¿Estás seguro de que deseas eliminar la alarma con ID {alarma_id}?"): # 1. Eliminar del modelo if alarma_id in config.alarmas_programadas: del config.alarmas_programadas[alarma_id] # 2. Eliminar del Treeview treeview_alarmas.delete(selected_item) log_event(f"Alarma {alarma_id} eliminada.") guardar_alarmas() # Guardar después de la eliminación def modificar_alarma_existente(root, alarma_id, hora_str, minuto_str, tarea, treeview_alarmas, window_to_close, sound_file): """ Sobreescribe los datos de una alarma existente en el modelo y actualiza el Treeview. """ try: data = config.alarmas_programadas.get(alarma_id) if not data: messagebox.showerror("Error", "Alarma no encontrada en el sistema.") return hora = int(hora_str) minuto = int(minuto_str) tarea = tarea.strip() or "Alarma sin descripción" # 1. Recalcular la hora objetivo now = datetime.datetime.now() target_time = datetime.datetime(now.year, now.month, now.day, hora, minuto, 0) if target_time < now: target_time += datetime.timedelta(days=1) # 2. Actualizar el modelo (manteniendo el estado 'active' actual) data['time'] = target_time data['message'] = tarea data['sound_file'] = sound_file # 3. Actualizar el Treeview new_status = "ACTIVA" if data['active'] else "INACTIVA" # Encontramos el item en el Treeview para actualizarlo for item in treeview_alarmas.get_children(): if treeview_alarmas.item(item, 'values')[0] == str(alarma_id): treeview_alarmas.item(item, values=( alarma_id, target_time.strftime('%H:%M'), tarea, new_status, target_time.strftime('%Y-%m-%d') ), tags=('active',) if data['active'] else ('inactive',)) break log_event(f"Alarma {alarma_id} modificada y re-programada para {target_time.strftime('%H:%M')}.") guardar_alarmas() window_to_close.destroy() except ValueError: messagebox.showerror("Error de entrada", "Asegúrate de seleccionar una hora y minuto válidos.") except Exception as e: log_event(f"ERROR al modificar alarma: {e}") messagebox.showerror("Error", f"Error al modificar la alarma: {e}") def seleccionar_archivo_alarma(root, label_archivo): """Abre un diálogo para seleccionar un archivo de sonido de la carpeta alarmas.""" try: # Asegurar que la carpeta exista antes de usarla como directorio inicial os.makedirs(config.ALARM_FOLDER, exist_ok=True) except Exception as e: log_event(f"ERROR: No se pudo crear la carpeta de alarmas: {e}") messagebox.showerror("Error", f"No se pudo crear el directorio de alarmas: {e}") try: archivo_seleccionado = filedialog.askopenfilename( initialdir=config.ALARM_FOLDER, title="Seleccionar Sonido de Alarma", filetypes=(("Archivos de Audio", "*.wav *.mp3"), ("Todos los archivos", "*.*")), parent=root ) if archivo_seleccionado: # 1. Almacenar la ruta completa en la variable de configuración global config.ALERTA_SOUND_FILE = archivo_seleccionado # 2. Actualizar el Label en la UI para mostrar el nombre del archivo label_archivo.config(text=os.path.basename(archivo_seleccionado)) log_event(f"Sonido seleccionado: {os.path.basename(archivo_seleccionado)}") return archivo_seleccionado else: log_event("Selección de sonido cancelada.") return config.ALERTA_SOUND_FILE except Exception as e: log_event(f"ERROR al iniciar el diálogo de selección de archivo: {e}") messagebox.showerror("Error", "No se pudo iniciar el diálogo de selección de archivo.") return config.ALERTA_SOUND_FILE # =============================================== # Funcionalidades de Reproducción de Música (NUEVAS) # =============================================== def seleccionar_mp3(root, label_archivo): """Abre un diálogo para seleccionar un archivo MP3 o WAV.""" try: # Directorio inicial initial_dir = os.path.join(config.BASE_DIR, "data") os.makedirs(initial_dir, exist_ok=True) archivo_seleccionado = filedialog.askopenfilename( initialdir=initial_dir, title="Seleccionar Archivo de Música", filetypes=(("Archivos de Audio", "*.mp3 *.wav"), ("Todos los archivos", "*.*")), parent=root ) if archivo_seleccionado: config.current_music_file = archivo_seleccionado label_archivo.config(text=os.path.basename(archivo_seleccionado)) log_event(f"Música seleccionada: {os.path.basename(archivo_seleccionado)}") return archivo_seleccionado else: log_event("Selección de música cancelada.") return None except Exception as e: log_event(f"ERROR al iniciar el diálogo de selección de música: {e}") messagebox.showerror("Error", "No se pudo iniciar el diálogo de selección de archivo.") return None def reproducir_mp3(root): """Carga y reproduce el archivo seleccionado, asegurando que no haya conflictos con la alarma.""" if not config.current_music_file or not os.path.exists(config.current_music_file): messagebox.showwarning("Advertencia", "Por favor, selecciona un archivo de música primero.") return # Detener cualquier reproducción previa (alarma o música) detener_sonido_alarma() # Detiene la alarma si está sonando detener_mp3() # Detiene la música si está sonando try: pygame.mixer.music.load(config.current_music_file) # Reutilizamos la variable de volumen global, ya que Pygame Mixer tiene un solo canal de control de volumen pygame.mixer.music.set_volume(config.alarma_volumen) pygame.mixer.music.play(-1) # Reproducir en bucle config.music_sonando = True log_event(f"Reproducción de música iniciada: {os.path.basename(config.current_music_file)}") except pygame.error as e: log_event(f"ERROR de Pygame al reproducir música: {e}") messagebox.showerror("Error de Reproducción", f"No se pudo reproducir el archivo. Asegúrate de que sea compatible con Pygame. ({e})") except Exception as e: log_event(f"ERROR inesperado al reproducir música: {e}") def detener_mp3(): """Detiene la reproducción de música si está activa.""" if hasattr(config, 'music_sonando') and config.music_sonando and pygame.mixer.music.get_busy(): pygame.mixer.music.stop() config.music_sonando = False log_event("Reproducción de música detenida.") elif hasattr(config, 'music_sonando') and config.music_sonando: config.music_sonando = False log_event("Estado de música limpiado. No estaba sonando.") def ajustar_volumen_mp3(nuevo_volumen_str): """Ajusta el volumen de Pygame (rango 0.0 a 1.0) desde el slider (0-100).""" # Reutiliza la misma lógica que ajustar_volumen_alarma, ya que Pygame Mixer solo tiene un volumen maestro. ajustar_volumen_alarma(nuevo_volumen_str) # =============================================== # Funcionalidades Externas (Existentes) # =============================================== def lanzar_url(url): """Abre una URL en el navegador y registra el evento.""" try: webbrowser.open_new_tab(url) log_event(f"Lanzando URL: {url}") except Exception as e: log_event(f"Error al intentar abrir la URL {url}: {e}") def ejecutar_script_en_hilo(status_label, root): """Ejecuta un script de Bash en un hilo separado con ProgressBar.""" def run_script(): if not root.winfo_exists(): return # 1. PREPARACIÓN E INICIO DE PROGRESSBAR if os.path.exists(config.PROGRESS_FILE): os.remove(config.PROGRESS_FILE) if config.progress_bar: root.after(0, config.progress_bar.config, {"value": 0, "maximum": 100}) root.after(0, config.progress_bar.start, 20) status_label.after(0, status_label.config, {"text": "Ejecutando Backup...", "bg": "orange"}) log_event("Iniciando copia de seguridad (backup)...") try: command = ["bash", config.SCRIPT_PATH, config.BASE_DIR] result = subprocess.run(command, capture_output=True, text=True, check=False) if not root.winfo_exists(): return # Doble chequeo después de subprocess # 2. FINALIZACIÓN Y LIMPIEZA if config.progress_bar: root.after(0, config.progress_bar.stop) root.after(0, config.progress_bar.config, {"value": 100 if result.returncode == 0 else 0}) if result.returncode == 0: if root.winfo_exists(): root.after(0, status_label.config, {"text": "Backup: OK", "bg": "green"}) log_event("Copia de seguridad finalizada con éxito.") else: if root.winfo_exists(): root.after(0, status_label.config, {"text": f"Backup: ERROR ({result.returncode})", "bg": "red"}) log_event(f"ERROR en la copia de seguridad. Código: {result.returncode}. Verifique la terminal.") except Exception as e: if config.progress_bar: root.after(0, config.progress_bar.stop) if root.winfo_exists(): root.after(0, status_label.config, {"text": f"Error desconocido: {str(e)}", "bg": "red"}) log_event(f"Error desconocido en backup: {str(e)}") finally: if os.path.exists(config.PROGRESS_FILE): os.remove(config.PROGRESS_FILE) # 3. INICIO DEL HILO DE TAREA Y EL HILO MONITOR threading.Thread(target=run_script, daemon=True).start() threading.Thread(target=lambda: read_progress_pipe(root), daemon=True).start() def read_progress_pipe(root): """Hilo que monitorea un archivo temporal para actualizar la ProgressBar.""" if not config.progress_bar: return while config.monitor_running: if not root.winfo_exists(): break try: if os.path.exists(config.PROGRESS_FILE): with open(config.PROGRESS_FILE, 'r') as f: content = f.read().strip() if content.isdigit(): value = int(content) root.after(0, config.progress_bar.config, {"value": value}) root.after(0, config.progress_bar.update) elif content.startswith("STATUS:"): log_event(content[7:]) except Exception as e: pass time.sleep(0.1) def update_time(status_bar, root): """Función que actualiza la hora y el día de la semana en un label (Hilo).""" while config.monitor_running: now = datetime.datetime.now() day_of_week = now.strftime("%A") time_str = now.strftime("%H:%M:%S") date_str = now.strftime("%Y-%m-%d") label_text = f"{day_of_week}, {date_str} - {time_str}" if root.winfo_exists(): root.after(1000, lambda: status_bar.winfo_exists() and status_bar.config(text=label_text)) else: break time.sleep(1) def manejar_registro_csv(status_label): """Inicia o detiene la escritura de datos de recursos a un archivo CSV.""" if config.registro_csv_activo: config.registro_csv_activo = False status_label.config(text="Registro: Detenido", bg="gray") log_event("Registro de historial de recursos detenido.") else: config.registro_csv_activo = True status_label.config(text="Registro: ACTIVO", bg="gold") log_event(f"Registro de historial de recursos iniciado en: {config.archivo_registro_csv}") try: with open(config.archivo_registro_csv, mode='a', newline='') as file: writer = csv.writer(file) if file.tell() == 0: writer.writerow(['Timestamp', 'CPU_Total (%)', 'RAM_Total (%)', 'Net_Sent (KB/s)', 'Net_Recv (KB/s)']) except Exception as e: log_event(f"ERROR: No se pudo iniciar el registro CSV: {e}") config.registro_csv_activo = False status_label.config(text="Registro: ERROR", bg="red") return config.registro_csv_activo # =============================================== # Funciones del Editor de Texto (Existentes) # =============================================== def nuevo_archivo(): """Limpia el contenido actual del editor de texto.""" if not config.editor_texto: log_event("ERROR: Editor de texto no inicializado.") return config.editor_texto.delete("1.0", tk.END) log_event("Editor de texto limpiado. Nuevo archivo iniciado.") def abrir_carpeta_especifica(ruta, nombre): """Abre una carpeta específica del proyecto en el explorador de archivos.""" try: os.makedirs(ruta, exist_ok=True) # Usamos subprocess.Popen para ser compatible con más sistemas if platform.system() == "Windows": subprocess.Popen(['explorer', ruta]) elif platform.system() == "Darwin": # macOS subprocess.Popen(['open', ruta]) else: # Asume Linux/Unix (usa xdg-open) subprocess.Popen(['xdg-open', ruta]) log_event(f"Carpeta '{nombre}' abierta: {ruta}") except Exception as e: log_event(f"ERROR al abrir la carpeta '{nombre}': {e}") messagebox.showerror("Error", f"No se pudo abrir el directorio {nombre}: {e}") def abrir_carpeta_notas(): """Función wrapper para abrir la carpeta de notas.""" abrir_carpeta_especifica(config.NOTES_FOLDER, "Notas") def abrir_archivo(root): """Muestra un diálogo para seleccionar y abrir un archivo .txt de la carpeta de notas.""" if not config.editor_texto: log_event("ERROR: Editor de texto no inicializado.") return # Usar la nueva carpeta de notas ruta_notas = config.NOTES_FOLDER # Asegurar que la carpeta exista antes de abrir el diálogo os.makedirs(ruta_notas, exist_ok=True) archivo_seleccionado = filedialog.askopenfilename( initialdir=ruta_notas, title="Abrir Archivo de Notas", filetypes=(("Archivos de texto", "*.txt"), ("Todos los archivos", "*.*")), parent=root ) if not archivo_seleccionado: log_event("Apertura de archivo de notas cancelada.") return try: with open(archivo_seleccionado, 'r', encoding='utf-8') as f: contenido = f.read() config.editor_texto.delete("1.0", tk.END) config.editor_texto.insert("1.0", contenido) nombre = os.path.basename(archivo_seleccionado) log_event(f"Archivo de notas cargado con éxito: {nombre}") messagebox.showinfo("Abierto", f"Archivo '{nombre}' cargado con éxito.") except Exception as e: log_event(f"ERROR al abrir o leer el archivo de notas: {e}") messagebox.showerror("Error de Lectura", f"No se pudo leer el archivo seleccionado: {e}") def guardar_texto(root): """ Pide al usuario un nombre de archivo y guarda el contenido del editor en la carpeta Proyecto/data/notas/ como un archivo .txt. """ if not config.editor_texto: log_event("ERROR: Editor de texto no inicializado.") return try: nombre_archivo = simpledialog.askstring( "Guardar Archivo", "Introduce el nombre del archivo (ej: notas)", parent=root ) except Exception as e: log_event(f"ERROR al iniciar diálogo de guardado: {e}") return if not nombre_archivo: log_event("Guardado cancelado por el usuario.") return ruta_notas = config.NOTES_FOLDER # Usar la nueva carpeta de notas if not nombre_archivo.lower().endswith('.txt'): nombre_archivo += '.txt' ruta_completa = os.path.join(ruta_notas, nombre_archivo) contenido = config.editor_texto.get("1.0", tk.END) try: os.makedirs(ruta_notas, exist_ok=True) with open(ruta_completa, 'w', encoding='utf-8') as f: f.write(contenido) log_event(f"Archivo de notas guardado con éxito: {ruta_completa}") messagebox.showinfo("Guardado", f"Archivo guardado como:\n{nombre_archivo}") except PermissionError: error_msg = f"ERROR: Permiso denegado al escribir en {ruta_completa}." log_event(error_msg) messagebox.showerror("Error de Permiso", error_msg) except Exception as e: error_msg = f"ERROR al guardar {nombre_archivo}: {e}" log_event(error_msg) messagebox.showerror("Error de Escritura", error_msg) # =============================================== # Funciones de Web Scraping Adicionales # =============================================== def detener_scraping(): """Detiene la ejecución del hilo de scraping si está activo.""" if config.scraping_running: config.scraping_running = False if config.scraping_progress_bar: config.scraping_progress_bar.stop() log_event("🛑 Proceso de Web Scrapear solicitado para detenerse.") # Opcional: Escribir un mensaje de detención en el área de salida if config.scraping_output_text: config.scraping_output_text.insert(tk.END, "\n--- PROCESO CANCELADO POR EL USUARIO ---\n") else: log_event("El proceso de Web Scrapear no está actualmente activo.") def guardar_scraping(contenido, root): """ Guarda el contenido del ScrolledText de scraping en un archivo TXT en la carpeta Proyecto/data/scraping/. """ if not contenido or contenido.strip() == "Resultado de la Extracción:": messagebox.showwarning("Advertencia", "El área de resultados está vacía.") return # Pedir un nombre de archivo try: nombre_base = datetime.datetime.now().strftime("scraping_%Y%m%d_%H%M%S") nombre_archivo = simpledialog.askstring( "Guardar Resultado", f"Introduce el nombre del archivo (predeterminado: {nombre_base})", initialvalue=nombre_base, parent=root ) except Exception as e: log_event(f"ERROR al iniciar diálogo de guardado de scraping: {e}") return if not nombre_archivo: log_event("Guardado de scraping cancelado por el usuario.") return # Rutas ruta_scrapping = config.SCRAPING_FOLDER if not nombre_archivo.lower().endswith('.txt'): nombre_archivo += '.txt' ruta_completa = os.path.join(ruta_scrapping, nombre_archivo) try: # Asegurar que la carpeta exista os.makedirs(ruta_scrapping, exist_ok=True) with open(ruta_completa, 'w', encoding='utf-8') as f: f.write(contenido) log_event(f"Resultado de scraping guardado con éxito: {ruta_completa}") messagebox.showinfo("Guardado", f"Resultado de scraping guardado como:\n{nombre_archivo}") except Exception as e: error_msg = f"ERROR al guardar el resultado de scraping: {e}" log_event(error_msg) messagebox.showerror("Error de Escritura", error_msg) def abrir_archivo_scraping_config(root): """ Abre un diálogo para seleccionar un archivo JSON de configuración de scraping y lo carga en config.scraping_config_data. """ try: # 1. Asegurar que la carpeta exista os.makedirs(config.SCRAPING_CONFIG_FOLDER, exist_ok=True) # 2. Abrir diálogo de selección archivo_seleccionado = filedialog.askopenfilename( initialdir=config.SCRAPING_CONFIG_FOLDER, title="Cargar Configuración de Scraping (JSON)", filetypes=(("Archivos JSON", "*.json"), ("Todos los archivos", "*.*")), parent=root ) if not archivo_seleccionado: log_event("Carga de configuración de scraping cancelada.") return # 3. Cargar el JSON with open(archivo_seleccionado, 'r', encoding='utf-8') as f: data = json.load(f) # 4. Validar estructura mínima (opcional, pero buena práctica) required_keys = ['type', 'selector'] if not all(key in data for key in required_keys): messagebox.showerror("Error de Configuración", f"El archivo JSON debe contener al menos las claves: {', '.join(required_keys)}.") log_event("ERROR: Configuración JSON incompleta.") return # 5. Guardar en la configuración global config.scraping_config_data = data # 6. Actualizar campos de la UI file_name = os.path.basename(archivo_seleccionado) # Actualizar URL de la interfaz si el JSON tiene 'url' if 'url' in data and config.scraping_url_input: config.scraping_url_input.set(data['url']) # Actualizar Label del archivo cargado if config.scraping_config_file_label: config.scraping_config_file_label.config(text=f"Config: [{file_name}]") log_event(f"Configuración de scraping '{file_name}' cargada con éxito.") messagebox.showinfo("Éxito", f"Configuración de '{file_name}' cargada. Presiona 'Iniciar Scrapear'.") except FileNotFoundError: log_event("ERROR: Archivo de configuración no encontrado.") messagebox.showerror("Error", "Archivo no encontrado.") except json.JSONDecodeError: log_event("ERROR: El archivo no es un JSON válido.") messagebox.showerror("Error", "El archivo cargado no es un formato JSON válido.") except Exception as e: log_event(f"ERROR al cargar la configuración de scraping: {e}") messagebox.showerror("Error", f"Fallo al cargar la configuración: {e}") # =============================================== # Funcionalidad de Juegos (NUEVO) # =============================================== def simular_juego_camellos(root): """ Simula una carrera de camellos con actualizaciones Thread-Safe, mostrando la simulación en una nueva ventana Toplevel. """ # 1. Chequeo de juego activo if hasattr(config, 'juego_window') and config.juego_window and config.juego_window.winfo_exists(): messagebox.showwarning("Advertencia", "Ya hay un juego activo. Ciérralo para iniciar uno nuevo.") return # 2. Inicializar la ventana de juego juego_window = tk.Toplevel(root) juego_window.title("Carrera de Camellos 🐫 (Thread-Safe)") juego_window.geometry("550x300") juego_window.resizable(False, False) config.juego_window = juego_window config.juego_running = True # Variables de estado del juego posiciones = {'Camello A': 0, 'Camello B': 0, 'Camello C': 0} meta = 35 # 3. Widgets de la UI tk.Label(juego_window, text="¡Iniciando Carrera!", font=('Helvetica', 14, 'bold')).pack(pady=10) track_frame = tk.Frame(juego_window) track_frame.pack(padx=20, pady=10, fill='x') # Labels para la pista labels = {} for i, camello in enumerate(posiciones.keys()): tk.Label(track_frame, text=f"{camello}:", font=('Helvetica', 10, 'bold')).grid(row=i, column=0, sticky='w', padx=5, pady=5) # Usamos un font monoespaciado para que la barra se vea bien labels[camello] = tk.Label(track_frame, text="🐫", anchor='w', width=45, font=('Courier', 10), bg='lightgray', relief='sunken') labels[camello].grid(row=i, column=1, sticky='ew', padx=5, pady=5) resultado_label = tk.Label(juego_window, text="En curso...", font=('Helvetica', 12)) resultado_label.pack(pady=10) def cerrar_juego(): """Función que maneja el cierre de la ventana del juego.""" config.juego_running = False juego_window.destroy() log_event("Juego de camellos cerrado.") juego_window.protocol("WM_DELETE_WINDOW", cerrar_juego) def avanzar_carrera(): """Lógica de avance de la carrera (simulación).""" if not config.juego_running or not juego_window.winfo_exists(): return ganador = None for camello in posiciones.keys(): if posiciones[camello] < meta: # Avance aleatorio (1-3 pasos) avance = random.randint(1, 3) posiciones[camello] += avance # Actualizar la representación visual (Thread-Safe) bar = " " * min(posiciones[camello], meta) # Para la visualización, el camello siempre está al final de la barra labels[camello].config(text=bar + "🐫" + " " * (meta - posiciones[camello]) + " | META") if posiciones[camello] >= meta: ganador = camello break if ganador: resultado_label.config(text=f"¡{ganador} ha ganado la carrera!", fg='green') config.juego_running = False log_event(f"Carrera de camellos finalizada. Ganador: {ganador}") # Botón para cerrar ttk.Button(juego_window, text="Cerrar Juego", command=cerrar_juego).pack(pady=10) elif config.juego_running: # Re-programar el siguiente paso (Thread-Safe) juego_window.after(300, avanzar_carrera) log_event("Simulación de Carrera de Camellos iniciada.") juego_window.after(100, avanzar_carrera) # Iniciar la simulación