diff --git a/backend/app/api/jobs.py b/backend/app/api/jobs.py index e26a7ba..0004b8a 100644 --- a/backend/app/api/jobs.py +++ b/backend/app/api/jobs.py @@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc from app.core.database import get_db -from app.models.models import User, Client, BackupJob -from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister +from app.models.models import User, Client, BackupJob, BackupFile +from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister, PurgeOrphansRequest from app.api.deps import get_current_user, require_admin, get_current_client from app.services.event_service import log_event from app.ws.manager import ws_manager +import logging + +logger = logging.getLogger("uvicorn.error") router = APIRouter(prefix="/jobs", tags=["Backup Jobs"]) @@ -53,6 +56,11 @@ async def agent_register_job( name=payload.name, source_path=payload.source_path, file_patterns=payload.file_patterns, + exclude_patterns=payload.exclude_patterns, + skip_hidden=payload.skip_hidden, + skip_system=payload.skip_system, + skip_readonly=payload.skip_readonly, + sync_deletions=payload.sync_deletions, schedule_cron=payload.schedule_cron, keep_daily=payload.keep_daily, keep_weekly=payload.keep_weekly, @@ -126,6 +134,11 @@ async def create_job( name=payload.name, source_path=payload.source_path, file_patterns=payload.file_patterns, + exclude_patterns=payload.exclude_patterns, + skip_hidden=payload.skip_hidden, + skip_system=payload.skip_system, + skip_readonly=payload.skip_readonly, + sync_deletions=payload.sync_deletions, schedule_cron=payload.schedule_cron, keep_daily=payload.keep_daily, keep_weekly=payload.keep_weekly, @@ -241,3 +254,71 @@ async def trigger_job( }) return {"message": f"Job {job.job_code} triggered"} + +@router.post("/{job_id}/purge-orphans") +async def purge_orphans( + job_id: int, + payload: PurgeOrphansRequest, + db: AsyncSession = Depends(get_db), + current_client: Client = Depends(get_current_client) +): + """ + Called by the Windows Agent to delete backup files on the server that no longer exist + in the client's local directory (orphans), enforcing replica mode. + """ + res = await db.execute(select(BackupJob).where(BackupJob.id == job_id, BackupJob.client_id == current_client.id)) + job = res.scalar_one_or_none() + if not job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Job not found or not owned by this client" + ) + + if not job.sync_deletions: + return {"purged_count": 0, "message": "Replica mode (sync_deletions) is not enabled for this job."} + + files_res = await db.execute( + select(BackupFile) + .where( + BackupFile.job_id == job_id, + BackupFile.client_id == current_client.id, + BackupFile.is_active == True + ) + ) + db_files = files_res.scalars().all() + + client_paths = {p.strip().replace("\\", "/").lower() for p in payload.active_relative_paths} + + purged_count = 0 + total_freed_bytes = 0 + from app.storage import storage_provider + + for db_file in db_files: + normalized_db_path = db_file.client_relative_path.strip().replace("\\", "/").lower() + if normalized_db_path not in client_paths: + try: + await storage_provider.delete_backup_file(db_file.relative_path) + db_file.is_active = False + total_freed_bytes += db_file.file_size + purged_count += 1 + + await log_event( + db=db, + event_type="FILE_PURGED", + message=f"Purged orphan file '{db_file.filename}' from server storage (Replica Mode).", + severity="WARNING", + client_id=current_client.id, + job_id=job.id + ) + except Exception as e: + logger.error(f"Failed to purge orphan file {db_file.filename}: {e}") + + if purged_count > 0: + current_client.storage_used_bytes = max(0, current_client.storage_used_bytes - total_freed_bytes) + await db.commit() + + return { + "purged_count": purged_count, + "freed_bytes": total_freed_bytes, + "message": f"Successfully purged {purged_count} orphan files from server." + } diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index c67ef33..bd5e9b2 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -36,7 +36,8 @@ async def init_session( file_size=payload.file_size, sha256_full=payload.sha256, chunk_size=payload.chunk_size, - job_id=payload.job_id + job_id=payload.job_id, + client_relative_path=payload.client_relative_path ) return { diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 4fc455b..5b97da1 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -81,7 +81,14 @@ class BackupJob(Base): file_patterns = Column(String(255), default="*.bak,*.mdf", nullable=False) schedule_cron = Column(String(100), default="0 2 * * *", nullable=False) # default 02:00 AM daily is_active = Column(Boolean, default=True, nullable=False) + sync_deletions = Column(Boolean, default=False, nullable=False) + # Advanced Filtering (Karen's Replicator Style) + exclude_patterns = Column(String(255), default="", nullable=False) + skip_hidden = Column(Boolean, default=False, nullable=False) + skip_system = Column(Boolean, default=False, nullable=False) + skip_readonly = Column(Boolean, default=False, nullable=False) + # Retention policies keep_daily = Column(Integer, default=7, nullable=False) keep_weekly = Column(Integer, default=4, nullable=False) @@ -106,6 +113,7 @@ class BackupSession(Base): job_id = Column(Integer, ForeignKey("backup_jobs.id", ondelete="CASCADE"), nullable=True) filename = Column(String(512), nullable=False) + client_relative_path = Column(String(1024), nullable=True) file_size = Column(BigInteger, nullable=False) chunk_size = Column(Integer, default=4 * 1024 * 1024, nullable=False) total_chunks = Column(Integer, nullable=False) @@ -152,6 +160,7 @@ class BackupFile(Base): filename = Column(String(512), nullable=False) relative_path = Column(String(1024), nullable=False) + client_relative_path = Column(String(1024), default="", nullable=False) file_size = Column(BigInteger, nullable=False) sha256 = Column(String(64), nullable=False) retention_tag = Column(String(50), default="DAILY", nullable=False) # DAILY, WEEKLY, MONTHLY, MANUAL diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 697215b..d56e5ae 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -82,6 +82,11 @@ class JobCreate(BaseModel): name: str source_path: str file_patterns: str = "*.bak,*.mdf" + exclude_patterns: str = "" + skip_hidden: bool = False + skip_system: bool = False + skip_readonly: bool = False + sync_deletions: bool = False schedule_cron: str = "0 2 * * *" keep_daily: int = 7 keep_weekly: int = 4 @@ -92,6 +97,11 @@ class JobUpdate(BaseModel): name: Optional[str] = None source_path: Optional[str] = None file_patterns: Optional[str] = None + exclude_patterns: Optional[str] = None + skip_hidden: Optional[bool] = None + skip_system: Optional[bool] = None + skip_readonly: Optional[bool] = None + sync_deletions: Optional[bool] = None schedule_cron: Optional[str] = None is_active: Optional[bool] = None keep_daily: Optional[int] = None @@ -106,6 +116,11 @@ class JobResponse(BaseModel): name: str source_path: str file_patterns: str + exclude_patterns: str + skip_hidden: bool + skip_system: bool + skip_readonly: bool + sync_deletions: bool schedule_cron: str is_active: bool keep_daily: int @@ -122,6 +137,7 @@ class JobResponse(BaseModel): # --- Upload Session & Chunk Schemas --- class UploadSessionInitRequest(BaseModel): filename: str + client_relative_path: Optional[str] = None file_size: int sha256: str chunk_size: int = 4 * 1024 * 1024 @@ -234,8 +250,16 @@ class AgentJobRegister(BaseModel): name: str source_path: str file_patterns: str = "*.bak,*.mdf" + exclude_patterns: str = "" + skip_hidden: bool = False + skip_system: bool = False + skip_readonly: bool = False + sync_deletions: bool = False schedule_cron: str = "daily" keep_daily: int = 7 keep_weekly: int = 4 keep_monthly: int = 12 min_stable_time_seconds: int = 60 + +class PurgeOrphansRequest(BaseModel): + active_relative_paths: List[str] diff --git a/backend/app/services/upload_service.py b/backend/app/services/upload_service.py index bc703fb..ab88dfa 100644 --- a/backend/app/services/upload_service.py +++ b/backend/app/services/upload_service.py @@ -16,7 +16,8 @@ async def create_or_resume_session( file_size: int, sha256_full: str, chunk_size: int = 4 * 1024 * 1024, - job_id: Optional[int] = None + job_id: Optional[int] = None, + client_relative_path: Optional[str] = None ) -> Tuple[BackupSession, List[int]]: """ Initializes a new upload session or resumes an existing incomplete session @@ -63,6 +64,8 @@ async def create_or_resume_session( # Resume existing session received_chunks = await storage_provider.get_received_chunks(session.session_code) session.received_chunks_count = len(received_chunks) + if client_relative_path: + session.client_relative_path = client_relative_path await db.commit() await db.refresh(session) return session, received_chunks @@ -72,6 +75,7 @@ async def create_or_resume_session( client_id=client.id, job_id=job_id, filename=filename, + client_relative_path=client_relative_path or filename, file_size=file_size, chunk_size=chunk_size, total_chunks=total_chunks, @@ -242,6 +246,7 @@ async def complete_session( session_id=session.id, filename=session.filename, relative_path=rel_path, + client_relative_path=session.client_relative_path or session.filename, file_size=total_bytes, sha256=final_sha256, retention_tag="DAILY", diff --git a/backend/migrate_karens_replicator.py b/backend/migrate_karens_replicator.py new file mode 100644 index 0000000..2695015 --- /dev/null +++ b/backend/migrate_karens_replicator.py @@ -0,0 +1,29 @@ +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db") + +def migrate(): + print(f"Connecting to {DB_PATH}...") + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + columns_to_add = [ + ("exclude_patterns", "VARCHAR(255) DEFAULT '' NOT NULL"), + ("skip_hidden", "BOOLEAN DEFAULT 0 NOT NULL"), + ("skip_system", "BOOLEAN DEFAULT 0 NOT NULL"), + ("skip_readonly", "BOOLEAN DEFAULT 0 NOT NULL") + ] + + for col_name, col_def in columns_to_add: + try: + cursor.execute(f"ALTER TABLE backup_jobs ADD COLUMN {col_name} {col_def}") + print(f"Column '{col_name}' added successfully to backup_jobs.") + except sqlite3.OperationalError as e: + print(f"Column '{col_name}' could not be added (maybe it already exists?): {e}") + + conn.commit() + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/backend/migrate_mirroring.py b/backend/migrate_mirroring.py new file mode 100644 index 0000000..9c2790f --- /dev/null +++ b/backend/migrate_mirroring.py @@ -0,0 +1,36 @@ +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db") + +def migrate(): + print(f"Connecting to {DB_PATH}...") + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Add sync_deletions to backup_jobs + try: + cursor.execute("ALTER TABLE backup_jobs ADD COLUMN sync_deletions BOOLEAN DEFAULT 0 NOT NULL") + print("Column 'sync_deletions' added to backup_jobs.") + except sqlite3.OperationalError as e: + print(f"Could not add 'sync_deletions' (maybe it already exists?): {e}") + + # Add client_relative_path to backup_sessions + try: + cursor.execute("ALTER TABLE backup_sessions ADD COLUMN client_relative_path VARCHAR(1024) NULL") + print("Column 'client_relative_path' added to backup_sessions.") + except sqlite3.OperationalError as e: + print(f"Could not add 'client_relative_path' to backup_sessions (maybe it already exists?): {e}") + + # Add client_relative_path to backup_files + try: + cursor.execute("ALTER TABLE backup_files ADD COLUMN client_relative_path VARCHAR(1024) DEFAULT '' NOT NULL") + print("Column 'client_relative_path' added to backup_files.") + except sqlite3.OperationalError as e: + print(f"Could not add 'client_relative_path' to backup_files (maybe it already exists?): {e}") + + conn.commit() + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/frontend/src/pages/JobsView.tsx b/frontend/src/pages/JobsView.tsx index f118c8c..8dd8f41 100644 --- a/frontend/src/pages/JobsView.tsx +++ b/frontend/src/pages/JobsView.tsx @@ -31,6 +31,11 @@ export const JobsView: React.FC = ({ jobs, clients, onRefresh, se keep_weekly: 4, keep_monthly: 12, min_stable_time_seconds: 60, + exclude_patterns: '', + skip_hidden: false, + skip_system: false, + skip_readonly: false, + sync_deletions: false, }); const handleCreateJob = async (e: React.FormEvent) => { @@ -103,6 +108,11 @@ export const JobsView: React.FC = ({ jobs, clients, onRefresh, se keep_weekly: settings?.default_keep_weekly ?? 4, keep_monthly: settings?.default_keep_monthly ?? 12, min_stable_time_seconds: 60, + exclude_patterns: '', + skip_hidden: false, + skip_system: false, + skip_readonly: false, + sync_deletions: false, }); setShowModal(true); }} @@ -136,7 +146,14 @@ export const JobsView: React.FC = ({ jobs, clients, onRefresh, se {job.job_code} - {job.name} + + {job.name} + {job.sync_deletions && ( + + Réplica + + )} + {getClientName(job.client_id)} @@ -257,7 +274,7 @@ export const JobsView: React.FC = ({ jobs, clients, onRefresh, se />
- + = ({ jobs, clients, onRefresh, se
+
+

+ Filtros Avanzados (Estilo Karen's Replicator) +

+
+
+ + setFormData({ ...formData, exclude_patterns: e.target.value })} + /> +
+
+
+ + + +
+
+ +
+
+
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 46447c2..d1f631c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -43,6 +43,11 @@ export interface BackupJobItem { name: string; source_path: string; file_patterns: string; + exclude_patterns: string; + skip_hidden: boolean; + skip_system: boolean; + skip_readonly: boolean; + sync_deletions: boolean; schedule_cron: string; is_active: boolean; keep_daily: number; @@ -190,6 +195,11 @@ export const api = { name: string; source_path: string; file_patterns: string; + exclude_patterns: string; + skip_hidden: boolean; + skip_system: boolean; + skip_readonly: boolean; + sync_deletions: boolean; schedule_cron: string; keep_daily: number; keep_weekly: number; diff --git a/windows-agent/agent/config.py b/windows-agent/agent/config.py index 425a11f..d6ea4be 100644 --- a/windows-agent/agent/config.py +++ b/windows-agent/agent/config.py @@ -21,6 +21,11 @@ class LocalFolderJob(BaseModel): name: str source_path: str file_patterns: str = "*.bak,*.mdf" + exclude_patterns: str = "" + skip_hidden: bool = False + skip_system: bool = False + skip_readonly: bool = False + sync_deletions: bool = False schedule_cron: str = "daily" schedule_interval_minutes: int = 60 min_stable_seconds: int = 60 diff --git a/windows-agent/agent/scanner.py b/windows-agent/agent/scanner.py index d86d720..c6c2136 100644 --- a/windows-agent/agent/scanner.py +++ b/windows-agent/agent/scanner.py @@ -45,12 +45,28 @@ def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval except Exception: return False +import platform +import stat + class DirectoryScanner: """Scans Windows source paths for files matching specific backup patterns.""" - def __init__(self, source_path: str, file_patterns: str = "*.bak,*.mdf", min_stable_seconds: int = 60): + def __init__( + self, + source_path: str, + file_patterns: str = "*.bak,*.mdf", + exclude_patterns: str = "", + skip_hidden: bool = False, + skip_system: bool = False, + skip_readonly: bool = False, + min_stable_seconds: int = 60 + ): self.source_path = Path(source_path).resolve() self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()] + self.exclude_patterns = [p.strip() for p in exclude_patterns.split(",") if p.strip()] + self.skip_hidden = skip_hidden + self.skip_system = skip_system + self.skip_readonly = skip_readonly self.min_stable_seconds = min_stable_seconds def scan(self) -> List[Path]: @@ -61,15 +77,20 @@ class DirectoryScanner: matched_files: List[Path] = [] if self.source_path.is_file(): - if self._matches_patterns(self.source_path.name): - matched_files.append(self.source_path) + if self._matches_patterns(self.source_path.name) and not self._matches_exclude(self.source_path.name): + if not self._should_skip_by_attributes(self.source_path): + matched_files.append(self.source_path) return matched_files - for root, _, files in os.walk(self.source_path): + for root, dirs, files in os.walk(self.source_path): + # Prune excluded directories from search in-place + dirs[:] = [d for d in dirs if not self._matches_exclude(d)] + for file in files: - if self._matches_patterns(file): + if self._matches_patterns(file) and not self._matches_exclude(file): full_path = Path(root) / file - matched_files.append(full_path) + if not self._should_skip_by_attributes(full_path): + matched_files.append(full_path) return matched_files @@ -80,3 +101,29 @@ class DirectoryScanner: if fnmatch.fnmatch(filename.lower(), pattern.lower()): return True return False + + def _matches_exclude(self, filename: str) -> bool: + for pattern in self.exclude_patterns: + if fnmatch.fnmatch(filename.lower(), pattern.lower()): + return True + return False + + def _should_skip_by_attributes(self, filepath: Path) -> bool: + if platform.system() != "Windows": + return False + + try: + st = filepath.stat() + attrs = getattr(st, "st_file_attributes", 0) + if not attrs: + return False + + if self.skip_readonly and (attrs & 0x01): # FILE_ATTRIBUTE_READONLY + return True + if self.skip_hidden and (attrs & 0x02): # FILE_ATTRIBUTE_HIDDEN + return True + if self.skip_system and (attrs & 0x04): # FILE_ATTRIBUTE_SYSTEM + return True + except Exception: + pass + return False diff --git a/windows-agent/agent/service.py b/windows-agent/agent/service.py index 8d8e5fa..1d1c413 100644 --- a/windows-agent/agent/service.py +++ b/windows-agent/agent/service.py @@ -167,6 +167,11 @@ class AgentDaemon: lj.name = sj.get("name", lj.name) lj.source_path = sj.get("source_path", lj.source_path) lj.file_patterns = sj.get("file_patterns", lj.file_patterns) + lj.exclude_patterns = sj.get("exclude_patterns", lj.exclude_patterns) + lj.skip_hidden = sj.get("skip_hidden", lj.skip_hidden) + lj.skip_system = sj.get("skip_system", lj.skip_system) + lj.skip_readonly = sj.get("skip_readonly", lj.skip_readonly) + lj.sync_deletions = sj.get("sync_deletions", lj.sync_deletions) lj.schedule_cron = sj.get("schedule_cron", lj.schedule_cron) lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds) @@ -191,6 +196,11 @@ class AgentDaemon: name=sj["name"], source_path=sj["source_path"], file_patterns=sj["file_patterns"], + exclude_patterns=sj.get("exclude_patterns", ""), + skip_hidden=sj.get("skip_hidden", False), + skip_system=sj.get("skip_system", False), + skip_readonly=sj.get("skip_readonly", False), + sync_deletions=sj.get("sync_deletions", False), schedule_cron=sj["schedule_cron"], min_stable_seconds=sj["min_stable_time_seconds"], last_status=sj.get("status", "En espera") @@ -209,6 +219,11 @@ class AgentDaemon: "name": lj.name, "source_path": lj.source_path, "file_patterns": lj.file_patterns, + "exclude_patterns": lj.exclude_patterns, + "skip_hidden": lj.skip_hidden, + "skip_system": lj.skip_system, + "skip_readonly": lj.skip_readonly, + "sync_deletions": lj.sync_deletions, "schedule_cron": lj.schedule_cron, "min_stable_time_seconds": lj.min_stable_seconds } @@ -243,11 +258,26 @@ class AgentDaemon: logger.warning(f"Source path {source_path} for job {job_name} does not exist. Skipping.") continue - scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable) + scanner = DirectoryScanner( + source_path=source_path, + file_patterns=file_patterns, + exclude_patterns=getattr(job, "exclude_patterns", ""), + skip_hidden=getattr(job, "skip_hidden", False), + skip_system=getattr(job, "skip_system", False), + skip_readonly=getattr(job, "skip_readonly", False), + min_stable_seconds=min_stable + ) files = scanner.scan() # Track files successfully backed up in this run + active_relative_paths = [] for filepath in files: + try: + rel_p = str(filepath.relative_to(Path(source_path).resolve())).replace("\\", "/") + active_relative_paths.append(rel_p) + except Exception: + pass + if not is_file_stable(filepath, min_stable_seconds=min_stable): logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.") continue @@ -270,6 +300,7 @@ class AgentDaemon: res = self.uploader.upload_file( filepath, job_id=job.job_id, + source_path=source_path, progress_callback=on_chunk_progress ) logger.info(f"Successfully backed up {filepath.name}!") @@ -284,6 +315,27 @@ class AgentDaemon: if self.on_error: self.on_error(filepath.name, str(ex)) + # Enforce mirror replica mode (purge orphans on server) + if job.sync_deletions and job.job_id is not None: + try: + base_url = self.config.server_url.rstrip("/") + purge_payload = { + "active_relative_paths": active_relative_paths + } + resp = httpx.post( + f"{base_url}/api/jobs/{job.job_id}/purge-orphans", + headers=self._get_headers(), + json=purge_payload, + timeout=30.0 + ) + if resp.status_code == 200: + purge_res = resp.json() + purged_count = purge_res.get("purged_count", 0) + if purged_count > 0: + logger.info(f"Purged {purged_count} orphan files from server for job '{job_name}'") + except Exception as e: + logger.error(f"Error purging orphan files from server: {e}") + # Update job state in config after checking directory job.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") job.last_status = "Backup Exitoso" if job.last_status != "Error" else "Error" diff --git a/windows-agent/agent/uploader.py b/windows-agent/agent/uploader.py index c3d037e..d2d2523 100644 --- a/windows-agent/agent/uploader.py +++ b/windows-agent/agent/uploader.py @@ -25,6 +25,7 @@ class ChunkUploader: self, filepath: Path, job_id: Optional[int] = None, + source_path: Optional[str] = None, progress_callback: Optional[Callable[[int, int, float], None]] = None, max_retries: int = 5 ) -> Dict[str, Any]: @@ -37,6 +38,14 @@ class ChunkUploader: full_sha256 = chunker.full_sha256 filename = filepath.name + client_relative_path = filename + if source_path: + try: + resolved_src = Path(source_path).resolve() + client_relative_path = str(filepath.relative_to(resolved_src)).replace("\\", "/") + except Exception: + pass + headers = self._get_headers() base_url = self.config.server_url.rstrip("/") @@ -44,6 +53,7 @@ class ChunkUploader: # 1. Initialize or resume upload session init_payload = { "filename": filename, + "client_relative_path": client_relative_path, "file_size": chunker.file_size, "sha256": full_sha256, "chunk_size": chunker.chunk_size, diff --git a/windows-agent/agent_app_pyqt.py b/windows-agent/agent_app_pyqt.py index 758b2c8..5db8e80 100644 --- a/windows-agent/agent_app_pyqt.py +++ b/windows-agent/agent_app_pyqt.py @@ -210,7 +210,7 @@ class AddFolderDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Añadir Carpeta de Backup — OnEver Drive") - self.resize(500, 320) + self.resize(500, 470) self.setStyleSheet(DARK_QSS) layout = QVBoxLayout(self) @@ -236,6 +236,10 @@ class AddFolderDialog(QDialog): self.txt_patterns.setText("*.bak,*.mdf") form.addRow("Filtros de archivo:", self.txt_patterns) + self.txt_exclude_patterns = QLineEdit() + self.txt_exclude_patterns.setPlaceholderText("Ej: *.tmp, *.log, node_modules") + form.addRow("Filtros de exclusión:", self.txt_exclude_patterns) + self.cmb_schedule = QComboBox() self.cmb_schedule.addItems(["hourly", "daily", "weekly", "monthly"]) self.cmb_schedule.setCurrentText("daily") @@ -247,6 +251,24 @@ class AddFolderDialog(QDialog): self.spin_stable.setSuffix(" seg") form.addRow("Estabilidad de archivo (Locks):", self.spin_stable) + # File attribute skip checkboxes (Karen's Replicator style) + self.chk_skip_hidden = QCheckBox("Omitir archivos ocultos (Hidden)") + self.chk_skip_hidden.setChecked(False) + form.addRow("", self.chk_skip_hidden) + + self.chk_skip_system = QCheckBox("Omitir archivos de sistema (System)") + self.chk_skip_system.setChecked(False) + form.addRow("", self.chk_skip_system) + + self.chk_skip_readonly = QCheckBox("Omitir archivos de solo lectura (Read-only)") + self.chk_skip_readonly.setChecked(False) + form.addRow("", self.chk_skip_readonly) + + # Mirror sync checkbox (Phase 2) + self.chk_sync_deletions = QCheckBox("Modo Réplica Exacta (Eliminar en destino si no existe en origen)") + self.chk_sync_deletions.setChecked(False) + form.addRow("", self.chk_sync_deletions) + layout.addLayout(form) btn_layout = QHBoxLayout() @@ -281,6 +303,11 @@ class AddFolderDialog(QDialog): name=self.txt_name.text().strip(), source_path=self.txt_path.text().strip(), file_patterns=self.txt_patterns.text().strip() or "*.*", + exclude_patterns=self.txt_exclude_patterns.text().strip(), + skip_hidden=self.chk_skip_hidden.isChecked(), + skip_system=self.chk_skip_system.isChecked(), + skip_readonly=self.chk_skip_readonly.isChecked(), + sync_deletions=self.chk_sync_deletions.isChecked(), schedule_cron=self.cmb_schedule.currentText(), min_stable_seconds=self.spin_stable.value() )