4363 lines
196 KiB
Python
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() |