feat: implementar Fase 2 del roadmap (Modo Replica Exacta / Mirroring)

This commit is contained in:
Carlos Tello
2026-08-14 21:50:00 -03:00
parent a72cf1480e
commit a1cafff90e
14 changed files with 421 additions and 14 deletions
+83 -2
View File
@@ -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."
}
+2 -1
View File
@@ -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 {
+9
View File
@@ -81,6 +81,13 @@ 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)
@@ -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
+24
View File
@@ -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]
+6 -1
View File
@@ -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",
+29
View File
@@ -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()
+36
View File
@@ -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()
+73 -2
View File
@@ -31,6 +31,11 @@ export const JobsView: React.FC<JobsViewProps> = ({ 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<JobsViewProps> = ({ 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<JobsViewProps> = ({ jobs, clients, onRefresh, se
{job.job_code}
</span>
</td>
<td style={{ fontWeight: 600 }}>{job.name}</td>
<td style={{ fontWeight: 600 }}>
{job.name}
{job.sync_deletions && (
<span className="badge" style={{ backgroundColor: 'rgba(6, 182, 212, 0.15)', color: 'var(--accent-cyan)', marginLeft: '8px', fontSize: '0.7rem', padding: '2px 6px', border: '1px solid rgba(6, 182, 212, 0.3)' }}>
Réplica
</span>
)}
</td>
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
{getClientName(job.client_id)}
</td>
@@ -257,7 +274,7 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
/>
</div>
<div className="form-group">
<label>Filtros de Archivo:</label>
<label>Filtros de Archivo (Inclusión):</label>
<input
type="text"
required
@@ -294,6 +311,60 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
</div>
</div>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', marginTop: '12px' }}>
<h4 style={{ fontSize: '0.9rem', fontWeight: 600, color: 'var(--accent-cyan)', marginBottom: '12px' }}>
Filtros Avanzados (Estilo Karen's Replicator)
</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '12px', marginBottom: '12px' }}>
<div className="form-group">
<label>Filtros de Exclusión (Separados por coma):</label>
<input
type="text"
className="form-input"
placeholder="Ej: *.tmp, *.log, node_modules, obj"
value={formData.exclude_patterns}
onChange={(e) => setFormData({ ...formData, exclude_patterns: e.target.value })}
/>
</div>
</div>
<div style={{ display: 'flex', gap: '18px', marginBottom: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.84rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.skip_hidden}
onChange={(e) => setFormData({ ...formData, skip_hidden: e.target.checked })}
/>
Omitir Archivos Ocultos
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.84rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.skip_system}
onChange={(e) => setFormData({ ...formData, skip_system: e.target.checked })}
/>
Omitir Archivos de Sistema
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.84rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.skip_readonly}
onChange={(e) => setFormData({ ...formData, skip_readonly: e.target.checked })}
/>
Omitir Solo Lectura
</label>
</div>
<div style={{ display: 'flex', gap: '18px', marginBottom: '16px', borderTop: '1px dashed rgba(255,255,255,0.08)', paddingTop: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.84rem', cursor: 'pointer', color: 'var(--accent-cyan)' }}>
<input
type="checkbox"
checked={formData.sync_deletions}
onChange={(e) => setFormData({ ...formData, sync_deletions: e.target.checked })}
/>
Modo Réplica Exacta (Eliminar en destino si se elimina en origen)
</label>
</div>
</div>
<div className="form-group">
<label>Políticas de Retención (Conservar copias):</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
+10
View File
@@ -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;
+5
View File
@@ -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
+53 -6
View File
@@ -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
+53 -1
View File
@@ -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"
+10
View File
@@ -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,
+28 -1
View File
@@ -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()
)