feat: implementar Fase 2 del roadmap (Modo Replica Exacta / Mirroring)
This commit is contained in:
+83
-2
@@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, func, desc
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.models import User, Client, BackupJob
|
from app.models.models import User, Client, BackupJob, BackupFile
|
||||||
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister
|
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.api.deps import get_current_user, require_admin, get_current_client
|
||||||
from app.services.event_service import log_event
|
from app.services.event_service import log_event
|
||||||
from app.ws.manager import ws_manager
|
from app.ws.manager import ws_manager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
router = APIRouter(prefix="/jobs", tags=["Backup Jobs"])
|
router = APIRouter(prefix="/jobs", tags=["Backup Jobs"])
|
||||||
|
|
||||||
@@ -53,6 +56,11 @@ async def agent_register_job(
|
|||||||
name=payload.name,
|
name=payload.name,
|
||||||
source_path=payload.source_path,
|
source_path=payload.source_path,
|
||||||
file_patterns=payload.file_patterns,
|
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,
|
schedule_cron=payload.schedule_cron,
|
||||||
keep_daily=payload.keep_daily,
|
keep_daily=payload.keep_daily,
|
||||||
keep_weekly=payload.keep_weekly,
|
keep_weekly=payload.keep_weekly,
|
||||||
@@ -126,6 +134,11 @@ async def create_job(
|
|||||||
name=payload.name,
|
name=payload.name,
|
||||||
source_path=payload.source_path,
|
source_path=payload.source_path,
|
||||||
file_patterns=payload.file_patterns,
|
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,
|
schedule_cron=payload.schedule_cron,
|
||||||
keep_daily=payload.keep_daily,
|
keep_daily=payload.keep_daily,
|
||||||
keep_weekly=payload.keep_weekly,
|
keep_weekly=payload.keep_weekly,
|
||||||
@@ -241,3 +254,71 @@ async def trigger_job(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {"message": f"Job {job.job_code} triggered"}
|
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."
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ async def init_session(
|
|||||||
file_size=payload.file_size,
|
file_size=payload.file_size,
|
||||||
sha256_full=payload.sha256,
|
sha256_full=payload.sha256,
|
||||||
chunk_size=payload.chunk_size,
|
chunk_size=payload.chunk_size,
|
||||||
job_id=payload.job_id
|
job_id=payload.job_id,
|
||||||
|
client_relative_path=payload.client_relative_path
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -81,7 +81,14 @@ class BackupJob(Base):
|
|||||||
file_patterns = Column(String(255), default="*.bak,*.mdf", nullable=False)
|
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
|
schedule_cron = Column(String(100), default="0 2 * * *", nullable=False) # default 02:00 AM daily
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
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
|
# Retention policies
|
||||||
keep_daily = Column(Integer, default=7, nullable=False)
|
keep_daily = Column(Integer, default=7, nullable=False)
|
||||||
keep_weekly = Column(Integer, default=4, 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)
|
job_id = Column(Integer, ForeignKey("backup_jobs.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
|
||||||
filename = Column(String(512), nullable=False)
|
filename = Column(String(512), nullable=False)
|
||||||
|
client_relative_path = Column(String(1024), nullable=True)
|
||||||
file_size = Column(BigInteger, nullable=False)
|
file_size = Column(BigInteger, nullable=False)
|
||||||
chunk_size = Column(Integer, default=4 * 1024 * 1024, nullable=False)
|
chunk_size = Column(Integer, default=4 * 1024 * 1024, nullable=False)
|
||||||
total_chunks = Column(Integer, nullable=False)
|
total_chunks = Column(Integer, nullable=False)
|
||||||
@@ -152,6 +160,7 @@ class BackupFile(Base):
|
|||||||
|
|
||||||
filename = Column(String(512), nullable=False)
|
filename = Column(String(512), nullable=False)
|
||||||
relative_path = Column(String(1024), nullable=False)
|
relative_path = Column(String(1024), nullable=False)
|
||||||
|
client_relative_path = Column(String(1024), default="", nullable=False)
|
||||||
file_size = Column(BigInteger, nullable=False)
|
file_size = Column(BigInteger, nullable=False)
|
||||||
sha256 = Column(String(64), nullable=False)
|
sha256 = Column(String(64), nullable=False)
|
||||||
retention_tag = Column(String(50), default="DAILY", nullable=False) # DAILY, WEEKLY, MONTHLY, MANUAL
|
retention_tag = Column(String(50), default="DAILY", nullable=False) # DAILY, WEEKLY, MONTHLY, MANUAL
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ class JobCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
source_path: str
|
source_path: str
|
||||||
file_patterns: str = "*.bak,*.mdf"
|
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 * * *"
|
schedule_cron: str = "0 2 * * *"
|
||||||
keep_daily: int = 7
|
keep_daily: int = 7
|
||||||
keep_weekly: int = 4
|
keep_weekly: int = 4
|
||||||
@@ -92,6 +97,11 @@ class JobUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
source_path: Optional[str] = None
|
source_path: Optional[str] = None
|
||||||
file_patterns: 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
|
schedule_cron: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
keep_daily: Optional[int] = None
|
keep_daily: Optional[int] = None
|
||||||
@@ -106,6 +116,11 @@ class JobResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
source_path: str
|
source_path: str
|
||||||
file_patterns: str
|
file_patterns: str
|
||||||
|
exclude_patterns: str
|
||||||
|
skip_hidden: bool
|
||||||
|
skip_system: bool
|
||||||
|
skip_readonly: bool
|
||||||
|
sync_deletions: bool
|
||||||
schedule_cron: str
|
schedule_cron: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
keep_daily: int
|
keep_daily: int
|
||||||
@@ -122,6 +137,7 @@ class JobResponse(BaseModel):
|
|||||||
# --- Upload Session & Chunk Schemas ---
|
# --- Upload Session & Chunk Schemas ---
|
||||||
class UploadSessionInitRequest(BaseModel):
|
class UploadSessionInitRequest(BaseModel):
|
||||||
filename: str
|
filename: str
|
||||||
|
client_relative_path: Optional[str] = None
|
||||||
file_size: int
|
file_size: int
|
||||||
sha256: str
|
sha256: str
|
||||||
chunk_size: int = 4 * 1024 * 1024
|
chunk_size: int = 4 * 1024 * 1024
|
||||||
@@ -234,8 +250,16 @@ class AgentJobRegister(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
source_path: str
|
source_path: str
|
||||||
file_patterns: str = "*.bak,*.mdf"
|
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_cron: str = "daily"
|
||||||
keep_daily: int = 7
|
keep_daily: int = 7
|
||||||
keep_weekly: int = 4
|
keep_weekly: int = 4
|
||||||
keep_monthly: int = 12
|
keep_monthly: int = 12
|
||||||
min_stable_time_seconds: int = 60
|
min_stable_time_seconds: int = 60
|
||||||
|
|
||||||
|
class PurgeOrphansRequest(BaseModel):
|
||||||
|
active_relative_paths: List[str]
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ async def create_or_resume_session(
|
|||||||
file_size: int,
|
file_size: int,
|
||||||
sha256_full: str,
|
sha256_full: str,
|
||||||
chunk_size: int = 4 * 1024 * 1024,
|
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]]:
|
) -> Tuple[BackupSession, List[int]]:
|
||||||
"""
|
"""
|
||||||
Initializes a new upload session or resumes an existing incomplete session
|
Initializes a new upload session or resumes an existing incomplete session
|
||||||
@@ -63,6 +64,8 @@ async def create_or_resume_session(
|
|||||||
# Resume existing session
|
# Resume existing session
|
||||||
received_chunks = await storage_provider.get_received_chunks(session.session_code)
|
received_chunks = await storage_provider.get_received_chunks(session.session_code)
|
||||||
session.received_chunks_count = len(received_chunks)
|
session.received_chunks_count = len(received_chunks)
|
||||||
|
if client_relative_path:
|
||||||
|
session.client_relative_path = client_relative_path
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(session)
|
await db.refresh(session)
|
||||||
return session, received_chunks
|
return session, received_chunks
|
||||||
@@ -72,6 +75,7 @@ async def create_or_resume_session(
|
|||||||
client_id=client.id,
|
client_id=client.id,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
|
client_relative_path=client_relative_path or filename,
|
||||||
file_size=file_size,
|
file_size=file_size,
|
||||||
chunk_size=chunk_size,
|
chunk_size=chunk_size,
|
||||||
total_chunks=total_chunks,
|
total_chunks=total_chunks,
|
||||||
@@ -242,6 +246,7 @@ async def complete_session(
|
|||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
filename=session.filename,
|
filename=session.filename,
|
||||||
relative_path=rel_path,
|
relative_path=rel_path,
|
||||||
|
client_relative_path=session.client_relative_path or session.filename,
|
||||||
file_size=total_bytes,
|
file_size=total_bytes,
|
||||||
sha256=final_sha256,
|
sha256=final_sha256,
|
||||||
retention_tag="DAILY",
|
retention_tag="DAILY",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -31,6 +31,11 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
|
|||||||
keep_weekly: 4,
|
keep_weekly: 4,
|
||||||
keep_monthly: 12,
|
keep_monthly: 12,
|
||||||
min_stable_time_seconds: 60,
|
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) => {
|
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_weekly: settings?.default_keep_weekly ?? 4,
|
||||||
keep_monthly: settings?.default_keep_monthly ?? 12,
|
keep_monthly: settings?.default_keep_monthly ?? 12,
|
||||||
min_stable_time_seconds: 60,
|
min_stable_time_seconds: 60,
|
||||||
|
exclude_patterns: '',
|
||||||
|
skip_hidden: false,
|
||||||
|
skip_system: false,
|
||||||
|
skip_readonly: false,
|
||||||
|
sync_deletions: false,
|
||||||
});
|
});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
@@ -136,7 +146,14 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
|
|||||||
{job.job_code}
|
{job.job_code}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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)' }}>
|
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
|
||||||
{getClientName(job.client_id)}
|
{getClientName(job.client_id)}
|
||||||
</td>
|
</td>
|
||||||
@@ -257,7 +274,7 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Filtros de Archivo:</label>
|
<label>Filtros de Archivo (Inclusión):</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@@ -294,6 +311,60 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="form-group">
|
||||||
<label>Políticas de Retención (Conservar copias):</label>
|
<label>Políticas de Retención (Conservar copias):</label>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export interface BackupJobItem {
|
|||||||
name: string;
|
name: string;
|
||||||
source_path: string;
|
source_path: string;
|
||||||
file_patterns: string;
|
file_patterns: string;
|
||||||
|
exclude_patterns: string;
|
||||||
|
skip_hidden: boolean;
|
||||||
|
skip_system: boolean;
|
||||||
|
skip_readonly: boolean;
|
||||||
|
sync_deletions: boolean;
|
||||||
schedule_cron: string;
|
schedule_cron: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
keep_daily: number;
|
keep_daily: number;
|
||||||
@@ -190,6 +195,11 @@ export const api = {
|
|||||||
name: string;
|
name: string;
|
||||||
source_path: string;
|
source_path: string;
|
||||||
file_patterns: string;
|
file_patterns: string;
|
||||||
|
exclude_patterns: string;
|
||||||
|
skip_hidden: boolean;
|
||||||
|
skip_system: boolean;
|
||||||
|
skip_readonly: boolean;
|
||||||
|
sync_deletions: boolean;
|
||||||
schedule_cron: string;
|
schedule_cron: string;
|
||||||
keep_daily: number;
|
keep_daily: number;
|
||||||
keep_weekly: number;
|
keep_weekly: number;
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class LocalFolderJob(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
source_path: str
|
source_path: str
|
||||||
file_patterns: str = "*.bak,*.mdf"
|
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_cron: str = "daily"
|
||||||
schedule_interval_minutes: int = 60
|
schedule_interval_minutes: int = 60
|
||||||
min_stable_seconds: int = 60
|
min_stable_seconds: int = 60
|
||||||
|
|||||||
@@ -45,12 +45,28 @@ def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
import platform
|
||||||
|
import stat
|
||||||
|
|
||||||
class DirectoryScanner:
|
class DirectoryScanner:
|
||||||
"""Scans Windows source paths for files matching specific backup patterns."""
|
"""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.source_path = Path(source_path).resolve()
|
||||||
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
|
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
|
self.min_stable_seconds = min_stable_seconds
|
||||||
|
|
||||||
def scan(self) -> List[Path]:
|
def scan(self) -> List[Path]:
|
||||||
@@ -61,15 +77,20 @@ class DirectoryScanner:
|
|||||||
matched_files: List[Path] = []
|
matched_files: List[Path] = []
|
||||||
|
|
||||||
if self.source_path.is_file():
|
if self.source_path.is_file():
|
||||||
if self._matches_patterns(self.source_path.name):
|
if self._matches_patterns(self.source_path.name) and not self._matches_exclude(self.source_path.name):
|
||||||
matched_files.append(self.source_path)
|
if not self._should_skip_by_attributes(self.source_path):
|
||||||
|
matched_files.append(self.source_path)
|
||||||
return matched_files
|
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:
|
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
|
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
|
return matched_files
|
||||||
|
|
||||||
@@ -80,3 +101,29 @@ class DirectoryScanner:
|
|||||||
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
|
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
|
||||||
return True
|
return True
|
||||||
return False
|
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
|
||||||
|
|||||||
@@ -167,6 +167,11 @@ class AgentDaemon:
|
|||||||
lj.name = sj.get("name", lj.name)
|
lj.name = sj.get("name", lj.name)
|
||||||
lj.source_path = sj.get("source_path", lj.source_path)
|
lj.source_path = sj.get("source_path", lj.source_path)
|
||||||
lj.file_patterns = sj.get("file_patterns", lj.file_patterns)
|
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.schedule_cron = sj.get("schedule_cron", lj.schedule_cron)
|
||||||
lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds)
|
lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds)
|
||||||
|
|
||||||
@@ -191,6 +196,11 @@ class AgentDaemon:
|
|||||||
name=sj["name"],
|
name=sj["name"],
|
||||||
source_path=sj["source_path"],
|
source_path=sj["source_path"],
|
||||||
file_patterns=sj["file_patterns"],
|
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"],
|
schedule_cron=sj["schedule_cron"],
|
||||||
min_stable_seconds=sj["min_stable_time_seconds"],
|
min_stable_seconds=sj["min_stable_time_seconds"],
|
||||||
last_status=sj.get("status", "En espera")
|
last_status=sj.get("status", "En espera")
|
||||||
@@ -209,6 +219,11 @@ class AgentDaemon:
|
|||||||
"name": lj.name,
|
"name": lj.name,
|
||||||
"source_path": lj.source_path,
|
"source_path": lj.source_path,
|
||||||
"file_patterns": lj.file_patterns,
|
"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,
|
"schedule_cron": lj.schedule_cron,
|
||||||
"min_stable_time_seconds": lj.min_stable_seconds
|
"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.")
|
logger.warning(f"Source path {source_path} for job {job_name} does not exist. Skipping.")
|
||||||
continue
|
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()
|
files = scanner.scan()
|
||||||
|
|
||||||
# Track files successfully backed up in this run
|
# Track files successfully backed up in this run
|
||||||
|
active_relative_paths = []
|
||||||
for filepath in files:
|
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):
|
if not is_file_stable(filepath, min_stable_seconds=min_stable):
|
||||||
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
||||||
continue
|
continue
|
||||||
@@ -270,6 +300,7 @@ class AgentDaemon:
|
|||||||
res = self.uploader.upload_file(
|
res = self.uploader.upload_file(
|
||||||
filepath,
|
filepath,
|
||||||
job_id=job.job_id,
|
job_id=job.job_id,
|
||||||
|
source_path=source_path,
|
||||||
progress_callback=on_chunk_progress
|
progress_callback=on_chunk_progress
|
||||||
)
|
)
|
||||||
logger.info(f"Successfully backed up {filepath.name}!")
|
logger.info(f"Successfully backed up {filepath.name}!")
|
||||||
@@ -284,6 +315,27 @@ class AgentDaemon:
|
|||||||
if self.on_error:
|
if self.on_error:
|
||||||
self.on_error(filepath.name, str(ex))
|
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
|
# 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_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"
|
job.last_status = "Backup Exitoso" if job.last_status != "Error" else "Error"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class ChunkUploader:
|
|||||||
self,
|
self,
|
||||||
filepath: Path,
|
filepath: Path,
|
||||||
job_id: Optional[int] = None,
|
job_id: Optional[int] = None,
|
||||||
|
source_path: Optional[str] = None,
|
||||||
progress_callback: Optional[Callable[[int, int, float], None]] = None,
|
progress_callback: Optional[Callable[[int, int, float], None]] = None,
|
||||||
max_retries: int = 5
|
max_retries: int = 5
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -37,6 +38,14 @@ class ChunkUploader:
|
|||||||
full_sha256 = chunker.full_sha256
|
full_sha256 = chunker.full_sha256
|
||||||
filename = filepath.name
|
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()
|
headers = self._get_headers()
|
||||||
base_url = self.config.server_url.rstrip("/")
|
base_url = self.config.server_url.rstrip("/")
|
||||||
|
|
||||||
@@ -44,6 +53,7 @@ class ChunkUploader:
|
|||||||
# 1. Initialize or resume upload session
|
# 1. Initialize or resume upload session
|
||||||
init_payload = {
|
init_payload = {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
|
"client_relative_path": client_relative_path,
|
||||||
"file_size": chunker.file_size,
|
"file_size": chunker.file_size,
|
||||||
"sha256": full_sha256,
|
"sha256": full_sha256,
|
||||||
"chunk_size": chunker.chunk_size,
|
"chunk_size": chunker.chunk_size,
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ class AddFolderDialog(QDialog):
|
|||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Añadir Carpeta de Backup — OnEver Drive")
|
self.setWindowTitle("Añadir Carpeta de Backup — OnEver Drive")
|
||||||
self.resize(500, 320)
|
self.resize(500, 470)
|
||||||
self.setStyleSheet(DARK_QSS)
|
self.setStyleSheet(DARK_QSS)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
@@ -236,6 +236,10 @@ class AddFolderDialog(QDialog):
|
|||||||
self.txt_patterns.setText("*.bak,*.mdf")
|
self.txt_patterns.setText("*.bak,*.mdf")
|
||||||
form.addRow("Filtros de archivo:", self.txt_patterns)
|
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 = QComboBox()
|
||||||
self.cmb_schedule.addItems(["hourly", "daily", "weekly", "monthly"])
|
self.cmb_schedule.addItems(["hourly", "daily", "weekly", "monthly"])
|
||||||
self.cmb_schedule.setCurrentText("daily")
|
self.cmb_schedule.setCurrentText("daily")
|
||||||
@@ -247,6 +251,24 @@ class AddFolderDialog(QDialog):
|
|||||||
self.spin_stable.setSuffix(" seg")
|
self.spin_stable.setSuffix(" seg")
|
||||||
form.addRow("Estabilidad de archivo (Locks):", self.spin_stable)
|
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)
|
layout.addLayout(form)
|
||||||
|
|
||||||
btn_layout = QHBoxLayout()
|
btn_layout = QHBoxLayout()
|
||||||
@@ -281,6 +303,11 @@ class AddFolderDialog(QDialog):
|
|||||||
name=self.txt_name.text().strip(),
|
name=self.txt_name.text().strip(),
|
||||||
source_path=self.txt_path.text().strip(),
|
source_path=self.txt_path.text().strip(),
|
||||||
file_patterns=self.txt_patterns.text().strip() or "*.*",
|
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(),
|
schedule_cron=self.cmb_schedule.currentText(),
|
||||||
min_stable_seconds=self.spin_stable.value()
|
min_stable_seconds=self.spin_stable.value()
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user