refactor: modularizar como PocketSync con soporte de perfiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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."""
|
||||
...
|
||||
Reference in New Issue
Block a user