Proyecto1AVApsp/app.py

4363 lines
196 KiB
Python

#!/usr/bin/env python3
"""Dashboard principal del Proyecto Global."""
from __future__ import annotations
import datetime
import json
import os
import shutil
import queue
import random
import socket
import subprocess
import sys
import tempfile
import threading
import time
import traceback
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk
from tkinter import font as tkfont
# -------------------- dependencias opcionales --------------------
try:
import psutil # type: ignore
except Exception: # pragma: no cover
psutil = None # type: ignore
try:
import matplotlib
matplotlib.use('TkAgg')
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
MATPLOTLIB_AVAILABLE = True
except Exception: # pragma: no cover
MATPLOTLIB_AVAILABLE = False
Figure = None
FigureCanvasTkAgg = None
try:
import pygame # type: ignore
pygame_available = True
except Exception: # pragma: no cover
pygame_available = False
try:
import requests # type: ignore
REQUESTS_AVAILABLE = True
except Exception: # pragma: no cover
requests = None # type: ignore
REQUESTS_AVAILABLE = False
try:
from bs4 import BeautifulSoup
BEAUTIFULSOUP_AVAILABLE = True
except Exception: # pragma: no cover
BEAUTIFULSOUP_AVAILABLE = False
SERVER_HOST_DEFAULT = '127.0.0.1'
SERVER_PORT_DEFAULT = 3333
# Clave predeterminada facilitada por el usuario para OpenWeather.
# Puede sobrescribirse exportando OPENWEATHER_API_KEY en el entorno.
OPENWEATHER_FALLBACK_API_KEY = os.environ.get(
'OPENWEATHER_FALLBACK_API_KEY',
'431239407e3628578c83e67180cf720f'
).strip()
_OPENWEATHER_ENV_KEY = os.environ.get('OPENWEATHER_API_KEY', '').strip()
OPENWEATHER_API_KEY = _OPENWEATHER_ENV_KEY or OPENWEATHER_FALLBACK_API_KEY
JAVEA_LATITUDE = 38.789166
JAVEA_LONGITUDE = 0.163055
# -------------------- paleta visual --------------------
PRIMARY_BG = '#f4f5fb'
PANEL_BG = '#ffffff'
SECONDARY_BG = '#eef2ff'
ACCENT_COLOR = '#ff6b6b'
ACCENT_DARK = '#c44569'
TEXT_COLOR = '#1f2d3d'
SUBTEXT_COLOR = '#67708a'
BUTTON_BG = '#e7ecff'
BUTTON_ACTIVE_BG = '#ffd6d1'
def _env_float(name: str, default: float) -> float:
value = os.environ.get(name)
if value is None:
return default
try:
return float(value)
except ValueError:
return default
WALLAPOP_DEVICE_ID = os.environ.get('WALLAPOP_DEVICE_ID', '48ca24ec-2e8c-4dbb-9f0b-6b4f99194626').strip()
WALLAPOP_APP_VERSION = os.environ.get('WALLAPOP_APP_VERSION', '814060').strip()
WALLAPOP_MPID = os.environ.get('WALLAPOP_MPID', '6088013701866267655').strip()
WALLAPOP_DEVICE_OS = os.environ.get('WALLAPOP_DEVICE_OS', '0').strip()
WALLAPOP_USER_AGENT = os.environ.get(
'WALLAPOP_USER_AGENT',
'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Mobile Safari/537.36'
).strip()
WALLAPOP_SEC_CH_UA = os.environ.get('WALLAPOP_SEC_CH_UA', '"Not_A Brand";v="99", "Chromium";v="142"')
WALLAPOP_SEC_CH_UA_MOBILE = os.environ.get('WALLAPOP_SEC_CH_UA_MOBILE', '?1')
WALLAPOP_SEC_CH_UA_PLATFORM = os.environ.get('WALLAPOP_SEC_CH_UA_PLATFORM', '"Android"')
WALLAPOP_LATITUDE = _env_float('WALLAPOP_LATITUDE', 40.416775)
WALLAPOP_LONGITUDE = _env_float('WALLAPOP_LONGITUDE', -3.70379)
WALLAPOP_COUNTRY_CODE = os.environ.get('WALLAPOP_COUNTRY_CODE', 'ES').strip() or 'ES'
WALLAPOP_LANGUAGE = os.environ.get('WALLAPOP_LANGUAGE', 'es').strip() or 'es'
WALLAPOP_CATEGORY_ID = os.environ.get('WALLAPOP_CATEGORY_ID', '').strip()
WALLAPOP_REFERER = os.environ.get('WALLAPOP_REFERER', 'https://es.wallapop.com/').strip()
WALLAPOP_HEADERS = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'es,es-ES;q=0.9',
'Referer': WALLAPOP_REFERER,
'User-Agent': WALLAPOP_USER_AGENT,
'x-deviceid': WALLAPOP_DEVICE_ID,
'x-deviceos': WALLAPOP_DEVICE_OS,
'deviceos': WALLAPOP_DEVICE_OS,
'x-appversion': WALLAPOP_APP_VERSION,
'mpid': WALLAPOP_MPID,
'sec-ch-ua': WALLAPOP_SEC_CH_UA,
'sec-ch-ua-mobile': WALLAPOP_SEC_CH_UA_MOBILE,
'sec-ch-ua-platform': WALLAPOP_SEC_CH_UA_PLATFORM
}
class GameClient:
"""Cliente TCP para el juego Minesweeper."""
def __init__(self, on_message: Callable[[dict], None], on_disconnect: Callable[[], None]):
self._on_message = on_message
self._on_disconnect = on_disconnect
self._sock: socket.socket | None = None
self._lock = threading.Lock()
self._connected = False
def connect(self, host: str, port: int) -> bool:
with self._lock:
if self._connected:
return True
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
self._sock = sock
self._connected = True
# Guardar nuestra dirección local
self.my_address = str(sock.getsockname())
print(f"[CLIENT] Mi dirección: {self.my_address}")
threading.Thread(target=self._recv_loop, daemon=True).start()
return True
except Exception as exc:
print(f"[ERROR] Conexión fallida: {exc}")
return False
def _recv_loop(self):
try:
while self._connected and self._sock:
data = self._sock.recv(4096)
if not data:
print("[CLIENT] No data received, connection closed")
break
try:
text_chunk = data.decode('utf-8', errors='replace')
print(f"[CLIENT] Received: {text_chunk[:200]}") # Debug: primeros 200 chars
# Split by newline for robust framing
lines = text_chunk.split('\n')
# Note: this simple split might break if a message is split across recv calls.
# For a robust production app we need a buffer.
# Assuming short JSONs for now.
for line in lines:
line = line.strip()
if not line: continue
try:
msg = json.loads(line)
print(f"[CLIENT] Parsed message: {msg.get('type')}")
self._on_message(msg)
except json.JSONDecodeError as e:
print(f"[CLIENT] JSON decode error: {e}, line: {line[:100]}")
except Exception as e:
print(f"[CLIENT] Exception in recv: {e}")
except Exception as exc:
print(f"[ERROR] Recv: {exc}")
finally:
with self._lock:
self._connected = False
if self._sock:
try:
self._sock.close()
except Exception:
pass
self._sock = None
print("[CLIENT] Calling _on_disconnect")
self._on_disconnect()
def send(self, data: dict) -> bool:
with self._lock:
if not self._connected or not self._sock:
return False
try:
msg = json.dumps(data) + '\n'
self._sock.sendall(msg.encode('utf-8'))
return True
except Exception:
self._connected = False
return False
def close(self):
with self._lock:
self._connected = False
if self._sock:
try:
self._sock.close()
except Exception:
pass
self._sock = None
class DashboardApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title('Panel de laboratorio - Proyecto Global')
self.geometry('1220x740')
self.minsize(1024, 660)
self.configure(bg=PRIMARY_BG)
self._setup_fonts()
self._maximize_with_borders()
self.columnconfigure(0, weight=0)
self.columnconfigure(1, weight=1)
self.columnconfigure(2, weight=0)
self.rowconfigure(0, weight=0)
self.rowconfigure(1, weight=1)
self.rowconfigure(2, weight=0)
self._running = True
self._game_queue: 'queue.Queue[dict]' = queue.Queue()
self._scraping_queue: 'queue.Queue[tuple[str, ...]]' = queue.Queue()
self._traffic_last = psutil.net_io_counters() if psutil else None
self._resource_history = {'cpu': [], 'mem': [], 'threads': []}
self._resource_poll_job: str | None = None
self.game_client = GameClient(self._enqueue_game_message, self._on_game_disconnect)
self._game_phase = 'LOBBY' # LOBBY, PLACING, PLAYING
self.grid_buttons = {} # (x, y) -> tk.Button
self.alarm_counter = 1
self.active_alarms: list[dict[str, datetime.datetime | str]] = []
self.game_window_canvas = None
self.game_window_status = None
self.game_move_queue: queue.Queue | None = None
self.game_queue_processor_active = False
self.game_done_event: threading.Event | None = None
self.game_finish_line = 0
self.game_racer_names: list[str] = []
self.game_camel_count = 3
self.game_speed_factor = 1.0
self.music_temp_file: str | None = None
self.weather_api_key = OPENWEATHER_API_KEY
self.results_area: tk.Frame | None = None
self.results_title: tk.Label | None = None
self.scraping_popup: tk.Toplevel | None = None
self.simple_scraping_popup: tk.Toplevel | None = None
self.weather_popup: tk.Toplevel | None = None
self.chart_canvas = None
self.ax_cpu = None
self.ax_mem = None
self.ax_threads = None
self.line_cpu = None
self.line_mem = None
self.mem_fill = None
self.thread_bars = None
if psutil:
try:
self._ps_process = psutil.Process(os.getpid())
except Exception:
self._ps_process = None
else:
self._ps_process = None
self._build_header()
self._build_left_panel()
self._build_center_panel()
self._build_right_panel()
self._build_status_bar()
self._update_clock()
if psutil:
self.after(1000, self._update_traffic)
try:
psutil.cpu_percent(interval=None)
except Exception:
pass
self._resource_poll_job = self.after(1000, self._resource_poll_tick)
threading.Thread(target=self._game_loop, daemon=True).start()
self.after(100, self._process_scraping_queue)
self.after(1000, self._refresh_alarms_loop)
# ------------------------ UI ------------------------
def _maximize_with_borders(self) -> None:
def _apply_zoom():
# Intentar diferentes atributos para maximizar/pantalla completa en diferentes OS
for attr in ('-zoomed',):
try:
self.attributes(attr, True)
return
except tk.TclError:
continue
try:
self.state('zoomed')
except tk.TclError:
pass
self.after(0, _apply_zoom)
def _setup_fonts(self) -> None:
base_family = 'Segoe UI'
self.font_title = tkfont.Font(family=base_family, size=16, weight='bold')
self.font_header = tkfont.Font(family=base_family, size=13, weight='bold')
self.font_body = tkfont.Font(family=base_family, size=11)
self.font_small = tkfont.Font(family=base_family, size=10)
def _build_header(self) -> None:
header = tk.Frame(self, bg=PANEL_BG, bd=0, highlightthickness=0)
header.grid(row=0, column=0, columnspan=3, sticky='new')
header.grid_columnconfigure(tuple(range(7)), weight=1)
tabs = [
('T1. Procesos', '#4c6ef5'),
('T2. Threads', '#ff6b6b'),
('T3. Sockets', '#ffa94d'),
('T4. Servicios', '#51cf66'),
('T5. Seguridad', '#845ef7'),
('Configuración', '#2f3545')
]
for idx, (text, color) in enumerate(tabs):
badge = tk.Label(
header,
text=text,
fg=color,
bg=PANEL_BG,
font=self.font_title,
padx=16,
pady=8
)
badge.grid(row=0, column=idx, padx=6, pady=8)
def _build_left_panel(self) -> None:
panel = tk.Frame(self, width=220, bg=SECONDARY_BG, bd=0, highlightthickness=0)
panel.grid(row=1, column=0, sticky='nsw', padx=6, pady=(50,6))
panel.grid_propagate(False)
self.left_panel = panel
self._left_section('Acciones')
self._left_button('Analizar Wallapop', self._open_wallapop_popup)
self._left_button('Scraping simple', self._open_simple_scraping_popup)
self._left_button('Navegar', lambda: self._open_web('https://www.google.com'))
self._left_button('API Tiempo', self._show_weather_popup)
self._left_section('Aplicaciones')
self._left_button('Visual Code', lambda: self._launch_process(['code']))
self._left_button('Camellos', self._open_game_window)
self._left_button('App3', lambda: self._launch_process(['firefox']))
self._left_section('Procesos batch')
self._left_button('Copias de seguridad', self._run_backup_script)
def _left_section(self, text: str) -> None:
tk.Label(self.left_panel, text=text.upper(), bg=SECONDARY_BG, fg=SUBTEXT_COLOR, font=self.font_small).pack(anchor='w', padx=16, pady=(16,4))
def _left_button(self, text: str, command) -> None:
btn = tk.Button(
self.left_panel,
text=text,
width=20,
bg=BUTTON_BG,
activebackground=BUTTON_ACTIVE_BG,
relief='flat',
command=command
)
btn.pack(fill='x', padx=16, pady=4)
def _build_center_panel(self) -> None:
center = tk.Frame(self, bg=PANEL_BG, bd=0)
center.grid(row=1, column=1, sticky='nsew', padx=6, pady=(50,6))
center.rowconfigure(1, weight=1)
center.columnconfigure(0, weight=1)
self.center_panel = center
self._build_notebook()
self._build_notes_panel()
def _build_notebook(self) -> None:
style = ttk.Style()
style.configure('Custom.TNotebook', background=PANEL_BG, borderwidth=0)
style.configure('Custom.TNotebook.Tab', padding=(16, 8), font=self.font_body)
style.map('Custom.TNotebook.Tab', background=[('selected', '#dde2ff')])
notebook = ttk.Notebook(self.center_panel, style='Custom.TNotebook')
notebook.grid(row=0, column=0, sticky='nsew', padx=4, pady=4)
self.notebook = notebook
self.tab_resultados = tk.Frame(notebook, bg='white')
self.tab_navegador = tk.Frame(notebook, bg='white')
self.tab_correos = tk.Frame(notebook, bg='white')
self.tab_tareas = tk.Frame(notebook, bg='white')
self.tab_alarmas = tk.Frame(notebook, bg='white')
self.tab_enlaces = tk.Frame(notebook, bg='white')
self.tab_bloc = tk.Frame(notebook, bg='white')
notebook.add(self.tab_resultados, text='Resultados')
notebook.add(self.tab_navegador, text='Navegador')
notebook.add(self.tab_correos, text='Correos')
notebook.add(self.tab_bloc, text='Bloc de notas')
notebook.add(self.tab_tareas, text='Tareas')
notebook.add(self.tab_alarmas, text='Alarmas')
notebook.add(self.tab_enlaces, text='Enlaces')
self._build_tab_resultados()
self._build_tab_navegador()
self._build_tab_correos()
self._build_tab_bloc_notas()
self._build_tab_tareas()
self._build_tab_alarmas()
self._build_tab_enlaces()
def _build_tab_resultados(self) -> None:
wrapper = tk.Frame(self.tab_resultados, bg=PANEL_BG)
wrapper.pack(fill='both', expand=True)
self.results_title = tk.Label(wrapper, text='Resultados de actividades', font=self.font_header, bg=PANEL_BG, fg=TEXT_COLOR)
self.results_title.pack(pady=(12, 4))
toolbar = tk.Frame(wrapper, bg=PANEL_BG)
toolbar.pack(fill='x', padx=12, pady=(0, 4))
tk.Button(toolbar, text='Ver monitor del sistema', command=self._show_resource_monitor, bg=BUTTON_BG, relief='flat').pack(side='left', padx=(0, 8))
tk.Button(toolbar, text='Limpiar resultados', command=self._reset_results_view, bg=BUTTON_BG, relief='flat').pack(side='left')
self.results_area = tk.Frame(wrapper, bg='#f4f7ff', bd=1, relief='solid')
self.results_area.pack(fill='both', expand=True, padx=12, pady=(0, 12))
self._reset_results_view()
def _prepare_results_area(self) -> None:
self._stop_game_queue_processor()
if not self.results_area:
return
for child in self.results_area.winfo_children():
child.destroy()
self.chart_canvas = None
self.ax_cpu = None
self.ax_mem = None
self.ax_threads = None
self.line_cpu = None
self.line_mem = None
self.mem_fill = None
self.thread_bars = None
self.game_window_canvas = None
self.game_window_status = None
def _reset_results_view(self) -> None:
self._prepare_results_area()
if self.results_title:
self.results_title.config(text='Resultados de actividades')
if self.results_area:
tk.Label(
self.results_area,
text='Ejecute una actividad para ver aquí sus resultados más recientes.',
bg='#f4f7ff',
fg=SUBTEXT_COLOR,
font=self.font_body,
wraplength=520
).pack(expand=True, padx=12, pady=12)
def _render_results_view(self, title: str, builder: Callable[[tk.Frame], None]) -> None:
if self.results_title:
self.results_title.config(text=f'Resultados • {title}')
self._prepare_results_area()
if not self.results_area:
return
builder(self.results_area)
def _show_resource_monitor(self) -> None:
def builder(parent: tk.Frame) -> None:
if not (MATPLOTLIB_AVAILABLE and psutil):
tk.Label(
parent,
text='Instale matplotlib + psutil para habilitar el monitor del sistema.',
fg=ACCENT_COLOR,
bg='#f4f7ff',
font=self.font_body
).pack(fill='both', expand=True, padx=20, pady=20)
return
chart_frame = tk.Frame(parent, bg=PANEL_BG)
chart_frame.pack(fill='both', expand=True, padx=12, pady=12)
fig = Figure(figsize=(7.2, 5.6), dpi=100)
self.ax_cpu = fig.add_subplot(311)
self.ax_mem = fig.add_subplot(312)
self.ax_threads = fig.add_subplot(313)
self.ax_cpu.set_title('CPU (línea)')
self.ax_cpu.set_ylim(0, 100)
self.ax_cpu.set_ylabel('%')
self.ax_cpu.grid(True, alpha=0.25)
self.line_cpu, = self.ax_cpu.plot([], [], label='CPU', color='#ff6b6b', linewidth=2)
self.ax_mem.set_title('Memoria (área)')
self.ax_mem.set_ylim(0, 100)
self.ax_mem.set_ylabel('%')
self.ax_mem.grid(True, alpha=0.2)
self.line_mem, = self.ax_mem.plot([], [], color='#4ecdc4', linewidth=1.5)
self.ax_threads.set_title('Hilos del proceso (barras)')
self.ax_threads.set_ylabel('Hilos')
self.ax_threads.grid(True, axis='y', alpha=0.2)
fig.tight_layout(pad=2.2)
self.chart_canvas = FigureCanvasTkAgg(fig, master=chart_frame)
self.chart_canvas.get_tk_widget().pack(fill='both', expand=True)
self.ax_cpu.set_xlim(0, 40)
self.ax_mem.set_xlim(0, 40)
self.ax_threads.set_xlim(-0.5, 39.5)
self.chart_canvas.draw_idle()
self._render_results_view('Monitoreo del sistema', builder)
def _show_text_result(self, title: str, text: str) -> None:
def builder(parent: tk.Frame) -> None:
frame = tk.Frame(parent, bg='white')
frame.pack(fill='both', expand=True, padx=12, pady=12)
box = scrolledtext.ScrolledText(frame, height=12)
box.pack(fill='both', expand=True)
box.insert('1.0', text)
box.config(state='disabled')
self._render_results_view(title, builder)
def _format_weather_summary(self, data: dict[str, Any]) -> str:
main = data.get('main', {})
weather = data.get('weather', [{}])[0]
wind = data.get('wind', {})
temp = main.get('temp')
feels = main.get('feels_like')
humidity = main.get('humidity')
desc = weather.get('description', '').capitalize()
wind_speed = wind.get('speed')
timestamp = data.get('dt')
updated = datetime.datetime.fromtimestamp(timestamp).strftime('%d/%m %H:%M') if timestamp else 'N/D'
summary = [
f'Descripción : {desc or "N/D"}',
f'Temperatura : {temp:.1f}°C' if temp is not None else 'Temperatura : N/D',
f'Sensación : {feels:.1f}°C' if feels is not None else 'Sensación : N/D',
f'Humedad : {humidity}%' if humidity is not None else 'Humedad : N/D',
f'Veloc. viento: {wind_speed:.1f} m/s' if wind_speed is not None else 'Veloc. viento: N/D',
f'Actualizado : {updated}'
]
return '\n'.join(summary)
def _get_javea_weather_snapshot(self) -> tuple[str, str, bool]:
try:
data = self._fetch_javea_weather()
summary = self._format_weather_summary(data)
timestamp = data.get('dt')
status = 'Última lectura: '
status += datetime.datetime.fromtimestamp(timestamp).strftime('%d/%m %H:%M') if timestamp else 'N/D'
return summary, status, False
except Exception as exc:
return str(exc), 'No se pudo obtener el clima', True
def _show_weather_popup(self) -> None:
self._log('Abriendo ventana del tiempo para Jávea')
text, status, is_error = self._get_javea_weather_snapshot()
if self.weather_popup and self.weather_popup.winfo_exists():
self.weather_popup.destroy()
popup = tk.Toplevel(self)
popup.title('Tiempo en Jávea')
popup.geometry('760x500')
popup.minsize(760, 500)
popup.resizable(False, False)
popup.configure(bg='white')
popup.transient(self)
popup.grab_set()
self.weather_popup = popup
header = tk.Label(popup, text='OpenWeather • Jávea, España', font=self.font_header, bg='white', fg=TEXT_COLOR)
header.pack(pady=(12, 6))
info_label = tk.Label(
popup,
text=text,
justify='left',
font=('Consolas', 12),
anchor='nw',
bg='white',
fg='red' if is_error else TEXT_COLOR,
padx=6,
pady=6
)
info_label.pack(fill='both', expand=True, padx=18, pady=8)
status_label = tk.Label(popup, text=status, font=self.font_small, bg='white', fg=SUBTEXT_COLOR)
status_label.pack(pady=(0, 8))
def close_popup() -> None:
if popup.winfo_exists():
popup.destroy()
if self.weather_popup is popup:
self.weather_popup = None
def refresh() -> None:
new_text, new_status, new_is_error = self._get_javea_weather_snapshot()
info_label.config(text=new_text, fg='red' if new_is_error else '#1f2d3d')
status_label.config(text=new_status)
buttons = tk.Frame(popup, bg='white')
buttons.pack(pady=(4, 14))
tk.Button(buttons, text='Actualizar', command=refresh, bg=BUTTON_BG, relief='flat').pack(side='left', padx=10)
tk.Button(buttons, text='Cerrar', command=close_popup, bg=BUTTON_BG, relief='flat').pack(side='left', padx=10)
popup.protocol('WM_DELETE_WINDOW', close_popup)
def _build_tab_navegador(self) -> None:
tk.Label(self.tab_navegador, text='Abrir URL en navegador externo', bg='white').pack(pady=6)
frame = tk.Frame(self.tab_navegador, bg='white')
frame.pack(pady=6)
self.url_entry = tk.Entry(frame, width=48)
self.url_entry.insert(0, 'https://www.python.org')
self.url_entry.pack(side='left', padx=4)
tk.Button(frame, text='Abrir', command=lambda: self._open_web(self.url_entry.get())).pack(side='left', padx=4)
def _build_tab_correos(self) -> None:
"""Construye la interfaz completa del cliente de correo - Diseño mejorado"""
self.tab_correos.configure(bg='#f0f2f5')
# Variables de estado del correo
self.mail_connected = False
self.imap_connection = None
self.current_mailbox = 'INBOX'
self.mail_list = []
self.mail_attachments = []
self.unread_count = 0
self.unread_label = None
self.mail_filter_unread = False # Filtro para mostrar solo no leídos
# Panel de conexión con diseño moderno
conn_frame = tk.LabelFrame(self.tab_correos, text='⚙️ Configuración del servidor',
bg='#ffffff', font=('Arial', 12, 'bold'), fg='#1a73e8',
padx=15, pady=12, relief='solid', bd=1)
conn_frame.pack(fill='x', padx=15, pady=12)
# Grid para campos de configuración
config_grid = tk.Frame(conn_frame, bg='#ffffff')
config_grid.pack(fill='x', pady=5)
# Fila 1: IMAP
tk.Label(config_grid, text='📥 IMAP:', bg='#ffffff', font=('Arial', 10, 'bold'),
fg='#5f6368', width=12, anchor='e').grid(row=0, column=0, padx=8, pady=6, sticky='e')
self.mail_imap_host = tk.Entry(config_grid, width=18, font=('Arial', 10),
relief='solid', bd=1, bg='#f8f9fa')
self.mail_imap_host.insert(0, '10.10.0.101')
self.mail_imap_host.grid(row=0, column=1, padx=5, pady=6, sticky='w')
tk.Label(config_grid, text='Puerto:', bg='#ffffff', font=('Arial', 10),
fg='#5f6368').grid(row=0, column=2, padx=5, pady=6, sticky='e')
self.mail_imap_port = tk.Entry(config_grid, width=8, font=('Arial', 10),
relief='solid', bd=1, bg='#f8f9fa')
self.mail_imap_port.insert(0, '143')
self.mail_imap_port.grid(row=0, column=3, padx=5, pady=6, sticky='w')
# Fila 2: SMTP
tk.Label(config_grid, text='📤 SMTP:', bg='#ffffff', font=('Arial', 10, 'bold'),
fg='#5f6368', width=12, anchor='e').grid(row=1, column=0, padx=8, pady=6, sticky='e')
self.mail_smtp_host = tk.Entry(config_grid, width=18, font=('Arial', 10),
relief='solid', bd=1, bg='#f8f9fa')
self.mail_smtp_host.insert(0, '10.10.0.101')
self.mail_smtp_host.grid(row=1, column=1, padx=5, pady=6, sticky='w')
tk.Label(config_grid, text='Puerto:', bg='#ffffff', font=('Arial', 10),
fg='#5f6368').grid(row=1, column=2, padx=5, pady=6, sticky='e')
self.mail_smtp_port = tk.Entry(config_grid, width=8, font=('Arial', 10),
relief='solid', bd=1, bg='#f8f9fa')
self.mail_smtp_port.insert(0, '25')
self.mail_smtp_port.grid(row=1, column=3, padx=5, pady=6, sticky='w')
# Fila 3: Credenciales
tk.Label(config_grid, text='👤 Usuario:', bg='#ffffff', font=('Arial', 10, 'bold'),
fg='#5f6368', width=12, anchor='e').grid(row=2, column=0, padx=8, pady=6, sticky='e')
self.mail_username = tk.Entry(config_grid, width=35, font=('Arial', 10),
relief='solid', bd=1, bg='#f8f9fa')
self.mail_username.grid(row=2, column=1, columnspan=3, padx=5, pady=6, sticky='ew')
tk.Label(config_grid, text='🔒 Contraseña:', bg='#ffffff', font=('Arial', 10, 'bold'),
fg='#5f6368', width=12, anchor='e').grid(row=3, column=0, padx=8, pady=6, sticky='e')
self.mail_password = tk.Entry(config_grid, width=35, font=('Arial', 10),
show='', relief='solid', bd=1, bg='#f8f9fa')
self.mail_password.grid(row=3, column=1, columnspan=3, padx=5, pady=6, sticky='ew')
# Checkbox para recordar credenciales
remember_frame = tk.Frame(config_grid, bg='#ffffff')
remember_frame.grid(row=4, column=1, columnspan=3, padx=5, pady=8, sticky='w')
self.mail_remember_var = tk.BooleanVar(value=True)
tk.Checkbutton(remember_frame, text='💾 Recordar credenciales', variable=self.mail_remember_var,
bg='#ffffff', font=('Arial', 9), fg='#5f6368',
selectcolor='#ffffff', activebackground='#ffffff',
cursor='hand2').pack(side='left')
# Cargar credenciales guardadas
self._load_mail_credentials()
# Botones de conexión con diseño mejorado
btn_row = tk.Frame(conn_frame, bg='#ffffff')
btn_row.pack(fill='x', pady=12)
self.btn_mail_connect = tk.Button(btn_row, text='🔗 Conectar', command=self._connect_mail_server,
bg='#1a73e8', fg='white', relief='flat',
font=('Arial', 11, 'bold'), padx=20, pady=8,
cursor='hand2', activebackground='#1557b0')
self.btn_mail_connect.pack(side='left', padx=8)
self.btn_mail_disconnect = tk.Button(btn_row, text='⚠️ Desconectar', command=self._disconnect_mail_server,
bg='#dc3545', fg='white', relief='flat',
font=('Arial', 11, 'bold'), padx=20, pady=8,
state='disabled', cursor='hand2', activebackground='#bd2130')
self.btn_mail_disconnect.pack(side='left', padx=8)
# Estado con diseño más llamativo
status_frame = tk.Frame(btn_row, bg='#fef3cd', relief='solid', bd=1, padx=12, pady=6)
status_frame.pack(side='left', padx=15)
self.mail_status_label = tk.Label(status_frame, text='⚫ Desconectado', bg='#fef3cd',
fg='#856404', font=('Arial', 10, 'bold'))
self.mail_status_label.pack()
# Panel principal dividido con mejor diseño
main_panel = tk.PanedWindow(self.tab_correos, orient='horizontal', bg='#f0f2f5',
sashwidth=8, sashrelief='raised', bd=0)
main_panel.pack(fill='both', expand=True, padx=15, pady=(0, 15))
# ========== Panel izquierdo: Lista de correos ==========
left_panel = tk.Frame(main_panel, bg='#ffffff', relief='solid', bd=1)
main_panel.add(left_panel, minsize=350)
# Header de la lista con gradiente visual
toolbar = tk.Frame(left_panel, bg='#1a73e8', height=50)
toolbar.pack(fill='x')
toolbar_content = tk.Frame(toolbar, bg='#1a73e8')
toolbar_content.pack(fill='both', expand=True, padx=12, pady=10)
# Label del buzón actual
self.mail_folder_label = tk.Label(toolbar_content, text='📬 Bandeja de entrada', bg='#1a73e8',
fg='white', font=('Arial', 13, 'bold'))
self.mail_folder_label.pack(side='left')
# Botones de carpetas
btn_folders = tk.Frame(toolbar_content, bg='#1a73e8')
btn_folders.pack(side='left', padx=15)
tk.Button(btn_folders, text='📬 Entrada', command=self._show_inbox,
bg='#5f9cf4', fg='white', relief='flat', font=('Arial', 9, 'bold'),
padx=10, pady=5, cursor='hand2', activebackground='#4285f4').pack(side='left', padx=2)
tk.Button(btn_folders, text='📤 Enviados', command=self._show_sent,
bg='#5f9cf4', fg='white', relief='flat', font=('Arial', 9, 'bold'),
padx=10, pady=5, cursor='hand2', activebackground='#4285f4').pack(side='left', padx=2)
# Botón de filtro "Sin leer"
self.btn_filter_unread = tk.Button(btn_folders, text='🔵 Sin leer', command=self._toggle_filter_unread,
bg='#7c8691', fg='white', relief='flat', font=('Arial', 9, 'bold'),
padx=10, pady=5, cursor='hand2', activebackground='#5f6368')
self.btn_filter_unread.pack(side='left', padx=2)
# Botones de acción con estilo
btn_actions = tk.Frame(toolbar_content, bg='#1a73e8')
btn_actions.pack(side='right')
tk.Button(btn_actions, text='✉️ Nuevo correo', command=self._open_compose_window,
bg='#34a853', fg='white', relief='flat', font=('Arial', 10, 'bold'),
padx=15, pady=6, cursor='hand2', activebackground='#2d8e47').pack(side='right', padx=3)
# Lista de correos mejorada
list_frame = tk.Frame(left_panel, bg='#ffffff')
list_frame.pack(fill='both', expand=True, padx=2, pady=2)
scrollbar = tk.Scrollbar(list_frame, width=14)
scrollbar.pack(side='right', fill='y')
self.mail_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set,
font=('Segoe UI', 10), height=20,
selectbackground='#e8f0fe', selectforeground='#1a73e8',
bg='#ffffff', fg='#202124', relief='flat',
activestyle='none', highlightthickness=0)
self.mail_listbox.pack(side='left', fill='both', expand=True)
scrollbar.config(command=self.mail_listbox.yview)
self.mail_listbox.bind('<<ListboxSelect>>', self._on_mail_select)
# ========== Panel derecho: Visor de correo ==========
right_panel = tk.Frame(main_panel, bg='#ffffff', relief='solid', bd=1)
main_panel.add(right_panel, minsize=500)
# Header del correo con diseño atractivo
mail_header = tk.Frame(right_panel, bg='#f8f9fa', relief='solid', bd=1)
mail_header.pack(fill='x', padx=0, pady=0)
headers_content = tk.Frame(mail_header, bg='#f8f9fa')
headers_content.pack(fill='x', padx=20, pady=15)
# Asunto destacado
subject_frame = tk.Frame(headers_content, bg='#f8f9fa')
subject_frame.pack(fill='x', pady=(0, 10))
self.mail_subject_label = tk.Label(subject_frame, text='', bg='#f8f9fa',
font=('Arial', 14, 'bold'), anchor='w',
fg='#202124', wraplength=600)
self.mail_subject_label.pack(fill='x')
# Info del remitente
from_frame = tk.Frame(headers_content, bg='#f8f9fa')
from_frame.pack(fill='x', pady=3)
tk.Label(from_frame, text='De:', bg='#f8f9fa', font=('Arial', 10, 'bold'),
fg='#5f6368', width=10, anchor='w').pack(side='left')
self.mail_from_label = tk.Label(from_frame, text='', bg='#f8f9fa',
font=('Arial', 10), anchor='w', fg='#1a73e8')
self.mail_from_label.pack(side='left', fill='x', expand=True)
# Fecha
date_frame = tk.Frame(headers_content, bg='#f8f9fa')
date_frame.pack(fill='x', pady=3)
tk.Label(date_frame, text='Fecha:', bg='#f8f9fa', font=('Arial', 10, 'bold'),
fg='#5f6368', width=10, anchor='w').pack(side='left')
self.mail_date_label = tk.Label(date_frame, text='', bg='#f8f9fa',
font=('Arial', 10), anchor='w', fg='#5f6368')
self.mail_date_label.pack(side='left', fill='x', expand=True)
# Separador elegante
tk.Frame(right_panel, height=2, bg='#dadce0').pack(fill='x')
# Frame para cuerpo y adjuntos
content_frame = tk.Frame(right_panel, bg='#ffffff')
content_frame.pack(fill='both', expand=True)
# Cuerpo del correo con mejor tipografía - usando Text para soportar imágenes inline
text_scroll_frame = tk.Frame(content_frame, bg='#ffffff')
text_scroll_frame.pack(fill='both', expand=True)
mail_body_scrollbar = tk.Scrollbar(text_scroll_frame)
mail_body_scrollbar.pack(side='right', fill='y')
self.mail_body_text = tk.Text(text_scroll_frame, wrap='word',
font=('Segoe UI', 11),
bg='#ffffff', fg='#202124',
relief='flat', padx=15, pady=15,
spacing1=3, spacing3=5,
yscrollcommand=mail_body_scrollbar.set)
self.mail_body_text.pack(side='left', fill='both', expand=True)
mail_body_scrollbar.config(command=self.mail_body_text.yview)
self.mail_body_text.config(state='disabled')
# Lista para almacenar referencias de imágenes inline (evitar garbage collection)
self.mail_inline_images = []
# Frame para mostrar adjuntos (imágenes)
self.mail_attachments_frame = tk.Frame(content_frame, bg='#f8f9fa')
# No se empaqueta hasta que haya adjuntos
def _build_tab_bloc_notas(self) -> None:
tk.Label(self.tab_bloc, text='Bloc de notas', bg='white', font=('Arial', 12, 'bold')).pack(pady=6)
toolbar = tk.Frame(self.tab_bloc, bg='white')
toolbar.pack(fill='x')
tk.Button(toolbar, text='Abrir', command=self._open_text).pack(side='left', padx=4, pady=4)
tk.Button(toolbar, text='Guardar', command=self._save_text).pack(side='left', padx=4, pady=4)
self.editor = scrolledtext.ScrolledText(self.tab_bloc, wrap='word')
self.editor.pack(fill='both', expand=True, padx=6, pady=6)
def _build_tab_tareas(self) -> None:
container = tk.Frame(self.tab_tareas, bg='white')
container.pack(fill='both', expand=True)
scraping_frame = tk.Frame(container, bg='white', padx=10, pady=10)
scraping_frame.pack(side='left', fill='both', expand=True)
tk.Label(scraping_frame, text='Análisis de enlaces Wallapop', bg='white', font=('Arial', 12, 'bold')).pack(pady=(12,4))
if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE):
tk.Label(
scraping_frame,
text='Instala requests y beautifulsoup4 para habilitar el análisis de enlaces.',
bg='white',
fg='red',
wraplength=260,
justify='left'
).pack(fill='x', padx=10, pady=10)
else:
tk.Label(
scraping_frame,
text='Pulsa el botón "Analizar Wallapop" en el panel izquierdo para introducir la URL de un anuncio. '
'Aquí verás notas generales o recordatorios.',
bg='white',
wraplength=260,
justify='left'
).pack(fill='x', padx=10, pady=10)
game_frame = tk.Frame(container, bg='white')
game_frame.pack(side='left', fill='both', expand=True, padx=10)
tk.Label(game_frame, text='Juego de camellos (ventana dedicada)', bg='white', font=('Arial', 12, 'bold')).pack(pady=(6,2))
tk.Label(
game_frame,
text='Abre el simulador en una ventana independiente para ver la carrera a pantalla completa.',
wraplength=260,
justify='left',
bg='white'
).pack(padx=8, pady=8)
tk.Button(game_frame, text='Abrir juego', command=self._open_game_window).pack(pady=6)
def _build_tab_alarmas(self) -> None:
self.tab_alarmas.configure(bg=PANEL_BG)
card = tk.Frame(self.tab_alarmas, bg='#fef9f4', bd=0, padx=18, pady=18)
card.pack(pady=16, padx=20, fill='both', expand=True)
tk.Label(card, text='Programar nuevas alarmas', font=self.font_header, bg='#fef9f4', fg=TEXT_COLOR).pack(pady=(0,8))
form = tk.Frame(card, bg='#fef9f4')
form.pack(pady=6)
tk.Label(form, text='Minutos', bg='#fef9f4', font=self.font_body).grid(row=0, column=0, padx=6)
self.alarm_minutes = tk.Spinbox(form, from_=1, to=240, width=6, justify='center')
self.alarm_minutes.grid(row=0, column=1, padx=6)
tk.Label(form, text='Título', bg='#fef9f4', font=self.font_body).grid(row=0, column=2, padx=6)
self.alarm_title = tk.Entry(form, width=24)
self.alarm_title.grid(row=0, column=3, padx=6)
tk.Button(form, text='Agregar alarma', bg=BUTTON_BG, relief='flat', command=self._start_alarm).grid(row=0, column=4, padx=10)
tk.Label(card, text='Alarmas activas', font=self.font_body, bg='#fef9f4', fg=SUBTEXT_COLOR).pack(pady=(18,6))
self.alarm_list = tk.Listbox(card, height=8, width=50, bd=0, highlightthickness=1, highlightbackground='#e3e5f0')
self.alarm_list.pack(pady=4, fill='x')
tk.Button(card, text='Cancelar seleccionada', command=self._cancel_selected_alarm, bg=BUTTON_BG, relief='flat').pack(pady=(10,4))
self.alarm_status = tk.Label(card, text='Sin alarmas programadas', bg='#fef9f4', fg=SUBTEXT_COLOR, font=self.font_small)
self.alarm_status.pack(pady=4)
def _build_tab_enlaces(self) -> None:
tk.Label(self.tab_enlaces, text='Enlaces útiles', bg='white', font=('Arial', 12, 'bold')).pack(pady=6)
for text, url in [
('Documentación Tkinter', 'https://docs.python.org/3/library/tk.html'),
('psutil', 'https://psutil.readthedocs.io'),
('Matplotlib', 'https://matplotlib.org')
]:
tk.Button(self.tab_enlaces, text=text, command=lambda u=url: self._open_web(u)).pack(pady=3)
def _build_notes_panel(self) -> None:
notes = tk.LabelFrame(self.center_panel, text='Panel de notas e hilos', bg='#eefee8')
notes.grid(row=1, column=0, sticky='nsew', padx=6, pady=(4,6))
self.notes = scrolledtext.ScrolledText(notes, height=5)
self.notes.pack(fill='both', expand=True, padx=6, pady=6)
def _build_right_panel(self) -> None:
right = tk.Frame(self, width=700, bg=PANEL_BG, bd=0)
right.grid(row=1, column=2, sticky='nse', padx=6, pady=(50,6))
right.grid_propagate(False)
# Header Juego
self.game_header = tk.Frame(right, bg=ACCENT_COLOR, pady=8)
self.game_header.pack(fill='x')
self.game_title_label = tk.Label(self.game_header, text='🎮 Minesweeper Multijugador',
font=('Arial', 18, 'bold'), fg='white', bg=ACCENT_COLOR)
self.game_title_label.pack()
# Panel de Estado (Vidas, Ronda)
stats = tk.Frame(right, bg='white', pady=8)
stats.pack(pady=6, fill='x', padx=10)
self.lbl_round = tk.Label(stats, text='Ronda: -', font=('Arial', 14, 'bold'), bg='white', fg=TEXT_COLOR)
self.lbl_round.pack(side='left', padx=10)
self.lbl_bombs = tk.Label(stats, text='💣 Bombas: -', font=('Arial', 14, 'bold'), fg='#e67e22', bg='white')
self.lbl_bombs.pack(side='left', padx=10)
self.lbl_lives = tk.Label(stats, text='❤️ Vidas: 3', font=('Arial', 14, 'bold'), fg='#c44569', bg='white')
self.lbl_lives.pack(side='right', padx=10)
# Panel de Juego (Grid) - MÁS GRANDE
self.game_frame = tk.Frame(right, bg='#eeeeee', bd=3, relief='sunken')
self.game_frame.pack(fill='both', expand=True, padx=10, pady=10)
# Mensaje inicial en el grid
tk.Label(self.game_frame, text='Conecta al servidor\npara comenzar',
font=('Arial', 14), bg='#eeeeee', fg=SUBTEXT_COLOR).place(relx=0.5, rely=0.5, anchor='center')
# Botones de Acción - MÁS GRANDES
actions = tk.Frame(right, bg='white', pady=6)
actions.pack(pady=6, fill='x', padx=10)
self.btn_game_action = tk.Button(actions, text='🎯 Iniciar Juego', bg='#90ee90',
font=('Arial', 12, 'bold'), command=self._start_game_req,
relief='raised', bd=3, padx=15, pady=8, state='disabled')
self.btn_game_action.pack(side='left', padx=5, expand=True, fill='x')
# Check Done Button
self.btn_check_done = tk.Button(actions, text='✓ Zona Despejada', bg='#fff3cd',
font=('Arial', 12, 'bold'), command=self._check_dungeon_cleared,
relief='raised', bd=3, padx=15, pady=8, state='disabled')
self.btn_check_done.pack(side='left', padx=5, expand=True, fill='x')
# Log de juego - MÁS GRANDE
log_frame = tk.LabelFrame(right, text='📋 Log del Juego', bg='white',
font=('Arial', 11, 'bold'), fg=TEXT_COLOR)
log_frame.pack(padx=10, pady=6, fill='both', expand=False)
self.game_log = scrolledtext.ScrolledText(log_frame, width=50, height=8,
state='normal', font=('Consolas', 10),
bg='#f8f9fa', fg=TEXT_COLOR)
self.game_log.pack(padx=6, pady=6, fill='both', expand=True)
self.game_log.insert('end', "🎮 Bienvenido al Minesweeper Multijugador\n")
self.game_log.insert('end', "" * 50 + "\n")
self.game_log.insert('end', "1. Conecta al servidor\n")
self.game_log.insert('end', "2. Espera a que otro jugador se conecte\n")
self.game_log.insert('end', "3. Haz clic en 'Iniciar Juego'\n")
self.game_log.insert('end', "" * 50 + "\n")
self.game_log.config(state='disabled')
# Conexión - MEJORADO
conn_frame = tk.LabelFrame(right, text='🔌 Conexión al Servidor', bg='white',
font=('Arial', 11, 'bold'), fg=TEXT_COLOR)
conn_frame.pack(pady=6, fill='x', padx=10)
conn = tk.Frame(conn_frame, bg='white')
conn.pack(pady=8, padx=10)
tk.Label(conn, text='Host:', bg='white', font=('Arial', 11)).grid(row=0, column=0, sticky='e', padx=5)
self.host_entry = tk.Entry(conn, width=15, font=('Arial', 11))
self.host_entry.insert(0, SERVER_HOST_DEFAULT)
self.host_entry.grid(row=0, column=1, padx=5)
tk.Label(conn, text='Puerto:', bg='white', font=('Arial', 11)).grid(row=1, column=0, sticky='e', padx=5)
self.port_entry = tk.Entry(conn, width=8, font=('Arial', 11))
self.port_entry.insert(0, str(SERVER_PORT_DEFAULT))
self.port_entry.grid(row=1, column=1, padx=5, sticky='w')
tk.Button(conn, text='🔗 Conectar', command=self._connect_game,
bg='#4CAF50', fg='white', font=('Arial', 11, 'bold'),
relief='raised', bd=3, padx=20, pady=5).grid(row=0, column=2, rowspan=2, padx=10)
# Reproductor música (mini)
player = tk.Frame(right, bg='#fdf5f5', bd=1, relief='flat', padx=4, pady=4)
player.pack(fill='x', padx=8, pady=(4,6))
tk.Label(player, text='🎵 Música', font=self.font_small, bg='#fdf5f5').pack(side='left')
tk.Button(player, text='', command=self._resume_music, width=3).pack(side='left', padx=2)
tk.Button(player, text='||', command=self._pause_music, width=3).pack(side='left', padx=2)
tk.Button(player, text='📁', command=self._select_music, width=3).pack(side='left', padx=2)
# ------------------ Lógica Juego ------------------
def _enqueue_game_message(self, msg: dict):
self._game_queue.put(msg)
def _on_game_disconnect(self):
self._game_queue.put({"type": "DISCONNECT"})
def _game_loop(self):
while self._running:
try:
msg = self._game_queue.get(timeout=0.1)
self.after(0, self._process_game_message, msg)
except queue.Empty:
continue
def _connect_game(self):
"""Conecta al servidor de juego"""
host = self.host_entry.get().strip()
if not host:
messagebox.showerror("Error", "Debes especificar un host")
return
try:
port = int(self.port_entry.get())
if port < 1 or port > 65535:
raise ValueError("Puerto fuera de rango")
except ValueError:
messagebox.showerror("Error", "Puerto inválido (debe ser 1-65535)")
return
self._log_game(f"Conectando a {host}:{port}...")
try:
if self.game_client.connect(host, port):
self._log_game("✓ Conectado al servidor exitosamente")
self.btn_game_action.config(state='normal')
messagebox.showinfo("Conectado", f"Conectado a {host}:{port}")
else:
self._log_game("✗ Error al conectar")
messagebox.showerror("Error", "No se pudo conectar al servidor")
except Exception as e:
self._log_game(f"✗ Excepción: {e}")
messagebox.showerror("Error", f"Error de conexión: {e}")
def _start_game_req(self):
print("[CLIENT] Enviando START_GAME al servidor")
self.game_client.send({"type": "START_GAME"})
self._log_game("Solicitando inicio de juego...")
def _check_dungeon_cleared(self):
self.game_client.send({"type": "CHECK_DUNGEON_CLEARED"})
def _log_game(self, text):
self.game_log.config(state='normal')
self.game_log.insert('end', f"> {text}\n")
self.game_log.see('end')
self.game_log.config(state='disabled')
def _process_game_message(self, msg: dict):
mtype = msg.get('type')
if mtype == 'DISCONNECT':
self._log_game("Desconectado del servidor.")
self._game_phase = 'LOBBY'
return
if mtype == 'NEW_ROUND':
r = msg.get('round')
size = msg.get('grid_size')
status = msg.get('status')
bombs_per_player = msg.get('total_bombs_per_player', 3)
# Guardar bombas restantes para el contador
self._bombs_remaining = bombs_per_player
self.lbl_bombs.config(text=f"💣 Bombas: {self._bombs_remaining}")
self.lbl_round.config(text=f"Ronda: {r}")
self._log_game(f"=== Ronda {r} ({size}x{size}) ===")
self._log_game(f"Cada jugador debe poner {bombs_per_player} bombas")
self._log_game(status)
self._build_grid(size)
self._game_phase = 'PLACING'
self.btn_game_action.config(state='disabled')
self.btn_check_done.config(state='disabled')
elif mtype == 'TURN_NOTIFY':
player = msg.get('active_player')
text = msg.get('msg')
self._log_game(f"🎯 {text}")
# Cambiar color del header si es mi turno
if hasattr(self.game_client, 'my_address') and player in self.game_client.my_address:
self.game_header.config(bg='#4CAF50') # Verde
self.game_title_label.config(bg='#4CAF50', text='🎮 ¡TU TURNO! - Coloca Bombas')
else:
self.game_header.config(bg=ACCENT_COLOR) # Rojo
self.game_title_label.config(bg=ACCENT_COLOR, text='🎮 Esperando Turno...')
elif mtype == 'BOMB_flash':
x, y = msg.get('x'), msg.get('y')
who = msg.get('who')
self._log_game(f"💣 {who} colocó una bomba")
# Decrementar contador de bombas SI es mi turno (mi bomba)
if hasattr(self.game_client, 'my_address') and who in self.game_client.my_address:
if hasattr(self, '_bombs_remaining') and self._bombs_remaining > 0:
self._bombs_remaining -= 1
self.lbl_bombs.config(text=f"💣 Bombas: {self._bombs_remaining}")
btn = self._get_grid_btn(x, y)
if btn:
# Mostrar bomba temporalmente
orig_bg = btn.cget('bg')
btn.config(bg='#ff6b6b', text='💣', fg='white', font=('Arial', 14, 'bold'))
# Ocultar en 400ms (más rápido)
def hide_bomb(b=btn, original=orig_bg):
try:
b.config(bg=original, text='', fg='black')
except Exception:
pass
self.after(400, hide_bomb)
elif mtype == 'PHASE_PLAY':
self._log_game("⚔️ " + msg.get('msg', 'Fase de búsqueda iniciada'))
self.btn_check_done.config(state='normal')
self._game_phase = 'PLAYING'
elif mtype == 'SEARCH_TURN':
player = msg.get('active_player')
text = msg.get('msg')
self._log_game(f"{text}")
# Cambiar color del header si es mi turno
if hasattr(self.game_client, 'my_address') and player in self.game_client.my_address:
self.game_header.config(bg='#4CAF50') # Verde
self.game_title_label.config(bg='#4CAF50', text='🎮 ¡TU TURNO! - Excava')
else:
self.game_header.config(bg=ACCENT_COLOR) # Rojo
self.game_title_label.config(bg=ACCENT_COLOR, text='🎮 Esperando Turno...')
elif mtype == 'EXPLOSION':
x, y = msg.get('x'), msg.get('y')
lives = msg.get('lives')
who = msg.get('who')
player_addr = msg.get('player_addr', who)
self._log_game(f"💥 BOOM! {who} pisó una bomba (Vidas: {lives})")
btn = self._get_grid_btn(x, y)
if btn:
btn.config(bg='#c44569', text='💥', fg='white', font=('Arial', 16, 'bold'),
state='disabled', relief='sunken')
# SOLO actualizar vidas si es el jugador local (comparación con my_address)
if hasattr(self.game_client, 'my_address') and player_addr in self.game_client.my_address:
try:
self.lbl_lives.config(text=f"❤️ Vidas: {lives}")
if lives <= 0:
self._log_game("☠️ ¡Sin vidas! Game Over")
except Exception:
pass # App cerrándose
elif mtype == 'SAFE':
x, y = msg.get('x'), msg.get('y')
self._log_game(f"✓ Celda ({x},{y}) segura")
btn = self._get_grid_btn(x, y)
if btn:
btn.config(bg='#90ee90', text='', fg='#2d5016', font=('Arial', 12, 'bold'),
state='disabled', relief='sunken')
elif mtype == 'WARNING':
msg_text = msg.get('msg', '')
self._log_game(f"⚠️ {msg_text}")
messagebox.showwarning("Advertencia", msg_text)
elif mtype == 'ROUND_WIN':
msg_text = msg.get('msg', '¡Ronda completada!')
self._log_game(f"🏆 {msg_text}")
messagebox.showinfo("¡Ronda Ganada!", msg_text)
elif mtype == 'ROUND_ADVANCE':
msg_text = msg.get('msg', 'Pasando a la siguiente ronda...')
self._log_game(f"⏭️ {msg_text}")
# Cambiar header a naranja mientras transiciona
self.game_header.config(bg='#FF9800')
self.game_title_label.config(bg='#FF9800', text='⏳ Siguiente Ronda...')
elif mtype == 'GAME_WIN':
self._log_game("👑 ¡VICTORIA! ¡Juego completado!")
messagebox.showinfo("¡Victoria Total!", "¡Has completado todas las rondas!")
self._game_phase = 'LOBBY'
self.btn_game_action.config(state='normal')
elif mtype == 'GAME_OVER':
loser = msg.get('loser', '')
msg_text = msg.get('msg', '¡Juego terminado!')
self._log_game(f"💀 {msg_text}")
# Verificar si el perdedor soy yo
if hasattr(self.game_client, 'my_address') and loser in self.game_client.my_address:
self.game_header.config(bg='#c0392b') # Rojo oscuro
self.game_title_label.config(bg='#c0392b', text='💀 HAS PERDIDO')
messagebox.showerror("¡DERROTA!", "¡Has perdido todas tus vidas!")
else:
self.game_header.config(bg='#27ae60') # Verde
self.game_title_label.config(bg='#27ae60', text='🏆 ¡HAS GANADO!')
messagebox.showinfo("¡VICTORIA!", "¡Tu oponente ha perdido todas sus vidas!")
self._game_phase = 'LOBBY'
self.btn_game_action.config(state='normal')
def _build_grid(self, size):
"""Construye el grid de juego dinámicamente según el tamaño de la ronda"""
# Limpiar grid anterior
for child in self.game_frame.winfo_children():
child.destroy()
# Resetear configuraciones de filas/columnas anteriores (máximo 14x14)
for i in range(14):
self.game_frame.columnconfigure(i, weight=0, uniform='')
self.game_frame.rowconfigure(i, weight=0, uniform='')
# Configurar grid nuevo
for i in range(size):
self.game_frame.columnconfigure(i, weight=1, uniform='cell')
self.game_frame.rowconfigure(i, weight=1, uniform='cell')
# Calcular tamaño de botón según grid
# Grids más grandes = botones más pequeños
if size <= 3:
btn_font = ('Arial', 14, 'bold')
btn_width = 3
btn_height = 1
elif size <= 5:
btn_font = ('Arial', 12, 'bold')
btn_width = 2
btn_height = 1
elif size <= 9:
btn_font = ('Arial', 10, 'bold')
btn_width = 2
btn_height = 1
else: # 12x12
btn_font = ('Arial', 8, 'bold')
btn_width = 1
btn_height = 1
self.grid_buttons = {}
for r in range(size):
for c in range(size):
btn = tk.Button(
self.game_frame,
bg='#e0e0e0',
activebackground='#d0d0d0',
relief='raised',
bd=2,
font=btn_font,
width=btn_width,
height=btn_height,
cursor='hand2'
)
btn.grid(row=r, column=c, sticky='nsew', padx=1, pady=1)
# Click handler
btn.config(command=lambda x=c, y=r: self._on_grid_click(x, y))
self.grid_buttons[(c, r)] = btn
self._log_game(f"Grid {size}x{size} creado ({size*size} celdas)")
def _on_grid_click(self, x, y):
"""Maneja clicks en el grid según la fase del juego"""
# Verificar que estamos conectados
if not self.game_client._connected:
messagebox.showwarning("No conectado", "Debes conectarte al servidor primero")
return
btn = self._get_grid_btn(x, y)
if not btn:
return
# No permitir clicks en celdas ya procesadas
if btn.cget('state') == 'disabled':
return
if self._game_phase == 'PLACING':
# Colocar bomba
self.game_client.send({"type": "PLACE_BOMB", "x": x, "y": y})
elif self._game_phase == 'PLAYING':
# Buscar/revelar celda
self.game_client.send({"type": "CLICK_CELL", "x": x, "y": y})
else:
# En LOBBY, no hacer nada
pass
def _get_grid_btn(self, x, y):
return self.grid_buttons.get((x, y))
def _build_status_bar(self) -> None:
status = tk.Frame(self, bg='#f1f1f1', bd=2, relief='ridge')
status.grid(row=2, column=0, columnspan=3, sticky='ew')
for idx in range(3):
status.columnconfigure(idx, weight=1)
self.unread_label = tk.Label(status, text='Correos sin leer: 0', font=('Arial', 11, 'bold'), bg='#f1f1f1')
self.unread_label.grid(row=0, column=0, padx=16, pady=6, sticky='w')
self.traffic_label = tk.Label(
status,
text='Red: ↑ --.- KB/s ↓ --.- KB/s',
font=('Arial', 11, 'bold'),
bg='#f1f1f1'
)
self.traffic_label.grid(row=0, column=1, padx=16, pady=6)
self.clock_label = tk.Label(status, text='--:--:--', font=('Arial', 12, 'bold'), bg='#f1f1f1')
self.clock_label.grid(row=0, column=2, padx=16, pady=6, sticky='e')
# ------------------------ acciones ------------------------
def _open_web(self, url: str) -> None:
webbrowser.open(url)
def _launch_process(self, cmd: list[str]) -> None:
try:
subprocess.Popen(cmd)
self._log(f'Proceso lanzado: {cmd}')
self._show_text_result('Aplicaciones', f'Se lanzó el proceso: {" ".join(cmd)}')
except Exception as exc:
messagebox.showerror('Error', f'No se pudo lanzar {cmd}: {exc}')
def _run_backup_script(self) -> None:
source = filedialog.askdirectory(title='Selecciona la carpeta a respaldar')
if not source:
return
destination = filedialog.askdirectory(title='Selecciona la carpeta destino para la copia')
if not destination:
return
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
src_name = os.path.basename(os.path.normpath(source)) or 'backup'
target = os.path.join(destination, f'{src_name}_{ts}')
if os.path.exists(target):
messagebox.showerror('Backup', f'Ya existe la carpeta destino:\n{target}')
return
self._log(f'Iniciando copia de seguridad de {source} a {target}')
self._show_text_result('Copias de seguridad', f'Copiando archivos...\nOrigen: {source}\nDestino: {target}')
self.notebook.select(self.tab_resultados)
def worker() -> None:
try:
copied_files, copied_bytes, skipped = self._copy_directory_with_progress(source, target)
except Exception as exc:
self._scraping_queue.put(('error', f'Backup: {exc}'))
return
summary = (
f'Copia completada correctamente.\n\n'
f'Origen: {source}\nDestino: {target}\n'
f'Archivos copiados: {copied_files}\n'
f'Tamaño total: {copied_bytes / (1024*1024):.2f} MB'
)
if skipped:
summary += f"\nArchivos omitidos: {skipped} (consulta el panel de notas)"
self._scraping_queue.put(('backup_success', summary, target))
threading.Thread(target=worker, daemon=True).start()
def _copy_directory_with_progress(self, source: str, target: str) -> tuple[int, int, int]:
copied_files = 0
copied_bytes = 0
skipped = 0
for root, dirs, files in os.walk(source):
rel = os.path.relpath(root, source)
dest_dir = os.path.join(target, rel) if rel != '.' else target
os.makedirs(dest_dir, exist_ok=True)
for file in files:
src_path = os.path.join(root, file)
dest_path = os.path.join(dest_dir, file)
try:
shutil.copy2(src_path, dest_path)
copied_files += 1
try:
copied_bytes += os.path.getsize(dest_path)
except OSError:
pass
except Exception as exc:
skipped += 1
self._log(f'Archivo omitido ({src_path}): {exc}')
continue
if copied_files % 20 == 0:
self._log(f'Copia en progreso: {copied_files} archivos...')
return copied_files, copied_bytes, skipped
def _open_saved_file(self, path: str) -> None:
if not path or not os.path.exists(path):
messagebox.showwarning('Archivo', 'El archivo indicado ya no existe.')
return
try:
if sys.platform.startswith('win'):
os.startfile(path) # type: ignore[attr-defined]
elif sys.platform == 'darwin':
subprocess.Popen(['open', path])
else:
subprocess.Popen(['xdg-open', path])
except Exception as exc:
messagebox.showerror('Archivo', f'No se pudo abrir el archivo: {exc}')
def _show_file_popup(self, path: str) -> None:
popup = tk.Toplevel(self)
popup.title('Archivo generado')
popup.configure(bg='white', padx=16, pady=16)
popup.resizable(False, False)
popup.transient(self)
popup.grab_set()
tk.Label(popup, text='Se creó el archivo con los datos:', font=('Arial', 12, 'bold'), bg='white').pack(anchor='w')
entry = tk.Entry(popup, width=60)
entry.pack(fill='x', pady=8)
entry.insert(0, path)
entry.config(state='readonly')
buttons = tk.Frame(popup, bg='white')
buttons.pack(fill='x', pady=(8,0))
tk.Button(buttons, text='Abrir archivo', command=lambda p=path: self._open_saved_file(p)).pack(side='left', padx=4)
tk.Button(buttons, text='Cerrar', command=popup.destroy).pack(side='right', padx=4)
def _open_text(self) -> None:
path = filedialog.askopenfilename(filetypes=[('Texto', '*.txt'), ('Todos', '*.*')])
if not path:
return
with open(path, 'r', encoding='utf-8', errors='ignore') as fh:
self.editor.delete('1.0', 'end')
self.editor.insert('1.0', fh.read())
self._log(f'Abriste {path}')
def _save_text(self) -> None:
path = filedialog.asksaveasfilename(defaultextension='.txt')
if not path:
return
with open(path, 'w', encoding='utf-8') as fh:
fh.write(self.editor.get('1.0', 'end'))
self._log(f'Guardado en {path}')
# -------------------- Funciones de correo electrónico --------------------
def _load_mail_credentials(self) -> None:
"""Carga las credenciales guardadas del archivo de configuración"""
try:
import base64
config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json')
if not os.path.exists(config_file):
return
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
# Cargar configuración del servidor
self.mail_imap_host.delete(0, 'end')
self.mail_imap_host.insert(0, config.get('imap_host', '10.10.0.101'))
self.mail_imap_port.delete(0, 'end')
self.mail_imap_port.insert(0, config.get('imap_port', '143'))
self.mail_smtp_host.delete(0, 'end')
self.mail_smtp_host.insert(0, config.get('smtp_host', '10.10.0.101'))
self.mail_smtp_port.delete(0, 'end')
self.mail_smtp_port.insert(0, config.get('smtp_port', '25'))
# Cargar credenciales (contraseña codificada en base64 para ofuscación básica)
self.mail_username.delete(0, 'end')
self.mail_username.insert(0, config.get('username', ''))
if 'password' in config and config['password']:
try:
# Decodificar contraseña
password_encoded = config['password']
password = base64.b64decode(password_encoded).decode('utf-8')
self.mail_password.delete(0, 'end')
self.mail_password.insert(0, password)
except Exception:
pass
self._log('Credenciales de correo cargadas')
except Exception as e:
self._log(f'No se pudieron cargar credenciales: {e}')
def _save_mail_credentials(self) -> None:
"""Guarda las credenciales en un archivo de configuración"""
if not self.mail_remember_var.get():
# Si no está marcado "recordar", eliminar el archivo de configuración
try:
config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json')
if os.path.exists(config_file):
os.remove(config_file)
self._log('Credenciales eliminadas')
except Exception:
pass
return
try:
import base64
config = {
'imap_host': self.mail_imap_host.get().strip(),
'imap_port': self.mail_imap_port.get().strip(),
'smtp_host': self.mail_smtp_host.get().strip(),
'smtp_port': self.mail_smtp_port.get().strip(),
'username': self.mail_username.get().strip(),
'password': base64.b64encode(self.mail_password.get().encode('utf-8')).decode('utf-8')
}
config_file = os.path.join(os.path.dirname(__file__), '.mail_config.json')
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2)
self._log('Credenciales guardadas correctamente')
except Exception as e:
self._log(f'Error al guardar credenciales: {e}')
def _connect_mail_server(self) -> None:
"""Conecta al servidor IMAP para leer correos"""
import imaplib
host = self.mail_imap_host.get().strip()
port = self.mail_imap_port.get().strip()
username = self.mail_username.get().strip()
password = self.mail_password.get()
if not host or not port or not username or not password:
messagebox.showerror('Error', 'Todos los campos son obligatorios')
return
try:
port_num = int(port)
self._log(f'Conectando a {host}:{port_num}...')
# Conectar a IMAP
self.imap_connection = imaplib.IMAP4(host, port_num)
self.imap_connection.login(username, password)
self.imap_connection.select('INBOX')
self.mail_connected = True
self.mail_status_label.config(text='🟢 Conectado', fg='#137333', bg='#ceead6')
self.btn_mail_connect.config(state='disabled')
self.btn_mail_disconnect.config(state='normal')
self._log('Conexión establecida correctamente')
# Listar carpetas IMAP disponibles para debugging
try:
status, folders = self.imap_connection.list()
if status == 'OK':
self._log('=== Carpetas IMAP disponibles en el servidor ===')
for folder in folders:
folder_str = folder.decode() if isinstance(folder, bytes) else str(folder)
self._log(f' - {folder_str}')
self._log('=' * 50)
except Exception as e:
self._log(f'No se pudieron listar carpetas: {e}')
messagebox.showinfo('✅ Éxito', 'Conectado al servidor de correo')
# Guardar credenciales si está marcado "recordar"
self._save_mail_credentials()
# Cargar lista de correos
self._refresh_mail_list()
except Exception as exc:
self._log(f'Error al conectar: {exc}')
messagebox.showerror('❌ Error de conexión', f'No se pudo conectar al servidor:\n{exc}')
self.imap_connection = None
def _disconnect_mail_server(self) -> None:
"""Desconecta del servidor IMAP"""
if self.imap_connection:
try:
self.imap_connection.close()
self.imap_connection.logout()
except Exception:
pass
self.imap_connection = None
self.mail_connected = False
self.mail_status_label.config(text='⚫ Desconectado', fg='#856404', bg='#fef3cd')
self.btn_mail_connect.config(state='normal')
self.btn_mail_disconnect.config(state='disabled')
self.mail_listbox.delete(0, 'end')
self.mail_list = []
self._clear_mail_display()
self._log('Desconectado del servidor de correo')
def _refresh_mail_list(self) -> None:
"""Actualiza la lista de correos desde el servidor"""
if not self.mail_connected or not self.imap_connection:
messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor')
return
try:
import email
from email.header import decode_header
self.mail_listbox.delete(0, 'end')
self.mail_list = []
# Buscar todos los correos en el buzón actual
self.imap_connection.select(self.current_mailbox)
status, messages = self.imap_connection.search(None, 'ALL')
if status != 'OK':
messagebox.showerror('Error', 'No se pudieron obtener los correos')
return
mail_ids = messages[0].split()
# Ordenar del más reciente al más antiguo
mail_ids = list(reversed(mail_ids))
# Limitar a los últimos 50 correos
mail_ids = mail_ids[:50]
self._log(f'Cargando {len(mail_ids)} correos...')
for mail_id in mail_ids:
# Obtenemos el correo con BODY.PEEK[] en lugar de RFC822 para NO marcarlo como leído
status, msg_data = self.imap_connection.fetch(mail_id, '(BODY.PEEK[] FLAGS)')
if status != 'OK':
continue
# Extraer flags correctamente desde la respuesta IMAP
is_seen = False
flags_debug = ""
try:
# La respuesta IMAP tiene formato: [(b'1 (FLAGS (\\Seen) BODY[] {size}', b'email_data'), b')']
# Buscamos el flag \Seen en toda la respuesta
for item in msg_data:
if isinstance(item, tuple):
for part in item:
if isinstance(part, bytes):
part_str = part.decode('utf-8', errors='ignore')
# Buscar específicamente la sección FLAGS entre paréntesis
if 'FLAGS (' in part_str:
flags_debug = part_str
# Extraer solo la parte de FLAGS (...)
import re
flags_match = re.search(r'FLAGS \(([^)]+)\)', part_str)
if flags_match:
flags_content = flags_match.group(1)
# Verificar si contiene \Seen
if '\\Seen' in flags_content:
is_seen = True
break
elif isinstance(item, bytes):
item_str = item.decode('utf-8', errors='ignore')
if 'FLAGS (' in item_str:
flags_debug = item_str
import re
flags_match = re.search(r'FLAGS \(([^)]+)\)', item_str)
if flags_match:
flags_content = flags_match.group(1)
if '\\Seen' in flags_content:
is_seen = True
break
if is_seen:
break
# Log de debug para ver qué flags se detectaron
if flags_debug:
self._log(f'DEBUG FLAGS correo {mail_id.decode()}: {flags_debug[:200]} -> is_seen={is_seen}')
except Exception as e:
# En caso de error, asumimos NO leído para evitar marcar incorrectamente
self._log(f'DEBUG: Error parseando flags del correo {mail_id}: {e}')
is_seen = False
# Parsear el correo - buscar el RFC822 en la respuesta
msg = None
try:
# La respuesta tiene formato: [(b'1 (FLAGS (...) RFC822 {size}', email_bytes), b')']
# Buscamos la parte que contiene los bytes del email
for item in msg_data:
if isinstance(item, tuple):
# item[0] es la cabecera, item[1] son los bytes del email
if len(item) >= 2 and isinstance(item[1], bytes):
msg = email.message_from_bytes(item[1])
break
if msg is None:
# Intento alternativo: a veces viene en msg_data[0][1]
if len(msg_data) > 0 and isinstance(msg_data[0], tuple) and len(msg_data[0]) > 1:
msg = email.message_from_bytes(msg_data[0][1])
except Exception as e:
self._log(f'Error parseando correo {mail_id}: {e}')
continue
if msg is None:
self._log(f'No se pudo parsear el correo {mail_id}')
continue
# Extraer asunto
subject = msg.get('Subject', 'Sin asunto')
if subject:
decoded_parts = decode_header(subject)
subject_parts = []
for part, encoding in decoded_parts:
if isinstance(part, bytes):
subject_parts.append(part.decode(encoding or 'utf-8', errors='ignore'))
else:
subject_parts.append(part)
subject = ''.join(subject_parts)
# Extraer remitente
from_addr = msg.get('From', 'Desconocido')
# Extraer fecha
date_str = msg.get('Date', '')
# Guardar información del correo
self.mail_list.append({
'id': mail_id.decode(),
'subject': subject,
'from': from_addr,
'date': date_str,
'msg': msg,
'is_seen': is_seen
})
# Aplicar filtro si está activo
if self.mail_filter_unread and is_seen:
# Si el filtro está activo y el correo está leído, no lo mostramos
continue
# Mostrar en la lista con indicador visual
if is_seen:
# Correo leído: texto normal en gris
display_text = f' {from_addr[:27]} - {subject[:37]}'
else:
# Correo NO leído: texto en negrita con indicador
display_text = f'🔵 {from_addr[:27]} - {subject[:37]}'
self.mail_listbox.insert('end', display_text)
# Aplicar color según estado
idx = self.mail_listbox.size() - 1
if is_seen:
self.mail_listbox.itemconfig(idx, fg='#888888', selectforeground='#666666')
else:
self.mail_listbox.itemconfig(idx, fg='#000000', selectforeground='#1a73e8')
# Contar correos sin leer
self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False))
if self.unread_label:
self.unread_label.config(text=f'Correos sin leer: {self.unread_count}')
self._log(f'{len(mail_ids)} correos cargados ({self.unread_count} sin leer)')
except Exception as exc:
self._log(f'Error al cargar correos: {exc}')
messagebox.showerror('Error', f'Error al cargar correos:\n{exc}')
def _show_inbox(self) -> None:
"""Muestra la bandeja de entrada"""
if not self.mail_connected or not self.imap_connection:
messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor')
return
self.current_mailbox = 'INBOX'
self.mail_folder_label.config(text='📬 Bandeja de entrada')
self._refresh_mail_list()
def _toggle_filter_unread(self) -> None:
"""Activa/desactiva el filtro de correos sin leer"""
self.mail_filter_unread = not self.mail_filter_unread
# Cambiar el aspecto del botón según el estado
if self.mail_filter_unread:
self.btn_filter_unread.config(bg='#ea4335', activebackground='#c5221f') # Rojo activo
self._log('Filtro activado: mostrando solo correos sin leer')
else:
self.btn_filter_unread.config(bg='#7c8691', activebackground='#5f6368') # Gris inactivo
self._log('Filtro desactivado: mostrando todos los correos')
# Recargar la lista con el nuevo filtro
self._refresh_mail_list()
def _show_sent(self) -> None:
"""Muestra la carpeta de correos enviados"""
if not self.mail_connected or not self.imap_connection:
messagebox.showwarning('Advertencia', 'Primero debes conectarte al servidor')
return
try:
import email
from email.header import decode_header
# Intentar acceder a la carpeta de enviados con diferentes nombres posibles
sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items']
mailbox_found = False
for folder in sent_folders:
try:
status, _ = self.imap_connection.select(folder)
if status == 'OK':
self.current_mailbox = folder
mailbox_found = True
self._log(f'Accediendo a carpeta: {folder}')
break
except Exception:
continue
if not mailbox_found:
messagebox.showwarning('Advertencia', 'No se encontró la carpeta de enviados en el servidor')
return
self.mail_folder_label.config(text='📤 Correos enviados')
# Limpiar la lista actual
self.mail_listbox.delete(0, 'end')
self.mail_list = []
# Buscar todos los correos
status, messages = self.imap_connection.search(None, 'ALL')
if status != 'OK':
messagebox.showerror('Error', 'No se pudieron obtener los correos enviados')
return
mail_ids = messages[0].split()
# Ordenar del más reciente al más antiguo
mail_ids = list(reversed(mail_ids))
# Limitar a los últimos 50 correos
mail_ids = mail_ids[:50]
self._log(f'Cargando {len(mail_ids)} correos enviados...')
for mail_id in mail_ids:
# Obtenemos el correo con BODY.PEEK[] en lugar de RFC822
status, msg_data = self.imap_connection.fetch(mail_id, '(BODY.PEEK[] FLAGS)')
if status != 'OK':
continue
# Extraer flags correctamente desde la respuesta IMAP
is_seen = False
try:
for item in msg_data:
if isinstance(item, tuple):
for part in item:
if isinstance(part, bytes):
part_str = part.decode('utf-8', errors='ignore')
# Buscar específicamente la sección FLAGS entre paréntesis
if 'FLAGS (' in part_str:
import re
flags_match = re.search(r'FLAGS \(([^)]+)\)', part_str)
if flags_match:
flags_content = flags_match.group(1)
if '\\Seen' in flags_content:
is_seen = True
break
elif isinstance(item, bytes):
item_str = item.decode('utf-8', errors='ignore')
if 'FLAGS (' in item_str:
import re
flags_match = re.search(r'FLAGS \(([^)]+)\)', item_str)
if flags_match:
flags_content = flags_match.group(1)
if '\\Seen' in flags_content:
is_seen = True
break
if is_seen:
break
except Exception as e:
self._log(f'DEBUG: Error parseando flags del correo enviado {mail_id}: {e}')
is_seen = False
# Parsear el correo - buscar el RFC822 en la respuesta
msg = None
try:
for item in msg_data:
if isinstance(item, tuple):
if len(item) >= 2 and isinstance(item[1], bytes):
msg = email.message_from_bytes(item[1])
break
if msg is None:
if len(msg_data) > 0 and isinstance(msg_data[0], tuple) and len(msg_data[0]) > 1:
msg = email.message_from_bytes(msg_data[0][1])
except Exception as e:
self._log(f'Error parseando correo enviado {mail_id}: {e}')
continue
if msg is None:
self._log(f'No se pudo parsear el correo enviado {mail_id}')
continue
# Extraer asunto
subject = msg.get('Subject', 'Sin asunto')
if subject:
decoded_parts = decode_header(subject)
subject_parts = []
for part, encoding in decoded_parts:
if isinstance(part, bytes):
subject_parts.append(part.decode(encoding or 'utf-8', errors='ignore'))
else:
subject_parts.append(part)
subject = ''.join(subject_parts)
# Extraer destinatario (To en lugar de From)
to_addr = msg.get('To', 'Desconocido')
# Extraer fecha
date_str = msg.get('Date', '')
# Guardar información del correo
self.mail_list.append({
'id': mail_id.decode(),
'subject': subject,
'to': to_addr,
'from': msg.get('From', 'Desconocido'),
'date': date_str,
'msg': msg,
'is_seen': is_seen
})
# Mostrar en la lista - Para enviados mostramos "Para: destinatario"
display_text = f'Para: {to_addr[:25]} - {subject[:35]}'
self.mail_listbox.insert('end', display_text)
# Para enviados NO actualizamos el contador (solo cuenta los de INBOX)
# El contador mantiene el valor de la bandeja de entrada
self._log(f'{len(mail_ids)} correos enviados cargados')
except Exception as exc:
self._log(f'Error al cargar correos enviados: {exc}')
messagebox.showerror('Error', f'Error al cargar correos enviados:\n{exc}')
def _on_mail_select(self, event) -> None:
"""Maneja la selección de un correo en la lista"""
try:
print('\n' + '='*80)
print('DEBUG: _on_mail_select() EJECUTADO')
print('='*80)
self._log('DEBUG: _on_mail_select() EJECUTADO')
selection = self.mail_listbox.curselection()
print(f'DEBUG: selection = {selection}')
self._log(f'DEBUG: selection = {selection}')
if not selection:
print('DEBUG: No hay selección, retornando')
self._log('DEBUG: No hay selección, retornando')
return
index = selection[0]
print(f'DEBUG: index = {index}, len(mail_list) = {len(self.mail_list)}')
self._log(f'DEBUG: index = {index}, len(mail_list) = {len(self.mail_list)}')
if index >= len(self.mail_list):
print('DEBUG: Índice fuera de rango')
self._log('DEBUG: Índice fuera de rango')
return
mail_info = self.mail_list[index]
print(f'=== CORREO SELECCIONADO #{index}: {mail_info["subject"]} ===')
self._log(f'=== CORREO SELECCIONADO #{index}: {mail_info["subject"]} ===')
# Marcar como leído en el servidor si estamos en INBOX
if self.mail_connected and self.imap_connection and self.current_mailbox == 'INBOX':
try:
mail_id = mail_info['id']
# Solo marcar si aún no está leído
if not mail_info.get('is_seen', False):
# Marcar el correo como leído (añadir flag \Seen)
self.imap_connection.store(mail_id, '+FLAGS', '\\Seen')
# Actualizar el flag local
mail_info['is_seen'] = True
# Actualizar visualmente el item en la lista
from_addr = mail_info['from']
subject = mail_info['subject']
display_text = f' {from_addr[:27]} - {subject[:37]}'
self.mail_listbox.delete(index)
self.mail_listbox.insert(index, display_text)
self.mail_listbox.itemconfig(index, fg='#888888', selectforeground='#666666')
self.mail_listbox.selection_set(index)
# Recalcular contador de sin leer
self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False))
if self.unread_label:
self.unread_label.config(text=f'Correos sin leer: {self.unread_count}')
print(f'DEBUG: Correo marcado como leído')
self._log(f'Correo marcado como leído en el servidor')
except Exception as mark_error:
print(f'DEBUG: Error al marcar como leído: {mark_error}')
self._log(f'Error al marcar correo como leído: {mark_error}')
print('DEBUG: Llamando a _display_mail()...')
self._log('DEBUG: Llamando a _display_mail()...')
self._display_mail(mail_info)
print('DEBUG: _display_mail() completado')
self._log('DEBUG: _display_mail() completado')
except Exception as e:
error_msg = f'ERROR CRÍTICO en _on_mail_select: {e}'
print(error_msg)
print(f'Traceback: {traceback.format_exc()}')
self._log(error_msg)
messagebox.showerror('Error', f'Error al seleccionar correo:\n{e}')
def _display_mail(self, mail_info: dict) -> None:
"""Muestra el contenido de un correo seleccionado con soporte para imágenes"""
try:
self._log(f'>>> Iniciando _display_mail para: {mail_info["subject"]}')
print(f'>>> Iniciando _display_mail para: {mail_info["subject"]}')
import email
from email.header import decode_header
import io
# Intentar importar PIL/Pillow (opcional)
try:
from PIL import Image, ImageTk
PIL_AVAILABLE = True
print('>>> PIL disponible - se mostrarán miniaturas de imágenes')
except ImportError:
PIL_AVAILABLE = False
print('>>> PIL NO disponible - se mostrarán iconos en lugar de imágenes')
self._log('ADVERTENCIA: PIL/Pillow no está instalado. Las imágenes se mostrarán como iconos.')
msg = mail_info['msg']
# Actualizar encabezados
self._log('>>> Actualizando encabezados')
print('>>> Actualizando encabezados')
self.mail_from_label.config(text=mail_info['from'])
self.mail_subject_label.config(text=mail_info['subject'])
self.mail_date_label.config(text=mail_info['date'])
self._log(f'>>> Encabezados actualizados: From={mail_info["from"][:30]}')
print(f'>>> Encabezados actualizados')
# Extraer cuerpo del correo y adjuntos
body = ''
attachments = [] # Todos los adjuntos (imágenes, PDFs, etc.)
self._log(f'>>> Procesando correo: {mail_info["subject"]}')
print(f'DEBUG: Procesando correo: {mail_info["subject"]}') # Debug extra
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get('Content-Disposition'))
self._log(f' Part: {content_type}, disposition: {content_disposition[:50]}')
# Saltar contenedores multipart
if content_type.startswith('multipart/'):
self._log(f' -> Saltando contenedor multipart')
continue
# Procesar texto
if content_type == 'text/plain' and 'attachment' not in content_disposition:
try:
payload = part.get_payload(decode=True)
if payload:
body = payload.decode('utf-8', errors='ignore')
self._log(f'Texto plano encontrado: {len(body)} caracteres')
except Exception as e:
self._log(f'Error al decodificar texto plano: {e}')
pass
elif content_type == 'text/html' and not body and 'attachment' not in content_disposition:
try:
payload = part.get_payload(decode=True)
if payload:
body = payload.decode('utf-8', errors='ignore')
self._log(f'HTML encontrado: {len(body)} caracteres')
except Exception as e:
self._log(f'Error al decodificar HTML: {e}')
pass
# Procesar archivos adjuntos (imágenes, PDFs, documentos, etc.)
else:
# Detectar adjuntos: si tiene filename o si es attachment explícito
filename = part.get_filename()
is_attachment = 'attachment' in content_disposition.lower()
# También considerar imágenes y PDFs como adjuntos potenciales
if filename or is_attachment or content_type.startswith('image/') or content_type.startswith('application/'):
try:
file_data = part.get_payload(decode=True)
if file_data:
if not filename:
# Generar nombre según el tipo
if content_type.startswith('image/'):
ext = content_type.split('/')[-1]
filename = f'imagen_{len(attachments) + 1}.{ext}'
elif content_type == 'application/pdf':
filename = f'documento_{len(attachments) + 1}.pdf'
elif 'word' in content_type:
filename = f'documento_{len(attachments) + 1}.docx'
elif 'excel' in content_type or 'spreadsheet' in content_type:
filename = f'hoja_{len(attachments) + 1}.xlsx'
else:
filename = f'archivo_{len(attachments) + 1}'
attachments.append({
'data': file_data,
'filename': filename,
'content_type': content_type,
'is_image': content_type.startswith('image/')
})
self._log(f'Adjunto detectado: {filename} ({content_type})')
except Exception as exc:
self._log(f'Error al procesar adjunto: {exc}')
else:
try:
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode('utf-8', errors='ignore')
else:
body = str(msg.get_payload())
self._log(f'Correo simple: {len(body)} caracteres')
except Exception as e:
body = str(msg.get_payload())
self._log(f'Error al decodificar correo simple: {e}')
# Mostrar el cuerpo
self._log(f'Mostrando cuerpo: {len(body)} caracteres')
print(f'>>> Mostrando cuerpo: {len(body)} caracteres')
# Limpiar imágenes inline previas
self.mail_inline_images = []
self.mail_body_text.config(state='normal')
self.mail_body_text.delete('1.0', 'end')
# Insertar texto del cuerpo
if body:
self.mail_body_text.insert('1.0', body)
else:
self.mail_body_text.insert('1.0', '[Sin contenido de texto]')
# Separar imágenes de otros adjuntos
images_to_show = []
other_attachments = []
for att in attachments:
if att['is_image']:
images_to_show.append(att)
else:
other_attachments.append(att)
# Mostrar imágenes inline en el cuerpo del correo
if images_to_show and PIL_AVAILABLE:
self.mail_body_text.insert('end', '\n\n' + '' * 60 + '\n')
self.mail_body_text.insert('end', '📷 Imágenes adjuntas:\n\n')
for idx, att_info in enumerate(images_to_show):
try:
# Cargar imagen
image = Image.open(io.BytesIO(att_info['data']))
# Redimensionar para mostrar inline (max 500px de ancho)
max_width = 500
if image.width > max_width:
ratio = max_width / image.width
new_height = int(image.height * ratio)
image = image.resize((max_width, new_height), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(image)
self.mail_inline_images.append(photo) # Guardar referencia
# Insertar nombre del archivo
self.mail_body_text.insert('end', f"📎 {att_info['filename']}\n")
# Insertar imagen
self.mail_body_text.image_create('end', image=photo)
self.mail_body_text.insert('end', '\n\n')
print(f'Imagen inline insertada: {att_info["filename"]}')
self._log(f'Imagen inline mostrada: {att_info["filename"]}')
except Exception as e:
print(f'Error al mostrar imagen inline: {e}')
self.mail_body_text.insert('end', f"[Error al cargar {att_info['filename']}]\n\n")
self.mail_body_text.config(state='disabled')
# Limpiar frame de adjuntos previo
for widget in self.mail_attachments_frame.winfo_children():
widget.destroy()
# Mostrar sección de adjuntos solo para archivos NO-imagen (PDFs, docs, etc.)
self._log(f'Total de adjuntos: {len(attachments)} ({len(images_to_show)} imágenes, {len(other_attachments)} otros)')
if other_attachments:
# Solo mostrar esta sección si hay archivos que no son imágenes
self.mail_attachments_frame.pack(fill='x', padx=5, pady=10, before=self.mail_body_text.master)
tk.Label(self.mail_attachments_frame, text='📎 Otros archivos adjuntos:',
bg='#f8f9fa', font=('Arial', 10, 'bold'), fg='#5f6368',
anchor='w').pack(fill='x', padx=10, pady=(10, 5))
# Frame con scroll para los adjuntos
attachments_container = tk.Frame(self.mail_attachments_frame, bg='#f8f9fa')
attachments_container.pack(fill='x', padx=10, pady=5)
for idx, att_info in enumerate(other_attachments):
try:
# Frame para cada adjunto
att_frame = tk.Frame(attachments_container, bg='#ffffff',
relief='solid', bd=1, padx=10, pady=10)
att_frame.pack(side='left', padx=5, pady=5)
# Mostrar icono según tipo de archivo
icon = '📄' if att_info['content_type'] == 'application/pdf' else '📎'
if 'word' in att_info['content_type'] or att_info['filename'].endswith(('.doc', '.docx')):
icon = '📝'
elif 'excel' in att_info['content_type'] or att_info['filename'].endswith(('.xls', '.xlsx')):
icon = '📊'
elif att_info['filename'].endswith(('.zip', '.rar', '.7z')):
icon = '📦'
tk.Label(att_frame, text=icon, bg='#ffffff',
font=('Arial', 48)).pack(pady=20)
# Nombre del archivo
tk.Label(att_frame, text=att_info['filename'], bg='#ffffff',
fg='#5f6368', font=('Arial', 9), wraplength=200).pack(pady=(5, 0))
# Tamaño del archivo
file_size = len(att_info['data'])
size_str = f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB"
tk.Label(att_frame, text=size_str, bg='#ffffff',
fg='#888', font=('Arial', 8)).pack()
# Botón para guardar el archivo
def save_file(data=att_info['data'], name=att_info['filename'], is_img=att_info['is_image']):
# Determinar extensión por defecto
if is_img:
default_ext = '.jpg'
filetypes = [('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp'),
('Todos los archivos', '*.*')]
elif name.endswith('.pdf'):
default_ext = '.pdf'
filetypes = [('PDF', '*.pdf'), ('Todos los archivos', '*.*')]
else:
default_ext = os.path.splitext(name)[1] or '.dat'
filetypes = [('Todos los archivos', '*.*')]
save_path = filedialog.asksaveasfilename(
defaultextension=default_ext,
initialfile=name,
filetypes=filetypes
)
if save_path:
try:
with open(save_path, 'wb') as f:
f.write(data)
messagebox.showinfo('✅ Éxito', f'Archivo guardado en:\n{save_path}')
except Exception as e:
messagebox.showerror('❌ Error', f'No se pudo guardar el archivo:\n{e}')
tk.Button(att_frame, text='💾 Guardar', command=save_file,
bg='#4285f4', fg='white', relief='flat',
font=('Arial', 9, 'bold'), cursor='hand2',
padx=10, pady=3).pack(pady=(5, 0))
except Exception as exc:
self._log(f'Error al mostrar adjunto: {exc}')
else:
# Ocultar frame si no hay adjuntos
self.mail_attachments_frame.pack_forget()
# Debugging final: verificar estado de widgets
print('\n>>> DEBUG FINAL DE WIDGETS:')
print(f' mail_from_label.text = {self.mail_from_label.cget("text")}')
print(f' mail_subject_label.text = {self.mail_subject_label.cget("text")}')
print(f' mail_date_label.text = {self.mail_date_label.cget("text")}')
print(f' mail_body_text visible = {self.mail_body_text.winfo_viewable()}')
print(f' mail_body_text width = {self.mail_body_text.winfo_width()}')
print(f' mail_body_text height = {self.mail_body_text.winfo_height()}')
body_content = self.mail_body_text.get('1.0', 'end')
print(f' mail_body_text contenido (primeros 100 chars) = {body_content[:100]}')
print(f' Adjuntos mostrados: {len(attachments)}')
self._log(f'>>> _display_mail COMPLETADO OK - Body: {len(body)} chars, Adjuntos: {len(attachments)}')
print(f'>>> _display_mail COMPLETADO OK')
except Exception as e:
error_msg = f'ERROR CRÍTICO en _display_mail: {e}'
print(error_msg)
print(f'Traceback: {traceback.format_exc()}')
self._log(error_msg)
messagebox.showerror('Error', f'Error al mostrar correo:\n{e}\n\nVer terminal para más detalles')
def _clear_mail_display(self) -> None:
"""Limpia la visualización del correo"""
self.mail_from_label.config(text='')
self.mail_subject_label.config(text='')
self.mail_date_label.config(text='')
self.mail_body_text.config(state='normal')
self.mail_body_text.delete('1.0', 'end')
self.mail_body_text.config(state='disabled')
# Limpiar adjuntos
for widget in self.mail_attachments_frame.winfo_children():
widget.destroy()
self.mail_attachments_frame.pack_forget()
def _open_compose_window(self) -> None:
"""Abre una ventana mejorada para redactar un nuevo correo con soporte para imágenes"""
if not self.mail_connected:
messagebox.showwarning('⚠️ Advertencia', 'Primero debes conectarte al servidor')
return
# Ventana más grande y moderna (casi fullscreen)
compose_window = tk.Toplevel(self)
compose_window.title('✉️ Redactar nuevo correo')
# Obtener dimensiones de la pantalla y hacer la ventana casi fullscreen
screen_width = compose_window.winfo_screenwidth()
screen_height = compose_window.winfo_screenheight()
window_width = int(screen_width * 0.85) # 85% del ancho de pantalla
window_height = int(screen_height * 0.85) # 85% del alto de pantalla
# Centrar la ventana
x = (screen_width - window_width) // 2
y = (screen_height - window_height) // 2
compose_window.geometry(f'{window_width}x{window_height}+{x}+{y}')
compose_window.minsize(900, 650)
compose_window.configure(bg='#f0f2f5')
compose_window.transient(self)
compose_window.grab_set()
# Lista para almacenar rutas de archivos adjuntos
attachments = []
attachment_labels = []
# ========== HEADER ==========
header = tk.Frame(compose_window, bg='#1a73e8', height=60)
header.pack(fill='x')
header.pack_propagate(False)
tk.Label(header, text='✍️ Nuevo mensaje', bg='#1a73e8', fg='white',
font=('Arial', 16, 'bold')).pack(side='left', padx=25, pady=15)
# ========== CAMPOS DEL CORREO ==========
fields_container = tk.Frame(compose_window, bg='#ffffff', relief='solid', bd=1)
fields_container.pack(fill='x', padx=20, pady=(20, 10))
fields_frame = tk.Frame(fields_container, bg='#ffffff', padx=25, pady=20)
fields_frame.pack(fill='x')
# Para (destinatarios múltiples)
to_frame = tk.Frame(fields_frame, bg='#ffffff')
to_frame.pack(fill='x', pady=8)
tk.Label(to_frame, text='Para:', bg='#ffffff', font=('Arial', 11, 'bold'),
fg='#5f6368', width=10, anchor='w').pack(side='left', padx=(0, 10))
# Frame para entry y label de ayuda
to_input_frame = tk.Frame(to_frame, bg='#ffffff')
to_input_frame.pack(side='left', fill='x', expand=True)
to_entry = tk.Entry(to_input_frame, font=('Arial', 11), relief='solid', bd=1,
bg='#f8f9fa', fg='#202124')
to_entry.pack(fill='x')
# Label de ayuda para múltiples destinatarios
tk.Label(to_input_frame, text='💡 Separa múltiples destinatarios con comas o punto y coma',
bg='#ffffff', fg='#5f6368', font=('Arial', 8, 'italic'),
anchor='w').pack(fill='x', pady=(2, 0))
to_entry.focus()
# Asunto
subject_frame = tk.Frame(fields_frame, bg='#ffffff')
subject_frame.pack(fill='x', pady=8)
tk.Label(subject_frame, text='Asunto:', bg='#ffffff', font=('Arial', 11, 'bold'),
fg='#5f6368', width=10, anchor='w').pack(side='left', padx=(0, 10))
subject_entry = tk.Entry(subject_frame, font=('Arial', 11), relief='solid', bd=1,
bg='#f8f9fa', fg='#202124')
subject_entry.pack(side='left', fill='x', expand=True)
# Separador
tk.Frame(fields_frame, height=1, bg='#dadce0').pack(fill='x', pady=10)
# ========== BARRA DE HERRAMIENTAS ==========
toolbar = tk.Frame(fields_container, bg='#f8f9fa', relief='solid', bd=1)
toolbar.pack(fill='x', padx=0, pady=0)
toolbar_content = tk.Frame(toolbar, bg='#f8f9fa')
toolbar_content.pack(fill='x', padx=15, pady=10)
tk.Label(toolbar_content, text='📎 Adjuntar:', bg='#f8f9fa',
font=('Arial', 10, 'bold'), fg='#5f6368').pack(side='left', padx=(0, 10))
def get_file_icon(file_path: str) -> str:
"""Devuelve el emoji apropiado según el tipo de archivo"""
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']:
return '🖼️'
elif ext == '.pdf':
return '📄'
elif ext in ['.doc', '.docx']:
return '📝'
elif ext in ['.xls', '.xlsx']:
return '📊'
elif ext in ['.zip', '.rar', '.7z']:
return '📦'
elif ext in ['.txt', '.log']:
return '📃'
else:
return '📎'
def attach_file(file_type: str = 'all'):
"""Adjunta un archivo al correo"""
if file_type == 'image':
title = 'Seleccionar imagen'
filetypes = [
('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp *.webp'),
('Todos los archivos', '*.*')
]
elif file_type == 'pdf':
title = 'Seleccionar PDF'
filetypes = [
('Documentos PDF', '*.pdf'),
('Todos los archivos', '*.*')
]
else:
title = 'Seleccionar archivo'
filetypes = [
('Todos los archivos', '*.*'),
('Documentos PDF', '*.pdf'),
('Imágenes', '*.png *.jpg *.jpeg *.gif *.bmp'),
('Documentos Word', '*.doc *.docx'),
('Hojas de cálculo', '*.xls *.xlsx'),
('Archivos comprimidos', '*.zip *.rar *.7z'),
('Archivos de texto', '*.txt *.log')
]
file_path = filedialog.askopenfilename(title=title, filetypes=filetypes)
if file_path:
attachments.append(file_path)
file_name = os.path.basename(file_path)
file_icon = get_file_icon(file_path)
# Crear label para mostrar el archivo adjunto
att_frame = tk.Frame(attachments_list, bg='#e8f0fe', relief='solid', bd=1)
att_frame.pack(fill='x', padx=5, pady=3)
tk.Label(att_frame, text=f'{file_icon} {file_name}', bg='#e8f0fe',
fg='#1a73e8', font=('Arial', 9), anchor='w').pack(side='left',
padx=10, pady=5, fill='x', expand=True)
def remove_attachment(frame=att_frame, path=file_path):
attachments.remove(path)
frame.destroy()
tk.Button(att_frame, text='', command=remove_attachment,
bg='#dc3545', fg='white', relief='flat',
font=('Arial', 9, 'bold'), cursor='hand2',
padx=8, pady=2).pack(side='right', padx=5, pady=2)
attachment_labels.append(att_frame)
self._log(f'Archivo adjunto: {file_name}')
# Botones de adjuntar
tk.Button(toolbar_content, text='🖼️ Imagen', command=lambda: attach_file('image'),
bg='#4285f4', fg='white', relief='flat', font=('Arial', 10, 'bold'),
padx=15, pady=6, cursor='hand2', activebackground='#3367d6').pack(side='left', padx=3)
tk.Button(toolbar_content, text='📄 PDF', command=lambda: attach_file('pdf'),
bg='#ea4335', fg='white', relief='flat', font=('Arial', 10, 'bold'),
padx=15, pady=6, cursor='hand2', activebackground='#c5362d').pack(side='left', padx=3)
tk.Button(toolbar_content, text='📎 Otro archivo', command=lambda: attach_file('all'),
bg='#fbbc04', fg='#202124', relief='flat', font=('Arial', 10, 'bold'),
padx=15, pady=6, cursor='hand2', activebackground='#f9ab00').pack(side='left', padx=3)
tk.Label(toolbar_content, text='💡 Puedes adjuntar imágenes, PDFs y otros documentos',
bg='#f8f9fa', fg='#5f6368', font=('Arial', 9)).pack(side='left', padx=15)
# ========== CONTENEDOR PRINCIPAL SCROLLABLE ==========
# Frame contenedor que se expandirá
main_content_frame = tk.Frame(compose_window, bg='#f0f2f5')
main_content_frame.pack(fill='both', expand=True, padx=20, pady=(0, 10))
# Canvas con scrollbar para todo el contenido
canvas = tk.Canvas(main_content_frame, bg='#f0f2f5', highlightthickness=0)
scrollbar = tk.Scrollbar(main_content_frame, orient='vertical', command=canvas.yview)
scrollable_frame = tk.Frame(canvas, bg='#f0f2f5')
scrollable_frame.bind(
'<Configure>',
lambda e: canvas.configure(scrollregion=canvas.bbox('all'))
)
canvas.create_window((0, 0), window=scrollable_frame, anchor='nw')
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side='left', fill='both', expand=True)
scrollbar.pack(side='right', fill='y')
# ========== LISTA DE ADJUNTOS ==========
attachments_frame = tk.Frame(scrollable_frame, bg='#ffffff', relief='solid', bd=1)
attachments_frame.pack(fill='x', pady=(0, 10))
att_header = tk.Frame(attachments_frame, bg='#f8f9fa')
att_header.pack(fill='x')
tk.Label(att_header, text='📋 Archivos adjuntos', bg='#f8f9fa',
font=('Arial', 10, 'bold'), fg='#5f6368').pack(side='left',
padx=15, pady=8)
# Scrollable frame para adjuntos
att_canvas_frame = tk.Frame(attachments_frame, bg='#ffffff', height=100)
att_canvas_frame.pack(fill='both', expand=True)
att_canvas_frame.pack_propagate(False)
attachments_list = tk.Frame(att_canvas_frame, bg='#ffffff')
attachments_list.pack(fill='both', expand=True, padx=10, pady=10)
# ========== CUERPO DEL MENSAJE ==========
body_container = tk.Frame(scrollable_frame, bg='#ffffff', relief='solid', bd=1)
body_container.pack(fill='both', expand=True, pady=(0, 10))
body_header = tk.Frame(body_container, bg='#f8f9fa')
body_header.pack(fill='x')
tk.Label(body_header, text='✏️ Mensaje (Ctrl+V para pegar imágenes)', bg='#f8f9fa',
font=('Arial', 11, 'bold'), fg='#5f6368').pack(anchor='w',
padx=20, pady=10)
# Usar Text en lugar de ScrolledText para soportar imágenes inline
text_frame = tk.Frame(body_container, bg='#ffffff')
text_frame.pack(fill='both', expand=True, padx=5, pady=(0, 5))
body_text_scroll = tk.Scrollbar(text_frame)
body_text_scroll.pack(side='right', fill='y')
body_text = tk.Text(text_frame, wrap='word',
font=('Segoe UI', 11), bg='#ffffff',
fg='#202124', relief='flat', padx=20, pady=15,
spacing1=3, spacing3=5, height=15,
yscrollcommand=body_text_scroll.set)
body_text.pack(side='left', fill='both', expand=True)
body_text_scroll.config(command=body_text.yview)
# Lista para guardar referencias a PhotoImage (evitar garbage collection)
inline_images = []
# Lista para guardar las imágenes como datos (para enviar)
inline_images_data = []
def paste_image(event=None):
"""Pega una imagen desde el portapapeles al cuerpo del correo"""
try:
from PIL import ImageGrab, ImageTk, Image
import io
# Intentar obtener imagen del portapapeles
img = ImageGrab.grabclipboard()
if img is not None and isinstance(img, Image.Image):
# Guardar imagen original para envío
img_buffer = io.BytesIO()
img.save(img_buffer, format='PNG')
img_data = img_buffer.getvalue()
# Generar nombre único para la imagen
img_name = f'imagen_pegada_{len(inline_images_data) + 1}.png'
inline_images_data.append({
'data': img_data,
'name': img_name,
'pil_image': img.copy()
})
# Redimensionar para mostrar (max 600px de ancho)
max_width = 600
if img.width > max_width:
ratio = max_width / img.width
new_height = int(img.height * ratio)
img = img.resize((max_width, new_height), Image.Resampling.LANCZOS)
# Convertir a PhotoImage
photo = ImageTk.PhotoImage(img)
inline_images.append(photo) # Guardar referencia
# Insertar en el Text widget
body_text.image_create('insert', image=photo)
body_text.insert('insert', '\n') # Nueva línea después de la imagen
self._log(f'Imagen pegada en el correo: {img_name}')
print(f'Imagen pegada y guardada para envío: {img_name}')
return 'break' # Prevenir el comportamiento por defecto
elif isinstance(img, list):
# Si es una lista de archivos (copiar archivos desde explorador)
for file_path in img:
if os.path.isfile(file_path):
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']:
# Cargar imagen desde archivo
img_file = Image.open(file_path)
# Guardar para envío
img_buffer = io.BytesIO()
img_file.save(img_buffer, format='PNG')
img_data = img_buffer.getvalue()
img_name = f'imagen_pegada_{len(inline_images_data) + 1}.png'
inline_images_data.append({
'data': img_data,
'name': img_name,
'pil_image': img_file.copy()
})
# Redimensionar si es muy grande
max_width = 600
if img_file.width > max_width:
ratio = max_width / img_file.width
new_height = int(img_file.height * ratio)
img_file = img_file.resize((max_width, new_height), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(img_file)
inline_images.append(photo)
body_text.image_create('insert', image=photo)
body_text.insert('insert', '\n')
self._log(f'Imagen pegada: {os.path.basename(file_path)}')
return 'break'
except Exception as e:
print(f'Error al pegar imagen: {e}')
# Si falla, permitir pegado normal de texto
pass
return None # Permitir comportamiento por defecto para texto
# Vincular Ctrl+V
body_text.bind('<Control-v>', paste_image)
body_text.bind('<Control-V>', paste_image)
# ========== BOTONES DE ACCIÓN (FIJOS EN LA PARTE INFERIOR) ==========
btn_frame = tk.Frame(compose_window, bg='#ffffff', relief='solid', bd=2)
btn_frame.pack(side='bottom', fill='x', padx=0, pady=0)
# Contenedor interno con padding
btn_container = tk.Frame(btn_frame, bg='#ffffff')
btn_container.pack(fill='x', padx=20, pady=15)
def send_mail():
to_addr_raw = to_entry.get().strip()
subject = subject_entry.get().strip()
body = body_text.get('1.0', 'end').strip()
if not to_addr_raw:
messagebox.showwarning('⚠️ Advertencia', 'Debes especificar al menos un destinatario')
to_entry.focus()
return
# Separar múltiples destinatarios (por coma o punto y coma)
import re
recipients = re.split(r'[;,]\s*', to_addr_raw)
recipients = [r.strip() for r in recipients if r.strip()]
# Validar que haya al menos un destinatario
if not recipients:
messagebox.showwarning('⚠️ Advertencia', 'Debes especificar al menos un destinatario válido')
to_entry.focus()
return
# Validar formato de emails
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
invalid_emails = [email for email in recipients if not re.match(email_pattern, email)]
if invalid_emails:
messagebox.showwarning('⚠️ Advertencia',
f'Los siguientes emails no son válidos:\n{", ".join(invalid_emails)}')
to_entry.focus()
return
if not subject:
messagebox.showwarning('⚠️ Advertencia', 'Debes especificar un asunto')
subject_entry.focus()
return
if not body:
messagebox.showwarning('⚠️ Advertencia', 'El mensaje no puede estar vacío')
body_text.focus()
return
# Mostrar confirmación si hay múltiples destinatarios
if len(recipients) > 1:
confirm = messagebox.askyesno('📧 Múltiples destinatarios',
f'¿Enviar correo a {len(recipients)} destinatarios?\n\n' +
'\n'.join(f'{email}' for email in recipients))
if not confirm:
return
# Combinar adjuntos de archivos con imágenes pegadas
all_attachments = attachments.copy() # Archivos adjuntados con botones
# Guardar imágenes pegadas como archivos temporales
temp_files = []
for img_data in inline_images_data:
try:
# Crear archivo temporal
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png', prefix='pasted_img_')
temp_file.write(img_data['data'])
temp_file.close()
temp_files.append(temp_file.name)
all_attachments.append(temp_file.name)
print(f"Imagen pegada guardada temporalmente: {temp_file.name}")
except Exception as e:
print(f"Error al guardar imagen temporal: {e}")
# Enviar con todos los adjuntos a múltiples destinatarios
self._send_mail_with_attachments(recipients, subject, body, all_attachments, compose_window)
# Limpiar archivos temporales después de enviar
for temp_file in temp_files:
try:
if os.path.exists(temp_file):
os.unlink(temp_file)
print(f"Archivo temporal eliminado: {temp_file}")
except Exception as e:
print(f"Error al eliminar temporal: {e}")
# Botón Enviar (destacado y más grande)
send_btn = tk.Button(btn_container, text='📤 ENVIAR CORREO', command=send_mail,
bg='#34a853', fg='white', relief='raised', bd=2,
font=('Arial', 13, 'bold'), padx=40, pady=15,
cursor='hand2', activebackground='#2d8e47')
send_btn.pack(side='left', padx=(0, 15))
# Botón Cancelar
cancel_btn = tk.Button(btn_container, text='❌ CANCELAR', command=compose_window.destroy,
bg='#dc3545', fg='white', relief='raised', bd=2,
font=('Arial', 12, 'bold'), padx=35, pady=13,
cursor='hand2', activebackground='#b02a37')
cancel_btn.pack(side='left', padx=5)
# Información adicional
info_label = tk.Label(btn_container, text='💡 Tip: Puedes adjuntar varias imágenes a tu correo',
bg='#ffffff', fg='#5f6368', font=('Arial', 10))
info_label.pack(side='right', padx=10)
def _send_mail(self, to_addr: str, subject: str, body: str, window: tk.Toplevel) -> None:
"""Envía un correo electrónico usando SMTP"""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
smtp_host = self.mail_smtp_host.get().strip()
smtp_port = self.mail_smtp_port.get().strip()
username = self.mail_username.get().strip()
password = self.mail_password.get()
try:
smtp_port_num = int(smtp_port)
self._log(f'Enviando correo a {to_addr}...')
# Crear el mensaje
msg = MIMEMultipart()
msg['From'] = username
msg['To'] = to_addr
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain', 'utf-8'))
# Conectar y enviar
with smtplib.SMTP(smtp_host, smtp_port_num, timeout=10) as server:
# Nota: Si el servidor requiere autenticación TLS, descomentar:
# server.starttls()
# server.login(username, password)
server.send_message(msg)
self._log('Correo enviado correctamente')
# Guardar copia en carpeta de enviados del servidor IMAP
self._save_to_sent_folder(msg)
# Si estamos viendo la carpeta de enviados, actualizar la lista
if self.current_mailbox != 'INBOX':
self._show_sent()
messagebox.showinfo('✅ Éxito', 'Correo enviado correctamente')
window.destroy()
except Exception as exc:
self._log(f'Error al enviar correo: {exc}')
messagebox.showerror('❌ Error', f'No se pudo enviar el correo:\n{exc}')
def _send_mail_with_attachments(self, to_addrs, subject: str, body: str,
attachments: list, window: tk.Toplevel) -> None:
"""Envía un correo electrónico con adjuntos a uno o múltiples destinatarios usando SMTP
Args:
to_addrs: String con un email o lista de emails
subject: Asunto del correo
body: Cuerpo del mensaje
attachments: Lista de rutas de archivos adjuntos
window: Ventana de composición a cerrar tras enviar
"""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.image import MIMEImage
from email.mime.application import MIMEApplication
from email.mime.base import MIMEBase
from email import encoders
smtp_host = self.mail_smtp_host.get().strip()
smtp_port = self.mail_smtp_port.get().strip()
username = self.mail_username.get().strip()
password = self.mail_password.get()
# Convertir a lista si es un string
if isinstance(to_addrs, str):
recipients = [to_addrs]
else:
recipients = to_addrs
try:
smtp_port_num = int(smtp_port)
recipients_str = ', '.join(recipients)
self._log(f'Enviando correo con {len(attachments)} adjunto(s) a {len(recipients)} destinatario(s)...')
# Crear el mensaje multipart
msg = MIMEMultipart()
msg['From'] = username
msg['To'] = recipients_str # Todos los destinatarios separados por comas
msg['Subject'] = subject
# Adjuntar el cuerpo del mensaje
msg.attach(MIMEText(body, 'plain', 'utf-8'))
# Adjuntar los archivos
for file_path in attachments:
try:
file_name = os.path.basename(file_path)
file_ext = os.path.splitext(file_path)[1].lower()
with open(file_path, 'rb') as file:
file_data = file.read()
# Determinar el tipo de archivo y usar el MIME apropiado
if file_ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']:
# Imágenes
image = MIMEImage(file_data, name=file_name)
image.add_header('Content-Disposition', 'attachment', filename=file_name)
msg.attach(image)
self._log(f'Imagen adjuntada: {file_name}')
elif file_ext == '.pdf':
# PDFs
pdf = MIMEApplication(file_data, _subtype='pdf')
pdf.add_header('Content-Disposition', 'attachment', filename=file_name)
msg.attach(pdf)
self._log(f'PDF adjuntado: {file_name}')
elif file_ext in ['.doc', '.docx']:
# Documentos Word
part = MIMEApplication(file_data, _subtype='msword' if file_ext == '.doc' else 'vnd.openxmlformats-officedocument.wordprocessingml.document')
part.add_header('Content-Disposition', 'attachment', filename=file_name)
msg.attach(part)
self._log(f'Documento Word adjuntado: {file_name}')
elif file_ext in ['.xls', '.xlsx']:
# Hojas de cálculo Excel
part = MIMEApplication(file_data, _subtype='vnd.ms-excel' if file_ext == '.xls' else 'vnd.openxmlformats-officedocument.spreadsheetml.sheet')
part.add_header('Content-Disposition', 'attachment', filename=file_name)
msg.attach(part)
self._log(f'Hoja de cálculo adjuntada: {file_name}')
elif file_ext in ['.zip', '.rar', '.7z']:
# Archivos comprimidos
part = MIMEApplication(file_data, _subtype='zip')
part.add_header('Content-Disposition', 'attachment', filename=file_name)
msg.attach(part)
self._log(f'Archivo comprimido adjuntado: {file_name}')
elif file_ext in ['.txt', '.log']:
# Archivos de texto
part = MIMEText(file_data.decode('utf-8', errors='ignore'), 'plain', 'utf-8')
part.add_header('Content-Disposition', 'attachment', filename=file_name)
msg.attach(part)
self._log(f'Archivo de texto adjuntado: {file_name}')
else:
# Para otros tipos de archivos (genérico)
part = MIMEBase('application', 'octet-stream')
part.set_payload(file_data)
encoders.encode_base64(part)
part.add_header('Content-Disposition', f'attachment; filename= {file_name}')
msg.attach(part)
self._log(f'Archivo adjuntado: {file_name}')
except Exception as exc:
self._log(f'Error al adjuntar {file_name}: {exc}')
messagebox.showwarning('⚠️ Advertencia',
f'No se pudo adjuntar {file_name}:\n{exc}')
# Conectar y enviar a todos los destinatarios
with smtplib.SMTP(smtp_host, smtp_port_num, timeout=15) as server:
# Nota: Si el servidor requiere autenticación TLS, descomentar:
# server.starttls()
# server.login(username, password)
# send_message maneja automáticamente múltiples destinatarios
server.send_message(msg, to_addrs=recipients)
# Mensaje de éxito
if len(recipients) == 1:
success_msg = f'Correo enviado correctamente con {len(attachments)} adjunto(s)'
else:
success_msg = f'Correo enviado correctamente a {len(recipients)} destinatarios con {len(attachments)} adjunto(s)'
self._log(success_msg)
# Guardar copia en carpeta de enviados del servidor IMAP
self._save_to_sent_folder(msg)
# Si estamos viendo la carpeta de enviados, actualizar la lista
if self.current_mailbox != 'INBOX':
self._show_sent()
messagebox.showinfo('✅ Éxito', success_msg)
window.destroy()
except Exception as exc:
self._log(f'Error al enviar correo: {exc}')
messagebox.showerror('❌ Error', f'No se pudo enviar el correo:\n{exc}')
def _save_to_sent_folder(self, msg) -> None:
"""Guarda una copia del correo enviado en la carpeta Sent del servidor IMAP"""
if not self.mail_connected or not self.imap_connection:
self._log('No se puede guardar en carpeta Sent: no hay conexión IMAP')
return
try:
import imaplib
import time
self._log('Intentando guardar correo en carpeta de enviados del servidor...')
# Convertir el mensaje a bytes (formato RFC822)
msg_bytes = msg.as_bytes()
# Intentar diferentes nombres de carpeta de enviados
sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items']
saved = False
for folder in sent_folders:
try:
self._log(f'Intentando guardar en carpeta: {folder}')
# Intentar agregar el mensaje a la carpeta
# Usar el flag \Seen para marcarlo como leído
date = imaplib.Time2Internaldate(time.time())
result = self.imap_connection.append(folder, '\\Seen', date, msg_bytes)
if result[0] == 'OK':
self._log(f'✓ Correo guardado exitosamente en carpeta: {folder}')
self._log(f'✓ El correo debería aparecer en Webmin en la carpeta {folder}')
saved = True
break
else:
self._log(f'Respuesta del servidor: {result}')
except Exception as e:
# Si esta carpeta no existe, intentar con la siguiente
self._log(f'Carpeta {folder} no disponible: {e}')
continue
if not saved:
# Si no se pudo guardar en ninguna carpeta, intentar crear "Sent"
try:
self._log('Ninguna carpeta de enviados encontrada. Intentando crear "Sent"...')
self.imap_connection.create('Sent')
self._log('Carpeta "Sent" creada exitosamente')
date = imaplib.Time2Internaldate(time.time())
result = self.imap_connection.append('Sent', '\\Seen', date, msg_bytes)
if result[0] == 'OK':
self._log('✓ Carpeta "Sent" creada y correo guardado')
self._log('✓ El correo debería aparecer en Webmin en la carpeta Sent')
saved = True
else:
self._log(f'Error al guardar en carpeta creada: {result}')
except Exception as e:
self._log(f'✗ No se pudo crear carpeta Sent ni guardar correo: {e}')
if not saved:
self._log('⚠ ADVERTENCIA: El correo se envió pero NO se guardó en el servidor')
self._log('⚠ No aparecerá en Webmin ni en otros clientes de correo')
except Exception as exc:
self._log(f'✗ Error crítico al guardar en carpeta de enviados: {exc}')
import traceback
self._log(f'Traceback: {traceback.format_exc()}')
def _open_simple_scraping_popup(self) -> None:
if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE):
messagebox.showerror('Scraping', 'Instala requests y beautifulsoup4 para usar esta función.')
return
if self.simple_scraping_popup and tk.Toplevel.winfo_exists(self.simple_scraping_popup):
self.simple_scraping_popup.lift()
return
popup = tk.Toplevel(self)
popup.title('Scraping simple')
popup.configure(bg='white', padx=18, pady=18)
popup.resizable(False, False)
popup.transient(self)
popup.grab_set()
tk.Label(
popup,
text='Introduce una o varias URL completas (una por línea):',
bg='white',
font=('Arial', 11, 'bold'),
justify='left'
).pack(anchor='w')
url_box = scrolledtext.ScrolledText(popup, width=60, height=5)
url_box.pack(pady=(8, 4))
url_box.insert('1.0', 'https://es.wallapop.com\nhttps://www.wikipedia.org')
error_label = tk.Label(popup, text='', fg='red', bg='white', wraplength=420, justify='left')
error_label.pack(anchor='w')
btn_frame = tk.Frame(popup, bg='white')
btn_frame.pack(fill='x', pady=(10, 0))
def _close_popup() -> None:
if self.simple_scraping_popup:
try:
self.simple_scraping_popup.destroy()
except Exception:
pass
self.simple_scraping_popup = None
def _start(_: object | None = None) -> None:
raw = url_box.get('1.0', 'end').strip()
urls = [line.strip() for line in raw.splitlines() if line.strip()]
if not urls:
error_label.config(text='Añade al menos una URL completa.')
return
invalid = [u for u in urls if not u.startswith(('http://', 'https://'))]
if invalid:
error_label.config(text=f'Las URL deben empezar por http:// o https:// (revisa: {invalid[0]})')
return
_close_popup()
self._start_simple_scraping(urls)
tk.Button(btn_frame, text='Iniciar', command=_start, bg='#d6f2ce').pack(side='left', padx=4)
tk.Button(btn_frame, text='Cancelar', command=_close_popup).pack(side='right', padx=4)
url_box.bind('<Control-Return>', _start)
popup.bind('<Return>', _start)
popup.protocol('WM_DELETE_WINDOW', _close_popup)
self.simple_scraping_popup = popup
url_box.focus_set()
def _start_simple_scraping(self, urls: list[str]) -> None:
if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE):
messagebox.showerror('Scraping', 'Instala requests y beautifulsoup4 para usar esta función.')
return
cleaned = [url.strip() for url in urls if url.strip()]
if not cleaned:
messagebox.showinfo('Scraping', 'No hay URL válidas para analizar.')
return
self._log(f'Scraping simple para {len(cleaned)} URL(s)')
preview = 'Analizando las siguientes URL:\n' + '\n'.join(f'{url}' for url in cleaned)
self._show_text_result('Scraping simple', preview)
self.notebook.select(self.tab_resultados)
threading.Thread(target=self._simple_scraping_runner, args=(cleaned,), daemon=True).start()
def _simple_scraping_runner(self, urls: list[str]) -> None:
results: list[dict[str, str]] = []
lock = threading.Lock()
threads: list[threading.Thread] = []
for url in urls:
t = threading.Thread(target=self._simple_scraping_worker, args=(url, results, lock), daemon=True)
t.start()
threads.append(t)
for thread in threads:
thread.join()
self._scraping_queue.put(('generic_success', urls, results))
def _simple_scraping_worker(self, url: str, results: list[dict[str, str]], lock: threading.Lock) -> None:
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
if not (requests and BEAUTIFULSOUP_AVAILABLE):
data = {
'url': url,
'error': 'Dependencias de scraping no disponibles',
'timestamp': timestamp
}
else:
try:
time.sleep(1)
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
title = soup.title.string.strip() if soup.title and soup.title.string else 'Sin título'
num_links = len(soup.find_all('a'))
num_images = len(soup.find_all('img'))
num_paragraphs = len(soup.find_all('p'))
meta_desc = ''
meta_tag = soup.find('meta', attrs={'name': 'description'})
if meta_tag and meta_tag.get('content'):
content = meta_tag['content']
meta_desc = content[:100] + '...' if len(content) > 100 else content
data = {
'url': url,
'titulo': title,
'descripcion': meta_desc,
'num_links': num_links,
'num_imagenes': num_images,
'num_parrafos': num_paragraphs,
'longitud': len(response.text),
'status_code': response.status_code,
'timestamp': timestamp
}
except Exception as exc:
data = {
'url': url,
'error': str(exc),
'timestamp': timestamp
}
with lock:
results.append(data)
# --- Funcionalidad de Web Scraping (Wallapop) ---
def _open_wallapop_popup(self) -> None:
if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE):
messagebox.showerror('Wallapop', 'Instala requests y beautifulsoup4 para analizar enlaces.')
return
if self.scraping_popup and tk.Toplevel.winfo_exists(self.scraping_popup):
self.scraping_popup.lift()
return
popup = tk.Toplevel(self)
popup.title('Búsqueda Wallapop')
popup.configure(bg='white', padx=18, pady=18)
popup.resizable(False, False)
popup.transient(self)
popup.grab_set()
tk.Label(
popup,
text='Escribe el producto que quieres buscar en Wallapop:',
bg='white',
font=('Arial', 11, 'bold')
).pack(anchor='w')
entry = tk.Entry(popup, width=60)
entry.pack(pady=(8, 4))
entry.insert(0, 'PlayStation 5')
controls = tk.Frame(popup, bg='white')
controls.pack(fill='x', pady=(2, 0))
tk.Label(controls, text='Resultados a mostrar:', bg='white').pack(side='left')
results_spin = tk.Spinbox(controls, from_=1, to=20, width=5, justify='center')
results_spin.pack(side='left', padx=(6, 0))
results_spin.delete(0, 'end')
results_spin.insert(0, '5')
error_label = tk.Label(popup, text='', fg='red', bg='white', wraplength=380, justify='left')
error_label.pack(anchor='w')
btn_frame = tk.Frame(popup, bg='white')
btn_frame.pack(fill='x', pady=(10, 0))
def _close_popup() -> None:
if self.scraping_popup:
try:
self.scraping_popup.destroy()
except Exception:
pass
self.scraping_popup = None
def _start(_: object | None = None) -> None:
query = entry.get().strip()
if not query:
error_label.config(text='Necesitas introducir un producto a buscar.')
return
try:
limit = max(1, min(20, int(results_spin.get())))
except ValueError:
error_label.config(text='Selecciona un número válido de resultados.')
return
_close_popup()
self._start_wallapop_search(query, limit)
tk.Button(btn_frame, text='Buscar', command=_start, bg='#d6f2ce').pack(side='left', padx=4)
tk.Button(btn_frame, text='Cancelar', command=_close_popup).pack(side='right', padx=4)
entry.bind('<Return>', _start)
popup.protocol('WM_DELETE_WINDOW', _close_popup)
self.scraping_popup = popup
entry.focus_set()
def _start_wallapop_search(self, query: str, limit: int) -> None:
if not (REQUESTS_AVAILABLE and BEAUTIFULSOUP_AVAILABLE):
messagebox.showerror('Wallapop', 'Instala requests y beautifulsoup4 para usar esta función.')
return
clean_query = query.strip()
if not clean_query:
messagebox.showerror('Wallapop', 'Escribe el producto que quieres buscar.')
return
max_results = max(1, min(20, int(limit)))
self._log(f'Buscando en Wallapop: "{clean_query}" (máx. {max_results})')
self._show_text_result(
'Wallapop',
f'Buscando "{clean_query}" en Wallapop...\nMostrando hasta {max_results} resultados.'
)
self.notebook.select(self.tab_resultados)
threading.Thread(
target=self._wallapop_search_worker,
args=(clean_query, max_results),
daemon=True
).start()
def _wallapop_search_worker(self, query: str, limit: int) -> None:
try:
results = self._fetch_wallapop_search_results(query, limit)
if not results:
raise ValueError('No se encontraron anuncios para tu búsqueda.')
self._scraping_queue.put(('search_success', query, results))
except Exception as exc:
self._scraping_queue.put(('search_error', str(exc)))
def _fetch_wallapop_search_results(self, query: str, limit: int) -> list[dict[str, str]]:
errors: list[str] = []
for fetcher in (self._fetch_wallapop_search_via_api, self._fetch_wallapop_search_via_html):
try:
results = fetcher(query, limit)
if results:
return results[:limit]
except Exception as exc:
errors.append(str(exc))
if errors:
raise ValueError('No se pudieron obtener resultados de Wallapop.\n' + '\n'.join(errors))
return []
def _fetch_wallapop_search_via_api(self, query: str, limit: int) -> list[dict[str, str]]:
if not requests:
return []
def _base_params() -> dict[str, object]:
params: dict[str, object] = {
'keywords': query,
'source': 'search_box',
'filters_source': 'quick_filters',
'order_by': 'most_relevance',
'latitude': f'{WALLAPOP_LATITUDE:.6f}',
'longitude': f'{WALLAPOP_LONGITUDE:.6f}',
'country_code': WALLAPOP_COUNTRY_CODE,
'language': WALLAPOP_LANGUAGE
}
if WALLAPOP_CATEGORY_ID:
params['category_id'] = WALLAPOP_CATEGORY_ID
return params
results: list[dict[str, str]] = []
seen_ids: set[str] = set()
next_page: str | None = None
attempts = 0
while len(results) < limit and attempts < 6:
params = {'next_page': next_page} if next_page else _base_params()
resp = requests.get(
'https://api.wallapop.com/api/v3/search',
params=params,
headers=WALLAPOP_HEADERS,
timeout=20
)
try:
resp.raise_for_status()
except requests.HTTPError as exc: # type: ignore[attr-defined]
if resp.status_code == 403:
raise ValueError(
'Wallapop devolvió 403. Asegúrate de definir WALLAPOP_DEVICE_ID/WALLAPOP_MPID '
'con valores obtenidos desde tu navegador.'
) from exc
raise
payload = resp.json()
items = self._extract_wallapop_items(payload)
if not items:
break
for entry in items:
if not isinstance(entry, dict):
continue
item_id = entry.get('id') or entry.get('itemId') or entry.get('item_id')
if not item_id:
continue
item_id = str(item_id)
if item_id in seen_ids:
continue
seen_ids.add(item_id)
slug = entry.get('web_slug') or entry.get('slug') or item_id
seller_info = entry.get('seller') if isinstance(entry.get('seller'), dict) else None
detail = self._format_wallapop_item(entry, self._build_wallapop_url(str(slug)), seller_info)
results.append(detail)
if len(results) >= limit:
break
next_page = self._extract_wallapop_next_page(payload)
if not next_page:
break
attempts += 1
return results
def _fetch_wallapop_search_via_html(self, query: str, limit: int) -> list[dict[str, str]]:
if not (requests and BEAUTIFULSOUP_AVAILABLE):
return []
params = {'keywords': query}
resp = requests.get(
'https://es.wallapop.com/app/search',
params=params,
headers=WALLAPOP_HEADERS,
timeout=20
)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
script = soup.find('script', id='__NEXT_DATA__')
if not script or not script.string:
return []
data = json.loads(script.string)
page_props = data.get('props', {}).get('pageProps', {})
items: list[dict[str, object]] = []
for key in ('searchResults', 'results', 'items'):
candidate = page_props.get(key)
if isinstance(candidate, list):
items = candidate
break
if not items:
initial_state = page_props.get('initialState', {})
if isinstance(initial_state, dict):
for key in ('items', 'results'):
candidate = initial_state.get(key)
if isinstance(candidate, list):
items = candidate
break
if not items:
search_state = initial_state.get('search')
if isinstance(search_state, dict):
candidate = search_state.get('items')
if isinstance(candidate, list):
items = candidate
elif isinstance(search_state.get('search'), dict):
nested = search_state['search'].get('items')
if isinstance(nested, list):
items = nested
if not items:
return []
results: list[dict[str, str]] = []
seen: set[str] = set()
for entry in items:
if not isinstance(entry, dict):
continue
slug = (
entry.get('slug')
or entry.get('webSlug')
or entry.get('web_slug')
or entry.get('shareLink')
or entry.get('share_link')
or entry.get('url')
)
url = self._build_wallapop_url(str(slug)) if slug else ''
item_id = entry.get('id') or entry.get('itemId') or entry.get('item_id')
entry_key = str(item_id or url)
if not entry_key or entry_key in seen:
continue
seen.add(entry_key)
detail: dict[str, str] | None = None
if url:
try:
detail = self._fetch_wallapop_item(url)
except Exception:
detail = None
if not detail and item_id:
try:
detail = self._fetch_wallapop_item_from_api(str(item_id))
except Exception:
detail = None
if detail:
if url and not detail.get('link'):
detail['link'] = url
results.append(detail)
if len(results) >= limit:
break
return results
def _extract_wallapop_items(self, payload: dict[str, object]) -> list[dict[str, object]]:
items: list[dict[str, object]] = []
data_block = payload.get('data')
if isinstance(data_block, dict):
section = data_block.get('section')
if isinstance(section, dict):
section_payload = section.get('payload')
if isinstance(section_payload, dict):
raw_items = section_payload.get('items') or section_payload.get('section_items')
if isinstance(raw_items, list):
items.extend(item for item in raw_items if isinstance(item, dict))
elif isinstance(data_block.get('items'), list):
items.extend(item for item in data_block['items'] if isinstance(item, dict))
if not items:
web_results = payload.get('web_search_results')
if isinstance(web_results, dict):
sections = web_results.get('sections')
if isinstance(sections, list):
for section in sections:
if not isinstance(section, dict):
continue
raw_items = section.get('items') or section.get('section_items')
if isinstance(raw_items, list):
items.extend(item for item in raw_items if isinstance(item, dict))
return items
def _extract_wallapop_next_page(self, payload: dict[str, object]) -> str | None:
meta = payload.get('meta')
if isinstance(meta, dict):
token = meta.get('next_page')
if isinstance(token, str) and token:
return token
headers = payload.get('headers')
if isinstance(headers, dict):
token = headers.get('X-NextPage') or headers.get('x-nextpage')
if isinstance(token, str) and token:
return token
return None
def _fetch_wallapop_item(self, url: str) -> dict[str, str]:
if not requests or not BEAUTIFULSOUP_AVAILABLE:
raise RuntimeError('Dependencias de scraping no disponibles')
resp = requests.get(url, headers=WALLAPOP_HEADERS, timeout=20)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
script = soup.find('script', id='__NEXT_DATA__')
if not script or not script.string:
raise ValueError('No se pudo encontrar la información del anuncio (sin datos Next.js).')
data = json.loads(script.string)
page_props = data.get('props', {}).get('pageProps', {})
item = page_props.get('item') or page_props.get('itemInfo', {}).get('item')
if not item:
raise ValueError('El formato de la página cambió y no se pudo leer el anuncio.')
seller_info = page_props.get('itemSeller') if isinstance(page_props.get('itemSeller'), dict) else None
return self._format_wallapop_item(item, url, seller_info)
def _fetch_wallapop_item_from_api(self, item_id: str) -> dict[str, str]:
if not requests:
raise RuntimeError('Dependencias de scraping no disponibles')
api_url = f'https://api.wallapop.com/api/v3/items/{item_id}'
resp = requests.get(api_url, headers=WALLAPOP_HEADERS, timeout=15)
resp.raise_for_status()
data = resp.json()
item = data.get('item') if isinstance(data.get('item'), dict) else data
if not isinstance(item, dict):
raise ValueError('Respuesta del API sin datos del artículo.')
slug = item.get('web_slug') or item.get('slug') or item.get('share_link')
link = self._build_wallapop_url(str(slug)) if slug else f'https://es.wallapop.com/item/{item_id}'
seller_info = data.get('seller') if isinstance(data.get('seller'), dict) else item.get('seller')
seller_dict = seller_info if isinstance(seller_info, dict) else None
return self._format_wallapop_item(item, link, seller_dict)
def _build_wallapop_url(self, slug_or_url: str) -> str:
if not slug_or_url:
return ''
text = slug_or_url.strip()
if text.startswith('http://') or text.startswith('https://'):
return text
text = text.strip('/')
if not text.startswith('item/'):
text = f'item/{text}'
return f'https://es.wallapop.com/{text}'
def _format_wallapop_item(
self,
item: dict[str, object],
url: str,
seller_info: dict[str, object] | None = None
) -> dict[str, str]:
title_field = item.get('title')
if isinstance(title_field, dict):
title = title_field.get('original') or title_field.get('translated') or '(Sin título)'
elif isinstance(title_field, str):
title = title_field or '(Sin título)'
else:
title = '(Sin título)'
price_text = 'Precio no disponible'
price_data = item.get('price')
price_cash = None
if isinstance(price_data, dict):
if isinstance(price_data.get('cash'), dict):
price_cash = price_data['cash']
else:
price_cash = price_data
if isinstance(price_cash, dict):
amount = price_cash.get('amount')
currency = price_cash.get('currency') or 'EUR'
if amount is not None:
price_text = f'{amount} {currency}'.strip()
elif isinstance(price_data, (int, float, str)):
price_text = f'{price_data} EUR'
desc_field = item.get('description')
if isinstance(desc_field, dict):
description = (desc_field.get('original') or desc_field.get('translated') or '').strip()
elif isinstance(desc_field, str):
description = desc_field.strip()
else:
description = ''
location = item.get('location') if isinstance(item.get('location'), dict) else {}
location_parts = []
if isinstance(location, dict):
for part in (location.get('city'), location.get('postalCode'), location.get('regionName')):
if part:
location_parts.append(part)
location_text = ', '.join(location_parts) if location_parts else 'Ubicación no disponible'
seller_name = ''
for candidate in (seller_info, item.get('seller'), item.get('user'), item.get('userInfo')):
if isinstance(candidate, dict):
seller_name = (
candidate.get('microName')
or candidate.get('name')
or candidate.get('userName')
or ''
)
if seller_name:
break
if not seller_name:
seller_name = 'Vendedor no disponible'
categories = ', '.join(
cat.get('name')
for cat in item.get('taxonomies', [])
if isinstance(cat, dict) and cat.get('name')
) or 'Sin categoría'
car_info = item.get('carInfo') if isinstance(item.get('carInfo'), dict) else {}
car_bits: list[str] = []
if isinstance(car_info, dict):
for key in ('year', 'km', 'engine', 'gearBox'):
field = car_info.get(key)
text = None
if isinstance(field, dict):
text = field.get('text') or field.get('iconText')
elif isinstance(field, str):
text = field
if text:
car_bits.append(text)
extra = ', '.join(car_bits)
images = item.get('images') if isinstance(item.get('images'), list) else []
image_url = ''
for img in images:
if not isinstance(img, dict):
continue
urls = img.get('urls') if isinstance(img.get('urls'), dict) else {}
if isinstance(urls, dict):
image_url = urls.get('medium') or urls.get('big') or urls.get('small') or ''
else:
image_url = img.get('url', '') if isinstance(img.get('url'), str) else ''
if image_url:
break
return {
'title': str(title),
'price': price_text,
'description': description,
'link': url,
'location': location_text,
'seller': seller_name,
'category': categories,
'extra': extra,
'image': image_url
}
def _process_scraping_queue(self) -> None:
if not self._running:
return
try:
while True:
status, *payload = self._scraping_queue.get_nowait()
if status == 'error':
message = payload[0] if payload else 'Error desconocido'
self._handle_scraping_error(str(message))
elif status == 'search_success':
query, data = payload
self._handle_wallapop_search_success(str(query), list(data))
elif status == 'search_error':
message = payload[0] if payload else 'Error desconocido'
self._handle_scraping_error(str(message))
elif status == 'generic_success':
urls, data = payload
self._handle_generic_scraping_success(list(urls), list(data))
elif status == 'backup_success':
summary, target = payload
self._handle_backup_success(str(summary), str(target))
except queue.Empty:
pass
finally:
if self._running:
self.after(100, self._process_scraping_queue)
def _handle_wallapop_search_success(self, query: str, results: list[dict[str, str]]) -> None:
count = len(results)
self._log(f'Se encontraron {count} anuncio(s) para "{query}" en Wallapop')
def builder(parent: tk.Frame) -> None:
frame = tk.Frame(parent, bg='white')
frame.pack(fill='both', expand=True, padx=12, pady=12)
summary = tk.Label(
frame,
text=f'{count} resultado(s) para "{query}"',
font=('Arial', 14, 'bold'),
bg='white',
fg='#2c3e50'
)
summary.pack(anchor='w', pady=(0, 6))
box = scrolledtext.ScrolledText(frame, wrap='word')
box.pack(fill='both', expand=True)
blocks: list[str] = []
for idx, item in enumerate(results, start=1):
block_lines = [
f'{idx}. {item.get("title", "Sin título")}',
f'Precio: {item.get("price", "N/D")}',
f'Vendedor: {item.get("seller", "N/D")}',
f'Ubicación: {item.get("location", "N/D")}',
f'Categoría: {item.get("category", "N/D")}'
]
extra = item.get('extra', '').strip()
if extra:
block_lines.append(f'Detalles: {extra}')
description = (item.get('description') or '').strip()
if description:
block_lines.append('Descripción:')
block_lines.append(description)
block_lines.append(f'Enlace: {item.get("link", "N/D")}')
block_lines.append('-' * 60)
blocks.append('\n'.join(block_lines))
box.insert('1.0', '\n\n'.join(blocks))
box.config(state='disabled')
self._render_results_view('Resultados Wallapop', builder)
self.notebook.select(self.tab_resultados)
try:
path = self._save_wallapop_results_to_file(query, results)
except Exception as exc:
self._log(f'[ERROR] No se pudo guardar el archivo: {exc}')
messagebox.showerror('Wallapop', f'No se pudo guardar el archivo: {exc}')
return
message = (
f'Se guardó el archivo con los resultados en:\n{path}\n\n'
'¿Quieres abrirlo ahora?'
)
if messagebox.askyesno('Wallapop', message):
self._open_saved_file(path)
else:
self._log(f'Archivo de resultados guardado en {path}')
def _handle_generic_scraping_success(self, urls: list[str], results: list[dict[str, str]]) -> None:
self._log(f'Scraping simple completado para {len(results)} URL(s)')
order = {url: idx for idx, url in enumerate(urls)}
ordered = sorted(results, key=lambda item: order.get(item.get('url', ''), len(order)))
def builder(parent: tk.Frame) -> None:
frame = tk.Frame(parent, bg='white')
frame.pack(fill='both', expand=True, padx=12, pady=12)
summary = tk.Label(
frame,
text='Resultados de scraping simple',
font=('Arial', 14, 'bold'),
bg='white'
)
summary.pack(anchor='w', pady=(0, 6))
box = scrolledtext.ScrolledText(frame, wrap='word')
box.pack(fill='both', expand=True)
blocks: list[str] = []
for idx, item in enumerate(ordered, start=1):
lines = [f'{idx}. {item.get("url", "URL desconocida")}', f"Hora: {item.get('timestamp', 'N/D')}" ]
if 'error' in item:
lines.append(f"Error: {item.get('error')}")
else:
lines.extend([
f"Título: {item.get('titulo', 'Sin título')}",
f"Descripción: {item.get('descripcion', '')}",
f"Estado HTTP: {item.get('status_code', 'N/D')}",
f"Longitud HTML: {item.get('longitud', 0)} caracteres",
f"Links: {item.get('num_links', 0)} | Imágenes: {item.get('num_imagenes', 0)} | Párrafos: {item.get('num_parrafos', 0)}"
])
lines.append('-' * 60)
blocks.append('\n'.join(lines))
box.insert('1.0', '\n\n'.join(blocks) if blocks else 'No se generaron resultados.')
box.config(state='disabled')
self._render_results_view('Scraping simple', builder)
self.notebook.select(self.tab_resultados)
def _handle_backup_success(self, summary: str, target: str) -> None:
self._log('Copia de seguridad finalizada')
self._show_text_result('Copias de seguridad', summary)
self.notebook.select(self.tab_resultados)
if messagebox.askyesno('Backup', f'{summary}\n\n¿Quieres abrir la carpeta destino?'):
self._open_saved_file(target)
def _save_wallapop_results_to_file(self, query: str, results: list[dict[str, str]]) -> str:
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'wallapop_search_{timestamp}.txt'
path = os.path.join(tempfile.gettempdir(), filename)
lines = [
f'Consulta: {query}',
f'Resultados: {len(results)}',
f'Generado: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}',
''
]
for idx, item in enumerate(results, start=1):
lines.append(f'Resultado {idx}')
lines.append(f'Título: {item.get("title", "Sin título")}')
lines.append(f'Precio: {item.get("price", "N/D")}')
lines.append(f'Vendedor: {item.get("seller", "N/D")}')
lines.append(f'Ubicación: {item.get("location", "N/D")}')
lines.append(f'Categoría: {item.get("category", "N/D")}')
if item.get('extra'):
lines.append(f'Detalles: {item.get("extra")}')
description = (item.get('description') or '').strip()
if description:
lines.append('Descripción:')
lines.append(description)
lines.append(f'Enlace: {item.get("link", "N/D")}')
if item.get('image'):
lines.append(f'Imagen: {item.get("image")}')
lines.append('-' * 60)
with open(path, 'w', encoding='utf-8') as fh:
fh.write('\n'.join(lines))
return path
def _handle_scraping_error(self, message: str) -> None:
self._log(f'[ERROR] Wallapop: {message}')
messagebox.showerror('Wallapop', message)
# --- Fin Web Scraping ---
def _open_game_window(self) -> None:
self._render_results_view('Carrera de camellos', self._build_camel_race_view)
def _build_camel_race_view(self, parent: tk.Frame) -> None:
container = tk.Frame(parent, bg='white')
container.pack(fill='both', expand=True)
tk.Label(
container,
text='Juego de camellos (sincronización de hilos)',
font=('Arial', 18, 'bold'),
bg='white'
).pack(pady=(16, 8))
canvas = tk.Canvas(container, height=380, bg='#fdfdfd', bd=2, relief='sunken')
canvas.pack(fill='both', expand=True, padx=16, pady=12)
status = tk.Label(container, text='Listo para correr', font=('Arial', 14, 'bold'), bg='white', fg='#2c3e50')
status.pack(pady=(0, 10))
controls = tk.Frame(container, bg='white')
controls.pack(pady=(0, 10))
tk.Button(
controls,
text='Iniciar carrera',
font=('Arial', 13, 'bold'),
width=16,
command=lambda: self._start_race(canvas, status)
).grid(row=0, column=0, padx=12, pady=4)
tk.Button(
controls,
text='Volver a resultados',
font=('Arial', 13),
width=16,
command=self._reset_results_view
).grid(row=0, column=1, padx=12, pady=4)
tk.Label(controls, text='Número de camellos:', bg='white', font=('Arial', 12)).grid(row=1, column=0, pady=6)
camel_spin = tk.Spinbox(
controls,
from_=3,
to=8,
width=5,
justify='center',
font=('Arial', 12),
command=lambda: self._set_camel_count(int(camel_spin.get()))
)
camel_spin.grid(row=1, column=1, pady=6)
camel_spin.delete(0, 'end')
camel_spin.insert(0, str(self.game_camel_count))
tk.Label(controls, text='Velocidad (1-5):', bg='white', font=('Arial', 12)).grid(row=2, column=0, pady=6)
speed_scale = tk.Scale(
controls,
from_=1,
to=5,
orient='horizontal',
resolution=0.5,
length=180,
bg='white',
command=lambda value: self._set_speed_factor(float(value))
)
speed_scale.grid(row=2, column=1, pady=6)
speed_scale.set(self.game_speed_factor)
self.game_window_canvas = canvas
self.game_window_status = status
def _set_camel_count(self, value: int) -> None:
self.game_camel_count = max(3, min(8, int(value)))
def _set_speed_factor(self, value: float) -> None:
self.game_speed_factor = max(1.0, min(5.0, float(value)))
def _start_race(self, canvas=None, status_label=None) -> None:
target_canvas = canvas or self.game_window_canvas
if target_canvas is None:
messagebox.showinfo('Juego', 'Abre el juego para iniciar la carrera.')
return
self._stop_game_queue_processor()
target_canvas.delete('all')
target_canvas.update_idletasks()
width = target_canvas.winfo_width() or int(target_canvas.cget('width'))
height = target_canvas.winfo_height() or int(target_canvas.cget('height'))
finish = width - 40
camel_count = max(3, min(8, int(self.game_camel_count)))
spacing = height / (camel_count + 1)
palette = ['#ff7675', '#74b9ff', '#55efc4', '#f1c40f', '#9b59b6', '#1abc9c', '#e67e22', '#95a5a6']
racers = []
for idx in range(camel_count):
y = spacing * (idx + 1)
target_canvas.create_line(20, y, finish, y, dash=(3,4))
color = palette[idx % len(palette)]
rect = target_canvas.create_rectangle(30, y-22, 130, y+22, fill=color)
racers.append(rect)
status = status_label or self.game_window_status
if status:
status.config(text='¡Carrera en curso!')
self.game_move_queue = queue.Queue()
self.game_done_event = threading.Event()
self.game_finish_line = finish
self.game_racer_names = [f'Camello {i+1}' for i in range(camel_count)]
self.game_queue_processor_active = True
self.after(30, self._process_game_queue)
indices = list(range(camel_count))
random.shuffle(indices)
for idx in indices:
rect = racers[idx]
threading.Thread(target=self._race_worker, args=(idx, rect), daemon=True).start()
def _race_worker(self, index: int, rect_id: int) -> None:
while True:
if not self.game_queue_processor_active or not self.game_move_queue or not self.game_done_event:
return
if self.game_done_event.is_set():
return
base_min = 3
base_max = 9
speed = max(1.0, float(self.game_speed_factor))
step = random.randint(int(base_min * speed), max(int(base_max * speed), int(base_min * speed) + 1))
self.game_move_queue.put(('move', (rect_id, step, index)))
sleep_time = max(0.02, 0.08 / speed)
time.sleep(random.uniform(sleep_time * 0.5, sleep_time * 1.2))
def _process_game_queue(self) -> None:
if not self.game_queue_processor_active or not self.game_move_queue:
return
canvas = self.game_window_canvas
if not canvas:
return
try:
while True:
action, payload = self.game_move_queue.get_nowait()
if action == 'move':
rect_id, step, idx = payload
try:
canvas.move(rect_id, step, 0)
except Exception:
continue
coords = canvas.coords(rect_id)
if coords and coords[2] >= self.game_finish_line and self.game_done_event and not self.game_done_event.is_set():
self.game_done_event.set()
if self.game_window_status:
self.game_window_status.config(text=f'{self.game_racer_names[idx]} ganó la carrera')
else:
continue
except queue.Empty:
pass
if not self.game_done_event or not self.game_done_event.is_set():
if self.game_queue_processor_active:
self.after(30, self._process_game_queue)
else:
self.game_queue_processor_active = False
def _stop_game_queue_processor(self) -> None:
self.game_queue_processor_active = False
self.game_move_queue = None
if self.game_done_event:
try:
self.game_done_event.set()
except Exception:
pass
self.game_done_event = None
def _start_alarm(self) -> None:
try:
minutes = max(1, int(self.alarm_minutes.get()))
except ValueError:
messagebox.showwarning('Alarmas', 'Ingresa un número válido de minutos.')
return
title = self.alarm_title.get().strip() or f'Alarma {self.alarm_counter}'
end_time = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
alarm = {'id': self.alarm_counter, 'title': title, 'end': end_time}
self.alarm_counter += 1
self.active_alarms.append(alarm)
self.alarm_title.delete(0, 'end')
self._update_alarm_list()
self._log(f'Alarma "{title}" programada para las {end_time.strftime("%H:%M:%S")}')
def _refresh_alarms_loop(self) -> None:
if not self._running:
return
now = datetime.datetime.now()
triggered: list[dict[str, datetime.datetime | str]] = []
for alarm in list(self.active_alarms):
remaining = (alarm['end'] - now).total_seconds() # type: ignore[arg-type]
if remaining <= 0:
triggered.append(alarm)
for alarm in triggered:
self.active_alarms.remove(alarm)
self._trigger_alarm(alarm)
self._update_alarm_list()
self.after(1000, self._refresh_alarms_loop)
def _update_alarm_list(self) -> None:
if not hasattr(self, 'alarm_list'):
return
selection = self.alarm_list.curselection()
selected_id = None
if selection:
idx = selection[0]
if idx < len(self.active_alarms):
selected_id = self.active_alarms[idx]['id']
self.alarm_list.delete(0, 'end')
if not self.active_alarms:
self.alarm_status.config(text='Sin alarmas programadas')
return
now = datetime.datetime.now()
for index, alarm in enumerate(self.active_alarms):
remaining = max(0, int((alarm['end'] - now).total_seconds())) # type: ignore[arg-type]
minutes, seconds = divmod(remaining, 60)
self.alarm_list.insert('end', f"{alarm['title']} - {minutes:02d}:{seconds:02d}")
if selected_id is not None and alarm['id'] == selected_id:
self.alarm_list.selection_set(index)
self.alarm_status.config(text=f"{len(self.active_alarms)} alarma(s) activas")
def _trigger_alarm(self, alarm: dict[str, datetime.datetime | str]) -> None:
self._log(f"Alarma '{alarm['title']}' activada")
try:
self.bell()
except Exception:
pass
self._show_alarm_popup(str(alarm['title']))
def _show_alarm_popup(self, title: str) -> None:
popup = tk.Toplevel(self)
popup.title('Alarma activa')
popup.configure(bg='#ffeaea', padx=20, pady=20)
popup.transient(self)
popup.grab_set()
tk.Label(popup, text='¡Tiempo cumplido!', font=('Arial', 16, 'bold'), fg='#c0392b', bg='#ffeaea').pack(pady=6)
tk.Label(popup, text=title, font=('Arial', 13), bg='#ffeaea').pack(pady=4)
tk.Button(popup, text='Detener alarma', command=popup.destroy, bg='#f9dede').pack(pady=10)
def _cancel_selected_alarm(self) -> None:
if not self.active_alarms:
messagebox.showinfo('Alarmas', 'No hay alarmas para cancelar.')
return
selection = self.alarm_list.curselection()
if not selection:
messagebox.showinfo('Alarmas', 'Selecciona una alarma en la lista.')
return
idx = selection[0]
if idx >= len(self.active_alarms):
messagebox.showwarning('Alarmas', 'La selección ya no es válida.')
return
alarm = self.active_alarms.pop(idx)
self._update_alarm_list()
self._log(f"Alarma '{alarm['title']}' cancelada")
def _select_music(self) -> None:
if not pygame_available:
messagebox.showwarning('Música', 'Instale pygame para reproducir audio.')
return
path = filedialog.askopenfilename(
filetypes=[
('Audio/Video', '*.mp3 *.wav *.ogg *.mp4 *.mkv *.mov *.avi *.m4a *.webm'),
('Todos', '*.*')
]
)
if not path:
return
self._cleanup_temp_audio()
ext = os.path.splitext(path)[1].lower()
direct_audio = {'.mp3', '.wav', '.ogg', '.m4a'}
playable_path = path
temp_path = None
if ext not in direct_audio:
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.wav')
temp_path = tmp.name
tmp.close()
cmd = ['ffmpeg', '-y', '-i', path, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', temp_path]
try:
# Usar subprocess.run para esperar la finalización de ffmpeg
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
playable_path = temp_path
except FileNotFoundError:
if os.path.exists(temp_path):
os.unlink(temp_path)
messagebox.showerror('Música', 'ffmpeg no está instalado. Instálalo para extraer el audio de videos.')
return
except subprocess.CalledProcessError as exc:
if os.path.exists(temp_path):
os.unlink(temp_path)
messagebox.showerror('Música', f'No se pudo extraer el audio: {exc}')
return
pygame.mixer.init()
pygame.mixer.music.load(playable_path)
pygame.mixer.music.play(-1)
self.music_temp_file = temp_path
self._log(f'Reproduciendo {os.path.basename(path)}')
def _stop_music(self) -> None:
if pygame_available and pygame.mixer.get_init():
pygame.mixer.music.stop()
self._cleanup_temp_audio()
def _pause_music(self) -> None:
if pygame_available and pygame.mixer.get_init():
pygame.mixer.music.pause()
def _resume_music(self) -> None:
if pygame_available and pygame.mixer.get_init():
pygame.mixer.music.unpause()
def _cleanup_temp_audio(self) -> None:
if self.music_temp_file and os.path.exists(self.music_temp_file):
try:
os.remove(self.music_temp_file)
except OSError:
pass
self.music_temp_file = None
def _resolve_openweather_key(self) -> str:
key = (self.weather_api_key or '').strip()
if not key:
raise RuntimeError('Configure OPENWEATHER_API_KEY u OPENWEATHER_FALLBACK_API_KEY')
return key
def _fetch_weather_by_coordinates(self, lat: float, lon: float) -> dict[str, Any]:
if not REQUESTS_AVAILABLE:
raise RuntimeError('Instale requests para consultar el clima')
key = self._resolve_openweather_key()
params = {
'lat': lat,
'lon': lon,
'appid': key,
'units': 'metric',
'lang': 'es'
}
resp = requests.get('https://api.openweathermap.org/data/2.5/weather', params=params, timeout=8)
resp.raise_for_status()
data = resp.json()
if 'main' not in data or 'weather' not in data:
raise ValueError('Respuesta incompleta de OpenWeatherMap')
return data
def _fetch_weather_data(self, city_query: str) -> dict[str, Any]:
if not REQUESTS_AVAILABLE:
raise RuntimeError('Instale requests para consultar el clima')
key = self._resolve_openweather_key()
params = {
'q': city_query,
'appid': key,
'units': 'metric',
'lang': 'es'
}
resp = requests.get('https://api.openweathermap.org/data/2.5/weather', params=params, timeout=8)
resp.raise_for_status()
data = resp.json()
if 'main' not in data or 'weather' not in data:
raise ValueError('Respuesta incompleta de OpenWeatherMap')
return data
def _fetch_javea_weather(self) -> dict[str, Any]:
return self._fetch_weather_by_coordinates(JAVEA_LATITUDE, JAVEA_LONGITUDE)
# ------------------------ chat ------------------------
def _connect_chat(self) -> None:
host = self.host_entry.get().strip() or SERVER_HOST_DEFAULT
try:
port = int(self.port_entry.get().strip())
except ValueError:
port = SERVER_PORT_DEFAULT
if self.chat_client.connect(host, port):
self._enqueue_chat_message('[INFO] Conectado al servidor')
def _send_chat(self) -> None:
text = self.message_entry.get('1.0', 'end').strip()
if not text:
return
if self.chat_client.send(text + '\n'):
self._enqueue_chat_message('Yo: ' + text)
self.message_entry.delete('1.0', 'end')
else:
messagebox.showwarning('Chat', 'No hay conexión activa.')
def _enqueue_chat_message(self, text: str) -> None:
self._chat_queue.put(text)
def _chat_loop(self) -> None:
while self._running:
try:
msg = self._chat_queue.get(timeout=0.5)
except queue.Empty:
continue
self.chat_display.after(0, lambda m=msg: self._append_chat(m))
def _append_chat(self, text: str) -> None:
self.chat_display.config(state='normal')
self.chat_display.insert('end', text + '\n')
self.chat_display.see('end')
self.chat_display.config(state='disabled')
# ------------------------ hilos auxiliares ------------------------
def _update_clock(self) -> None:
if not self._running:
return
now = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
self.clock_label.config(text=now)
self.after(1000, self._update_clock)
def _update_traffic(self) -> None:
if not (self._running and psutil):
return
if self._traffic_last is None:
try:
self._traffic_last = psutil.net_io_counters()
except Exception as exc:
self._log(f'Tráfico: psutil no disponible ({exc})')
self.traffic_label.config(text='Red: N/D')
return
try:
current = psutil.net_io_counters()
sent = (current.bytes_sent - self._traffic_last.bytes_sent) / 1024
recv = (current.bytes_recv - self._traffic_last.bytes_recv) / 1024
self._traffic_last = current
self.traffic_label.config(text=f'Red: ↑ {sent:.1f} KB/s ↓ {recv:.1f} KB/s')
except Exception as exc:
self._log(f'Tráfico: {exc}')
self.traffic_label.config(text='Red: N/D')
finally:
if self._running:
self.after(1000, self._update_traffic)
def _resource_poll_tick(self) -> None:
if not (self._running and psutil):
return
try:
cpu = psutil.cpu_percent(interval=None)
mem = psutil.virtual_memory().percent
except Exception as exc:
self._log(f'Recursos: {exc}')
if self._running:
self._resource_poll_job = self.after(2000, self._resource_poll_tick)
return
threads = 0
if self._ps_process:
try:
threads = self._ps_process.num_threads()
except Exception:
threads = 0
self._resource_history['cpu'].append(cpu)
self._resource_history['mem'].append(mem)
self._resource_history['threads'].append(threads)
self._resource_history['cpu'] = self._resource_history['cpu'][-40:]
self._resource_history['mem'] = self._resource_history['mem'][-40:]
self._resource_history['threads'] = self._resource_history['threads'][-40:]
self._update_chart()
if self._running:
self._resource_poll_job = self.after(1000, self._resource_poll_tick)
def _update_chart(self) -> None:
if not (
MATPLOTLIB_AVAILABLE
and self.chart_canvas
and self.line_cpu
and self.line_mem
and self.ax_cpu
and self.ax_mem
):
return
x = list(range(len(self._resource_history['cpu'])))
self.line_cpu.set_data(x, self._resource_history['cpu'])
self.line_mem.set_data(x, self._resource_history['mem'])
max_x = max(40, len(x))
self.ax_cpu.set_xlim(0, max_x)
self.ax_mem.set_xlim(0, max_x)
self.ax_cpu.set_ylim(0, 100)
self.ax_mem.set_ylim(0, 100)
if self.mem_fill:
try:
self.mem_fill.remove()
except Exception:
pass
if self.ax_mem and x:
self.mem_fill = self.ax_mem.fill_between(
x,
self._resource_history['mem'],
color='#4ecdc4',
alpha=0.3
)
if self.ax_threads:
threads = self._resource_history['threads']
if threads:
if self.thread_bars and len(self.thread_bars) == len(threads):
for rect, height in zip(self.thread_bars, threads):
rect.set_height(height)
else:
if self.thread_bars:
for rect in self.thread_bars:
rect.remove()
self.thread_bars = self.ax_threads.bar(x, threads, color='#ffa726') if x else None
if threads:
max_threads = max(threads)
self.ax_threads.set_ylim(0, max(max_threads * 1.2, 1))
self.ax_threads.set_xlim(-0.5, max(39.5, len(x) - 0.5))
if self.chart_canvas:
self.chart_canvas.draw_idle()
def _log(self, text: str) -> None:
if threading.current_thread() is not threading.main_thread():
self.after(0, lambda t=text: self._log(t))
return
# Verificar si el widget notes existe antes de intentar usarlo
if not hasattr(self, 'notes') or self.notes is None:
# Si no existe, simplemente imprimir en consola durante inicialización
print(f'[LOG] {text}')
return
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
self.notes.insert('end', f'[{timestamp}] {text}\n')
self.notes.see('end')
def on_close(self) -> None:
"""Cierra la aplicación correctamente"""
print("Cerrando aplicación...")
self._running = False
# Cancelar jobs programados
if self._resource_poll_job is not None:
try:
self.after_cancel(self._resource_poll_job)
except Exception:
pass
self._resource_poll_job = None
# Cerrar cliente de juego
try:
if hasattr(self, 'game_client'):
self.game_client.close()
except Exception as e:
print(f"Error cerrando game_client: {e}")
# Detener música si está sonando
try:
if pygame and pygame.mixer.get_init():
pygame.mixer.music.stop()
pygame.mixer.quit()
except Exception:
pass
# Destruir ventana
try:
self.destroy()
except Exception:
pass
# Forzar salida si es necesario
import sys
sys.exit(0)
def main() -> None:
app = DashboardApp()
app.protocol('WM_DELETE_WINDOW', app.on_close)
app.mainloop()
if __name__ == '__main__':
main()