Compare commits
8 Commits
018ea9172a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e67a09621 | |||
| cecb1809e6 | |||
| 44bae4552d | |||
| 1563de74c5 | |||
| 2915cbbb80 | |||
| b86408e7e6 | |||
| 994b25fc82 | |||
| 69e5990db0 |
@@ -100,9 +100,11 @@ exe = EXE(
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
icon='icon.ico',
|
||||
)
|
||||
|
||||
@@ -219,49 +219,60 @@ class BackupStrategy(ABC):
|
||||
def is_cancelled(self):
|
||||
return self._cancel_flag
|
||||
|
||||
def cleanup_old_backups(self, dest, retention_days):
|
||||
if retention_days <= 0:
|
||||
def cleanup_old_backups(self, target_folder, retention_limit):
|
||||
"""
|
||||
Count-Based Retention (Scoped):
|
||||
Keep 'retention_limit' newest items (files/folders) in 'target_folder'.
|
||||
Do NOT scan recursively. Do NOT look at other date folders.
|
||||
"""
|
||||
if retention_limit <= 0:
|
||||
return
|
||||
|
||||
self.log("Menjalankan pembersihan (Retensi: {} hari)...".format(retention_days))
|
||||
now = time.time()
|
||||
cutoff = now - (retention_days * 86400)
|
||||
if not os.path.exists(target_folder):
|
||||
return
|
||||
|
||||
self.log("Menjalankan pembersihan di {} (Batasan: {} item terbaru)...".format(os.path.basename(target_folder), retention_limit))
|
||||
|
||||
if not os.path.exists(dest):
|
||||
return
|
||||
all_backups = []
|
||||
|
||||
count = 0
|
||||
try:
|
||||
# Recursive scan for YYYY/MM/DD structure
|
||||
for root, dirs, files in os.walk(dest):
|
||||
for filename in files:
|
||||
filepath = os.path.join(root, filename)
|
||||
try:
|
||||
# Check modified time
|
||||
file_time = os.path.getmtime(filepath)
|
||||
if file_time < cutoff:
|
||||
os.remove(filepath)
|
||||
self.log("Menghapus backup lama: {}".format(filename))
|
||||
count += 1
|
||||
except Exception as e:
|
||||
self.log("Gagal menghapus {}: {}".format(filename, e))
|
||||
# 1. Collect all items in this SPECIFIC folder only (Non-recursive)
|
||||
# Python 3.4 compatible (os.scandir came in 3.5)
|
||||
for entry in os.listdir(target_folder):
|
||||
try:
|
||||
full_path = os.path.join(target_folder, entry)
|
||||
# Get modified time
|
||||
mtime = os.path.getmtime(full_path)
|
||||
all_backups.append((full_path, mtime))
|
||||
except:
|
||||
pass
|
||||
|
||||
# Optional: Remove empty folders after cleanup
|
||||
for root, dirs, files in os.walk(dest, topdown=False):
|
||||
for name in dirs:
|
||||
d_path = os.path.join(root, name)
|
||||
# 2. Sort by time DESCENDING (Newest first)
|
||||
all_backups.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# 3. Check if we have excess
|
||||
if len(all_backups) > retention_limit:
|
||||
items_to_delete = all_backups[retention_limit:]
|
||||
count = 0
|
||||
|
||||
for path, _ in items_to_delete:
|
||||
try:
|
||||
if not os.listdir(d_path): # Check if empty
|
||||
os.rmdir(d_path)
|
||||
except: pass
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.remove(path)
|
||||
self.log("Pruning item lama: {}".format(os.path.basename(path)))
|
||||
count += 1
|
||||
except Exception as e:
|
||||
self.log("Gagal menghapus {}: {}".format(os.path.basename(path), e))
|
||||
|
||||
if count > 0:
|
||||
self.log("Pembersihan selesai. Dihapus: {} item.".format(count))
|
||||
else:
|
||||
self.log("Jumlah item ({}) aman (Batas: {}).".format(len(all_backups), retention_limit))
|
||||
|
||||
except Exception as e:
|
||||
self.log("Error saat pembersihan: {}".format(e))
|
||||
|
||||
if count > 0:
|
||||
self.log("Pembersihan selesai. Menghapus {} file lama.".format(count))
|
||||
else:
|
||||
self.log("Pembersihan selesai. Tidak ada file lama ditemukan.")
|
||||
|
||||
def _get_dated_dest_dir(self, base_dest, job_name=None):
|
||||
"""Creates and returns JobName/YYYY/MM/DD subfolder path inside base_dest."""
|
||||
@@ -376,14 +387,10 @@ class FileBackup(BackupStrategy):
|
||||
dst_file = os.path.join(current_dest_dir, file)
|
||||
shutil.copy2(src_file, dst_file)
|
||||
|
||||
processed_files += 1
|
||||
if total_files > 0:
|
||||
self.progress(int(processed_files / total_files * 100))
|
||||
|
||||
self.log("Penyalinan berhasil diselesaikan.")
|
||||
|
||||
# Cleanup
|
||||
self.cleanup_old_backups(dest, config.get('retention_days', 0))
|
||||
retention_days = config.get('retention_days', 0)
|
||||
if retention_days > 0:
|
||||
self.cleanup_old_backups(target_dest, retention_days)
|
||||
self.progress(100)
|
||||
|
||||
|
||||
@@ -546,7 +553,7 @@ class MySQLBackup(BackupStrategy):
|
||||
|
||||
self.log("Backup MySQL berhasil diselesaikan.")
|
||||
self.progress(100)
|
||||
self.cleanup_old_backups(dest, config.get('retention_days', 0))
|
||||
self.cleanup_old_backups(target_dest, config.get('retention_days', 0))
|
||||
|
||||
except Exception as e:
|
||||
self.log("Gagal melakukan backup: {}".format(e))
|
||||
@@ -707,7 +714,7 @@ class PostgresBackup(BackupStrategy):
|
||||
|
||||
self.log("Backup PostgreSQL berhasil diselesaikan.")
|
||||
self.progress(100)
|
||||
self.cleanup_old_backups(dest, config.get('retention_days', 0))
|
||||
self.cleanup_old_backups(target_dest, config.get('retention_days', 0))
|
||||
|
||||
except Exception as e:
|
||||
self.log("Gagal melakukan backup: {}".format(e))
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import uuid
|
||||
import base64
|
||||
|
||||
CONFIG_FILE = "config.dat" # Obfuscated file
|
||||
import sys
|
||||
|
||||
class ConfigManager:
|
||||
DEFAULT_JOB = {
|
||||
@@ -30,6 +30,16 @@ class ConfigManager:
|
||||
"schedule_unit": "Menit"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_base_dir():
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.dirname(sys.executable)
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
@staticmethod
|
||||
def _get_config_path(filename="config.dat"):
|
||||
return os.path.join(ConfigManager._get_base_dir(), filename)
|
||||
|
||||
@staticmethod
|
||||
def create_new_job():
|
||||
job = ConfigManager.DEFAULT_JOB.copy()
|
||||
@@ -38,24 +48,27 @@ class ConfigManager:
|
||||
|
||||
@staticmethod
|
||||
def load_config():
|
||||
config_dat = ConfigManager._get_config_path("config.dat")
|
||||
config_json = ConfigManager._get_config_path("config.json")
|
||||
|
||||
# Check for old unencrypted config.json and migrate
|
||||
if os.path.exists("config.json"):
|
||||
if os.path.exists(config_json):
|
||||
try:
|
||||
with open("config.json", 'r') as f:
|
||||
with open(config_json, 'r') as f:
|
||||
data = json.load(f)
|
||||
print("Migrasi config.json...")
|
||||
config = ConfigManager._migrate_old_config(data)
|
||||
ConfigManager.save_config(config)
|
||||
os.rename("config.json", "config.json.bak")
|
||||
os.rename(config_json, config_json + ".bak")
|
||||
return config
|
||||
except Exception as e:
|
||||
print("Error migrasi config: {}".format(e))
|
||||
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
if not os.path.exists(config_dat):
|
||||
return ConfigManager.DEFAULT_CONFIG.copy()
|
||||
|
||||
try:
|
||||
with open(CONFIG_FILE, 'rb') as f:
|
||||
with open(config_dat, 'rb') as f:
|
||||
encoded_data = f.read()
|
||||
|
||||
# Simple Base64 Decode
|
||||
@@ -113,7 +126,8 @@ class ConfigManager:
|
||||
# Simple Base64 Encode
|
||||
encoded_data = base64.b64encode(json_data.encode('utf-8'))
|
||||
|
||||
with open(CONFIG_FILE, 'wb') as f:
|
||||
config_path = ConfigManager._get_config_path("config.dat")
|
||||
with open(config_path, 'wb') as f:
|
||||
f.write(encoded_data)
|
||||
except Exception as e:
|
||||
print("Error saving config: {}".format(e))
|
||||
|
||||
Binary file not shown.
@@ -4,6 +4,7 @@ import subprocess
|
||||
import platform
|
||||
import os
|
||||
import base64
|
||||
import sys
|
||||
|
||||
# Simple Secret Salt - In a real app, obfuscate this or use asymmetric keys (RSA)
|
||||
# For this level, a symmetric secret is "good enough" for basic protection.
|
||||
@@ -14,6 +15,35 @@ CREATE_NO_WINDOW = 0x08000000
|
||||
|
||||
class LicenseManager:
|
||||
@staticmethod
|
||||
def _get_base_dir():
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.dirname(sys.executable)
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
@staticmethod
|
||||
def _get_license_path():
|
||||
return os.path.join(LicenseManager._get_base_dir(), "license.dat")
|
||||
|
||||
@staticmethod
|
||||
def save_license(key):
|
||||
try:
|
||||
with open(LicenseManager._get_license_path(), "w") as f:
|
||||
f.write(key.strip())
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def load_license():
|
||||
path = LicenseManager._get_license_path()
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return f.read().strip()
|
||||
except:
|
||||
return None
|
||||
@staticmethod
|
||||
def get_motherboard_id():
|
||||
"""Gets the motherboard UUID using WMIC (Windows only)."""
|
||||
try:
|
||||
@@ -124,21 +154,4 @@ class LicenseManager:
|
||||
# Neither matches - could be both changed or completely invalid key
|
||||
return False, 'both_changed'
|
||||
|
||||
@staticmethod
|
||||
def save_license(key):
|
||||
try:
|
||||
with open("license.dat", "w") as f:
|
||||
f.write(key.strip())
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def load_license():
|
||||
if not os.path.exists("license.dat"):
|
||||
return None
|
||||
try:
|
||||
with open("license.dat", "r") as f:
|
||||
return f.read().strip()
|
||||
except:
|
||||
return None
|
||||
|
||||
30
main.py
30
main.py
@@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||
QProgressBar, QFileDialog, QMessageBox, QGroupBox,
|
||||
QSystemTrayIcon, QMenu, QSpinBox, QTabWidget,
|
||||
QListWidget, QSplitter, QListWidgetItem, QInputDialog, QDialog,
|
||||
QSizePolicy, QAction, QStyle)
|
||||
QSizePolicy, QAction, QStyle, QTimeEdit)
|
||||
from PyQt5.QtCore import Qt, QThread, pyqtSignal as Signal, QTimer, QTime
|
||||
from PyQt5.QtGui import QIcon
|
||||
|
||||
@@ -278,8 +278,11 @@ class BackupApp(QMainWindow):
|
||||
|
||||
# Ensure sql_tools folder exists
|
||||
self.ensure_sql_tools_folder()
|
||||
self.settings = self.load_settings()
|
||||
self.trial_limit = 10 # Max uses in trial mode
|
||||
|
||||
self.load_settings()
|
||||
# Job Queue System
|
||||
self.job_queue = []
|
||||
|
||||
if self.trial_mode:
|
||||
# Check if strict limit reached immediately
|
||||
@@ -487,7 +490,6 @@ class BackupApp(QMainWindow):
|
||||
pd_layout = QHBoxLayout(page_daily)
|
||||
pd_layout.setContentsMargins(0,0,0,0)
|
||||
pd_layout.addWidget(QLabel("Pukul:"))
|
||||
from PyQt5.QtWidgets import QTimeEdit
|
||||
self.job_time_edit = QTimeEdit()
|
||||
self.job_time_edit.setDisplayFormat("HH:mm")
|
||||
self.create_spinbox_with_buttons(self.job_time_edit, pd_layout)
|
||||
@@ -501,7 +503,8 @@ class BackupApp(QMainWindow):
|
||||
self.retention_spin = QSpinBox()
|
||||
self.retention_spin.setRange(0, 3650)
|
||||
self.retention_spin.setSpecialValueText("Selamanya")
|
||||
self.retention_spin.setToolTip("Hapus backup lebih lama dari X hari. 0 = Simpan Selamanya.")
|
||||
self.retention_spin.setSuffix(" File")
|
||||
self.retention_spin.setToolTip("Simpan X file backup terbaru. File yang lebih lama akan dihapus.")
|
||||
self.create_spinbox_with_buttons(self.retention_spin, sched_layout)
|
||||
|
||||
sched_group.setLayout(sched_layout)
|
||||
@@ -1308,9 +1311,9 @@ class BackupApp(QMainWindow):
|
||||
def scheduled_backup_wrapper(self, job_id):
|
||||
# Determine if we can run
|
||||
if self.worker and self.worker.isRunning():
|
||||
# Concurrent jobs? For now let's skip or queue?
|
||||
# Simple approach: Skip if busy.
|
||||
self.log(">>> Jadwal Skip (App Sibuk) untuk Job ID: {}".format(job_id))
|
||||
# Concurrent jobs? Queue them!
|
||||
self.log(">>> App Sibuk. Job ID {} masuk antrian.".format(job_id))
|
||||
self.job_queue.append(job_id)
|
||||
return
|
||||
|
||||
# --- Check Trial Limit ---
|
||||
@@ -1370,6 +1373,13 @@ class BackupApp(QMainWindow):
|
||||
if hasattr(self, 'run_single_btn'):
|
||||
self.run_single_btn.setEnabled(True)
|
||||
self.log(">>> Eksekusi Selesai.")
|
||||
|
||||
# Check Queue
|
||||
if self.job_queue:
|
||||
next_job_id = self.job_queue.pop(0)
|
||||
self.log(">>> Memproses antrian: Job ID {}".format(next_job_id))
|
||||
# Use QTimer to schedule it on main thread to be safe, though we are likely already on main thread slot
|
||||
QTimer.singleShot(100, lambda: self.scheduled_backup_wrapper(next_job_id))
|
||||
|
||||
def processing_error(self, error_msg):
|
||||
self.run_all_btn.setEnabled(True)
|
||||
@@ -1378,6 +1388,12 @@ class BackupApp(QMainWindow):
|
||||
if hasattr(self, 'run_single_btn'):
|
||||
self.run_single_btn.setEnabled(True)
|
||||
self.log("!!! Kesalahan Thread: {}".format(error_msg))
|
||||
|
||||
# Check Queue even on error
|
||||
if self.job_queue:
|
||||
next_job_id = self.job_queue.pop(0)
|
||||
self.log(">>> Memproses antrian (setelah error): Job ID {}".format(next_job_id))
|
||||
QTimer.singleShot(100, lambda: self.scheduled_backup_wrapper(next_job_id))
|
||||
|
||||
# --- Tray ---
|
||||
def setup_tray(self):
|
||||
|
||||
Reference in New Issue
Block a user