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.
+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."""
...