diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..67f5ec6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Comandos + +**Ejecutar la aplicación:** +```bash +python pocketsync.py +``` + +No hay proceso de build, instalación de dependencias, ni tests automatizados. Todo depende de la librería estándar de Python 3 y del comando `robocopy` de Windows. + +## Arquitectura + +Aplicación de escritorio en Python/Tkinter para sincronizar archivos de emulación retro (ES-DE y ROMs) hacia almacenamiento externo usando Robocopy. Soporta múltiples perfiles nombrados (uno por consola/dispositivo). + +### Estructura de archivos + +``` +pocketsync.py # Entry point (~20 líneas) +config.json # Config v2 con perfiles +CLAUDE.md + +core/ + __init__.py + config.py # Profile dataclass + ConfigManager (load/save/migrate v1→v2) + sync_engine.py # ABC SyncEngine + robocopy_engine.py # RobocopySyncEngine (subprocess + parsing) + +ui/ + __init__.py + app.py # PocketSyncApp — orquestación y loop de sync + profile_bar.py # Widget: dropdown de perfiles + New/Rename/Delete + path_panel.py # Widget: los 2 selectores de ruta (ES-DE + ROMs) + system_list.py # Widget: listbox de sistemas + Select All/None + status_bar.py # Widget: barra de estado (sistema/fase/archivo) + summary_panel.py # Widget: área de texto de resumen + styles.py # Constantes visuales (colores, fuentes) +``` + +**Entry point:** `pocketsync.py` — instancia `ConfigManager` y `PocketSyncApp`. + +**Persistencia:** `config.json` (versión 2) guarda todos los perfiles. Si existe un config v1 (plano), se migra automáticamente a un perfil "Default" al primer arranque. + +### Esquema config.json (v2) + +```json +{ + "version": 2, + "active_profile": "Default", + "profiles": [ + { + "name": "Default", + "esde_src": "...", + "roms_src": "...", + "esde_dst": "...", + "roms_dst": "...", + "selected": ["arcade", "nes"] + } + ] +} +``` + +### Flujo de la aplicación + +1. Al iniciar: `ConfigManager.load()` restaura perfiles desde `config.json` (con migración automática de v1 → v2). +2. El usuario selecciona o crea un perfil en `ProfileBar`. +3. Configura 4 rutas (ES-DE origen/destino, ROMs origen/destino) y selecciona los sistemas. +4. Al pulsar "Sync Now": hilo daemon → `_sync_thread` en `app.py`. +5. Por cada sistema: 3 fases usando `SyncEngine.sync_folder()`. +6. Al cambiar de perfil: se guarda el estado actual en el perfil activo antes de cargar el nuevo. +7. Al cerrar: `ConfigManager.save()` persiste todos los perfiles. + +### Flags de Robocopy usados +- `/MIR` — modo espejo (sincroniza origen → destino) +- `/NP` — sin porcentaje de progreso en la salida + +### Threading +Las operaciones de copia corren en un hilo daemon separado para no bloquear la UI. Las actualizaciones de widgets se hacen desde ese hilo directamente. + +### Extensibilidad de backends +`core/sync_engine.py` define la ABC `SyncEngine`. `RobocopySyncEngine` es la implementación concreta. Para añadir rsync u otro backend: crear un nuevo archivo en `core/` que implemente la misma interfaz. + +## Plataforma + +Windows 10/11 obligatorio (depende de `robocopy`). Python 3.6+, sin dependencias externas. diff --git a/config.json b/config.json index 3230492..8c79707 100644 --- a/config.json +++ b/config.json @@ -1,51 +1,60 @@ { - "esde_src": "C:/Users/jaild/Retroid/ES-DE", - "roms_src": "C:/Users/jaild/Retroid/ROMs", - "esde_dst": "F:/ES-DE", - "roms_dst": "F:/ROMs", - "selected": [ - "arcade", - "arcadia", - "atari2600", - "atari5200", - "atari7800", - "atarijaguar", - "atarilynx", - "atomiswave", - "channelf", - "colecovision", - "dreamcast", - "fds", - "gamegear", - "gb", - "gba", - "gbc", - "intellivision", - "mastersystem", - "megadrive", - "megaduck", - "msx", - "msx2", - "n64", - "neogeo", - "neogeocd", - "nes", - "ngp", - "ngpc", - "pcengine", - "pcenginecd", - "psx", - "satellaview", - "saturn", - "sega32x", - "segacd", - "sg-1000", - "snes", - "supergrafx", - "supervision", - "videopac", - "virtualboy", - "wonderswan", - "wonderswancolor" + "version": 2, + "active_profile": "Default", + "profiles": [ + { + "name": "Default", + "esde_src": "C:/Users/jaild/Retroid/ES-DE", + "roms_src": "C:/Users/jaild/Retroid/ROMs", + "esde_dst": "F:/ES-DE", + "roms_dst": "F:/ROMs", + "selected": [ + "arcade", + "arcadia", + "atari2600", + "atari5200", + "atari7800", + "atarijaguar", + "atarilynx", + "atomiswave", + "channelf", + "colecovision", + "dreamcast", + "fds", + "gamegear", + "gb", + "gba", + "gbc", + "intellivision", + "mastersystem", + "megadrive", + "megaduck", + "msx", + "msx2", + "n64", + "nds", + "neogeo", + "neogeocd", + "nes", + "ngp", + "ngpc", + "pcengine", + "pcenginecd", + "psp", + "psx", + "satellaview", + "saturn", + "sega32x", + "segacd", + "sg-1000", + "snes", + "supergrafx", + "supervision", + "videopac", + "virtualboy", + "wonderswan", + "wonderswancolor" + ] + } ] } \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/__pycache__/__init__.cpython-310.pyc b/core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..b0cc5e9 Binary files /dev/null and b/core/__pycache__/__init__.cpython-310.pyc differ diff --git a/core/__pycache__/config.cpython-310.pyc b/core/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..68a4b38 Binary files /dev/null and b/core/__pycache__/config.cpython-310.pyc differ diff --git a/core/__pycache__/robocopy_engine.cpython-310.pyc b/core/__pycache__/robocopy_engine.cpython-310.pyc new file mode 100644 index 0000000..b5049bc Binary files /dev/null and b/core/__pycache__/robocopy_engine.cpython-310.pyc differ diff --git a/core/__pycache__/sync_engine.cpython-310.pyc b/core/__pycache__/sync_engine.cpython-310.pyc new file mode 100644 index 0000000..6542274 Binary files /dev/null and b/core/__pycache__/sync_engine.cpython-310.pyc differ diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..2f8baed --- /dev/null +++ b/core/config.py @@ -0,0 +1,135 @@ +import json +import os +from dataclasses import dataclass, field +from typing import List, Optional + +CONFIG_VERSION = 2 + + +@dataclass +class Profile: + name: str + esde_src: str = "" + roms_src: str = "" + esde_dst: str = "" + roms_dst: str = "" + selected: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "name": self.name, + "esde_src": self.esde_src, + "roms_src": self.roms_src, + "esde_dst": self.esde_dst, + "roms_dst": self.roms_dst, + "selected": self.selected, + } + + @staticmethod + def from_dict(data: dict) -> "Profile": + return Profile( + name=data.get("name", "Default"), + esde_src=data.get("esde_src", ""), + roms_src=data.get("roms_src", ""), + esde_dst=data.get("esde_dst", ""), + roms_dst=data.get("roms_dst", ""), + selected=data.get("selected", []), + ) + + +class ConfigManager: + def __init__(self, config_path: str): + self.config_path = config_path + self.profiles: List[Profile] = [Profile(name="Default")] + self.active_profile_name: str = "Default" + + @property + def active_profile(self) -> Profile: + for p in self.profiles: + if p.name == self.active_profile_name: + return p + # Si el perfil activo no existe, devolver el primero + return self.profiles[0] + + def profile_names(self) -> List[str]: + return [p.name for p in self.profiles] + + def get_profile(self, name: str) -> Optional[Profile]: + for p in self.profiles: + if p.name == name: + return p + return None + + def add_profile(self, name: str) -> Profile: + p = Profile(name=name) + self.profiles.append(p) + return p + + def rename_profile(self, old_name: str, new_name: str) -> bool: + p = self.get_profile(old_name) + if p is None or self.get_profile(new_name) is not None: + return False + p.name = new_name + if self.active_profile_name == old_name: + self.active_profile_name = new_name + return True + + def delete_profile(self, name: str) -> bool: + if len(self.profiles) <= 1: + return False + p = self.get_profile(name) + if p is None: + return False + self.profiles.remove(p) + if self.active_profile_name == name: + self.active_profile_name = self.profiles[0].name + return True + + def load(self) -> None: + if not os.path.exists(self.config_path): + return + + try: + with open(self.config_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception: + return + + version = data.get("version", 1) + + if version == 1: + self._migrate_v1(data) + self.save() + else: + self._load_v2(data) + + def _migrate_v1(self, data: dict) -> None: + default = Profile( + name="Default", + esde_src=data.get("esde_src", ""), + roms_src=data.get("roms_src", ""), + esde_dst=data.get("esde_dst", ""), + roms_dst=data.get("roms_dst", ""), + selected=data.get("selected", []), + ) + self.profiles = [default] + self.active_profile_name = "Default" + + def _load_v2(self, data: dict) -> None: + raw_profiles = data.get("profiles", []) + if not raw_profiles: + return + self.profiles = [Profile.from_dict(p) for p in raw_profiles] + self.active_profile_name = data.get("active_profile", self.profiles[0].name) + # Asegurar que el perfil activo existe + if self.get_profile(self.active_profile_name) is None: + self.active_profile_name = self.profiles[0].name + + def save(self) -> None: + data = { + "version": CONFIG_VERSION, + "active_profile": self.active_profile_name, + "profiles": [p.to_dict() for p in self.profiles], + } + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) diff --git a/core/robocopy_engine.py b/core/robocopy_engine.py new file mode 100644 index 0000000..b2546fe --- /dev/null +++ b/core/robocopy_engine.py @@ -0,0 +1,109 @@ +import os +import subprocess +from typing import Callable + +from core.sync_engine import SyncEngine + + +class RobocopySyncEngine(SyncEngine): + """Motor de sincronización basado en robocopy (Windows).""" + + def is_available(self) -> bool: + try: + result = subprocess.run( + ["robocopy", "/?"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + # robocopy devuelve 16 en error fatal, cualquier otro código es OK + return result.returncode != 16 + except FileNotFoundError: + return False + + def sync_folder( + self, + src: str, + dst: str, + on_file: Callable[[str], None], + on_summary: Callable[[str], None], + ) -> None: + if not os.path.isdir(src): + on_summary(" ⚠️ Carpeta no existe (omitido)") + on_file("(carpeta no existe)") + return + + os.makedirs(dst, exist_ok=True) + + cmd = ["robocopy", src, dst, "/MIR", "/NP"] + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + universal_newlines=True, + ) + + files_copied = 0 + dirs_copied = 0 + total_bytes = 0 + + for line in process.stdout: + line = line.strip() + + if line and not line.startswith("---") and not line.startswith("Total"): + if len(line) > 5 and not any( + x in line + for x in ["Files :", "Dirs :", "Bytes :", "Speed :", "Times :"] + ): + on_file(line) + + if "Files :" in line: + parts = line.split() + try: + idx = parts.index("Files") + if idx + 2 < len(parts): + files_copied = int(parts[idx + 2]) + except (ValueError, IndexError): + pass + + elif "Dirs :" in line: + parts = line.split() + try: + idx = parts.index("Dirs") + if idx + 2 < len(parts): + dirs_copied = int(parts[idx + 2]) + except (ValueError, IndexError): + pass + + elif "Bytes :" in line: + parts = line.split() + try: + idx = parts.index("Bytes") + if idx + 2 < len(parts): + bytes_str = parts[idx + 2].replace(",", "").replace(".", "") + bytes_str = "".join(c for c in bytes_str if c.isdigit()) + if bytes_str: + total_bytes = int(bytes_str) + except (ValueError, IndexError): + pass + + process.wait() + + if files_copied > 0 or dirs_copied > 0: + size_str = self._format_bytes(total_bytes) + on_summary(f" ✓ {files_copied} archivos, {dirs_copied} carpetas ({size_str})") + else: + on_summary(" ✓ Sin cambios (ya sincronizado)") + + on_file("-") + + @staticmethod + def _format_bytes(n: int) -> str: + if n < 1024: + return f"{n} B" + if n < 1024 ** 2: + return f"{n / 1024:.2f} KB" + if n < 1024 ** 3: + return f"{n / 1024 ** 2:.2f} MB" + return f"{n / 1024 ** 3:.2f} GB" diff --git a/core/sync_engine.py b/core/sync_engine.py new file mode 100644 index 0000000..3dd8826 --- /dev/null +++ b/core/sync_engine.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from typing import Callable + + +class SyncEngine(ABC): + """Interfaz abstracta para motores de sincronización.""" + + @abstractmethod + def sync_folder( + self, + src: str, + dst: str, + on_file: Callable[[str], None], + on_summary: Callable[[str], None], + ) -> None: + """ + Sincroniza src → dst. + + on_file(path) — llamado con cada archivo que se procesa + on_summary(line) — llamado con cada línea de resumen al finalizar + """ + ... + + @abstractmethod + def is_available(self) -> bool: + """Devuelve True si el motor está disponible en el sistema actual.""" + ... diff --git a/pocketsync.py b/pocketsync.py new file mode 100644 index 0000000..3e99e9d --- /dev/null +++ b/pocketsync.py @@ -0,0 +1,26 @@ +import os +import sys +import tkinter as tk + +# Asegurar que el directorio raíz esté en el path para imports relativos +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +if BASE_DIR not in sys.path: + sys.path.insert(0, BASE_DIR) + +from core.config import ConfigManager +from ui.app import PocketSyncApp + +CONFIG_FILE = os.path.join(BASE_DIR, "config.json") + + +def main() -> None: + cm = ConfigManager(CONFIG_FILE) + cm.load() + + root = tk.Tk() + PocketSyncApp(root, cm) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/test_tkinter.py b/test_tkinter.py deleted file mode 100644 index e452a75..0000000 --- a/test_tkinter.py +++ /dev/null @@ -1,367 +0,0 @@ -import os -import json -import threading -import subprocess -import tkinter as tk -from tkinter import filedialog, messagebox - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -CONFIG_FILE = os.path.join(BASE_DIR, "config.json") - - -class DirectorySelectorApp: - def __init__(self, root): - self.root = root - self.root.title("Selector de Directorios ES-DE / ROMs") - self.root.geometry("700x700") - - # Variables de rutas - self.path_esde_src = tk.StringVar() - self.path_roms_src = tk.StringVar() - self.path_esde_dst = tk.StringVar() - self.path_roms_dst = tk.StringVar() - - # --------------------------- - # RUTAS DE ORIGEN - # --------------------------- - tk.Label(root, text="Rutas de origen", font=("Arial", 12, "bold")).pack(pady=(10, 0)) - - self.create_path_selector("Origen ES-DE:", self.path_esde_src) - self.create_path_selector("Origen ROMs:", self.path_roms_src, callback=self.update_directory_list) - - # --------------------------- - # LISTA DE DIRECTORIOS - # --------------------------- - tk.Label(root, text="Directorios encontrados en ROMs:", font=("Arial", 11)).pack(pady=(15, 5)) - - self.listbox = tk.Listbox(root, selectmode=tk.MULTIPLE, height=10) - self.listbox.pack(fill="both", expand=True, padx=10) - - # --------------------------- - # RUTAS DE DESTINO - # --------------------------- - tk.Label(root, text="Rutas de destino", font=("Arial", 12, "bold")).pack(pady=(15, 0)) - - self.create_path_selector("Destino ES-DE:", self.path_esde_dst) - self.create_path_selector("Destino ROMs:", self.path_roms_dst) - - # --------------------------- - # BOTÓN ROBOCOPY - # --------------------------- - tk.Button(root, text="Robocopy", font=("Arial", 12, "bold"), - command=self.run_robocopy).pack(pady=10) - - # --------------------------- - # LABELS DE ESTADO - # --------------------------- - status_frame = tk.Frame(root, bg="#2a2a2a", relief="sunken", bd=2) - status_frame.pack(fill="x", padx=10, pady=5) - - self.label_system = tk.Label(status_frame, text="Sistema: -", - font=("Arial", 10, "bold"), - bg="#2a2a2a", fg="#00ff00", anchor="w") - self.label_system.pack(fill="x", padx=10, pady=3) - - self.label_phase = tk.Label(status_frame, text="Fase: -", - font=("Arial", 10), - bg="#2a2a2a", fg="#00aaff", anchor="w") - self.label_phase.pack(fill="x", padx=10, pady=3) - - self.label_current = tk.Label(status_frame, text="Archivo: -", - font=("Arial", 9), - bg="#2a2a2a", fg="#ffaa00", anchor="w") - self.label_current.pack(fill="x", padx=10, pady=3) - - # --------------------------- - # RESUMEN - # --------------------------- - tk.Label(root, text="Resumen:", font=("Arial", 11)).pack(pady=(10, 5)) - - self.summary_text = tk.Text(root, height=6, state="disabled", bg="#f0f0f0", fg="#000") - self.summary_text.pack(fill="both", expand=False, padx=10, pady=5) - - # Evento de cierre - self.root.protocol("WM_DELETE_WINDOW", self.on_close) - - # Cargar configuración previa - self.load_config() - - # --------------------------------------------------------- - # CREAR SELECTOR DE RUTA - # --------------------------------------------------------- - def create_path_selector(self, label_text, var, callback=None): - frame = tk.Frame(self.root) - frame.pack(fill="x", padx=10, pady=5) - - tk.Label(frame, text=label_text, width=15, anchor="w").pack(side="left") - tk.Entry(frame, textvariable=var, width=50).pack(side="left", padx=5) - tk.Button(frame, text="Buscar", command=lambda: self.choose_path(var, callback)).pack(side="left") - - def choose_path(self, var, callback): - path = filedialog.askdirectory() - if not path: - return - var.set(path) - if callback: - callback(path) - - # --------------------------------------------------------- - # LISTA DE DIRECTORIOS - # --------------------------------------------------------- - def update_directory_list(self, path): - self.listbox.delete(0, tk.END) - - if not os.path.isdir(path): - return - - try: - dirs = sorted( - d for d in os.listdir(path) - if os.path.isdir(os.path.join(path, d)) - ) - for d in dirs: - self.listbox.insert(tk.END, d) - - except Exception as e: - messagebox.showerror("Error", f"No se pudo leer la ruta:\n{e}") - - # --------------------------------------------------------- - # ACTUALIZACIÓN DE LABELS Y RESUMEN - # --------------------------------------------------------- - def append_summary(self, text): - self.summary_text.configure(state="normal") - self.summary_text.insert(tk.END, text + "\n") - self.summary_text.see(tk.END) - self.summary_text.configure(state="disabled") - - def clear_summary(self): - self.summary_text.configure(state="normal") - self.summary_text.delete(1.0, tk.END) - self.summary_text.configure(state="disabled") - - def update_status_system(self, text): - self.label_system.config(text=text) - self.root.update_idletasks() - - def update_status_phase(self, text): - self.label_phase.config(text=text) - self.root.update_idletasks() - - def update_status_current(self, text): - # Limitar longitud para que no se salga de la ventana - if len(text) > 80: - text = "..." + text[-77:] - self.label_current.config(text=text) - self.root.update_idletasks() - - # --------------------------------------------------------- - # EJECUTAR ROBOCOPY (HILO) - # --------------------------------------------------------- - def run_robocopy(self): - thread = threading.Thread(target=self._run_robocopy_thread) - thread.daemon = True - thread.start() - - def _run_robocopy_thread(self): - selected = [self.listbox.get(i) for i in self.listbox.curselection()] - - if not selected: - self.append_summary("❌ No hay sistemas seleccionados.") - return - - esde_src = self.path_esde_src.get() - roms_src = self.path_roms_src.get() - esde_dst = self.path_esde_dst.get() - roms_dst = self.path_roms_dst.get() - - if not all([esde_src, roms_src, esde_dst, roms_dst]): - self.append_summary("❌ ERROR: Debes configurar todas las rutas antes de continuar.") - return - - self.clear_summary() - self.append_summary("=" * 60) - self.append_summary("🚀 INICIANDO PROCESO DE COPIA") - self.append_summary(f"📦 Total de sistemas a procesar: {len(selected)}") - self.append_summary("=" * 60) - - total_systems = len(selected) - - for idx, system in enumerate(selected, 1): - # Actualizar label de sistema - self.update_status_system(f"Sistema: {idx}/{total_systems} - {system.upper()}") - - self.append_summary(f"\n🎮 SISTEMA [{idx}/{total_systems}]: {system.upper()}") - - # ROMs - self.update_status_phase("Fase: [1/3] Copiando ROMs...") - self.append_summary(" 📁 [1/3] Copiando ROMs...") - self.launch_robocopy_with_log( - os.path.join(roms_src, system), - os.path.join(roms_dst, system) - ) - - # ES-DE gamelists - self.update_status_phase("Fase: [2/3] Copiando gamelists...") - self.append_summary(" 📋 [2/3] Copiando gamelists...") - self.launch_robocopy_with_log( - os.path.join(esde_src, "gamelists", system), - os.path.join(esde_dst, "gamelists", system) - ) - - # ES-DE downloaded_media - self.update_status_phase("Fase: [3/3] Copiando media...") - self.append_summary(" 🖼️ [3/3] Copiando media...") - self.launch_robocopy_with_log( - os.path.join(esde_src, "downloaded_media", system), - os.path.join(esde_dst, "downloaded_media", system) - ) - - self.append_summary(f" ✅ Sistema '{system}' completado\n") - - # Limpiar labels al finalizar - self.update_status_system("Sistema: ✅ COMPLETADO") - self.update_status_phase("Fase: -") - self.update_status_current("Archivo: -") - - self.append_summary("=" * 60) - self.append_summary("🎉 PROCESO COMPLETADO EXITOSAMENTE") - self.append_summary("=" * 60) - - def launch_robocopy_with_log(self, src, dst): - if not os.path.isdir(src): - self.append_summary(f" ⚠️ Carpeta no existe (omitido)") - self.update_status_current("Archivo: (carpeta no existe)") - return - - os.makedirs(dst, exist_ok=True) - - # Quitar /NFL y /NDL para ver los archivos en tiempo real - cmd = ["robocopy", src, dst, "/MIR", "/NP"] - - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - universal_newlines=True - ) - - # Variables para extraer el resumen - files_copied = 0 - dirs_copied = 0 - total_bytes = 0 - - for line in process.stdout: - line = line.strip() - - # Actualizar label con el archivo actual - if line and not line.startswith("---") and not line.startswith("Total"): - # Si la línea parece un archivo siendo copiado - if len(line) > 5 and not any(x in line for x in ["Files :", "Dirs :", "Bytes :", "Speed :", "Times :"]): - self.update_status_current(f"Archivo: {line}") - - # Extraer información relevante del output de robocopy - if "Files :" in line: - parts = line.split() - try: - total_idx = parts.index("Files") - if total_idx + 2 < len(parts): - files_copied = int(parts[total_idx + 2]) - except (ValueError, IndexError): - pass - - elif "Dirs :" in line: - parts = line.split() - try: - total_idx = parts.index("Dirs") - if total_idx + 2 < len(parts): - dirs_copied = int(parts[total_idx + 2]) - except (ValueError, IndexError): - pass - - elif "Bytes :" in line: - parts = line.split() - try: - bytes_idx = parts.index("Bytes") - if bytes_idx + 2 < len(parts): - bytes_str = parts[bytes_idx + 2].replace(",", "").replace(".", "") - # Extraer solo números - bytes_str = ''.join(c for c in bytes_str if c.isdigit()) - if bytes_str: - total_bytes = int(bytes_str) - except (ValueError, IndexError): - pass - - process.wait() - - # Convertir bytes a formato legible - if files_copied > 0 or dirs_copied > 0: - if total_bytes < 1024: - size_str = f"{total_bytes} B" - elif total_bytes < 1024**2: - size_str = f"{total_bytes/1024:.2f} KB" - elif total_bytes < 1024**3: - size_str = f"{total_bytes/(1024**2):.2f} MB" - else: - size_str = f"{total_bytes/(1024**3):.2f} GB" - - self.append_summary(f" ✓ {files_copied} archivos, {dirs_copied} carpetas ({size_str})") - else: - self.append_summary(f" ✓ Sin cambios (ya sincronizado)") - - self.update_status_current("Archivo: -") - - # --------------------------------------------------------- - # PERSISTENCIA - # --------------------------------------------------------- - def save_config(self): - data = { - "esde_src": self.path_esde_src.get(), - "roms_src": self.path_roms_src.get(), - "esde_dst": self.path_esde_dst.get(), - "roms_dst": self.path_roms_dst.get(), - "selected": [self.listbox.get(i) for i in self.listbox.curselection()] - } - - try: - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - except Exception as e: - messagebox.showerror("Error", f"No se pudo guardar la configuración:\n{e}") - - def load_config(self): - if not os.path.exists(CONFIG_FILE): - return - - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - except Exception: - return - - self.path_esde_src.set(data.get("esde_src", "")) - self.path_roms_src.set(data.get("roms_src", "")) - self.path_esde_dst.set(data.get("esde_dst", "")) - self.path_roms_dst.set(data.get("roms_dst", "")) - - roms_path = self.path_roms_src.get() - if os.path.isdir(roms_path): - self.update_directory_list(roms_path) - - selected = set(data.get("selected", [])) - for i in range(self.listbox.size()): - if self.listbox.get(i) in selected: - self.listbox.selection_set(i) - - # --------------------------------------------------------- - # CIERRE - # --------------------------------------------------------- - def on_close(self): - self.save_config() - self.root.destroy() - - -if __name__ == "__main__": - root = tk.Tk() - app = DirectorySelectorApp(root) - root.mainloop() \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/__pycache__/__init__.cpython-310.pyc b/ui/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..d45cefc Binary files /dev/null and b/ui/__pycache__/__init__.cpython-310.pyc differ diff --git a/ui/__pycache__/app.cpython-310.pyc b/ui/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000..2336dfc Binary files /dev/null and b/ui/__pycache__/app.cpython-310.pyc differ diff --git a/ui/__pycache__/path_panel.cpython-310.pyc b/ui/__pycache__/path_panel.cpython-310.pyc new file mode 100644 index 0000000..77e16f0 Binary files /dev/null and b/ui/__pycache__/path_panel.cpython-310.pyc differ diff --git a/ui/__pycache__/profile_bar.cpython-310.pyc b/ui/__pycache__/profile_bar.cpython-310.pyc new file mode 100644 index 0000000..0d33ef1 Binary files /dev/null and b/ui/__pycache__/profile_bar.cpython-310.pyc differ diff --git a/ui/__pycache__/status_bar.cpython-310.pyc b/ui/__pycache__/status_bar.cpython-310.pyc new file mode 100644 index 0000000..bb24bb1 Binary files /dev/null and b/ui/__pycache__/status_bar.cpython-310.pyc differ diff --git a/ui/__pycache__/styles.cpython-310.pyc b/ui/__pycache__/styles.cpython-310.pyc new file mode 100644 index 0000000..331522b Binary files /dev/null and b/ui/__pycache__/styles.cpython-310.pyc differ diff --git a/ui/__pycache__/summary_panel.cpython-310.pyc b/ui/__pycache__/summary_panel.cpython-310.pyc new file mode 100644 index 0000000..1ea4707 Binary files /dev/null and b/ui/__pycache__/summary_panel.cpython-310.pyc differ diff --git a/ui/__pycache__/system_list.cpython-310.pyc b/ui/__pycache__/system_list.cpython-310.pyc new file mode 100644 index 0000000..7ee3b30 Binary files /dev/null and b/ui/__pycache__/system_list.cpython-310.pyc differ diff --git a/ui/app.py b/ui/app.py new file mode 100644 index 0000000..27db7d2 --- /dev/null +++ b/ui/app.py @@ -0,0 +1,220 @@ +import os +import threading +import tkinter as tk + +from core.config import ConfigManager, Profile +from core.robocopy_engine import RobocopySyncEngine +from core.sync_engine import SyncEngine +from ui import styles +from ui.path_panel import PathPanel +from ui.profile_bar import ProfileBar +from ui.status_bar import StatusBar +from ui.summary_panel import SummaryPanel +from ui.system_list import SystemList + + +class PocketSyncApp: + def __init__(self, root: tk.Tk, config_manager: ConfigManager): + self.root = root + self.cm = config_manager + self.engine: SyncEngine = RobocopySyncEngine() + + self._build_ui() + self._load_profile(self.cm.active_profile) + + # ------------------------------------------------------------------ + # Construcción de la UI + # ------------------------------------------------------------------ + + def _build_ui(self) -> None: + self.root.title("PocketSync") + self.root.geometry(f"{styles.WINDOW_WIDTH}x{styles.WINDOW_HEIGHT}") + + # --- Barra de perfiles --- + self.profile_bar = ProfileBar(self.root, on_change=self._on_profile_change) + self.profile_bar.pack(fill="x", padx=styles.PAD_X, pady=(8, 2)) + self.profile_bar.set_callbacks( + on_new=self._on_new_profile, + on_rename=self._on_rename_profile, + on_delete=self._on_delete_profile, + ) + self._refresh_profile_bar() + + # --- Rutas de origen --- + self.src_panel = PathPanel(self.root, title="Rutas de origen") + self.src_panel.pack(fill="x", padx=styles.PAD_X, pady=styles.PAD_Y) + self.src_panel.set_roms_callback(self._on_roms_src_change) + + # --- Lista de sistemas --- + self.system_list = SystemList(self.root) + self.system_list.pack(fill="both", expand=True, padx=styles.PAD_X) + + # --- Rutas de destino --- + self.dst_panel = PathPanel(self.root, title="Rutas de destino") + self.dst_panel.pack(fill="x", padx=styles.PAD_X, pady=styles.PAD_Y) + + # --- Botón Sync --- + tk.Button( + self.root, + text="Sync Now", + font=styles.FONT_BUTTON, + command=self._run_sync, + ).pack(pady=6) + + # --- Barra de estado --- + self.status_bar = StatusBar(self.root) + self.status_bar.pack(fill="x", padx=styles.PAD_X, pady=styles.PAD_Y) + + # --- Panel de resumen --- + self.summary = SummaryPanel(self.root) + self.summary.pack(fill="x", padx=styles.PAD_X, pady=styles.PAD_Y) + + self.root.protocol("WM_DELETE_WINDOW", self._on_close) + + # ------------------------------------------------------------------ + # Gestión de perfiles + # ------------------------------------------------------------------ + + def _refresh_profile_bar(self) -> None: + self.profile_bar.refresh(self.cm.profile_names(), self.cm.active_profile_name) + + def _collect_ui_into_profile(self) -> None: + """Escribe el estado actual de la UI en el perfil activo.""" + p = self.cm.active_profile + p.esde_src = self.src_panel.get_esde() + p.roms_src = self.src_panel.get_roms() + p.esde_dst = self.dst_panel.get_esde() + p.roms_dst = self.dst_panel.get_roms() + p.selected = self.system_list.get_selected() + + def _load_profile(self, profile: Profile) -> None: + self.src_panel.set_esde(profile.esde_src) + self.src_panel.set_roms(profile.roms_src) + self.dst_panel.set_esde(profile.esde_dst) + self.dst_panel.set_roms(profile.roms_dst) + + if os.path.isdir(profile.roms_src): + self.system_list.populate(profile.roms_src) + else: + self.system_list.populate("") + + self.system_list.set_selected(profile.selected) + + def _on_profile_change(self, name: str) -> None: + self._collect_ui_into_profile() + self.cm.active_profile_name = name + self._load_profile(self.cm.active_profile) + self._refresh_profile_bar() + + def _on_new_profile(self, name: str) -> bool: + if self.cm.get_profile(name) is not None: + return False + self._collect_ui_into_profile() + self.cm.add_profile(name) + self.cm.active_profile_name = name + self._load_profile(self.cm.active_profile) + self._refresh_profile_bar() + return True + + def _on_rename_profile(self, old: str, new: str) -> bool: + ok = self.cm.rename_profile(old, new) + if ok: + self._refresh_profile_bar() + return ok + + def _on_delete_profile(self, name: str) -> bool: + ok = self.cm.delete_profile(name) + if ok: + self._load_profile(self.cm.active_profile) + self._refresh_profile_bar() + return ok + + # ------------------------------------------------------------------ + # Callbacks de widgets + # ------------------------------------------------------------------ + + def _on_roms_src_change(self, path: str) -> None: + self.system_list.populate(path) + + # ------------------------------------------------------------------ + # Loop de sincronización + # ------------------------------------------------------------------ + + def _run_sync(self) -> None: + thread = threading.Thread(target=self._sync_thread) + thread.daemon = True + thread.start() + + def _sync_thread(self) -> None: + selected = self.system_list.get_selected() + + if not selected: + self.summary.append("No hay sistemas seleccionados.") + return + + esde_src = self.src_panel.get_esde() + roms_src = self.src_panel.get_roms() + esde_dst = self.dst_panel.get_esde() + roms_dst = self.dst_panel.get_roms() + + if not all([esde_src, roms_src, esde_dst, roms_dst]): + self.summary.append("ERROR: Debes configurar todas las rutas antes de continuar.") + return + + self.summary.clear() + self.summary.append("=" * 60) + self.summary.append("INICIANDO PROCESO DE COPIA") + self.summary.append(f"Total de sistemas a procesar: {len(selected)}") + self.summary.append("=" * 60) + + total = len(selected) + + for idx, system in enumerate(selected, 1): + self.status_bar.set_system(f"Sistema: {idx}/{total} - {system.upper()}") + self.summary.append(f"\nSISTEMA [{idx}/{total}]: {system.upper()}") + + # Fase 1: ROMs + self.status_bar.set_phase("Fase: [1/3] Copiando ROMs...") + self.summary.append(" [1/3] Copiando ROMs...") + self.engine.sync_folder( + os.path.join(roms_src, system), + os.path.join(roms_dst, system), + on_file=self.status_bar.set_file, + on_summary=self.summary.append, + ) + + # Fase 2: gamelists + self.status_bar.set_phase("Fase: [2/3] Copiando gamelists...") + self.summary.append(" [2/3] Copiando gamelists...") + self.engine.sync_folder( + os.path.join(esde_src, "gamelists", system), + os.path.join(esde_dst, "gamelists", system), + on_file=self.status_bar.set_file, + on_summary=self.summary.append, + ) + + # Fase 3: media + self.status_bar.set_phase("Fase: [3/3] Copiando media...") + self.summary.append(" [3/3] Copiando media...") + self.engine.sync_folder( + os.path.join(esde_src, "downloaded_media", system), + os.path.join(esde_dst, "downloaded_media", system), + on_file=self.status_bar.set_file, + on_summary=self.summary.append, + ) + + self.summary.append(f" Sistema '{system}' completado\n") + + self.status_bar.reset() + self.summary.append("=" * 60) + self.summary.append("PROCESO COMPLETADO EXITOSAMENTE") + self.summary.append("=" * 60) + + # ------------------------------------------------------------------ + # Cierre + # ------------------------------------------------------------------ + + def _on_close(self) -> None: + self._collect_ui_into_profile() + self.cm.save() + self.root.destroy() diff --git a/ui/path_panel.py b/ui/path_panel.py new file mode 100644 index 0000000..ca16c67 --- /dev/null +++ b/ui/path_panel.py @@ -0,0 +1,66 @@ +import tkinter as tk +from tkinter import filedialog +from typing import Callable, Optional + +from ui import styles + + +class PathPanel(tk.LabelFrame): + """Panel con dos selectores de ruta (origen o destino).""" + + def __init__(self, parent, title: str, **kwargs): + super().__init__( + parent, + text=title, + font=styles.FONT_HEADING, + padx=styles.PAD_X, + pady=styles.PAD_Y, + **kwargs, + ) + + self.path_esde = tk.StringVar() + self.path_roms = tk.StringVar() + + self._add_selector("ES-DE:", self.path_esde) + self._add_selector("ROMs:", self.path_roms) + + def _add_selector(self, label: str, var: tk.StringVar, callback: Optional[Callable] = None): + frame = tk.Frame(self) + frame.pack(fill="x", pady=2) + + tk.Label(frame, text=label, width=10, anchor="w", font=styles.FONT_LABEL).pack(side="left") + tk.Entry(frame, textvariable=var, width=55, font=styles.FONT_SMALL).pack(side="left", padx=4) + tk.Button( + frame, + text="Buscar", + font=styles.FONT_SMALL, + command=lambda: self._choose(var, callback), + ).pack(side="left") + + def _choose(self, var: tk.StringVar, callback: Optional[Callable]): + path = filedialog.askdirectory() + if not path: + return + var.set(path) + if callback: + callback(path) + + def set_roms_callback(self, callback: Callable[[str], None]) -> None: + """Registra callback que se invoca al cambiar la ruta de ROMs.""" + # Reemplaza el selector de ROMs con uno que tenga el callback + for widget in self.winfo_children(): + widget.destroy() + self._add_selector("ES-DE:", self.path_esde) + self._add_selector("ROMs:", self.path_roms, callback=callback) + + def get_esde(self) -> str: + return self.path_esde.get() + + def get_roms(self) -> str: + return self.path_roms.get() + + def set_esde(self, value: str) -> None: + self.path_esde.set(value) + + def set_roms(self, value: str) -> None: + self.path_roms.set(value) diff --git a/ui/profile_bar.py b/ui/profile_bar.py new file mode 100644 index 0000000..96a33e2 --- /dev/null +++ b/ui/profile_bar.py @@ -0,0 +1,93 @@ +import tkinter as tk +from tkinter import simpledialog, messagebox +from typing import Callable, List + +from ui import styles + + +class ProfileBar(tk.Frame): + """Barra superior con dropdown de perfiles y botones New/Rename/Delete.""" + + def __init__(self, parent, on_change: Callable[[str], None], **kwargs): + super().__init__(parent, **kwargs) + + self._on_change = on_change + + tk.Label(self, text="Perfil:", font=styles.FONT_LABEL).pack(side="left", padx=(0, 4)) + + self._var = tk.StringVar() + self._dropdown = tk.OptionMenu(self, self._var, "") + self._dropdown.config(font=styles.FONT_LABEL, width=20) + self._dropdown.pack(side="left", padx=4) + + tk.Button(self, text="New", font=styles.FONT_SMALL, command=self._new_profile).pack(side="left", padx=2) + tk.Button(self, text="Rename", font=styles.FONT_SMALL, command=self._rename_profile).pack(side="left", padx=2) + tk.Button(self, text="Delete", font=styles.FONT_SMALL, command=self._delete_profile).pack(side="left", padx=2) + + # Callbacks inyectados desde app.py + self._cb_new: Callable[[str], bool] = lambda name: False + self._cb_rename: Callable[[str, str], bool] = lambda old, new: False + self._cb_delete: Callable[[str], bool] = lambda name: False + + # ------------------------------------------------------------------ + # API pública + # ------------------------------------------------------------------ + + def set_callbacks( + self, + on_new: Callable[[str], bool], + on_rename: Callable[[str, str], bool], + on_delete: Callable[[str], bool], + ) -> None: + self._cb_new = on_new + self._cb_rename = on_rename + self._cb_delete = on_delete + + def refresh(self, names: List[str], active: str) -> None: + """Actualiza el dropdown con la lista de nombres de perfil.""" + menu = self._dropdown["menu"] + menu.delete(0, "end") + for name in names: + menu.add_command(label=name, command=lambda n=name: self._select(n)) + self._var.set(active) + + def current(self) -> str: + return self._var.get() + + # ------------------------------------------------------------------ + # Acciones internas + # ------------------------------------------------------------------ + + def _select(self, name: str) -> None: + self._var.set(name) + self._on_change(name) + + def _new_profile(self) -> None: + name = simpledialog.askstring("Nuevo perfil", "Nombre del perfil:", parent=self) + if not name: + return + name = name.strip() + if not name: + return + if self._cb_new(name): + self._on_change(name) + else: + messagebox.showerror("Error", f"Ya existe un perfil con el nombre '{name}'.") + + def _rename_profile(self) -> None: + old = self._var.get() + new = simpledialog.askstring("Renombrar perfil", f"Nuevo nombre para '{old}':", parent=self) + if not new: + return + new = new.strip() + if not new: + return + if not self._cb_rename(old, new): + messagebox.showerror("Error", f"No se pudo renombrar el perfil.") + + def _delete_profile(self) -> None: + name = self._var.get() + if not messagebox.askyesno("Eliminar perfil", f"¿Eliminar el perfil '{name}'?"): + return + if not self._cb_delete(name): + messagebox.showerror("Error", "No se puede eliminar el único perfil existente.") diff --git a/ui/status_bar.py b/ui/status_bar.py new file mode 100644 index 0000000..334ef41 --- /dev/null +++ b/ui/status_bar.py @@ -0,0 +1,61 @@ +import tkinter as tk + +from ui import styles + +_MAX_FILE_LEN = 80 + + +class StatusBar(tk.Frame): + """Barra de estado con tres labels: sistema, fase y archivo actual.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, bg=styles.STATUS_BG, relief="sunken", bd=2, **kwargs) + + self._label_system = tk.Label( + self, + text="Sistema: -", + font=styles.FONT_LABEL + ("bold",) if isinstance(styles.FONT_LABEL, tuple) else styles.FONT_LABEL, + bg=styles.STATUS_BG, + fg=styles.STATUS_SYSTEM_FG, + anchor="w", + ) + self._label_system.pack(fill="x", padx=10, pady=3) + + self._label_phase = tk.Label( + self, + text="Fase: -", + font=styles.FONT_LABEL, + bg=styles.STATUS_BG, + fg=styles.STATUS_PHASE_FG, + anchor="w", + ) + self._label_phase.pack(fill="x", padx=10, pady=3) + + self._label_file = tk.Label( + self, + text="Archivo: -", + font=styles.FONT_SMALL, + bg=styles.STATUS_BG, + fg=styles.STATUS_FILE_FG, + anchor="w", + ) + self._label_file.pack(fill="x", padx=10, pady=3) + + def set_system(self, text: str) -> None: + self._label_system.config(text=text) + self._label_system.update_idletasks() + + def set_phase(self, text: str) -> None: + self._label_phase.config(text=text) + self._label_phase.update_idletasks() + + def set_file(self, text: str) -> None: + if len(text) > _MAX_FILE_LEN: + text = "..." + text[-(_MAX_FILE_LEN - 3):] + self._label_file.config(text=f"Archivo: {text}") + self._label_file.update_idletasks() + + def reset(self) -> None: + self.set_system("Sistema: ✅ COMPLETADO") + self.set_phase("Fase: -") + self.set_file("-") diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..2c42629 --- /dev/null +++ b/ui/styles.py @@ -0,0 +1,27 @@ +# Constantes visuales de PocketSync + +FONT_FAMILY = "Segoe UI" + +FONT_HEADING = (FONT_FAMILY, 11, "bold") +FONT_LABEL = (FONT_FAMILY, 10) +FONT_SMALL = (FONT_FAMILY, 9) +FONT_BUTTON = (FONT_FAMILY, 11, "bold") +FONT_MONO = ("Consolas", 9) + +# Colores de la barra de estado +STATUS_BG = "#2a2a2a" +STATUS_SYSTEM_FG = "#00ff00" +STATUS_PHASE_FG = "#00aaff" +STATUS_FILE_FG = "#ffaa00" + +# Colores del panel de resumen +SUMMARY_BG = "#f0f0f0" +SUMMARY_FG = "#000000" + +# Dimensiones de ventana +WINDOW_WIDTH = 800 +WINDOW_HEIGHT = 720 + +# Padding genérico +PAD_X = 10 +PAD_Y = 5 diff --git a/ui/summary_panel.py b/ui/summary_panel.py new file mode 100644 index 0000000..6a6aa1c --- /dev/null +++ b/ui/summary_panel.py @@ -0,0 +1,33 @@ +import tkinter as tk + +from ui import styles + + +class SummaryPanel(tk.Frame): + """Panel de texto deshabilitado para mostrar el resumen de sync.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + + tk.Label(self, text="Resumen:", font=styles.FONT_LABEL).pack(pady=(6, 2)) + + self._text = tk.Text( + self, + height=6, + state="disabled", + bg=styles.SUMMARY_BG, + fg=styles.SUMMARY_FG, + font=styles.FONT_MONO, + ) + self._text.pack(fill="both", expand=False, padx=styles.PAD_X, pady=styles.PAD_Y) + + def append(self, line: str) -> None: + self._text.configure(state="normal") + self._text.insert(tk.END, line + "\n") + self._text.see(tk.END) + self._text.configure(state="disabled") + + def clear(self) -> None: + self._text.configure(state="normal") + self._text.delete(1.0, tk.END) + self._text.configure(state="disabled") diff --git a/ui/system_list.py b/ui/system_list.py new file mode 100644 index 0000000..2c5de17 --- /dev/null +++ b/ui/system_list.py @@ -0,0 +1,69 @@ +import os +import tkinter as tk +from tkinter import messagebox +from typing import List + +from ui import styles + + +class SystemList(tk.Frame): + """Listbox de sistemas con botones Select All / Select None.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + + tk.Label( + self, + text="Sistemas encontrados en ROMs:", + font=styles.FONT_LABEL, + ).pack(pady=(6, 2)) + + self.listbox = tk.Listbox(self, selectmode=tk.MULTIPLE, height=10, font=styles.FONT_SMALL) + self.listbox.pack(fill="both", expand=True, padx=styles.PAD_X) + + btn_frame = tk.Frame(self) + btn_frame.pack(fill="x", padx=styles.PAD_X, pady=2) + + tk.Button( + btn_frame, + text="Select All", + font=styles.FONT_SMALL, + command=self._select_all, + ).pack(side="left", padx=2) + + tk.Button( + btn_frame, + text="Select None", + font=styles.FONT_SMALL, + command=self._select_none, + ).pack(side="left", padx=2) + + def _select_all(self): + self.listbox.selection_set(0, tk.END) + + def _select_none(self): + self.listbox.selection_clear(0, tk.END) + + def populate(self, path: str) -> None: + """Rellena el listbox con los subdirectorios de path.""" + self.listbox.delete(0, tk.END) + if not os.path.isdir(path): + return + try: + dirs = sorted( + d for d in os.listdir(path) if os.path.isdir(os.path.join(path, d)) + ) + for d in dirs: + self.listbox.insert(tk.END, d) + except Exception as e: + messagebox.showerror("Error", f"No se pudo leer la ruta:\n{e}") + + def get_selected(self) -> List[str]: + return [self.listbox.get(i) for i in self.listbox.curselection()] + + def set_selected(self, names: List[str]) -> None: + selected = set(names) + self.listbox.selection_clear(0, tk.END) + for i in range(self.listbox.size()): + if self.listbox.get(i) in selected: + self.listbox.selection_set(i)