refactor: modularizar como PocketSync con soporte de perfiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
JailDesignerandClaude Sonnet 4.6 committed 2026-02-21 19:22:08 +01:00
1 parent ebfa5d5fa2
commit a0ef53922e
28 files changed
+1010 -415

No files matched your search

View File
Whitespace-only changes.
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."""
...