refactor: modularizar como PocketSync con soporte de perfiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 19:22:08 +01:00
parent ebfa5d5fa2
commit a0ef53922e
28 changed files with 1010 additions and 415 deletions
+87
View File
@@ -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.
+9
View File
@@ -1,4 +1,9 @@
{
"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",
@@ -27,6 +32,7 @@
"msx",
"msx2",
"n64",
"nds",
"neogeo",
"neogeocd",
"nes",
@@ -34,6 +40,7 @@
"ngpc",
"pcengine",
"pcenginecd",
"psp",
"psx",
"satellaview",
"saturn",
@@ -49,3 +56,5 @@
"wonderswancolor"
]
}
]
}
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+135
View File
@@ -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)
+109
View File
@@ -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"
+27
View File
@@ -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."""
...
+26
View File
@@ -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()
-367
View File
@@ -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()
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+220
View File
@@ -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()
+66
View File
@@ -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)
+93
View File
@@ -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.")
+61
View File
@@ -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("-")
+27
View File
@@ -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
+33
View File
@@ -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")
+69
View File
@@ -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)