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
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)