refactor: modularizar como PocketSync con soporte de perfiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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",
|
||||
@@ -48,4 +55,6 @@
|
||||
"wonderswan",
|
||||
"wonderswancolor"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+135
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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
@@ -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()
|
||||
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.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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("-")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user