Compare commits
33 Commits
f65e7feb67
...
4a700194c3
Author | SHA1 | Date | |
---|---|---|---|
4a700194c3 | |||
4d70e0eee0 | |||
2097573cbc | |||
069d2ea94d | |||
a843a875c6 | |||
e1b12227d0 | |||
739c18f2a9 | |||
abc0f4e8cf | |||
452a56b813 | |||
0359b37ff8 | |||
77ac4f5c4f | |||
a19699287d | |||
e2fe8f9b5c | |||
827f3a1e08 | |||
4b6062981a | |||
2d685e1d97 | |||
0b9c58410f | |||
76a27e12b2 | |||
3f5bac2d7e | |||
adf78124a4 | |||
158bc6ec97 | |||
9a7470f017 | |||
c35c5c52a4 | |||
988b0e8d1d | |||
05500f0303 | |||
16b8a92a24 | |||
058dc1e951 | |||
fbfc6a7224 | |||
7c765019ff | |||
b6b05633a7 | |||
f69e77cdd2 | |||
ed9700aca1 | |||
974c8295f1 |
@@ -1,553 +0,0 @@
|
||||
import subprocess
|
||||
import os
|
||||
import threading
|
||||
import re
|
||||
import signal
|
||||
import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pathlib import Path
|
||||
from crontab import CronTab
|
||||
import tempfile
|
||||
import stat
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""
|
||||
Handles the logic for creating and managing backups using rsync.
|
||||
"""
|
||||
|
||||
def __init__(self, logger):
|
||||
self.logger = logger
|
||||
self.process = None
|
||||
self.app_tag = "# Py-Backup Job"
|
||||
self.is_system_process = False
|
||||
|
||||
def _execute_as_root(self, script_content: str) -> bool:
|
||||
"""Executes a shell script with root privileges using pkexec."""
|
||||
script_path = ''
|
||||
try:
|
||||
# Use tempfile for secure temporary file creation
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="pybackup_script_", suffix=".sh", dir="/tmp") as tmp_script:
|
||||
tmp_script.write("#!/bin/bash\n\n")
|
||||
tmp_script.write("set -e\n\n") # Exit on error
|
||||
tmp_script.write(script_content)
|
||||
script_path = tmp_script.name
|
||||
|
||||
# Make the script executable
|
||||
os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP |
|
||||
stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
|
||||
command = ['pkexec', script_path]
|
||||
|
||||
self.logger.log(
|
||||
f"Executing privileged command via script: {script_path}")
|
||||
self.logger.log(
|
||||
f"Script content:\n---\n{script_content}\n---")
|
||||
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.log(
|
||||
f"Privileged script executed successfully. Output:\n{result.stdout}")
|
||||
return True
|
||||
else:
|
||||
self.logger.log(
|
||||
f"Privileged script failed. Return code: {result.returncode}\nStderr: {result.stderr}\nStdout: {result.stdout}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Failed to set up or execute privileged command: {e}")
|
||||
return False
|
||||
finally:
|
||||
if script_path and os.path.exists(script_path):
|
||||
os.remove(script_path)
|
||||
|
||||
def cancel_and_delete_privileged_backup(self, delete_path: str):
|
||||
"""Cancels a running system backup and deletes the target directory in one atomic pkexec call."""
|
||||
if not self.process or self.process.poll() is not None:
|
||||
self.logger.log("No active backup process to cancel.")
|
||||
return
|
||||
|
||||
self.logger.log(
|
||||
"Attempting to cancel backup and delete directory with root privileges...")
|
||||
try:
|
||||
pgid = os.getpgid(self.process.pid)
|
||||
|
||||
script_parts = [
|
||||
f"echo 'Attempting to terminate process group {pgid}'",
|
||||
f"kill -SIGTERM -- -{pgid} || echo 'Process group {pgid} not found or already terminated.'",
|
||||
f"echo 'Attempting to delete directory {delete_path}'",
|
||||
f'if [ -n "{delete_path}" ] && [ "{delete_path}" != "/" ]; then',
|
||||
f' rm -rf "{delete_path}"',
|
||||
f'fi'
|
||||
]
|
||||
script_content = "\n".join(script_parts)
|
||||
|
||||
if self._execute_as_root(script_content):
|
||||
self.logger.log(
|
||||
"Backup cancellation and deletion script succeeded.")
|
||||
else:
|
||||
self.logger.log(
|
||||
"Backup cancellation and deletion script failed.")
|
||||
|
||||
except ProcessLookupError:
|
||||
self.logger.log("Backup process already terminated before action.")
|
||||
# Still try to delete the directory
|
||||
self.delete_privileged_path(delete_path)
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"An error occurred during privileged cancel and delete: {e}")
|
||||
|
||||
def delete_privileged_path(self, path: str):
|
||||
"""Deletes a given path using root privileges."""
|
||||
self.logger.log(f"Requesting privileged deletion of: {path}")
|
||||
if not path or path == "/":
|
||||
self.logger.log("Invalid path for deletion provided.")
|
||||
return
|
||||
|
||||
script_content = f'rm -rf "{path}"'
|
||||
if self._execute_as_root(script_content):
|
||||
self.logger.log(f"Successfully deleted path: {path}")
|
||||
else:
|
||||
self.logger.log(f"Failed to delete path: {path}")
|
||||
|
||||
def cancel_backup(self):
|
||||
if self.process and self.process.poll() is None: # Check if process is still running
|
||||
self.logger.log("Attempting to cancel backup...")
|
||||
try:
|
||||
pgid = os.getpgid(self.process.pid)
|
||||
if self.is_system_process:
|
||||
self.logger.log(
|
||||
f"Cancelling system process with pgid {pgid} via privileged script.")
|
||||
script_content = f"kill -SIGTERM -- -{pgid}"
|
||||
self._execute_as_root(script_content)
|
||||
else:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
self.logger.log("Backup process terminated.")
|
||||
except ProcessLookupError:
|
||||
self.logger.log(
|
||||
"Backup process already terminated or not found.")
|
||||
except Exception as e:
|
||||
self.logger.log(f"Failed to terminate backup process: {e}")
|
||||
else:
|
||||
self.logger.log("No active backup process to cancel.")
|
||||
|
||||
def start_backup(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool = False, exclude_files: Optional[List[Path]] = None, source_size: int = 0):
|
||||
"""Starts a generic backup process for a specific path, reporting to a queue."""
|
||||
thread = threading.Thread(target=self._run_backup_path, args=(
|
||||
queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def _find_latest_backup(self, base_backup_path: str) -> Optional[str]:
|
||||
"""Finds the most recent backup directory in a given path."""
|
||||
self.logger.log(f"Searching for latest backup in: {base_backup_path}")
|
||||
|
||||
backup_names = self.list_backups(base_backup_path)
|
||||
|
||||
if not backup_names:
|
||||
self.logger.log("No previous backups found to link against.")
|
||||
return None
|
||||
|
||||
latest_backup_name = backup_names[0]
|
||||
latest_backup_path = os.path.join(base_backup_path, latest_backup_name)
|
||||
|
||||
if os.path.isdir(latest_backup_path):
|
||||
self.logger.log(f"Found latest backup for --link-dest: {latest_backup_path}")
|
||||
return latest_backup_path
|
||||
|
||||
self.logger.log(f"Latest backup entry '{latest_backup_name}' was not a directory. No link will be used.")
|
||||
return None
|
||||
|
||||
def _run_backup_path(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool, exclude_files: Optional[List[Path]], source_size: int):
|
||||
try:
|
||||
self.is_system_process = is_system
|
||||
self.logger.log(
|
||||
f"Starting backup from '{source_path}' to '{dest_path}'...")
|
||||
|
||||
if os.path.isdir(source_path) and not source_path.endswith('/'):
|
||||
source_path += '/'
|
||||
|
||||
parent_dest = os.path.dirname(dest_path)
|
||||
# Ensure the parent directory exists. For system backups, rsync with pkexec will create the final destination.
|
||||
# For user backups, this creates the destination.
|
||||
if not os.path.exists(parent_dest):
|
||||
os.makedirs(parent_dest, exist_ok=True)
|
||||
|
||||
latest_backup_path = self._find_latest_backup(parent_dest)
|
||||
|
||||
command = []
|
||||
if is_system:
|
||||
command.extend(['pkexec', 'rsync', '-aAXHv'])
|
||||
else:
|
||||
command.extend(['rsync', '-av'])
|
||||
|
||||
if latest_backup_path and not is_dry_run:
|
||||
self.logger.log(f"Using --link-dest='{latest_backup_path}'")
|
||||
command.append(f"--link-dest={latest_backup_path}")
|
||||
|
||||
command.extend(['--info=progress2'])
|
||||
|
||||
if exclude_files:
|
||||
for exclude_file in exclude_files:
|
||||
command.append(f"--exclude-from={exclude_file}")
|
||||
|
||||
if is_dry_run:
|
||||
command.append('--dry-run')
|
||||
|
||||
command.extend([source_path, dest_path])
|
||||
|
||||
self._execute_rsync(queue, command)
|
||||
|
||||
if self.process:
|
||||
self.logger.log(
|
||||
f"Rsync process finished with return code: {self.process.returncode}")
|
||||
if self.process.returncode == 0 and not is_dry_run:
|
||||
# For user backups, the info file is named after the folder.
|
||||
# For system backups, it's named after the folder inside 'pybackup'.
|
||||
info_filename_base = os.path.basename(dest_path)
|
||||
self._create_info_file(
|
||||
dest_path, f"{info_filename_base}.txt", source_size)
|
||||
else:
|
||||
self.logger.log(
|
||||
"Info file not created due to non-zero return code or dry run.")
|
||||
else:
|
||||
self.logger.log(
|
||||
"Rsync process did not start or self.process is None.")
|
||||
|
||||
self.logger.log(
|
||||
f"Backup to '{dest_path}' completed.")
|
||||
finally:
|
||||
self.process = None
|
||||
queue.put(('completion', None))
|
||||
|
||||
def _create_info_file(self, dest_path: str, filename: str, source_size: int):
|
||||
try:
|
||||
# Info file is now stored in the parent directory of the backup folder.
|
||||
parent_dir = os.path.dirname(dest_path)
|
||||
info_file_path = os.path.join(parent_dir, filename)
|
||||
|
||||
original_bytes = source_size
|
||||
if source_size > 0:
|
||||
power = 1024
|
||||
n = 0
|
||||
power_labels = {0: 'B', 1: 'KB', 2: 'MB', 3: 'GB', 4: 'TB'}
|
||||
display_size = original_bytes
|
||||
while display_size >= power and n < len(power_labels) - 1:
|
||||
display_size /= power
|
||||
n += 1
|
||||
size_str = f"{display_size:.2f} {power_labels[n]}"
|
||||
else:
|
||||
size_str = "0 B"
|
||||
date_str = datetime.datetime.now().strftime("%d. %B %Y, %H:%M:%S")
|
||||
|
||||
info_content = (
|
||||
f"Backup-Datum: {date_str}\n"
|
||||
f"Originalgröße: {size_str} ({original_bytes} Bytes)\n"
|
||||
)
|
||||
|
||||
self.logger.log(
|
||||
f"Attempting to write info file to {info_file_path} as current user.")
|
||||
with open(info_file_path, 'w') as f:
|
||||
f.write(info_content)
|
||||
self.logger.log(
|
||||
f"Successfully created metadata file: {info_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Failed to create metadata file. Please check permissions for {os.path.dirname(info_file_path)}. Error: {e}")
|
||||
|
||||
def _execute_rsync(self, queue, command: List[str]):
|
||||
try:
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, preexec_fn=os.setsid)
|
||||
except FileNotFoundError:
|
||||
self.logger.log(
|
||||
"Error: 'pkexec' or 'rsync' command not found in PATH during Popen call.")
|
||||
queue.put(('error', None))
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Error starting rsync process with Popen: {e}")
|
||||
queue.put(('error', None))
|
||||
return
|
||||
|
||||
if self.process is None: # This check might be redundant if exceptions are caught, but good for safety
|
||||
self.logger.log(
|
||||
"Error: subprocess.Popen returned None for rsync process (after exception handling).")
|
||||
queue.put(('error', None))
|
||||
return # Exit early if process didn't start
|
||||
|
||||
progress_regex = re.compile(r'\s*(\d+)%\s+')
|
||||
|
||||
if self.process.stdout:
|
||||
for line in iter(self.process.stdout.readline, ''):
|
||||
stripped_line = line.strip()
|
||||
self.logger.log(stripped_line)
|
||||
|
||||
match = progress_regex.search(stripped_line)
|
||||
if match:
|
||||
percentage = int(match.group(1))
|
||||
queue.put(('progress', percentage))
|
||||
else:
|
||||
if stripped_line and not stripped_line.startswith(('sending incremental file list', 'sent', 'total size')):
|
||||
queue.put(('file_update', stripped_line))
|
||||
|
||||
self.process.wait()
|
||||
if self.process.stderr:
|
||||
stderr_output = self.process.stderr.read()
|
||||
if stderr_output:
|
||||
self.logger.log(f"Rsync Error: {stderr_output.strip()}")
|
||||
queue.put(('error', None))
|
||||
|
||||
except FileNotFoundError:
|
||||
self.logger.log(
|
||||
"Error: 'rsync' command not found. Please ensure it is installed and in your PATH.")
|
||||
queue.put(('error', None))
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred: {e}")
|
||||
queue.put(('error', None))
|
||||
|
||||
def get_scheduled_jobs(self) -> List[Dict[str, Any]]:
|
||||
jobs_list = []
|
||||
try:
|
||||
user_cron = CronTab(user=True)
|
||||
for job in user_cron:
|
||||
if self.app_tag in job.comment:
|
||||
details = self._parse_job_comment(job.comment)
|
||||
if details:
|
||||
jobs_list.append({
|
||||
"id": job.comment,
|
||||
"active": job.is_enabled(),
|
||||
"type": details.get("type", "N/A"),
|
||||
"frequency": details.get("freq", "N/A"),
|
||||
"destination": details.get("dest", "N/A"),
|
||||
"sources": details.get("sources", []),
|
||||
"command": job.command
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error loading cron jobs: {e}")
|
||||
return jobs_list
|
||||
|
||||
def add_scheduled_job(self, job_details: Dict[str, Any]):
|
||||
try:
|
||||
user_cron = CronTab(user=True)
|
||||
job = user_cron.new(
|
||||
command=job_details["command"], comment=job_details["comment"])
|
||||
|
||||
if job_details["frequency"] == "daily":
|
||||
job.day.every(1)
|
||||
elif job_details["frequency"] == "weekly":
|
||||
job.dow.every(1)
|
||||
elif job_details["frequency"] == "monthly":
|
||||
job.dom.every(1)
|
||||
|
||||
job.enable()
|
||||
user_cron.write()
|
||||
self.logger.log(
|
||||
f"Job successfully added: {job_details['comment']}")
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error adding cron job: {e}")
|
||||
|
||||
def remove_scheduled_job(self, job_id: str):
|
||||
try:
|
||||
user_cron = CronTab(user=True)
|
||||
user_cron.remove_all(comment=job_id)
|
||||
user_cron.write()
|
||||
self.logger.log(f"Job successfully removed: {job_id}")
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error removing cron job: {e}")
|
||||
|
||||
def _parse_job_comment(self, comment: str) -> Dict[str, Any]:
|
||||
details = {}
|
||||
parts = comment.split("; ")
|
||||
for part in parts:
|
||||
if ":" in part:
|
||||
key, value = part.split(":", 1)
|
||||
if key.strip() == "sources":
|
||||
details[key.strip()] = [s.strip()
|
||||
for s in value.split(",")]
|
||||
else:
|
||||
details[key.strip()] = value.strip()
|
||||
return details
|
||||
|
||||
def list_backups(self, base_backup_path: str) -> List[str]:
|
||||
backups = []
|
||||
if os.path.isdir(base_backup_path):
|
||||
for item in os.listdir(base_backup_path):
|
||||
full_path = os.path.join(base_backup_path, item)
|
||||
if os.path.isdir(full_path):
|
||||
backups.append(item)
|
||||
return sorted(backups, reverse=True)
|
||||
|
||||
def list_system_backups(self, base_backup_path: str) -> List[Dict[str, str]]:
|
||||
"""Lists all system backups found in the pybackup subdirectory."""
|
||||
system_backups = []
|
||||
pybackup_path = os.path.join(base_backup_path, "pybackup")
|
||||
|
||||
if not os.path.isdir(pybackup_path):
|
||||
return system_backups
|
||||
|
||||
# Regex to parse folder names like '6-März-2024_system_full'
|
||||
name_regex = re.compile(
|
||||
r"^(\d{1,2}-\w+-\d{4})_system_(full|incremental)$", re.IGNORECASE)
|
||||
|
||||
for item in os.listdir(pybackup_path):
|
||||
full_path = os.path.join(pybackup_path, item)
|
||||
if not os.path.isdir(full_path):
|
||||
continue
|
||||
|
||||
match = name_regex.match(item)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
date_str = match.group(1)
|
||||
backup_type = match.group(2).capitalize()
|
||||
backup_size = "N/A"
|
||||
comment = ""
|
||||
|
||||
# NEW: Look for info file in the parent directory, named after the backup folder
|
||||
info_file_path = os.path.join(pybackup_path, f"{item}.txt")
|
||||
if os.path.exists(info_file_path):
|
||||
try:
|
||||
with open(info_file_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip().lower().startswith("originalgröße:"):
|
||||
# Extract size, e.g., "Originalgröße: 13.45 GB (...)"
|
||||
size_match = re.search(r":\s*(.*)\s*((", line)
|
||||
if size_match:
|
||||
backup_size = size_match.group(1).strip()
|
||||
else: # Fallback if format is just "Originalgröße: 13.45 GB"
|
||||
backup_size = line.split(":")[1].strip()
|
||||
elif line.strip().lower().startswith("kommentar:"):
|
||||
comment = line.split(":", 1)[1].strip()
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Could not read info file {info_file_path}: {e}")
|
||||
|
||||
system_backups.append({
|
||||
"date": date_str,
|
||||
"type": backup_type,
|
||||
"size": backup_size,
|
||||
"folder_name": item,
|
||||
"full_path": full_path,
|
||||
"comment": comment
|
||||
})
|
||||
|
||||
# Sort by parsing the date from the folder name
|
||||
try:
|
||||
system_backups.sort(key=lambda x: datetime.datetime.strptime(
|
||||
x['date'], '%d-%B-%Y'), reverse=True)
|
||||
except ValueError:
|
||||
self.logger.log(
|
||||
"Could not sort backups by date due to format mismatch.")
|
||||
# Fallback to simple string sort if date parsing fails
|
||||
system_backups.sort(key=lambda x: x['folder_name'], reverse=True)
|
||||
|
||||
return system_backups
|
||||
|
||||
def list_user_backups(self, base_backup_path: str) -> List[Dict[str, str]]:
|
||||
"""Lists all user backups found in the base backup path."""
|
||||
user_backups = []
|
||||
if not os.path.isdir(base_backup_path):
|
||||
return user_backups
|
||||
|
||||
for item in os.listdir(base_backup_path):
|
||||
full_path = os.path.join(base_backup_path, item)
|
||||
if not os.path.isdir(full_path):
|
||||
continue
|
||||
|
||||
# NEW: Look for info file in the parent directory, named after the backup folder
|
||||
info_file_path = os.path.join(base_backup_path, f"{item}.txt")
|
||||
|
||||
# We identify a user backup by the presence of its corresponding info file.
|
||||
if os.path.exists(info_file_path):
|
||||
backup_size = "N/A"
|
||||
backup_date = "N/A"
|
||||
comment = ""
|
||||
try:
|
||||
with open(info_file_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip().lower().startswith("originalgröße:"):
|
||||
size_match = re.search(r":\s*(.*)\s*((", line)
|
||||
if size_match:
|
||||
backup_size = size_match.group(1).strip()
|
||||
else:
|
||||
backup_size = line.split(":")[1].strip()
|
||||
elif line.strip().lower().startswith("backup-datum:"):
|
||||
backup_date = line.split(":", 1)[1].strip()
|
||||
elif line.strip().lower().startswith("kommentar:"):
|
||||
comment = line.split(":", 1)[1].strip()
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Could not read info file {info_file_path}: {e}")
|
||||
|
||||
user_backups.append({
|
||||
"date": backup_date,
|
||||
"size": backup_size,
|
||||
"folder_name": item,
|
||||
"full_path": full_path,
|
||||
"comment": comment
|
||||
})
|
||||
|
||||
user_backups.sort(key=lambda x: x['folder_name'], reverse=True)
|
||||
return user_backups
|
||||
|
||||
def get_comment(self, info_file_path: str) -> str:
|
||||
"""Reads an info file and returns the comment, if it exists."""
|
||||
if not os.path.exists(info_file_path):
|
||||
return ""
|
||||
try:
|
||||
with open(info_file_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip().lower().startswith("kommentar:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error reading comment from {info_file_path}: {e}")
|
||||
return ""
|
||||
|
||||
def update_comment(self, info_file_path: str, new_comment: str):
|
||||
"""Updates the comment in a given info file."""
|
||||
try:
|
||||
lines = []
|
||||
comment_found = False
|
||||
if os.path.exists(info_file_path):
|
||||
with open(info_file_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
if line.strip().lower().startswith("kommentar:"):
|
||||
if new_comment: # Update existing comment
|
||||
new_lines.append(f"Kommentar: {new_comment}\n")
|
||||
comment_found = True
|
||||
# If new_comment is empty, the old line is effectively deleted
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not comment_found and new_comment:
|
||||
new_lines.append(f"Kommentar: {new_comment}\n")
|
||||
|
||||
with open(info_file_path, 'w') as f:
|
||||
f.writelines(new_lines)
|
||||
self.logger.log(f"Successfully updated comment in {info_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error updating comment in {info_file_path}: {e}")
|
||||
|
||||
def test_pkexec_rsync(self, source_path: str, dest_path: str):
|
||||
self.logger.log(f"Testing pkexec rsync command...")
|
||||
command = ['pkexec', 'rsync', '-aAXHv', source_path, dest_path]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, text=True, check=False)
|
||||
self.logger.log(f"pkexec rsync return code: {result.returncode}")
|
||||
self.logger.log(f"pkexec rsync stdout: {result.stdout.strip()}")
|
||||
self.logger.log(f"pkexec rsync stderr: {result.stderr.strip()}")
|
||||
except FileNotFoundError:
|
||||
self.logger.log("Error: 'pkexec' or 'rsync' command not found.")
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"An unexpected error occurred during pkexec rsync test: {e}")
|
1072
core/backup_manager.py
Normal file
1072
core/backup_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
83
core/config_manager.py
Normal file
83
core/config_manager.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from json import JSONEncoder
|
||||
|
||||
|
||||
class PathEncoder(JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, Path):
|
||||
return str(obj)
|
||||
return JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Manages loading and saving of application settings in a JSON file."""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
"""
|
||||
Initializes the ConfigManager.
|
||||
|
||||
Args:
|
||||
file_path: The path to the configuration file.
|
||||
"""
|
||||
self.file_path = file_path
|
||||
self.settings: Dict[str, Any] = {}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
"""Loads the settings from the JSON file."""
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, 'r', encoding='utf-8') as f:
|
||||
self.settings = json.load(f)
|
||||
except (IOError, json.JSONDecodeError) as e:
|
||||
print(f"Error loading config file {self.file_path}: {e}")
|
||||
self.settings = {}
|
||||
else:
|
||||
self.settings = {}
|
||||
|
||||
def save(self) -> None:
|
||||
"""Saves the current settings to the JSON file."""
|
||||
try:
|
||||
# Ensure the parent directory exists
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.settings, f, indent=4, cls=PathEncoder)
|
||||
except IOError as e:
|
||||
print(f"Error saving config file {self.file_path}: {e}")
|
||||
|
||||
def get_setting(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Gets a setting value.
|
||||
|
||||
Args:
|
||||
key: The key of the setting.
|
||||
default: The default value to return if the key is not found.
|
||||
|
||||
Returns:
|
||||
The value of the setting or the default value.
|
||||
"""
|
||||
return self.settings.get(key, default)
|
||||
|
||||
def set_setting(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Sets a setting value and immediately saves it to the file.
|
||||
|
||||
Args:
|
||||
key: The key of the setting.
|
||||
value: The value to set.
|
||||
"""
|
||||
self.settings[key] = value
|
||||
self.save()
|
||||
|
||||
def remove_setting(self, key: str) -> None:
|
||||
"""
|
||||
Removes a setting from the configuration.
|
||||
|
||||
Args:
|
||||
key: The key of the setting to remove.
|
||||
"""
|
||||
if key in self.settings:
|
||||
del self.settings[key]
|
||||
self.save()
|
@@ -3,7 +3,9 @@ import os
|
||||
import fnmatch
|
||||
import shutil
|
||||
import re
|
||||
from pbp_app_config import AppConfig
|
||||
import subprocess
|
||||
from queue import Empty
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
from shared_libs.logger import app_logger
|
||||
|
||||
|
||||
@@ -41,6 +43,20 @@ class DataProcessing:
|
||||
except IOError as e:
|
||||
app_logger.log(f"Error loading user-defined exclusion list: {e}")
|
||||
|
||||
try:
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
manual_patterns = [
|
||||
line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
all_patterns.update(manual_patterns)
|
||||
app_logger.log(
|
||||
f"Loaded manual exclusion patterns: {manual_patterns}")
|
||||
except FileNotFoundError:
|
||||
app_logger.log(
|
||||
f"Manual exclusion list not found: {AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
|
||||
except IOError as e:
|
||||
app_logger.log(f"Error loading manual exclusion list: {e}")
|
||||
|
||||
final_patterns = sorted(list(all_patterns))
|
||||
app_logger.log(f"Combined exclusion patterns: {final_patterns}")
|
||||
return final_patterns
|
||||
@@ -101,63 +117,90 @@ class DataProcessing:
|
||||
if not stop_event.is_set():
|
||||
self.app.queue.put((button_text, total_size, mode))
|
||||
|
||||
def process_queue(self):
|
||||
def get_incremental_backup_size(self, source_path: str, dest_path: str, is_system: bool, exclude_files: list = None) -> int:
|
||||
"""
|
||||
Calculates the approximate size of an incremental backup using rsync's dry-run feature.
|
||||
This is much faster than walking the entire file tree.
|
||||
"""
|
||||
app_logger.log(f"Calculating incremental backup size for source: {source_path}")
|
||||
|
||||
parent_dest = os.path.dirname(dest_path)
|
||||
if not os.path.exists(parent_dest):
|
||||
# If the parent destination doesn't exist, there are no previous backups to link to.
|
||||
# In this case, the incremental size is the full size of the source.
|
||||
# We can use the existing full-size calculation method.
|
||||
# This is a simplified approach for the estimation.
|
||||
# A more accurate one would run rsync without --link-dest.
|
||||
app_logger.log("Destination parent does not exist, cannot calculate incremental size. Returning 0.")
|
||||
return 0
|
||||
|
||||
# Find the latest backup to link against
|
||||
try:
|
||||
message = self.app.queue.get_nowait()
|
||||
button_text, folder_size, mode_when_started = message
|
||||
backups = sorted([d for d in os.listdir(parent_dest) if os.path.isdir(os.path.join(parent_dest, d))], reverse=True)
|
||||
if not backups:
|
||||
app_logger.log("No previous backups found. Incremental size is full size.")
|
||||
return 0 # Or trigger a full size calculation
|
||||
latest_backup_path = os.path.join(parent_dest, backups[0])
|
||||
except FileNotFoundError:
|
||||
app_logger.log("Could not list backups, assuming no prior backups exist.")
|
||||
return 0
|
||||
|
||||
if mode_when_started != self.app.mode:
|
||||
return # Discard stale result from a different mode
|
||||
|
||||
if self.app.mode == 'restore':
|
||||
self.app.restore_destination_folder_size_bytes = folder_size
|
||||
else: # backup mode
|
||||
self.app.source_size_bytes = folder_size
|
||||
command = []
|
||||
if is_system:
|
||||
command.extend(['pkexec', 'rsync', '-aAXHvn', '--stats'])
|
||||
else:
|
||||
command.extend(['rsync', '-avn', '--stats'])
|
||||
|
||||
current_folder_name = self.app.left_canvas_data.get('folder')
|
||||
if current_folder_name == button_text:
|
||||
if self.app.left_canvas_animation:
|
||||
self.app.left_canvas_animation.stop()
|
||||
self.app.left_canvas_animation.destroy()
|
||||
self.app.left_canvas_animation = None
|
||||
command.append(f"--link-dest={latest_backup_path}")
|
||||
|
||||
if not self.app.right_canvas_data.get('calculating', False):
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
if exclude_files:
|
||||
for exclude_file in exclude_files:
|
||||
command.append(f"--exclude-from={exclude_file}")
|
||||
|
||||
size_in_gb = folder_size / (1024**3)
|
||||
if size_in_gb >= 1:
|
||||
size_str = f"{size_in_gb:.2f} GB"
|
||||
else:
|
||||
size_str = f"{folder_size / (1024*1024):.2f} MB"
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
command.append(f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
|
||||
|
||||
self.app.left_canvas_data['size'] = size_str
|
||||
self.app.left_canvas_data['calculating'] = False
|
||||
self.app.drawing.redraw_left_canvas()
|
||||
# The destination for a dry run can be a dummy path, but it must exist.
|
||||
# Let's use a temporary directory.
|
||||
dummy_dest = os.path.join(parent_dest, "dry_run_dest")
|
||||
os.makedirs(dummy_dest, exist_ok=True)
|
||||
|
||||
if self.app.mode == 'backup':
|
||||
total_disk_size, _, _ = shutil.disk_usage(
|
||||
AppConfig.FOLDER_PATHS[button_text])
|
||||
if folder_size > total_disk_size:
|
||||
self.app.source_larger_than_partition = True
|
||||
else:
|
||||
self.app.source_larger_than_partition = False
|
||||
command.extend([source_path, dummy_dest])
|
||||
|
||||
app_logger.log(f"Executing rsync dry-run command: {' '.join(command)}")
|
||||
|
||||
if total_disk_size > 0:
|
||||
percentage = (folder_size / total_disk_size) * 100
|
||||
else:
|
||||
percentage = 0
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
|
||||
# Clean up the dummy directory
|
||||
shutil.rmtree(dummy_dest)
|
||||
|
||||
self.app.source_size_canvas.delete("all")
|
||||
fill_width = (
|
||||
self.app.source_size_canvas.winfo_width() / 100) * percentage
|
||||
self.app.source_size_canvas.create_rectangle(
|
||||
0, 0, fill_width, self.app.source_size_canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
self.app.source_size_label.config(
|
||||
text=f"{folder_size / (1024**3):.2f} GB / {total_disk_size / (1024**3):.2f} GB")
|
||||
if result.returncode != 0:
|
||||
app_logger.log(f"Rsync dry-run failed with code {result.returncode}: {result.stderr}")
|
||||
return 0
|
||||
|
||||
self.app.drawing.update_target_projection()
|
||||
output = result.stdout + "\n" + result.stderr
|
||||
# The regex now accepts dots as thousands separators (e.g., 1.234.567).
|
||||
match = re.search(r"Total transferred file size: ([\d,.]+) bytes", output)
|
||||
if match:
|
||||
# Remove both dots and commas before converting to an integer.
|
||||
size_str = match.group(1).replace(',', '').replace('.', '')
|
||||
size_bytes = int(size_str)
|
||||
app_logger.log(f"Estimated incremental backup size: {size_bytes} bytes")
|
||||
return size_bytes
|
||||
else:
|
||||
app_logger.log("Could not find 'Total transferred file size' in rsync output.")
|
||||
# Log the output just in case something changes in the future
|
||||
app_logger.log(f"Full rsync output for debugging:\n{output}")
|
||||
return 0
|
||||
|
||||
except FileNotFoundError:
|
||||
app_logger.log("Error: 'rsync' or 'pkexec' command not found.")
|
||||
return 0
|
||||
except Exception as e:
|
||||
pass
|
||||
finally:
|
||||
self.app.after(100, self.process_queue)
|
||||
app_logger.log(f"An unexpected error occurred during incremental size calculation: {e}")
|
||||
return 0
|
||||
|
||||
# The queue processing logic has been moved to main_app.py
|
||||
# to fix a race condition and ensure all queue messages are handled correctly.
|
||||
|
346
core/encryption_manager.py
Normal file
346
core/encryption_manager.py
Normal file
@@ -0,0 +1,346 @@
|
||||
import keyring
|
||||
import keyring.errors
|
||||
from keyring.backends import SecretService
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import stat
|
||||
import re
|
||||
from typing import Optional, List
|
||||
|
||||
from core.pbp_app_config import AppConfig
|
||||
from pyimage_ui.password_dialog import PasswordDialog
|
||||
|
||||
|
||||
class EncryptionManager:
|
||||
def __init__(self, logger, app=None):
|
||||
try:
|
||||
keyring.set_keyring(SecretService.Keyring())
|
||||
except Exception as e:
|
||||
logger.log(f"Failed to set keyring backend to SecretService: {e}")
|
||||
self.logger = logger
|
||||
self.app = app
|
||||
self.service_id = "py-backup-encryption"
|
||||
self.session_password = None
|
||||
self.mounted_destinations = set()
|
||||
self.auth_method = None
|
||||
self.is_mounting = False
|
||||
|
||||
def get_key_file_path(self, base_dest_path: str) -> str:
|
||||
"""Generates the standard path for the key file for a given destination."""
|
||||
key_filename = f"keyfile_{os.path.basename(base_dest_path.rstrip('/'))}.key"
|
||||
return os.path.join(AppConfig.CONFIG_DIR, key_filename)
|
||||
|
||||
def get_password_from_keyring(self, username: str) -> Optional[str]:
|
||||
try:
|
||||
return keyring.get_password(self.service_id, username)
|
||||
except keyring.errors.InitError as e:
|
||||
self.logger.log(f"Could not initialize keyring: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.log(f"Could not get password from keyring: {e}")
|
||||
return None
|
||||
|
||||
def is_key_in_keyring(self, username: str) -> bool:
|
||||
try:
|
||||
return self.get_password_from_keyring(username) is not None
|
||||
except Exception as e:
|
||||
self.logger.log(f"Could not check password in keyring: {e}")
|
||||
return False
|
||||
|
||||
def set_password_in_keyring(self, username: str, password: str) -> bool:
|
||||
try:
|
||||
keyring.set_password(self.service_id, username, password)
|
||||
self.logger.log(f"Password for {username} stored in keyring.")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.log(f"Could not set password in keyring: {e}")
|
||||
return False
|
||||
|
||||
def get_password(self, username: str, confirm: bool = True) -> Optional[str]:
|
||||
if self.session_password:
|
||||
return self.session_password
|
||||
|
||||
password = self.get_password_from_keyring(username)
|
||||
if password:
|
||||
self.session_password = password
|
||||
return password
|
||||
|
||||
dialog = PasswordDialog(
|
||||
self.app, title=f"Enter password for {username}", confirm=confirm)
|
||||
password, save_to_keyring = dialog.get_password()
|
||||
if password and save_to_keyring:
|
||||
self.set_password_in_keyring(username, password)
|
||||
|
||||
if password:
|
||||
self.session_password = password
|
||||
|
||||
return password
|
||||
|
||||
def is_encrypted(self, base_dest_path: str) -> bool:
|
||||
if os.path.basename(base_dest_path) == "pybackup":
|
||||
pybackup_dir = base_dest_path
|
||||
else:
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
|
||||
return os.path.exists(container_path)
|
||||
|
||||
def is_mounted(self, base_dest_path: str) -> bool:
|
||||
if os.path.basename(base_dest_path) == "pybackup":
|
||||
pybackup_dir = base_dest_path
|
||||
else:
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
return os.path.ismount(mount_point)
|
||||
|
||||
def mount(self, base_dest_path: str, queue=None, size_gb: int = 0) -> Optional[str]:
|
||||
if self.is_mounting:
|
||||
self.logger.log("Mount process already in progress. Aborting new request.")
|
||||
return None
|
||||
|
||||
self.is_mounting = True
|
||||
try:
|
||||
if self.is_mounted(base_dest_path):
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
return os.path.join(pybackup_dir, "encrypted")
|
||||
|
||||
username = os.path.basename(base_dest_path.rstrip('/'))
|
||||
|
||||
# Use a dummy queue if none is provided
|
||||
if queue is None:
|
||||
from queue import Queue
|
||||
queue = Queue()
|
||||
|
||||
# 1. Try keyring
|
||||
password = self.get_password_from_keyring(username)
|
||||
if password:
|
||||
self.logger.log("Found password in keyring. Attempting to mount.")
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, password=password)
|
||||
if mount_point:
|
||||
self.auth_method = "keyring"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
else:
|
||||
# If mounting with keyring key fails, stop here and report error.
|
||||
self.logger.log("Mounting with keyring password failed. Aborting mount attempt.")
|
||||
return None
|
||||
|
||||
# 2. Try key file
|
||||
key_file_path = self.get_key_file_path(base_dest_path)
|
||||
if os.path.exists(key_file_path):
|
||||
self.logger.log(f"Found key file at {key_file_path}. Attempting to mount.")
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, key_file=key_file_path)
|
||||
if mount_point:
|
||||
self.auth_method = "keyfile"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
|
||||
# 3. Prompt for password
|
||||
self.logger.log("No password in keyring or key file found. Prompting user.")
|
||||
password = self.get_password(username, confirm=False)
|
||||
if not password:
|
||||
self.logger.log("No password provided, cannot mount container.")
|
||||
self.auth_method = None
|
||||
return None
|
||||
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, password=password)
|
||||
if mount_point:
|
||||
self.auth_method = "password"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
finally:
|
||||
self.is_mounting = False
|
||||
|
||||
def unmount(self, base_dest_path: str):
|
||||
if base_dest_path in self.mounted_destinations:
|
||||
self._unmount_encrypted_backup(base_dest_path)
|
||||
self.mounted_destinations.remove(base_dest_path)
|
||||
|
||||
def unmount_all(self):
|
||||
self.logger.log(f"Unmounting all mounted backup destinations: {self.mounted_destinations}")
|
||||
# Create a copy for safe iteration
|
||||
for path in list(self.mounted_destinations):
|
||||
self.unmount(path)
|
||||
|
||||
def _unmount_encrypted_backup(self, base_dest_path: str):
|
||||
""" Gently unmounts the container without destroying LVM structures. """
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
|
||||
base_name = os.path.basename(base_dest_path.rstrip('/'))
|
||||
vg_name = f"pybackup_vg_{base_name}"
|
||||
mapper_name = f"pybackup_luks_{base_name}"
|
||||
self.logger.log(f"Unmounting encrypted LVM backup for {base_dest_path}")
|
||||
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
|
||||
script = f"""
|
||||
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
|
||||
if mountpoint -q {mount_point}; then
|
||||
umount {mount_point} || echo "Umount failed, continuing..."
|
||||
fi
|
||||
if [ -e /dev/mapper/{mapper_name} ]; then
|
||||
cryptsetup luksClose {mapper_name} || echo "luksClose failed, continuing..."
|
||||
fi
|
||||
if vgdisplay {vg_name} >/dev/null 2>&1; then
|
||||
vgchange -an {vg_name} || echo "Could not deactivate volume group {vg_name}."
|
||||
fi
|
||||
if [ -n "$LOOP_DEVICE" ] && losetup $LOOP_DEVICE >/dev/null 2>&1; then
|
||||
losetup -d $LOOP_DEVICE || echo "Could not detach loop device $LOOP_DEVICE."
|
||||
fi
|
||||
"""
|
||||
if not self._execute_as_root(script):
|
||||
self.logger.log("Encrypted LVM backup unmount script failed.")
|
||||
|
||||
def _setup_encrypted_backup(self, queue, base_dest_path: str, size_gb: int, password: Optional[str] = None, key_file: Optional[str] = None) -> Optional[str]:
|
||||
self.logger.log(f"Setting up LVM-based encrypted container at {base_dest_path}")
|
||||
|
||||
for tool in ["cryptsetup", "losetup", "pvcreate", "vgcreate", "lvcreate", "lvextend", "resize2fs"]:
|
||||
if not shutil.which(tool):
|
||||
self.logger.log(f"Error: Required tool '{tool}' is not installed.")
|
||||
queue.put(('error', f"Required tool '{tool}' is not installed."))
|
||||
return None
|
||||
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
|
||||
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
|
||||
mount_point = encrypted_dir
|
||||
|
||||
base_name = os.path.basename(base_dest_path.rstrip('/'))
|
||||
vg_name = f"pybackup_vg_{base_name}"
|
||||
lv_name = "backup_lv"
|
||||
mapper_name = f"pybackup_luks_{base_name}"
|
||||
lv_path = f"/dev/{vg_name}/{lv_name}"
|
||||
|
||||
if not password and not key_file:
|
||||
self.logger.log("No password or key file provided for encryption.")
|
||||
queue.put(('error', "No password or key file provided for encryption."))
|
||||
return None
|
||||
|
||||
if os.path.ismount(mount_point):
|
||||
self.logger.log(f"Mount point {mount_point} already in use. Assuming it's correctly mounted.")
|
||||
return mount_point
|
||||
|
||||
if os.path.exists(container_path):
|
||||
auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -" if password else f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
|
||||
script = f"""
|
||||
mkdir -p {user_encrypt_dir}
|
||||
LOOP_DEVICE=$(losetup -f --show {container_path})
|
||||
pvscan --cache
|
||||
vgchange -ay {vg_name}
|
||||
{auth_part}
|
||||
mount /dev/mapper/{mapper_name} {mount_point}
|
||||
echo "LOOP_DEVICE_PATH=$LOOP_DEVICE"
|
||||
"""
|
||||
if not self._execute_as_root(script):
|
||||
self.logger.log("Failed to unlock existing LVM container.")
|
||||
self._destroy_encrypted_structures(base_dest_path)
|
||||
return None
|
||||
self.logger.log(f"Encrypted LVM container unlocked and mounted at {mount_point}")
|
||||
return mount_point
|
||||
else:
|
||||
format_auth_part = f"echo -n '{password}' | cryptsetup luksFormat {lv_path} -" if password else f"cryptsetup luksFormat {lv_path} --key-file {key_file}"
|
||||
open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -" if password else f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
|
||||
script = f"""
|
||||
mkdir -p {encrypted_dir}
|
||||
mkdir -p {user_encrypt_dir}
|
||||
fallocate -l {size_gb}G {container_path}
|
||||
LOOP_DEVICE=$(losetup -f --show {container_path})
|
||||
pvcreate $LOOP_DEVICE
|
||||
vgcreate {vg_name} $LOOP_DEVICE
|
||||
lvcreate -n {lv_name} -l 100%FREE {vg_name}
|
||||
{format_auth_part}
|
||||
{open_auth_part}
|
||||
mkfs.ext4 /dev/mapper/{mapper_name}
|
||||
mount /dev/mapper/{mapper_name} {mount_point}
|
||||
echo "LOOP_DEVICE_PATH=$LOOP_DEVICE"
|
||||
"""
|
||||
if not self._execute_as_root(script):
|
||||
self.logger.log("Failed to create and setup LVM-based encrypted container.")
|
||||
self._destroy_encrypted_structures(base_dest_path)
|
||||
if os.path.exists(container_path):
|
||||
self._execute_as_root(f"rm -f {container_path}")
|
||||
queue.put(('error', "Failed to setup LVM-based encrypted container."))
|
||||
return None
|
||||
self.logger.log(f"Encrypted LVM container is ready and mounted at {mount_point}")
|
||||
return mount_point
|
||||
|
||||
def _destroy_encrypted_structures(self, base_dest_path: str):
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
|
||||
base_name = os.path.basename(base_dest_path.rstrip('/'))
|
||||
vg_name = f"pybackup_vg_{base_name}"
|
||||
mapper_name = f"pybackup_luks_{base_name}"
|
||||
self.logger.log(f"Cleaning up encrypted LVM backup for {base_dest_path}")
|
||||
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
|
||||
lv_name = "backup_lv"
|
||||
lv_path = f"/dev/{vg_name}/{lv_name}"
|
||||
|
||||
script = f"""
|
||||
set -x # Log executed commands
|
||||
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
|
||||
if mountpoint -q {mount_point}; then
|
||||
umount {mount_point} || echo "Umount failed, continuing cleanup..."
|
||||
fi
|
||||
if [ -e /dev/mapper/{mapper_name} ]; then
|
||||
cryptsetup luksClose {mapper_name} || echo "luksClose failed, continuing cleanup..."
|
||||
fi
|
||||
# Deactivate and remove all LVM structures associated with the VG
|
||||
if vgdisplay {vg_name} >/dev/null 2>&1; then
|
||||
lvchange -an {lv_path} >/dev/null 2>&1 || echo "lvchange failed, continuing..."
|
||||
vgchange -an {vg_name} || echo "vgchange -an failed, continuing cleanup..."
|
||||
lvremove -f {vg_name} || echo "lvremove failed, continuing cleanup..."
|
||||
vgremove -f {vg_name} || echo "vgremove failed, continuing cleanup..."
|
||||
fi
|
||||
if [ -n "$LOOP_DEVICE" ] && losetup $LOOP_DEVICE >/dev/null 2>&1; then
|
||||
pvremove -f $LOOP_DEVICE || echo "pvremove failed, continuing cleanup..."
|
||||
losetup -d $LOOP_DEVICE || echo "losetup -d failed, continuing cleanup..."
|
||||
fi
|
||||
"""
|
||||
if not self._execute_as_root(script):
|
||||
self.logger.log("Encrypted LVM backup cleanup script failed.")
|
||||
|
||||
def _execute_as_root(self, script_content: str) -> bool:
|
||||
script_path = ''
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="pybackup_script_", suffix=".sh", dir="/tmp") as tmp_script:
|
||||
tmp_script.write("#!/bin/bash\n\n")
|
||||
tmp_script.write("set -e\n\n")
|
||||
tmp_script.write(script_content)
|
||||
script_path = tmp_script.name
|
||||
|
||||
os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP |
|
||||
stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
|
||||
command = ['pkexec', script_path]
|
||||
|
||||
sanitized_script_content = re.sub(
|
||||
r"echo -n \'.*?\'", "echo -n \'[REDACTED]\'", script_content)
|
||||
self.logger.log(
|
||||
f"Executing privileged command via script: {script_path}")
|
||||
self.logger.log(
|
||||
f"Script content:\n---\n{sanitized_script_content}\n---")
|
||||
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.log(
|
||||
f"Privileged script executed successfully. Output:\n{result.stdout}")
|
||||
return True
|
||||
else:
|
||||
self.logger.log(
|
||||
f"Privileged script failed. Return code: {result.returncode}\nStderr: {result.stderr}\nStdout: {result.stdout}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Failed to set up or execute privileged command: {e}")
|
||||
return False
|
||||
finally:
|
||||
if script_path and os.path.exists(script_path):
|
||||
os.remove(script_path)
|
@@ -15,6 +15,8 @@ class AppConfig:
|
||||
GENERATED_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / "rsync-generated-excludes.conf"
|
||||
USER_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / \
|
||||
"rsync-user-excludes.conf" # Single file
|
||||
MANUAL_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / \
|
||||
"rsync-manual-excludes.conf" # Single file
|
||||
APP_ICONS_DIR: Path = Path(__file__).parent / "lx-icons"
|
||||
|
||||
# --- Application Info ---
|
||||
@@ -86,6 +88,7 @@ class AppConfig:
|
||||
**/root/.gvfs
|
||||
/snap/*
|
||||
/home/*/.gvfs
|
||||
/home/*/.cache/*
|
||||
/var/cache/pacman/pkg/*
|
||||
/var/cache/apt/archives/*
|
||||
/var/cache/yum/*
|
||||
@@ -236,6 +239,7 @@ class Msg:
|
||||
"name": _("Name"),
|
||||
"path": _("Path"),
|
||||
"date": _("Date"),
|
||||
"time": _("Time"),
|
||||
"size": _("Size"),
|
||||
"type": _("Type"),
|
||||
"folder": _("Folder"),
|
||||
@@ -256,6 +260,23 @@ class Msg:
|
||||
"cancel_backup": _("Cancel"),
|
||||
"backup_cancelled_and_deleted_msg": _("Backup cancelled and partially completed backup deleted."),
|
||||
"info_text_placeholder": _("Info text about the current view."),
|
||||
"backup_finished_successfully": _("Backup finished successfully."),
|
||||
"backup_finished_with_warnings": _("Backup finished with warnings. See log for details."),
|
||||
"backup_failed": _("Backup failed. See log for details."),
|
||||
"backup_cancelled_by_user": _("Backup was cancelled by the user."),
|
||||
"accurate_size_cb_label": _("Accurate inkrem. size"),
|
||||
"accurate_size_info_label": _("(Calculation may take longer)"),
|
||||
"accurate_size_success": _("Accurate size calculated successfully."),
|
||||
"accurate_size_failed": _("Failed to calculate size. See log for details."),
|
||||
"please_wait": _("Please wait, calculating..."),
|
||||
"accurate_calc_cancelled": _("Calculate size cancelled."),
|
||||
"add_to_exclude_list": _("Add to exclude list"),
|
||||
"exclude_dialog_text": _("Do you want to add a folder or a file?"),
|
||||
"add_folder_button": _("Folder"),
|
||||
"add_file_button": _("File"),
|
||||
"system_excludes": _("System Excludes"),
|
||||
"manual_excludes": _("Manual Excludes"),
|
||||
"manual_excludes_info": _("Here, manually add files or folders to be excluded from the backup. Each entry should be on a new line."),
|
||||
|
||||
# Menus
|
||||
"file_menu": _("File"),
|
||||
@@ -278,6 +299,8 @@ class Msg:
|
||||
"cat_documents": _("Documents"),
|
||||
"cat_music": _("Music"),
|
||||
"cat_videos": _("Videos"),
|
||||
"show_encrypted_backups": _("Show Encrypted Backups"),
|
||||
"show_normal_backups": _("Show Normal Backups"),
|
||||
|
||||
# Browser View
|
||||
"backup_location": _("Backup Location"),
|
||||
@@ -294,11 +317,16 @@ class Msg:
|
||||
"err_no_dest_folder": _("Please select a destination folder."),
|
||||
"err_no_source_folder": _("Please select at least one source folder."),
|
||||
"err_no_backup_selected": _("Please select a backup from the list."),
|
||||
"err_unlock_failed": _("Failed to unlock the container. Please check the password and try again."),
|
||||
"err_encrypted_not_mounted": _("Encrypted container is not unlocked. Please unlock it first from the header bar."),
|
||||
"confirm_user_restore_title": _("Confirm User Data Restore"),
|
||||
"confirm_user_restore_msg": _("Do you really want to restore the backup of '{backup_name}' to its original location? \n\nAny newer files may be overwritten."),
|
||||
"confirm_user_restore_msg": _("Do you really want to restore the backup of '{backup_name}' to its original location? Any newer files may be overwritten."),
|
||||
"confirm_delete_title": _("Confirm Deletion"),
|
||||
"confirm_delete_text": _("Do you really want to delete the backup '{folder_name}'? This action cannot be undone."),
|
||||
"final_warning_system_restore_title": _("FINAL WARNING"),
|
||||
"final_warning_system_restore_msg": _("ATTENTION: You are about to restore the system. This process cannot be safely interrupted. All changes since the backup will be lost. \n\nThe computer will automatically restart upon completion. \n\nREALLY PROCEED?"),
|
||||
"btn_continue": _("PROCEED"),
|
||||
"deleting_backup_in_progress": _("Deletion in progress... Please wait."),
|
||||
"select_restore_source_title": _("Select Restore Source"),
|
||||
"select_restore_destination_title": _("Select Restore Destination"),
|
||||
|
||||
@@ -315,6 +343,7 @@ class Msg:
|
||||
"projected_usage_label": _("Projected usage after backup"),
|
||||
"header_title": _("Lx Tools Py-Backup"),
|
||||
"header_subtitle": _("Simple GUI for rsync"),
|
||||
"encrypted_backup_content": _("Encrypted Backups"),
|
||||
"compressed": _("Compressed"),
|
||||
"encrypted": _("Encrypted"),
|
||||
"bypass_security": _("Bypass security"),
|
||||
@@ -323,5 +352,15 @@ class Msg:
|
||||
"force_incremental_backup": _("Always force incremental backup (except first)"),
|
||||
"force_compression": _("Always compress backup"),
|
||||
"force_encryption": _("Always encrypt backup"),
|
||||
"use_trash_bin": _("Papierkorb verwenden"),
|
||||
"no_trash_bin": _("Kein Papierkorb (reine Synchronisierung)"),
|
||||
"sync_mode_pure_sync": _("Synchronisierungsmodus: Reine Synchronisierung (Dateien werden gelöscht)"),
|
||||
"sync_mode_trash_bin": _("Synchronisierungsmodus: Papierkorb verwenden (Gelöschte Dateien werden verschoben)"),
|
||||
"sync_mode_no_delete": _("Synchronisierungsmodus: Keine Löschung (Dateien bleiben im Ziel)"),
|
||||
"encryption_note_system_backup": _("Note: For system backups, encryption only applies to files directly within the /home directory. Folders are not automatically encrypted unless explicitly included in the backup."),
|
||||
"keyfile_settings": _("Keyfile Settings"), # New
|
||||
"backup_defaults_title": _("Backup Defaults"), # New
|
||||
"automation_settings_title": _("Automation Settings"), # New
|
||||
"create_add_key_file": _("Create/Add Key File"), # New
|
||||
"key_file_not_created": _("Key file not created."), # New
|
||||
}
|
23
core/user_utils.py
Normal file
23
core/user_utils.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
import pwd
|
||||
|
||||
|
||||
def is_admin_user() -> bool:
|
||||
"""Checks if the current user is a member of the 'sudo' or 'wheel' group."""
|
||||
try:
|
||||
# Get current user's groups
|
||||
groups = [g.gr_name for g in pwd.getpwall() if os.getuid() in g.gr_mem]
|
||||
# Also get groups current user is in by gid
|
||||
groups.extend([g.gr_name for g in pwd.getgrall()
|
||||
if os.getgid() == g.gr_gid])
|
||||
|
||||
# Common admin groups
|
||||
admin_groups = ["sudo", "wheel", "admin"]
|
||||
|
||||
for group in admin_groups:
|
||||
if group in groups:
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
# Fallback if group lookup fails for some reason
|
||||
return False
|
300
main_app.py
300
main_app.py
@@ -4,23 +4,24 @@ from tkinter import ttk
|
||||
import os
|
||||
import datetime
|
||||
from queue import Queue, Empty
|
||||
import shutil
|
||||
|
||||
from shared_libs.log_window import LogWindow
|
||||
from shared_libs.logger import app_logger
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
from shared_libs.common_tools import IconManager
|
||||
from shared_libs.config_manager import ConfigManager
|
||||
from backup_manager import BackupManager
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
from core.config_manager import ConfigManager
|
||||
from core.backup_manager import BackupManager
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
from pyimage_ui.scheduler_frame import SchedulerFrame
|
||||
from pyimage_ui.backup_content_frame import BackupContentFrame
|
||||
from pyimage_ui.header_frame import HeaderFrame
|
||||
from pyimage_ui.settings_frame import SettingsFrame
|
||||
|
||||
from core.data_processing import DataProcessing
|
||||
from pyimage_ui.drawing import Drawing
|
||||
from pyimage_ui.navigation import Navigation
|
||||
from pyimage_ui.actions import Actions
|
||||
from pyimage_ui.shared_logic import enforce_backup_type_exclusivity
|
||||
|
||||
|
||||
class MainApplication(tk.Tk):
|
||||
@@ -48,6 +49,8 @@ class MainApplication(tk.Tk):
|
||||
self.style.layout("Toolbutton"))
|
||||
self.style.configure("Gray.Toolbutton", foreground="gray")
|
||||
|
||||
self.style.configure("Green.Sidebar.TButton", foreground="green")
|
||||
|
||||
main_frame = ttk.Frame(self)
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
@@ -70,7 +73,7 @@ class MainApplication(tk.Tk):
|
||||
self.content_frame.grid_rowconfigure(6, weight=0)
|
||||
self.content_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.backup_manager = BackupManager(app_logger)
|
||||
self.backup_manager = BackupManager(app_logger, self)
|
||||
self.queue = Queue()
|
||||
self.image_manager = IconManager()
|
||||
|
||||
@@ -80,12 +83,14 @@ class MainApplication(tk.Tk):
|
||||
self.navigation = Navigation(self)
|
||||
self.actions = Actions(self)
|
||||
|
||||
self.mode = "backup" # Default mode
|
||||
self.backup_is_running = False
|
||||
self.start_time = None
|
||||
|
||||
self.calculation_thread = None
|
||||
self.calculation_stop_event = None
|
||||
self.source_larger_than_partition = False
|
||||
self.accurate_calculation_running = False
|
||||
self.is_first_backup = False
|
||||
|
||||
self.left_canvas_animation = None
|
||||
@@ -142,7 +147,8 @@ class MainApplication(tk.Tk):
|
||||
self.sidebar_buttons_frame, text=Msg.STR["settings"], command=lambda: self.navigation.toggle_settings_frame(4), style="Sidebar.TButton")
|
||||
self.settings_button.pack(fill=tk.X, pady=10)
|
||||
|
||||
self.header_frame = HeaderFrame(self.content_frame, self.image_manager)
|
||||
self.header_frame = HeaderFrame(
|
||||
self.content_frame, self.image_manager, self.backup_manager.encryption_manager, self)
|
||||
self.header_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.top_bar = ttk.Frame(self.content_frame)
|
||||
@@ -292,14 +298,13 @@ class MainApplication(tk.Tk):
|
||||
self.restore_size_frame_after.grid_remove()
|
||||
|
||||
self._load_state_and_initialize()
|
||||
self.update_backup_options_from_config() # Add this call
|
||||
self.update_backup_options_from_config()
|
||||
self.protocol("WM_DELETE_WINDOW", self.on_closing)
|
||||
|
||||
def _load_state_and_initialize(self):
|
||||
"""Loads saved state from config and initializes the UI."""
|
||||
# self.log_window.clear_log()
|
||||
last_mode = self.config_manager.get_setting("last_mode", "backup")
|
||||
|
||||
# Pre-load data from config before initializing the UI
|
||||
backup_source_path = self.config_manager.get_setting(
|
||||
"backup_source_path")
|
||||
if backup_source_path and os.path.isdir(backup_source_path):
|
||||
@@ -309,9 +314,8 @@ class MainApplication(tk.Tk):
|
||||
if folder_name:
|
||||
icon_name = self.buttons_map[folder_name]['icon']
|
||||
else:
|
||||
# Handle custom folder path
|
||||
folder_name = os.path.basename(backup_source_path.rstrip('/'))
|
||||
icon_name = 'folder_extralarge' # A generic folder icon
|
||||
icon_name = 'folder_extralarge'
|
||||
|
||||
self.backup_left_canvas_data.update({
|
||||
'icon': icon_name,
|
||||
@@ -322,7 +326,7 @@ class MainApplication(tk.Tk):
|
||||
backup_dest_path = self.config_manager.get_setting(
|
||||
"backup_destination_path")
|
||||
if backup_dest_path and os.path.isdir(backup_dest_path):
|
||||
self.destination_path = backup_dest_path # Still needed for some logic
|
||||
self.destination_path = backup_dest_path
|
||||
total, used, free = shutil.disk_usage(backup_dest_path)
|
||||
self.backup_right_canvas_data.update({
|
||||
'folder': os.path.basename(backup_dest_path.rstrip('/')),
|
||||
@@ -332,6 +336,23 @@ class MainApplication(tk.Tk):
|
||||
self.destination_total_bytes = total
|
||||
self.destination_used_bytes = used
|
||||
|
||||
if hasattr(self, 'header_frame'):
|
||||
self.header_frame.refresh_status()
|
||||
|
||||
container_path = os.path.join(
|
||||
backup_dest_path, "pybackup_encrypted.luks")
|
||||
if os.path.exists(container_path):
|
||||
username = os.path.basename(backup_dest_path.rstrip('/'))
|
||||
password = self.backup_manager.encryption_manager.get_password_from_keyring(
|
||||
username)
|
||||
if password:
|
||||
self.backup_manager.encryption_manager.unlock_container(
|
||||
backup_dest_path, password)
|
||||
app_logger.log(
|
||||
"Automatically unlocked encrypted container.")
|
||||
if hasattr(self, 'header_frame'):
|
||||
self.header_frame.refresh_status()
|
||||
|
||||
restore_src_path = self.config_manager.get_setting(
|
||||
"restore_source_path")
|
||||
if restore_src_path and os.path.isdir(restore_src_path):
|
||||
@@ -343,7 +364,6 @@ class MainApplication(tk.Tk):
|
||||
restore_dest_path = self.config_manager.get_setting(
|
||||
"restore_destination_path")
|
||||
if restore_dest_path and os.path.isdir(restore_dest_path):
|
||||
# Find the corresponding button_text for the path
|
||||
folder_name = ""
|
||||
for name, path_obj in AppConfig.FOLDER_PATHS.items():
|
||||
if str(path_obj) == restore_dest_path:
|
||||
@@ -356,23 +376,20 @@ class MainApplication(tk.Tk):
|
||||
'path_display': restore_dest_path,
|
||||
})
|
||||
|
||||
# Initialize UI for the last active mode
|
||||
self.navigation.initialize_ui_for_mode(last_mode)
|
||||
|
||||
# Trigger calculations if needed
|
||||
if last_mode == 'backup':
|
||||
self.after(100, self.actions.on_sidebar_button_click,
|
||||
self.backup_left_canvas_data.get('folder', 'Computer'))
|
||||
elif last_mode == 'restore':
|
||||
# Trigger calculation for the right canvas (source) if a path is set
|
||||
if restore_src_path:
|
||||
self.drawing.calculate_restore_folder_size()
|
||||
# Trigger calculation for the left canvas (destination) based on last selection
|
||||
restore_dest_folder = self.restore_left_canvas_data.get(
|
||||
'folder', 'Computer')
|
||||
self.after(100, self.actions.on_sidebar_button_click,
|
||||
restore_dest_folder)
|
||||
self.data_processing.process_queue()
|
||||
self._process_queue()
|
||||
self._update_sync_mode_display() # Call after loading state
|
||||
|
||||
def _setup_log_window(self):
|
||||
self.log_frame = ttk.Frame(self.content_frame)
|
||||
@@ -396,11 +413,19 @@ class MainApplication(tk.Tk):
|
||||
|
||||
def _setup_backup_content_frame(self):
|
||||
self.backup_content_frame = BackupContentFrame(
|
||||
self.content_frame, self.backup_manager, padding=10)
|
||||
self.content_frame, self.backup_manager, self.actions, self, padding=10)
|
||||
self.backup_content_frame.grid(row=2, column=0, sticky="nsew")
|
||||
self.backup_content_frame.grid_remove()
|
||||
|
||||
def _setup_task_bar(self):
|
||||
self.vollbackup_var = tk.BooleanVar()
|
||||
self.inkrementell_var = tk.BooleanVar()
|
||||
self.genaue_berechnung_var = tk.BooleanVar()
|
||||
self.testlauf_var = tk.BooleanVar()
|
||||
self.compressed_var = tk.BooleanVar()
|
||||
self.encrypted_var = tk.BooleanVar()
|
||||
self.bypass_security_var = tk.BooleanVar()
|
||||
|
||||
self.info_checkbox_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.info_checkbox_frame.grid(row=3, column=0, sticky="ew")
|
||||
|
||||
@@ -408,40 +433,51 @@ class MainApplication(tk.Tk):
|
||||
self.info_checkbox_frame, text=Msg.STR["info_text_placeholder"])
|
||||
self.info_label.pack(anchor=tk.W, fill=tk.X, pady=5)
|
||||
|
||||
# Frame for time info
|
||||
self.sync_mode_label = ttk.Label(
|
||||
self.info_checkbox_frame, text="", foreground="blue")
|
||||
self.sync_mode_label.pack(anchor=tk.W, fill=tk.X, pady=2)
|
||||
|
||||
self.time_info_frame = ttk.Frame(self.info_checkbox_frame)
|
||||
self.time_info_frame.pack(anchor=tk.W, fill=tk.X, pady=5)
|
||||
|
||||
self.start_time_label = ttk.Label(self.time_info_frame, text="Start: --:--:--")
|
||||
self.start_time_label = ttk.Label(
|
||||
self.time_info_frame, text="Start: --:--:--")
|
||||
self.start_time_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.end_time_label = ttk.Label(self.time_info_frame, text="Ende: --:--:--")
|
||||
|
||||
self.duration_label = ttk.Label(
|
||||
self.time_info_frame, text="Dauer: --:--:--")
|
||||
self.duration_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.end_time_label = ttk.Label(
|
||||
self.time_info_frame, text="Ende: --:--:--")
|
||||
self.end_time_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.duration_label = ttk.Label(self.time_info_frame, text="Dauer: --:--:--")
|
||||
self.duration_label.pack(side=tk.LEFT, padx=5)
|
||||
accurate_size_frame = ttk.Frame(self.time_info_frame)
|
||||
accurate_size_frame.pack(side=tk.LEFT, padx=20)
|
||||
|
||||
self.accurate_size_cb = ttk.Checkbutton(accurate_size_frame, text=Msg.STR["accurate_size_cb_label"],
|
||||
variable=self.genaue_berechnung_var, command=self.actions.on_toggle_accurate_size_calc)
|
||||
self.accurate_size_cb.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
accurate_size_info_label = ttk.Label(
|
||||
accurate_size_frame, text=Msg.STR["accurate_size_info_label"], foreground="gray")
|
||||
accurate_size_info_label.pack(side=tk.LEFT)
|
||||
|
||||
checkbox_frame = ttk.Frame(self.info_checkbox_frame)
|
||||
checkbox_frame.pack(fill=tk.X, pady=5)
|
||||
self.vollbackup_var = tk.BooleanVar()
|
||||
self.inkrementell_var = tk.BooleanVar()
|
||||
self.testlauf_var = tk.BooleanVar()
|
||||
self.compressed_var = tk.BooleanVar()
|
||||
self.encrypted_var = tk.BooleanVar()
|
||||
self.bypass_security_var = tk.BooleanVar()
|
||||
|
||||
self.full_backup_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["full_backup"],
|
||||
variable=self.vollbackup_var, command=lambda: enforce_backup_type_exclusivity(self.vollbackup_var, self.inkrementell_var, self.vollbackup_var.get()))
|
||||
variable=self.vollbackup_var, command=lambda: self.actions.handle_backup_type_change('voll'))
|
||||
self.full_backup_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.incremental_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["incremental"],
|
||||
variable=self.inkrementell_var, command=lambda: enforce_backup_type_exclusivity(self.inkrementell_var, self.vollbackup_var, self.inkrementell_var.get()))
|
||||
variable=self.inkrementell_var, command=lambda: self.actions.handle_backup_type_change('inkrementell'))
|
||||
self.incremental_cb.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.compressed_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["compressed"],
|
||||
variable=self.compressed_var)
|
||||
variable=self.compressed_var, command=self.actions.handle_compression_change)
|
||||
self.compressed_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.encrypted_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["encrypted"],
|
||||
variable=self.encrypted_var)
|
||||
variable=self.encrypted_var, command=self.actions.handle_encryption_change)
|
||||
self.encrypted_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.test_run_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["test_run"],
|
||||
variable=self.testlauf_var)
|
||||
@@ -484,10 +520,10 @@ class MainApplication(tk.Tk):
|
||||
self.start_pause_button.grid(row=0, column=2, rowspan=2, padx=5)
|
||||
|
||||
def on_closing(self):
|
||||
"""Handles window closing events and saves the app state."""
|
||||
self.backup_manager.encryption_manager.unmount_all()
|
||||
|
||||
self.config_manager.set_setting("last_mode", self.mode)
|
||||
|
||||
# Save paths from the data dictionaries
|
||||
if self.backup_left_canvas_data.get('path_display'):
|
||||
self.config_manager.set_setting(
|
||||
"backup_source_path", self.backup_left_canvas_data['path_display'])
|
||||
@@ -512,7 +548,6 @@ class MainApplication(tk.Tk):
|
||||
else:
|
||||
self.config_manager.set_setting("restore_source_path", None)
|
||||
|
||||
# Stop any ongoing animations before destroying the application
|
||||
if self.left_canvas_animation:
|
||||
self.left_canvas_animation.stop()
|
||||
self.left_canvas_animation.destroy()
|
||||
@@ -527,71 +562,176 @@ class MainApplication(tk.Tk):
|
||||
self.animated_icon = None
|
||||
|
||||
app_logger.log(Msg.STR["app_quit"])
|
||||
self.destroy()
|
||||
|
||||
def _process_backup_queue(self):
|
||||
"""Processes messages from the backup thread queue to update the UI safely."""
|
||||
try:
|
||||
while True:
|
||||
self.destroy()
|
||||
except tk.TclError:
|
||||
pass # App is already destroyed
|
||||
|
||||
def _process_queue(self):
|
||||
try:
|
||||
for _ in range(100):
|
||||
message = self.queue.get_nowait()
|
||||
|
||||
# Check if it's a backup status message (2-element tuple)
|
||||
if isinstance(message, tuple) and len(message) == 2:
|
||||
if isinstance(message, tuple) and len(message) in [3, 5]:
|
||||
calc_type, status = None, None
|
||||
if len(message) == 5:
|
||||
button_text, folder_size, mode_when_started, calc_type, status = message
|
||||
else:
|
||||
button_text, folder_size, mode_when_started = message
|
||||
|
||||
if mode_when_started != self.mode:
|
||||
if calc_type == 'accurate_incremental':
|
||||
self.actions._set_ui_state(True)
|
||||
self.genaue_berechnung_var.set(False)
|
||||
self.accurate_calculation_running = False
|
||||
self.animated_icon.stop("DISABLE")
|
||||
else:
|
||||
current_folder_name = self.left_canvas_data.get(
|
||||
'folder')
|
||||
if current_folder_name == button_text:
|
||||
if self.left_canvas_animation:
|
||||
self.left_canvas_animation.stop()
|
||||
self.left_canvas_animation.destroy()
|
||||
self.left_canvas_animation = None
|
||||
|
||||
size_in_gb = folder_size / (1024**3)
|
||||
size_str = f"{size_in_gb:.2f} GB" if size_in_gb >= 1 else f"{folder_size / (1024*1024):.2f} MB"
|
||||
|
||||
self.left_canvas_data['size'] = size_str
|
||||
self.left_canvas_data['total_bytes'] = folder_size
|
||||
self.left_canvas_data['calculating'] = False
|
||||
self.drawing.redraw_left_canvas()
|
||||
|
||||
self.source_size_bytes = folder_size
|
||||
if self.mode == 'backup':
|
||||
if button_text in AppConfig.FOLDER_PATHS:
|
||||
total_disk_size, _, _ = shutil.disk_usage(
|
||||
AppConfig.FOLDER_PATHS[button_text])
|
||||
if folder_size > total_disk_size:
|
||||
self.source_larger_than_partition = True
|
||||
else:
|
||||
self.source_larger_than_partition = False
|
||||
percentage = (
|
||||
folder_size / total_disk_size) * 100 if total_disk_size > 0 else 0
|
||||
self.source_size_canvas.delete("all")
|
||||
fill_width = (
|
||||
self.source_size_canvas.winfo_width() / 100) * percentage
|
||||
self.source_size_canvas.create_rectangle(
|
||||
0, 0, fill_width, self.source_size_canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
self.source_size_label.config(
|
||||
text=f"{folder_size / (1024**3):.2f} GB / {total_disk_size / (1024**3):.2f} GB")
|
||||
|
||||
self.drawing.update_target_projection()
|
||||
|
||||
if calc_type == 'accurate_incremental':
|
||||
self.source_size_bytes = folder_size
|
||||
self.drawing.update_target_projection()
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.task_progress.stop()
|
||||
self.task_progress.config(
|
||||
mode="determinate", value=0)
|
||||
self.actions._set_ui_state(True)
|
||||
self.genaue_berechnung_var.set(False)
|
||||
self.accurate_calculation_running = False
|
||||
self.start_pause_button.config(
|
||||
text=Msg.STR["start"])
|
||||
if status == 'success':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["accurate_size_success"], foreground="#0078d7")
|
||||
self.current_file_label.config(text="")
|
||||
else:
|
||||
self.info_label.config(
|
||||
text=Msg.STR["accurate_size_failed"], foreground="#D32F2F")
|
||||
self.current_file_label.config(text="")
|
||||
|
||||
elif isinstance(message, tuple) and len(message) == 2:
|
||||
message_type, value = message
|
||||
|
||||
if message_type == 'progress':
|
||||
self.task_progress["value"] = value
|
||||
self.info_label.config(text=f"Fortschritt: {value}%") # Update progress text
|
||||
self.info_label.config(text=f"Fortschritt: {value}%")
|
||||
elif message_type == 'file_update':
|
||||
# Truncate long text to avoid window resizing issues
|
||||
max_len = 120
|
||||
if len(value) > max_len:
|
||||
value = "..." + value[-max_len:]
|
||||
self.current_file_label.config(text=value)
|
||||
elif message_type == 'status_update':
|
||||
self.info_label.config(text=value)
|
||||
elif message_type == 'progress_mode':
|
||||
self.task_progress.config(mode=value)
|
||||
if value == 'indeterminate':
|
||||
self.task_progress.start()
|
||||
else:
|
||||
self.task_progress.stop()
|
||||
elif message_type == 'cancel_button_state':
|
||||
self.start_pause_button.config(state=value)
|
||||
elif message_type == 'deletion_complete':
|
||||
self.actions._set_ui_state(True)
|
||||
self.backup_content_frame.hide_deletion_status()
|
||||
self.backup_content_frame.system_backups_frame._load_backup_content()
|
||||
self.backup_content_frame.user_backups_frame._load_backup_content()
|
||||
elif message_type == 'error':
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.start_pause_button["text"] = "Start"
|
||||
self.backup_is_running = False
|
||||
elif message_type == 'completion':
|
||||
status_info = value
|
||||
status = 'error'
|
||||
if isinstance(status_info, dict):
|
||||
status = status_info.get('status', 'error')
|
||||
elif status_info is None:
|
||||
status = 'success'
|
||||
|
||||
if status == 'success':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_finished_successfully"])
|
||||
elif status == 'warning':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_finished_with_warnings"])
|
||||
elif status == 'error':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_failed"])
|
||||
elif status == 'cancelled':
|
||||
pass
|
||||
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.start_pause_button["text"] = "Start"
|
||||
self.task_progress["value"] = 0
|
||||
self.current_file_label.config(text="Backup finished.")
|
||||
|
||||
self.current_file_label.config(text="")
|
||||
|
||||
if self.start_time:
|
||||
end_time = datetime.datetime.now()
|
||||
duration = end_time - self.start_time
|
||||
total_seconds = int(duration.total_seconds())
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
duration_str = f"{hours:02}:{minutes:02}:{seconds:02}"
|
||||
end_str = end_time.strftime("%H:%M:%S")
|
||||
self.end_time_label.config(text=f"Ende: {end_str}")
|
||||
self.duration_label.config(text=f"Dauer: {duration_str}")
|
||||
self.start_time = None
|
||||
|
||||
self.backup_is_running = False
|
||||
self.actions._set_ui_state(True) # Re-enable UI
|
||||
self.actions._set_ui_state(True)
|
||||
self.backup_content_frame.system_backups_frame._load_backup_content()
|
||||
elif message_type == 'completion_accurate':
|
||||
pass
|
||||
else:
|
||||
# This message is not for us (likely for DataProcessing), put it back and yield.
|
||||
self.queue.put(message)
|
||||
break
|
||||
app_logger.log(f"Unknown message in queue: {message}")
|
||||
|
||||
except Empty:
|
||||
pass # Queue is empty, do nothing
|
||||
pass
|
||||
finally:
|
||||
self.after(100, self._process_queue)
|
||||
|
||||
# Reschedule the queue check
|
||||
self.after(100, self._process_backup_queue)
|
||||
def _update_duration(self):
|
||||
if self.backup_is_running and self.start_time:
|
||||
duration = datetime.datetime.now() - self.start_time
|
||||
total_seconds = int(duration.total_seconds())
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
duration_str = f"{hours:02}:{minutes:02}:{seconds:02}"
|
||||
self.duration_label.config(text=f"Dauer: {duration_str}")
|
||||
self.after(1000, self._update_duration)
|
||||
|
||||
def quit(self):
|
||||
self.on_closing()
|
||||
|
||||
def update_backup_options_from_config(self):
|
||||
"""
|
||||
Reads the 'force' settings from the config and updates the main UI checkboxes.
|
||||
A 'force' setting overrides the user's selection and disables the control.
|
||||
"""
|
||||
# Full/Incremental Logic
|
||||
force_full = self.config_manager.get_setting(
|
||||
"force_full_backup", False)
|
||||
force_incremental = self.config_manager.get_setting(
|
||||
@@ -608,25 +748,45 @@ class MainApplication(tk.Tk):
|
||||
self.full_backup_cb.config(state="disabled")
|
||||
self.incremental_cb.config(state="disabled")
|
||||
|
||||
# Compression Logic
|
||||
force_compression = self.config_manager.get_setting(
|
||||
"force_compression", False)
|
||||
if force_compression:
|
||||
self.compressed_var.set(True)
|
||||
self.compressed_cb.config(state="disabled")
|
||||
|
||||
# Encryption Logic
|
||||
force_encryption = self.config_manager.get_setting(
|
||||
"force_encryption", False)
|
||||
if force_encryption:
|
||||
self.encrypted_var.set(True)
|
||||
self.encrypted_cb.config(state="disabled")
|
||||
|
||||
self.actions._refresh_backup_options_ui()
|
||||
# Update sync mode display after options are loaded
|
||||
self._update_sync_mode_display()
|
||||
|
||||
def _update_sync_mode_display(self):
|
||||
use_trash_bin = self.config_manager.get_setting("use_trash_bin", False)
|
||||
no_trash_bin = self.config_manager.get_setting("no_trash_bin", False)
|
||||
|
||||
if self.left_canvas_data.get('folder') == "Computer":
|
||||
# Not applicable for system backups
|
||||
self.sync_mode_label.config(text="")
|
||||
return
|
||||
|
||||
if no_trash_bin:
|
||||
self.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_pure_sync"], foreground="red")
|
||||
elif use_trash_bin:
|
||||
self.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_trash_bin"], foreground="orange")
|
||||
else:
|
||||
self.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_no_delete"], foreground="green")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
parser = argparse.ArgumentParser(description="Py-Backup Application.")
|
||||
parser.add_argument(
|
||||
|
96
pybackup-cli.py
Normal file
96
pybackup-cli.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/python3
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
from queue import Queue
|
||||
import threading
|
||||
|
||||
from core.backup_manager import BackupManager
|
||||
from shared_libs.logger import app_logger
|
||||
from core.pbp_app_config import AppConfig
|
||||
|
||||
# A simple logger for the CLI that just prints to the console
|
||||
class CliLogger:
|
||||
def log(self, message):
|
||||
print(f"[CLI] {message}")
|
||||
|
||||
def init_logger(self, log_method):
|
||||
pass # Not needed for CLI
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Py-Backup Command-Line Interface.")
|
||||
parser.add_argument("--backup-type", choices=['user', 'system'], required=True, help="Type of backup to perform.")
|
||||
parser.add_argument("--destination", required=True, help="Destination directory for the backup.")
|
||||
parser.add_argument("--source", help="Source directory for user backup. Required for --backup-type user.")
|
||||
parser.add_argument("--mode", choices=['full', 'incremental'], default='incremental', help="Mode for system backup.")
|
||||
parser.add_argument("--encrypted", action='store_true', help="Flag to indicate the backup should be encrypted.")
|
||||
parser.add_argument("--key-file", help="Path to the key file for unlocking an encrypted container.")
|
||||
parser.add_argument("--password", help="Password for the encrypted container (use with caution). If --key-file is not provided, this will be used.")
|
||||
parser.add_argument("--compressed", action='store_true', help="Flag to indicate the backup should be compressed.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.backup_type == 'user' and not args.source:
|
||||
parser.error("--source is required for --backup-type 'user'.")
|
||||
|
||||
if args.encrypted and not (args.key_file or args.password):
|
||||
parser.error("For encrypted backups, either --key-file or --password must be provided.")
|
||||
|
||||
cli_logger = CliLogger()
|
||||
backup_manager = BackupManager(cli_logger)
|
||||
queue = Queue() # Dummy queue for now, might be used for progress later
|
||||
|
||||
source_path = "/" # Default for system backup
|
||||
if args.backup_type == 'user':
|
||||
source_path = args.source
|
||||
if not os.path.isdir(source_path):
|
||||
cli_logger.log(f"Error: Source path '{source_path}' does not exist or is not a directory.")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine password or key_file to pass
|
||||
auth_password = None
|
||||
auth_key_file = None
|
||||
if args.encrypted:
|
||||
if args.key_file:
|
||||
auth_key_file = args.key_file
|
||||
if not os.path.exists(auth_key_file):
|
||||
cli_logger.log(f"Error: Key file '{auth_key_file}' does not exist.")
|
||||
sys.exit(1)
|
||||
elif args.password:
|
||||
auth_password = args.password
|
||||
|
||||
cli_logger.log(f"Starting backup with the following configuration:")
|
||||
cli_logger.log(f" Type: {args.backup_type}")
|
||||
cli_logger.log(f" Source: {source_path}")
|
||||
cli_logger.log(f" Destination: {args.destination}")
|
||||
cli_logger.log(f" Mode: {args.mode}")
|
||||
cli_logger.log(f" Encrypted: {args.encrypted}")
|
||||
cli_logger.log(f" Compressed: {args.compressed}")
|
||||
if auth_key_file:
|
||||
cli_logger.log(f" Auth Method: Key File ({auth_key_file})")
|
||||
elif auth_password:
|
||||
cli_logger.log(f" Auth Method: Password (REDACTED)")
|
||||
|
||||
# Call the backup manager
|
||||
backup_thread = backup_manager.start_backup(
|
||||
queue=queue,
|
||||
source_path=source_path,
|
||||
dest_path=args.destination,
|
||||
is_system=(args.backup_type == 'system'),
|
||||
is_dry_run=False,
|
||||
exclude_files=None, # Excludes are handled by AppConfig.MANUAL_EXCLUDE_LIST_PATH
|
||||
source_size=0, # Not accurately calculable in CLI without scanning, set to 0
|
||||
is_compressed=args.compressed,
|
||||
is_encrypted=args.encrypted,
|
||||
mode=args.mode,
|
||||
password=auth_password,
|
||||
key_file=auth_key_file
|
||||
)
|
||||
|
||||
# Wait for the backup thread to complete
|
||||
backup_thread.join()
|
||||
|
||||
cli_logger.log("CLI backup process finished.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
0
pybackup/01-September-2025_000000_system_full.txt
Normal file
0
pybackup/01-September-2025_000000_system_full.txt
Normal file
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared_libs.message import MessageDialog
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
from shared_libs.logger import app_logger
|
||||
from shared_libs.common_tools import message_box_animation
|
||||
|
||||
@@ -18,49 +18,178 @@ class Actions:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def _update_backup_type_controls(self):
|
||||
"""
|
||||
Updates the state of the Full/Incremental backup radio buttons based on
|
||||
advanced settings and the content of the destination folder.
|
||||
This logic only applies to 'Computer' backups.
|
||||
"""
|
||||
# Do nothing if the backup mode is not 'backup' or source is not 'Computer'
|
||||
if self.app.mode != 'backup' or self.app.left_canvas_data.get('folder') != "Computer":
|
||||
self.app.full_backup_cb.config(state="normal")
|
||||
self.app.incremental_cb.config(state="normal")
|
||||
return
|
||||
|
||||
# Respect that advanced settings might have already disabled the controls
|
||||
# This check is based on the user's confirmation that this logic exists elsewhere
|
||||
if self.app.full_backup_cb.cget('state') == 'disabled':
|
||||
return
|
||||
|
||||
# --- Standard Logic ---
|
||||
full_backup_exists = False
|
||||
if self.app.destination_path and os.path.isdir(self.app.destination_path):
|
||||
system_backups = self.app.backup_manager.list_system_backups(
|
||||
self.app.destination_path)
|
||||
for backup in system_backups:
|
||||
if backup.get('type') == 'Full':
|
||||
full_backup_exists = True
|
||||
break
|
||||
|
||||
if full_backup_exists:
|
||||
# Case 1: A full backup exists. Allow user to choose, default to incremental.
|
||||
def _set_backup_type(self, backup_type: str):
|
||||
if backup_type == "full":
|
||||
self.app.vollbackup_var.set(True)
|
||||
self.app.inkrementell_var.set(False)
|
||||
elif backup_type == "incremental":
|
||||
self.app.vollbackup_var.set(False)
|
||||
self.app.inkrementell_var.set(True)
|
||||
self.app.full_backup_cb.config(state="normal")
|
||||
self.app.incremental_cb.config(state="normal")
|
||||
|
||||
def _update_backup_type_controls(self):
|
||||
# Only applies to system backups in backup mode
|
||||
if self.app.mode != 'backup' or self.app.left_canvas_data.get('folder') != "Computer":
|
||||
self._set_backup_type("full") # Default for user backups
|
||||
self.app.full_backup_cb.config(state='disabled')
|
||||
self.app.incremental_cb.config(state='disabled')
|
||||
return
|
||||
else:
|
||||
# Case 2: No full backup exists. Force a full backup.
|
||||
# Re-enable if we switch back to system backup
|
||||
self.app.full_backup_cb.config(state='normal')
|
||||
self.app.incremental_cb.config(state='normal')
|
||||
|
||||
# If controls are forced by advanced settings, do nothing
|
||||
if self.app.full_backup_cb.cget('state') == 'disabled' and self.app.incremental_cb.cget('state') == 'disabled':
|
||||
return
|
||||
|
||||
full_backup_exists = False
|
||||
if self.app.destination_path and os.path.isdir(self.app.destination_path):
|
||||
pybackup_dir = os.path.join(self.app.destination_path, "pybackup")
|
||||
if not os.path.isdir(pybackup_dir):
|
||||
self._set_backup_type("full")
|
||||
return
|
||||
|
||||
is_encrypted_backup = self.app.encrypted_var.get()
|
||||
|
||||
system_backups = self.app.backup_manager.list_system_backups(
|
||||
self.app.destination_path, mount_if_needed=False)
|
||||
|
||||
if system_backups is None: # Encrypted, but not inspected
|
||||
full_backup_exists = True # Assume one exists to be safe
|
||||
else:
|
||||
for backup in system_backups:
|
||||
# Match the encryption status and check if it's a full backup
|
||||
if backup.get('is_encrypted') == is_encrypted_backup and backup.get('backup_type_base') == 'Full':
|
||||
full_backup_exists = True
|
||||
break
|
||||
|
||||
if full_backup_exists:
|
||||
self._set_backup_type("incremental")
|
||||
else:
|
||||
self._set_backup_type("full")
|
||||
|
||||
def _refresh_backup_options_ui(self):
|
||||
# Reset enabled/disabled state for all potentially affected controls
|
||||
self.app.full_backup_cb.config(state="normal")
|
||||
self.app.incremental_cb.config(state="normal")
|
||||
self.app.compressed_cb.config(state="normal")
|
||||
self.app.encrypted_cb.config(state="normal")
|
||||
self.app.accurate_size_cb.config(state="normal")
|
||||
|
||||
# Apply logic: Encryption and Compression are mutually exclusive
|
||||
if self.app.encrypted_var.get():
|
||||
self.app.compressed_var.set(False)
|
||||
self.app.compressed_cb.config(state="disabled")
|
||||
|
||||
if self.app.compressed_var.get():
|
||||
self.app.encrypted_var.set(False)
|
||||
self.app.encrypted_cb.config(state="disabled")
|
||||
# Compression forces full backup
|
||||
self.app.vollbackup_var.set(True)
|
||||
self.app.inkrementell_var.set(False)
|
||||
self.app.full_backup_cb.config(state="disabled")
|
||||
self.app.incremental_cb.config(state="disabled")
|
||||
self.app.accurate_size_cb.config(state="disabled")
|
||||
self.app.genaue_berechnung_var.set(False)
|
||||
|
||||
# After setting the states, determine the final full/incremental choice
|
||||
self._update_backup_type_controls()
|
||||
|
||||
def handle_backup_type_change(self, changed_var_name):
|
||||
if changed_var_name == 'voll':
|
||||
if self.app.vollbackup_var.get():
|
||||
self._set_backup_type("full")
|
||||
elif changed_var_name == 'inkrementell':
|
||||
if self.app.inkrementell_var.get():
|
||||
self._set_backup_type("incremental")
|
||||
|
||||
def handle_compression_change(self):
|
||||
self._refresh_backup_options_ui()
|
||||
|
||||
def handle_encryption_change(self):
|
||||
self._refresh_backup_options_ui()
|
||||
|
||||
def on_toggle_accurate_size_calc(self):
|
||||
if not self.app.genaue_berechnung_var.get():
|
||||
return
|
||||
|
||||
if self.app.calculation_thread and self.app.calculation_thread.is_alive():
|
||||
self.app.calculation_stop_event.set()
|
||||
app_logger.log("Stopping previous size calculation.")
|
||||
|
||||
app_logger.log("Accurate incremental size calculation requested.")
|
||||
self.app.accurate_calculation_running = True
|
||||
self._set_ui_state(False, keep_cancel_enabled=True)
|
||||
self.app.start_pause_button.config(text=Msg.STR["cancel_backup"])
|
||||
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
|
||||
self.app.info_label.config(
|
||||
text=Msg.STR["please_wait"], foreground="#0078d7")
|
||||
self.app.task_progress.config(mode="indeterminate")
|
||||
self.app.task_progress.start()
|
||||
self.app.left_canvas_data.update({
|
||||
'size': Msg.STR["calculating_size"],
|
||||
'calculating': True,
|
||||
})
|
||||
self.app.drawing.start_backup_calculation_display()
|
||||
self.app.animated_icon.start()
|
||||
|
||||
folder_path = self.app.left_canvas_data.get('path_display')
|
||||
button_text = self.app.left_canvas_data.get('folder')
|
||||
|
||||
if not folder_path or not button_text:
|
||||
app_logger.log(
|
||||
"Cannot start accurate calculation, source folder info missing.")
|
||||
self._set_ui_state(True)
|
||||
self.app.genaue_berechnung_var.set(False)
|
||||
self.app.accurate_calculation_running = False
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
return
|
||||
|
||||
def threaded_incremental_calc():
|
||||
status = 'failure'
|
||||
size = 0
|
||||
try:
|
||||
exclude_file_paths = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(
|
||||
AppConfig.GENERATED_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.USER_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(
|
||||
AppConfig.MANUAL_EXCLUDE_LIST_PATH)
|
||||
|
||||
base_dest = self.app.destination_path
|
||||
correct_parent_dir = os.path.join(base_dest, "pybackup")
|
||||
dummy_dest_for_calc = os.path.join(
|
||||
correct_parent_dir, "dummy_name")
|
||||
|
||||
size = self.app.data_processing.get_incremental_backup_size(
|
||||
source_path=folder_path,
|
||||
dest_path=dummy_dest_for_calc,
|
||||
is_system=True,
|
||||
exclude_files=exclude_file_paths
|
||||
)
|
||||
status = 'success' if size > 0 else 'failure'
|
||||
except Exception as e:
|
||||
app_logger.log(f"Error during threaded_incremental_calc: {e}")
|
||||
status = 'failure'
|
||||
finally:
|
||||
if self.app.accurate_calculation_running:
|
||||
self.app.queue.put(
|
||||
(button_text, size, self.app.mode, 'accurate_incremental', status))
|
||||
|
||||
self.app.calculation_thread = threading.Thread(
|
||||
target=threaded_incremental_calc)
|
||||
self.app.calculation_thread.daemon = True
|
||||
self.app.calculation_thread.start()
|
||||
|
||||
def on_sidebar_button_click(self, button_text):
|
||||
if self.app.backup_is_running:
|
||||
app_logger.log("Action blocked: Backup is in progress.")
|
||||
if self.app.backup_is_running or self.app.accurate_calculation_running:
|
||||
app_logger.log(
|
||||
"Action blocked: Backup or accurate calculation is in progress.")
|
||||
return
|
||||
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
@@ -68,9 +197,8 @@ class Actions:
|
||||
self.app.navigation.toggle_mode(
|
||||
self.app.mode, trigger_calculation=False)
|
||||
|
||||
self.app.log_window.clear_log()
|
||||
# self.app.log_window.clear_log()
|
||||
|
||||
# Reverse map from translated UI string to canonical key
|
||||
REVERSE_FOLDER_MAP = {
|
||||
"Computer": "Computer",
|
||||
Msg.STR["cat_documents"]: "Documents",
|
||||
@@ -99,22 +227,28 @@ class Actions:
|
||||
|
||||
icon_name = self.app.buttons_map[button_text]['icon']
|
||||
|
||||
# Determine the correct description based on mode and selection
|
||||
extra_info = ""
|
||||
if button_text == "Computer":
|
||||
if self.app.mode == 'backup':
|
||||
extra_info = Msg.STR["system_backup_info"]
|
||||
else: # restore
|
||||
else:
|
||||
extra_info = Msg.STR["system_restore_info"]
|
||||
else: # User folder
|
||||
else:
|
||||
if self.app.mode == 'backup':
|
||||
extra_info = Msg.STR["user_backup_info"]
|
||||
else: # restore
|
||||
else:
|
||||
extra_info = Msg.STR["user_restore_info"]
|
||||
|
||||
# Unified logic for starting a calculation on the left canvas
|
||||
if self.app.mode == 'backup':
|
||||
self._update_backup_type_controls()
|
||||
else:
|
||||
self.app.config_manager.set_setting(
|
||||
"restore_destination_path", folder_path)
|
||||
|
||||
self._start_left_canvas_calculation(
|
||||
button_text, str(folder_path), icon_name, extra_info)
|
||||
# Update sync mode display when source changes
|
||||
self.app._update_sync_mode_display()
|
||||
|
||||
def _start_left_canvas_calculation(self, button_text, folder_path, icon_name, extra_info):
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
@@ -159,15 +293,14 @@ class Actions:
|
||||
|
||||
self.app.calculation_stop_event = threading.Event()
|
||||
|
||||
# Decide which calculation method to use based on the source
|
||||
if button_text == "Computer":
|
||||
# For system backup, use exclusions
|
||||
app_logger.log(
|
||||
"Using default (full) size calculation for system backup display.")
|
||||
exclude_patterns = self.app.data_processing.load_exclude_patterns()
|
||||
target_method = self.app.data_processing.get_folder_size_threaded
|
||||
args = (folder_path, button_text, self.app.calculation_stop_event,
|
||||
exclude_patterns, self.app.mode)
|
||||
else:
|
||||
# For user folders, do not use any exclusions
|
||||
target_method = self.app.data_processing.get_user_folder_size_threaded
|
||||
args = (folder_path, button_text,
|
||||
self.app.calculation_stop_event, self.app.mode)
|
||||
@@ -179,7 +312,7 @@ class Actions:
|
||||
|
||||
if self.app.mode == 'backup':
|
||||
self._update_backup_type_controls()
|
||||
else: # restore mode
|
||||
else:
|
||||
self.app.config_manager.set_setting(
|
||||
"restore_destination_path", folder_path)
|
||||
|
||||
@@ -208,6 +341,13 @@ class Actions:
|
||||
def _update_right_canvas_info(self, path):
|
||||
try:
|
||||
if self.app.mode == "backup":
|
||||
# Unmount previous destination if it was mounted
|
||||
if self.app.destination_path:
|
||||
self.app.backup_manager.encryption_manager.unmount(
|
||||
self.app.destination_path)
|
||||
|
||||
self.app.destination_path = path
|
||||
|
||||
backup_root_to_exclude = f"/{path.strip('/').split('/')[0]}"
|
||||
try:
|
||||
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r+') as f:
|
||||
@@ -223,7 +363,6 @@ class Actions:
|
||||
app_logger.log(f"Error updating exclusion list: {e}")
|
||||
|
||||
total, used, free = shutil.disk_usage(path)
|
||||
self.app.destination_path = path
|
||||
self.app.destination_total_bytes = total
|
||||
self.app.destination_used_bytes = used
|
||||
size_str = f"{used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB"
|
||||
@@ -235,9 +374,9 @@ class Actions:
|
||||
})
|
||||
self.app.config_manager.set_setting(
|
||||
"backup_destination_path", path)
|
||||
self.app.header_frame.refresh_status() # Refresh keyring status
|
||||
self.app.drawing.redraw_right_canvas()
|
||||
self.app.drawing.update_target_projection()
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
|
||||
current_source = self.app.left_canvas_data.get('folder')
|
||||
if current_source:
|
||||
@@ -270,7 +409,6 @@ class Actions:
|
||||
self.app.config_manager.set_setting("restore_source_path", None)
|
||||
self.app.config_manager.set_setting("restore_destination_path", None)
|
||||
|
||||
# Remove advanced settings
|
||||
self.app.config_manager.remove_setting("backup_animation_type")
|
||||
self.app.config_manager.remove_setting("calculation_animation_type")
|
||||
self.app.config_manager.remove_setting("force_full_backup")
|
||||
@@ -278,7 +416,6 @@ class Actions:
|
||||
self.app.config_manager.remove_setting("force_compression")
|
||||
self.app.config_manager.remove_setting("force_encryption")
|
||||
|
||||
# Update the main UI to reflect the cleared settings
|
||||
self.app.update_backup_options_from_config()
|
||||
|
||||
AppConfig.generate_and_write_final_exclude_list()
|
||||
@@ -291,29 +428,34 @@ class Actions:
|
||||
self.app.destination_path = None
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
|
||||
# Clear the canvases and reset the UI to its initial state for the current mode
|
||||
self.app.backup_left_canvas_data.clear()
|
||||
self.app.backup_right_canvas_data.clear()
|
||||
self.app.restore_left_canvas_data.clear()
|
||||
self.app.restore_right_canvas_data.clear()
|
||||
|
||||
current_source = self.app.left_canvas_data.get('folder')
|
||||
if current_source:
|
||||
self.on_sidebar_button_click(current_source)
|
||||
|
||||
self.app.backup_content_frame.system_backups_frame._load_backup_content()
|
||||
self.app.backup_content_frame.user_backups_frame._load_backup_content()
|
||||
|
||||
with message_box_animation(self.app.animated_icon):
|
||||
MessageDialog(master=self.app, message_type="info",
|
||||
title=Msg.STR["settings_reset_title"], text=Msg.STR["settings_reset_text"])
|
||||
|
||||
def _parse_size_string_to_bytes(self, size_str: str) -> int:
|
||||
"""Parses a size string like '38.61 GB' into bytes."""
|
||||
if not size_str or size_str == Msg.STR["calculating_size"]:
|
||||
return 0
|
||||
|
||||
|
||||
parts = size_str.split()
|
||||
if len(parts) != 2:
|
||||
return 0 # Invalid format
|
||||
return 0
|
||||
|
||||
try:
|
||||
value = float(parts[0])
|
||||
unit = parts[1].upper()
|
||||
|
||||
|
||||
if unit == 'B':
|
||||
return int(value)
|
||||
elif unit == 'KB':
|
||||
@@ -329,38 +471,25 @@ class Actions:
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def _set_ui_state(self, enable: bool):
|
||||
# Sidebar Buttons
|
||||
def _set_ui_state(self, enable: bool, keep_cancel_enabled: bool = False, allow_log_and_backup_toggle: bool = False):
|
||||
for text, data in self.app.buttons_map.items():
|
||||
# Find the actual button widget in the sidebar_buttons_frame
|
||||
# This assumes the order of creation is consistent or we can identify by text
|
||||
# A more robust way would be to store references to the buttons in a dict in MainApplication
|
||||
# For now, let's iterate through children and match text
|
||||
for child in self.app.sidebar_buttons_frame.winfo_children():
|
||||
if isinstance(child, tk.ttk.Button) and child.cget("text") == text:
|
||||
child.config(state="normal" if enable else "disabled")
|
||||
break
|
||||
|
||||
# Schedule and Settings buttons in sidebar
|
||||
self.app.schedule_dialog_button.config(
|
||||
state="normal" if enable else "disabled")
|
||||
self.app.settings_button.config(
|
||||
state="normal" if enable else "disabled")
|
||||
|
||||
# Mode Button (arrow between canvases)
|
||||
self.app.mode_button.config(state="normal" if enable else "disabled")
|
||||
|
||||
# Top Navigation Buttons
|
||||
for i, button in enumerate(self.app.nav_buttons):
|
||||
# Keep "Backup" and "Log" always enabled
|
||||
if (
|
||||
self.app.nav_buttons_defs[i][0] == Msg.STR["backup_menu"] or
|
||||
self.app.nav_buttons_defs[i][0] == Msg.STR["log"]):
|
||||
button.config(state="normal")
|
||||
else:
|
||||
button.config(state="normal" if enable else "disabled")
|
||||
if allow_log_and_backup_toggle and self.app.nav_buttons_defs[i][0] in [Msg.STR["log"], Msg.STR["backup_menu"]]:
|
||||
continue
|
||||
button.config(state="normal" if enable else "disabled")
|
||||
|
||||
# Right Canvas (Destination/Restore Source)
|
||||
if enable:
|
||||
self.app.right_canvas.bind(
|
||||
"<Button-1>", self.app.actions.on_right_canvas_click)
|
||||
@@ -369,17 +498,14 @@ class Actions:
|
||||
self.app.right_canvas.unbind("<Button-1>")
|
||||
self.app.right_canvas.config(cursor="")
|
||||
|
||||
# Checkboxes in the task bar
|
||||
if enable:
|
||||
# When enabling, re-run the logic that sets the correct state
|
||||
# for all checkboxes based on config and context.
|
||||
self.app.update_backup_options_from_config()
|
||||
self.app.actions._update_backup_type_controls()
|
||||
else:
|
||||
# When disabling, just disable all of them.
|
||||
checkboxes = [
|
||||
self.app.full_backup_cb,
|
||||
self.app.incremental_cb,
|
||||
self.app.accurate_size_cb,
|
||||
self.app.compressed_cb,
|
||||
self.app.encrypted_cb,
|
||||
self.app.test_run_cb,
|
||||
@@ -388,8 +514,29 @@ class Actions:
|
||||
for cb in checkboxes:
|
||||
cb.config(state="disabled")
|
||||
|
||||
if keep_cancel_enabled:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
|
||||
def toggle_start_cancel(self):
|
||||
# If a backup is already running, we must be cancelling.
|
||||
if self.app.accurate_calculation_running:
|
||||
app_logger.log("Accurate size calculation cancelled by user.")
|
||||
self.app.accurate_calculation_running = False
|
||||
self.app.genaue_berechnung_var.set(False)
|
||||
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
if self.app.left_canvas_animation:
|
||||
self.app.left_canvas_animation.stop()
|
||||
self.app.left_canvas_data['calculating'] = False
|
||||
self.app.drawing.redraw_left_canvas()
|
||||
|
||||
self.app.task_progress.stop()
|
||||
self.app.task_progress.config(mode="determinate", value=0)
|
||||
self.app.info_label.config(
|
||||
text=Msg.STR["accurate_calc_cancelled"], foreground="#E8740C")
|
||||
self.app.start_pause_button.config(text=Msg.STR["start"])
|
||||
self._set_ui_state(True)
|
||||
return
|
||||
|
||||
if self.app.backup_is_running:
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
|
||||
@@ -435,46 +582,54 @@ class Actions:
|
||||
if hasattr(self.app, 'current_backup_path'):
|
||||
self.app.current_backup_path = None
|
||||
|
||||
# Reset state
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
|
||||
# Otherwise, we are starting a new backup.
|
||||
else:
|
||||
if self.app.start_pause_button['state'] == 'disabled':
|
||||
return
|
||||
|
||||
self.app.backup_is_running = True
|
||||
|
||||
# --- Record and Display Start Time ---
|
||||
|
||||
self.app.start_time = datetime.datetime.now()
|
||||
start_str = self.app.start_time.strftime("%H:%M:%S")
|
||||
self.app.start_time_label.config(text=f"Start: {start_str}")
|
||||
self.app.end_time_label.config(text="Ende: --:--:--")
|
||||
self.app.duration_label.config(text="Dauer: --:--:--")
|
||||
self.app.info_label.config(text="Backup wird vorbereitet...")
|
||||
# --- End Time Logic ---
|
||||
self.app._update_duration()
|
||||
|
||||
self.app.start_pause_button["text"] = Msg.STR["cancel_backup"]
|
||||
self.app.update_idletasks()
|
||||
|
||||
self.app.log_window.clear_log()
|
||||
self._set_ui_state(False)
|
||||
# self.app.log_window.clear_log()
|
||||
self._set_ui_state(False, allow_log_and_backup_toggle=True)
|
||||
|
||||
self.app.animated_icon.start()
|
||||
|
||||
self.app._process_backup_queue()
|
||||
|
||||
if self.app.mode == "backup":
|
||||
if self.app.vollbackup_var.get():
|
||||
self._start_system_backup("full")
|
||||
source_folder = self.app.left_canvas_data.get('folder')
|
||||
source_size_bytes = self.app.source_size_bytes
|
||||
|
||||
if not source_folder:
|
||||
app_logger.log(
|
||||
"No source folder selected, aborting backup.")
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
return
|
||||
|
||||
if source_folder == "Computer":
|
||||
mode = "full" if self.app.vollbackup_var.get() else "incremental"
|
||||
self._start_system_backup(mode, source_size_bytes)
|
||||
else:
|
||||
self._start_system_backup("incremental")
|
||||
else:
|
||||
self._start_user_backup()
|
||||
else: # restore mode
|
||||
# Restore logic would go here
|
||||
pass
|
||||
|
||||
def _start_system_backup(self, mode):
|
||||
def _start_system_backup(self, mode, source_size_bytes):
|
||||
base_dest = self.app.destination_path
|
||||
if not base_dest:
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
@@ -487,29 +642,47 @@ class Actions:
|
||||
title=Msg.STR["error"], text=Msg.STR["system_backup_in_home_error"])
|
||||
return
|
||||
|
||||
is_encrypted = self.app.encrypted_var.get()
|
||||
password = None
|
||||
if is_encrypted:
|
||||
username = os.path.basename(base_dest.rstrip('/'))
|
||||
password = self.app.backup_manager.encryption_manager.get_password(
|
||||
username, confirm=True)
|
||||
if not password:
|
||||
app_logger.log(
|
||||
"Encryption enabled, but no password provided. Aborting backup.")
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
return
|
||||
|
||||
try:
|
||||
locale.setlocale(locale.LC_TIME, 'de_DE.UTF-8')
|
||||
except locale.Error:
|
||||
app_logger.log(
|
||||
"Could not set locale to de_DE.UTF-8. Using default.")
|
||||
|
||||
date_str = datetime.datetime.now().strftime("%d-%B-%Y")
|
||||
folder_name = f"{date_str}_system_{mode}"
|
||||
final_dest = os.path.join(base_dest, "pybackup", folder_name)
|
||||
# Store the path for potential deletion
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_system_{mode}"
|
||||
# The backup_manager will add /pybackup/
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
||||
# Get source size from canvas data and parse it
|
||||
size_display_str = self.app.left_canvas_data.get('size', '0 B')
|
||||
source_size_bytes = self._parse_size_string_to_bytes(size_display_str)
|
||||
source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0)
|
||||
|
||||
exclude_file_paths = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.GENERATED_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.USER_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.MANUAL_EXCLUDE_LIST_PATH)
|
||||
|
||||
is_dry_run = self.app.testlauf_var.get()
|
||||
is_compressed = self.app.compressed_var.get()
|
||||
|
||||
self.app.backup_manager.start_backup(
|
||||
queue=self.app.queue,
|
||||
source_path="/",
|
||||
@@ -517,20 +690,75 @@ class Actions:
|
||||
is_system=True,
|
||||
is_dry_run=is_dry_run,
|
||||
exclude_files=exclude_file_paths,
|
||||
source_size=source_size_bytes)
|
||||
source_size=source_size_bytes,
|
||||
is_compressed=is_compressed,
|
||||
is_encrypted=is_encrypted,
|
||||
mode=mode)
|
||||
|
||||
def _start_user_backup(self, sources):
|
||||
dest = self.app.destination_path
|
||||
if not dest:
|
||||
def _start_user_backup(self):
|
||||
base_dest = self.app.destination_path
|
||||
source_path = self.app.left_canvas_data.get('path_display')
|
||||
source_name = self.app.left_canvas_data.get('folder')
|
||||
source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0)
|
||||
|
||||
if not base_dest or not source_path:
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
return
|
||||
|
||||
is_encrypted = self.app.encrypted_var.get()
|
||||
password = None
|
||||
if is_encrypted:
|
||||
username = os.path.basename(base_dest.rstrip('/'))
|
||||
password = self.app.backup_manager.encryption_manager.get_password(
|
||||
username, confirm=True)
|
||||
if not password:
|
||||
app_logger.log(
|
||||
"Encryption enabled, but no password provided. Aborting backup.")
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
return
|
||||
|
||||
# Determine mode for user backup based on UI selection
|
||||
mode = "full" if self.app.vollbackup_var.get() else "incremental"
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_user_{source_name}_{mode}"
|
||||
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
||||
is_dry_run = self.app.testlauf_var.get()
|
||||
for source in sources:
|
||||
self.app.backup_manager.start_backup(
|
||||
queue=self.app.queue,
|
||||
source_path=source,
|
||||
dest_path=dest,
|
||||
is_system=False,
|
||||
is_dry_run=is_dry_run)
|
||||
is_compressed = self.app.compressed_var.get()
|
||||
use_trash_bin = self.app.config_manager.get_setting(
|
||||
"use_trash_bin", False)
|
||||
no_trash_bin = self.app.config_manager.get_setting(
|
||||
"no_trash_bin", False)
|
||||
|
||||
self.app.backup_manager.start_backup(
|
||||
queue=self.app.queue,
|
||||
source_path=source_path,
|
||||
dest_path=final_dest,
|
||||
is_system=False,
|
||||
is_dry_run=is_dry_run,
|
||||
exclude_files=None,
|
||||
source_size=source_size_bytes,
|
||||
is_compressed=is_compressed,
|
||||
is_encrypted=is_encrypted,
|
||||
mode=mode,
|
||||
use_trash_bin=use_trash_bin,
|
||||
no_trash_bin=no_trash_bin)
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_user_{source_name}_{mode}"
|
||||
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
@@ -3,28 +3,67 @@ from tkinter import ttk
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
from pyimage_ui.shared_logic import enforce_backup_type_exclusivity
|
||||
from shared_libs.message import MessageDialog
|
||||
from pyimage_ui.password_dialog import PasswordDialog
|
||||
|
||||
|
||||
class AdvancedSettingsFrame(tk.Toplevel):
|
||||
def __init__(self, master, config_manager, app_instance, **kwargs):
|
||||
class AdvancedSettingsFrame(ttk.Frame):
|
||||
def __init__(self, master, config_manager, app_instance, show_main_settings_callback, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
|
||||
self.title(Msg.STR["advanced_settings_title"])
|
||||
self.show_main_settings_callback = show_main_settings_callback
|
||||
self.config_manager = config_manager
|
||||
self.app_instance = app_instance
|
||||
self.current_view_index = 0
|
||||
|
||||
# --- Warning Label ---
|
||||
warning_label = ttk.Label(
|
||||
self, text=Msg.STR["advanced_settings_warning"], wraplength=780, justify="center")
|
||||
warning_label.pack(pady=10, fill=tk.X, padx=10)
|
||||
self.info_label = ttk.Label(
|
||||
self, text=Msg.STR["advanced_settings_warning"], wraplength=780, justify="left")
|
||||
self.info_label.pack(pady=10, fill=tk.X, padx=10)
|
||||
|
||||
nav_frame = ttk.Frame(self)
|
||||
nav_frame.pack(fill=tk.X, padx=10, pady=5)
|
||||
|
||||
top_nav_frame = ttk.Frame(nav_frame)
|
||||
top_nav_frame.pack(side=tk.LEFT)
|
||||
|
||||
self.nav_buttons_defs = [
|
||||
(Msg.STR["system_excludes"], lambda: self._switch_view(0)),
|
||||
(Msg.STR["manual_excludes"], lambda: self._switch_view(1)),
|
||||
# New button for Keyfile/Automation
|
||||
(Msg.STR["keyfile_settings"], lambda: self._switch_view(2)),
|
||||
(Msg.STR["animation_settings_title"],
|
||||
lambda: self._switch_view(3)), # Animation settings
|
||||
(Msg.STR["backup_defaults_title"],
|
||||
lambda: self._switch_view(4)), # Backup Defaults
|
||||
]
|
||||
|
||||
self.nav_buttons = []
|
||||
self.nav_progress_bars = []
|
||||
|
||||
for i, (text, command) in enumerate(self.nav_buttons_defs):
|
||||
button_frame = ttk.Frame(top_nav_frame)
|
||||
button_frame.pack(side=tk.LEFT, padx=5)
|
||||
button = ttk.Button(button_frame, text=text,
|
||||
command=command, style="TButton.Borderless.Round")
|
||||
button.pack(side=tk.TOP)
|
||||
self.nav_buttons.append(button)
|
||||
progress_bar = ttk.Progressbar(
|
||||
button_frame, orient="horizontal", length=50, mode="determinate", style="Small.Horizontal.TProgressbar")
|
||||
progress_bar.pack_forget()
|
||||
self.nav_progress_bars.append(progress_bar)
|
||||
|
||||
if i < len(self.nav_buttons_defs) - 1:
|
||||
ttk.Separator(top_nav_frame, orient=tk.VERTICAL).pack(
|
||||
side=tk.LEFT, fill=tk.Y, padx=2)
|
||||
|
||||
view_container = ttk.Frame(self)
|
||||
view_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
|
||||
# --- Treeview for system folder exclusion ---
|
||||
self.tree_frame = ttk.LabelFrame(
|
||||
self, text=Msg.STR["exclude_system_folders"], padding=10)
|
||||
self.tree_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
view_container, text=Msg.STR["exclude_system_folders"], padding=10)
|
||||
|
||||
columns = ("included", "name", "path")
|
||||
self.tree = ttk.Treeview(
|
||||
@@ -36,76 +75,225 @@ class AdvancedSettingsFrame(tk.Toplevel):
|
||||
self.tree.column("name", anchor="center")
|
||||
self.tree.column("included", width=100, anchor="center")
|
||||
self.tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
self.tree.tag_configure("backup_dest_exclude", foreground="gray")
|
||||
|
||||
self.tree.bind("<Button-1>", self._toggle_include_status)
|
||||
|
||||
# --- Animation Settings ---
|
||||
animation_frame = ttk.LabelFrame(
|
||||
self, text=Msg.STR["animation_settings_title"], padding=10)
|
||||
animation_frame.pack(fill=tk.X, padx=10, pady=5)
|
||||
self.manual_excludes_frame = ttk.LabelFrame(
|
||||
view_container, text=Msg.STR["manual_excludes"], padding=10)
|
||||
|
||||
self.manual_excludes_listbox = tk.Listbox(
|
||||
self.manual_excludes_frame, selectmode=tk.MULTIPLE)
|
||||
self.manual_excludes_listbox.pack(
|
||||
fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
delete_button = ttk.Button(
|
||||
self.manual_excludes_frame, text=Msg.STR["delete"], command=self._delete_manual_exclude)
|
||||
delete_button.pack(pady=5)
|
||||
|
||||
self.animation_settings_frame = ttk.LabelFrame(
|
||||
view_container, text=Msg.STR["animation_settings_title"], padding=10)
|
||||
|
||||
animation_types = ["counter_arc", "double_arc", "line", "blink"]
|
||||
|
||||
ttk.Label(animation_frame, text=Msg.STR["backup_animation_label"]).grid(
|
||||
ttk.Label(self.animation_settings_frame, text=Msg.STR["backup_animation_label"]).grid(
|
||||
row=0, column=0, sticky="w", pady=2)
|
||||
self.backup_anim_var = tk.StringVar()
|
||||
self.backup_anim_combo = ttk.Combobox(
|
||||
animation_frame, textvariable=self.backup_anim_var, values=animation_types, state="readonly")
|
||||
self.animation_settings_frame, textvariable=self.backup_anim_var, values=animation_types, state="readonly")
|
||||
self.backup_anim_combo.grid(row=0, column=1, sticky="ew", padx=5)
|
||||
|
||||
ttk.Label(animation_frame, text=Msg.STR["calc_animation_label"]).grid(
|
||||
ttk.Label(self.animation_settings_frame, text=Msg.STR["calc_animation_label"]).grid(
|
||||
row=1, column=0, sticky="w", pady=2)
|
||||
self.calc_anim_var = tk.StringVar()
|
||||
self.calc_anim_combo = ttk.Combobox(
|
||||
animation_frame, textvariable=self.calc_anim_var, values=animation_types, state="readonly")
|
||||
self.animation_settings_frame, textvariable=self.calc_anim_var, values=animation_types, state="readonly")
|
||||
self.calc_anim_combo.grid(row=1, column=1, sticky="ew", padx=5)
|
||||
|
||||
reset_button = ttk.Button(
|
||||
animation_frame, text=Msg.STR["default_settings"], command=self._reset_animation_settings)
|
||||
self.animation_settings_frame, text=Msg.STR["default_settings"], command=self._reset_animation_settings)
|
||||
reset_button.grid(row=0, column=2, rowspan=2, padx=10)
|
||||
|
||||
animation_frame.columnconfigure(1, weight=1)
|
||||
self.animation_settings_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# --- Backup Default Settings ---
|
||||
defaults_frame = ttk.LabelFrame(
|
||||
self, text="Backup Defaults", padding=10)
|
||||
defaults_frame.pack(fill=tk.X, padx=10, pady=5)
|
||||
self.backup_defaults_frame = ttk.LabelFrame(
|
||||
view_container, text=Msg.STR["backup_defaults_title"], padding=10)
|
||||
|
||||
self.force_full_var = tk.BooleanVar()
|
||||
self.force_incremental_var = tk.BooleanVar()
|
||||
self.force_compression_var = tk.BooleanVar()
|
||||
self.force_encryption_var = tk.BooleanVar()
|
||||
|
||||
ttk.Checkbutton(defaults_frame, text=Msg.STR["force_full_backup"], variable=self.force_full_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_full_backup"], variable=self.force_full_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
self.force_full_var, self.force_incremental_var, self.force_full_var.get())).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(defaults_frame, text=Msg.STR["force_incremental_backup"], variable=self.force_incremental_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_incremental_backup"], variable=self.force_incremental_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
self.force_incremental_var, self.force_full_var, self.force_incremental_var.get())).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(defaults_frame, text=Msg.STR["force_compression"],
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_compression"],
|
||||
variable=self.force_compression_var).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(defaults_frame, text=Msg.STR["force_encryption"],
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_encryption"],
|
||||
variable=self.force_encryption_var).pack(anchor=tk.W)
|
||||
|
||||
ttk.Separator(defaults_frame, orient=tk.HORIZONTAL).pack(
|
||||
ttk.Separator(self.backup_defaults_frame, orient=tk.HORIZONTAL).pack(
|
||||
fill=tk.X, pady=5)
|
||||
|
||||
encryption_note = ttk.Label(
|
||||
defaults_frame, text=Msg.STR["encryption_note_system_backup"], wraplength=750, justify="left")
|
||||
self.backup_defaults_frame, text=Msg.STR["encryption_note_system_backup"], wraplength=750, justify="left")
|
||||
encryption_note.pack(anchor=tk.W, pady=5)
|
||||
|
||||
# --- Keyfile/Automation Settings ---
|
||||
self.keyfile_settings_frame = ttk.LabelFrame(
|
||||
view_container, text=Msg.STR["automation_settings_title"], padding=10)
|
||||
|
||||
key_file_button = ttk.Button(
|
||||
self.keyfile_settings_frame, text=Msg.STR["create_add_key_file"], command=self._create_key_file)
|
||||
key_file_button.grid(row=0, column=0, padx=5, pady=5)
|
||||
|
||||
self.key_file_status_var = tk.StringVar(
|
||||
value=Msg.STR["key_file_not_created"])
|
||||
key_file_status_label = ttk.Label(
|
||||
self.keyfile_settings_frame, textvariable=self.key_file_status_var, foreground="gray")
|
||||
key_file_status_label.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
sudoers_info_text = (f"To run automated backups, an administrator must create a file in /etc/sudoers.d/\n"
|
||||
f"with the following content (replace 'punix' with the correct username):\n"
|
||||
# Path needs to be updated
|
||||
f"punix ALL=(ALL) NOPASSWD: /path/to/pybackup-cli.py")
|
||||
sudoers_info_label = ttk.Label(
|
||||
self.keyfile_settings_frame, text=sudoers_info_text, justify="left")
|
||||
|
||||
sudoers_info_label.grid(
|
||||
row=1, column=0, columnspan=2, sticky="w", padx=5, pady=5)
|
||||
|
||||
self.keyfile_settings_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# --- Action Buttons ---
|
||||
button_frame = ttk.Frame(self)
|
||||
button_frame.pack(pady=10)
|
||||
|
||||
ttk.Button(button_frame, text=Msg.STR["apply"], command=self._apply_changes).pack(
|
||||
side=tk.LEFT, padx=5)
|
||||
ttk.Button(button_frame, text=Msg.STR["cancel"], command=self.destroy).pack(
|
||||
ttk.Button(button_frame, text=Msg.STR["cancel"], command=self._cancel_changes).pack(
|
||||
side=tk.LEFT, padx=5)
|
||||
|
||||
# Initial packing of frames (all hidden except the first one by _switch_view)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.tree_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.manual_excludes_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.animation_settings_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.backup_defaults_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self._load_system_folders()
|
||||
self._load_animation_settings()
|
||||
self._load_backup_defaults()
|
||||
self._load_manual_excludes()
|
||||
self._update_key_file_status()
|
||||
|
||||
self._switch_view(self.current_view_index)
|
||||
|
||||
def _create_key_file(self):
|
||||
if not self.app_instance.destination_path:
|
||||
MessageDialog(self, message_type="error", title="Error",
|
||||
text="Please select a backup destination first.")
|
||||
return
|
||||
|
||||
pybackup_dir = os.path.join(
|
||||
self.app_instance.destination_path, "pybackup")
|
||||
container_path = os.path.join(pybackup_dir, "pybackup_encrypted.luks")
|
||||
if not os.path.exists(container_path):
|
||||
MessageDialog(self, message_type="error", title="Error",
|
||||
text="No encrypted container found at the destination.")
|
||||
return
|
||||
|
||||
# Prompt for the existing password to authorize adding a new key
|
||||
password_dialog = PasswordDialog(
|
||||
self, title="Enter Existing Password", confirm=False)
|
||||
password, _ = password_dialog.get_password()
|
||||
|
||||
if not password:
|
||||
return # User cancelled
|
||||
|
||||
key_file_path = self.app_instance.backup_manager.encryption_manager.create_and_add_key_file(
|
||||
self.app_instance.destination_path, password)
|
||||
|
||||
if key_file_path:
|
||||
MessageDialog(self, message_type="info", title="Success",
|
||||
text=f"Key file created and added successfully!\nPath: {key_file_path}")
|
||||
else:
|
||||
MessageDialog(self, message_type="error", title="Error",
|
||||
text="Failed to create or add key file. See log for details.")
|
||||
|
||||
self._update_key_file_status()
|
||||
|
||||
def _update_key_file_status(self):
|
||||
if not self.app_instance.destination_path:
|
||||
self.key_file_status_var.set(
|
||||
"Key file status unknown (no destination set).")
|
||||
return
|
||||
|
||||
key_file_path = self.app_instance.backup_manager.encryption_manager.get_key_file_path(
|
||||
self.app_instance.destination_path)
|
||||
if os.path.exists(key_file_path):
|
||||
self.key_file_status_var.set(f"Key file exists: {key_file_path}")
|
||||
else:
|
||||
self.key_file_status_var.set(
|
||||
"Key file has not been created for this destination.")
|
||||
|
||||
def _switch_view(self, index):
|
||||
self.current_view_index = index
|
||||
self.update_nav_buttons(index)
|
||||
|
||||
# Hide all frames first
|
||||
self.tree_frame.pack_forget()
|
||||
self.manual_excludes_frame.pack_forget()
|
||||
self.keyfile_settings_frame.pack_forget()
|
||||
self.animation_settings_frame.pack_forget()
|
||||
self.backup_defaults_frame.pack_forget()
|
||||
|
||||
# Show the selected frame and update info label
|
||||
if index == 0:
|
||||
self.tree_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.info_label.config(text=Msg.STR["advanced_settings_warning"])
|
||||
elif index == 1:
|
||||
self.manual_excludes_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.info_label.config(text=Msg.STR["manual_excludes_info"])
|
||||
elif index == 2:
|
||||
self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Use automation title for info
|
||||
self.info_label.config(text=Msg.STR["automation_settings_title"])
|
||||
elif index == 3:
|
||||
self.animation_settings_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.info_label.config(text=Msg.STR["animation_settings_title"])
|
||||
elif index == 4:
|
||||
self.backup_defaults_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.info_label.config(text=Msg.STR["backup_defaults_title"])
|
||||
|
||||
def update_nav_buttons(self, active_index):
|
||||
for i, button in enumerate(self.nav_buttons):
|
||||
if i == active_index:
|
||||
button.configure(style="Toolbutton")
|
||||
self.nav_progress_bars[i].pack(side=tk.BOTTOM, fill=tk.X)
|
||||
self.nav_progress_bars[i]['value'] = 100
|
||||
else:
|
||||
button.configure(style="Gray.Toolbutton")
|
||||
self.nav_progress_bars[i].pack_forget()
|
||||
|
||||
def _load_manual_excludes(self):
|
||||
self.manual_excludes_listbox.delete(0, tk.END)
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
self.manual_excludes_listbox.insert(tk.END, line)
|
||||
|
||||
def _delete_manual_exclude(self):
|
||||
selected_indices = self.manual_excludes_listbox.curselection()
|
||||
for i in reversed(selected_indices):
|
||||
self.manual_excludes_listbox.delete(i)
|
||||
|
||||
def _reset_animation_settings(self):
|
||||
self.config_manager.remove_setting("backup_animation_type")
|
||||
@@ -183,7 +371,6 @@ class AdvancedSettingsFrame(tk.Toplevel):
|
||||
item_values = items_to_display[item_path_str]
|
||||
tag = "yes" if item_values[0] == Msg.STR["yes"] else "no"
|
||||
|
||||
# Special tag for the backup destination, which is always excluded and read-only
|
||||
is_backup_dest = (self.app_instance and self.app_instance.destination_path and
|
||||
item_path_str == str(Path(f"/{self.app_instance.destination_path.strip('/').split('/')[0]}").absolute()))
|
||||
is_restore_src = (restore_src_path and
|
||||
@@ -225,10 +412,8 @@ class AdvancedSettingsFrame(tk.Toplevel):
|
||||
|
||||
if self.app_instance:
|
||||
self.app_instance.update_backup_options_from_config()
|
||||
# Destroy the old icon
|
||||
self.app_instance.animated_icon.destroy()
|
||||
|
||||
# Create a new one
|
||||
bg_color = self.app_instance.style.lookup('TFrame', 'background')
|
||||
backup_animation_type = self.backup_anim_var.get()
|
||||
|
||||
@@ -239,11 +424,9 @@ class AdvancedSettingsFrame(tk.Toplevel):
|
||||
self.app_instance.animated_icon = AnimatedIcon(
|
||||
self.app_instance.action_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type=initial_animation_type)
|
||||
|
||||
# Pack it in the correct order
|
||||
self.app_instance.animated_icon.pack(
|
||||
side=tk.LEFT, padx=5, before=self.app_instance.task_progress)
|
||||
|
||||
# Set the correct state
|
||||
self.app_instance.animated_icon.stop("DISABLE")
|
||||
self.app_instance.animated_icon.animation_type = backup_animation_type
|
||||
|
||||
@@ -285,7 +468,14 @@ class AdvancedSettingsFrame(tk.Toplevel):
|
||||
for path in final_excludes:
|
||||
f.write(f"{path}\n")
|
||||
|
||||
self.destroy()
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'w') as f:
|
||||
for item in self.manual_excludes_listbox.get(0, tk.END):
|
||||
f.write(f"{item}\n")
|
||||
|
||||
# Instead of destroying the Toplevel, hide this frame and show main settings
|
||||
self.pack_forget()
|
||||
if self.show_main_settings_callback:
|
||||
self.show_main_settings_callback()
|
||||
|
||||
if self.app_instance:
|
||||
current_source = self.app_instance.left_canvas_data.get('folder')
|
||||
@@ -293,6 +483,12 @@ class AdvancedSettingsFrame(tk.Toplevel):
|
||||
self.app_instance.actions.on_sidebar_button_click(
|
||||
current_source)
|
||||
|
||||
def _cancel_changes(self):
|
||||
# Hide this frame and show main settings without applying changes
|
||||
self.pack_forget()
|
||||
if self.show_main_settings_callback:
|
||||
self.show_main_settings_callback()
|
||||
|
||||
def _load_exclude_patterns(self):
|
||||
generated_patterns = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
@@ -303,7 +499,12 @@ class AdvancedSettingsFrame(tk.Toplevel):
|
||||
user_patterns = []
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
user_patterns = [
|
||||
line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
user_patterns.extend(
|
||||
[line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
user_patterns.extend(
|
||||
[line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
|
||||
return generated_patterns, user_patterns
|
||||
|
@@ -1,56 +1,206 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from pbp_app_config import Msg
|
||||
import os
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
from core.pbp_app_config import Msg
|
||||
from pyimage_ui.system_backup_content_frame import SystemBackupContentFrame
|
||||
from pyimage_ui.user_backup_content_frame import UserBackupContentFrame
|
||||
from shared_libs.logger import app_logger
|
||||
from shared_libs.message import MessageDialog
|
||||
|
||||
|
||||
class BackupContentFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, **kwargs):
|
||||
def __init__(self, master, backup_manager, actions, app, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
app_logger.log("BackupContentFrame: __init__ called")
|
||||
self.backup_manager = backup_manager
|
||||
self.actions = actions
|
||||
self.app = app
|
||||
|
||||
self.backup_path = None
|
||||
self.base_backup_path = None
|
||||
self.current_view_index = 0
|
||||
self.viewing_encrypted = False
|
||||
|
||||
self.grid_rowconfigure(1, weight=1)
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# --- Header with buttons ---
|
||||
header_frame = ttk.Frame(self)
|
||||
header_frame.grid(row=0, column=0, sticky=tk.NSEW)
|
||||
self.grid_rowconfigure(1, weight=1) # Row for content frames
|
||||
self.grid_columnconfigure(0, weight=1) # Column for content frames
|
||||
|
||||
self.system_button = ttk.Button(
|
||||
header_frame, text=Msg.STR["system_backup_info"], command=self.show_system_backups)
|
||||
self.system_button.pack(side=tk.LEFT, padx=5)
|
||||
top_nav_frame = ttk.Frame(header_frame)
|
||||
top_nav_frame.pack(side=tk.LEFT)
|
||||
|
||||
self.user_button = ttk.Button(
|
||||
header_frame, text=Msg.STR["user_backup_info"], command=self.show_user_backups)
|
||||
self.user_button.pack(side=tk.LEFT, padx=5)
|
||||
self.nav_buttons_defs = [
|
||||
(Msg.STR["system_backup_info"], lambda: self._switch_view(0)),
|
||||
(Msg.STR["user_backup_info"], lambda: self._switch_view(1)),
|
||||
]
|
||||
|
||||
self.nav_buttons = []
|
||||
self.nav_progress_bars = []
|
||||
|
||||
for i, (text, command) in enumerate(self.nav_buttons_defs):
|
||||
button_frame = ttk.Frame(top_nav_frame)
|
||||
button_frame.pack(side=tk.LEFT, padx=5)
|
||||
button = ttk.Button(button_frame, text=text,
|
||||
command=command, style="TButton.Borderless.Round")
|
||||
button.pack(side=tk.TOP)
|
||||
self.nav_buttons.append(button)
|
||||
progress_bar = ttk.Progressbar(
|
||||
button_frame, orient="horizontal", length=50, mode="determinate", style="Small.Horizontal.TProgressbar")
|
||||
progress_bar.pack_forget()
|
||||
self.nav_progress_bars.append(progress_bar)
|
||||
|
||||
if i < len(self.nav_buttons_defs) - 1:
|
||||
ttk.Separator(top_nav_frame, orient=tk.VERTICAL).pack(
|
||||
side=tk.LEFT, fill=tk.Y, padx=2)
|
||||
|
||||
# Deletion Status UI
|
||||
self.deletion_status_frame = ttk.Frame(header_frame)
|
||||
self.deletion_status_frame.pack(side=tk.LEFT, padx=15)
|
||||
|
||||
bg_color = self.winfo_toplevel().style.lookup('TFrame', 'background')
|
||||
self.deletion_animated_icon = AnimatedIcon(
|
||||
self.deletion_status_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type="blink")
|
||||
self.deletion_animated_icon.pack(side=tk.LEFT, padx=5)
|
||||
self.deletion_animated_icon.stop("DISABLE")
|
||||
|
||||
self.deletion_status_label = ttk.Label(
|
||||
self.deletion_status_frame, text="", font=("Ubuntu", 10, "bold"))
|
||||
self.deletion_status_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
content_container = ttk.Frame(self)
|
||||
content_container.grid(row=1, column=0, sticky="nsew")
|
||||
content_container.grid_rowconfigure(0, weight=1)
|
||||
content_container.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# --- Content Frames ---
|
||||
self.system_backups_frame = SystemBackupContentFrame(
|
||||
self, backup_manager)
|
||||
self.user_backups_frame = UserBackupContentFrame(self, backup_manager)
|
||||
content_container, backup_manager, actions, parent_view=self)
|
||||
self.user_backups_frame = UserBackupContentFrame(
|
||||
content_container, backup_manager, actions, parent_view=self)
|
||||
self.system_backups_frame.grid(row=0, column=0, sticky=tk.NSEW)
|
||||
self.user_backups_frame.grid(row=0, column=0, sticky=tk.NSEW)
|
||||
|
||||
self.system_backups_frame.grid(row=1, column=0, sticky=tk.NSEW)
|
||||
self.user_backups_frame.grid(row=1, column=0, sticky=tk.NSEW)
|
||||
action_button_frame = ttk.Frame(self, padding=10)
|
||||
action_button_frame.grid(row=2, column=0, sticky="ew")
|
||||
|
||||
self.show_system_backups() # Show system backups by default
|
||||
self.restore_button = ttk.Button(
|
||||
action_button_frame, text=Msg.STR["restore"], command=self._restore_selected, state="disabled")
|
||||
self.restore_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.delete_button = ttk.Button(
|
||||
action_button_frame, text=Msg.STR["delete"], command=self._delete_selected, state="disabled")
|
||||
self.delete_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.edit_comment_button = ttk.Button(
|
||||
action_button_frame, text=Msg.STR["comment"], command=self._edit_comment, state="disabled")
|
||||
self.edit_comment_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self._switch_view(0)
|
||||
|
||||
def update_button_state(self, is_selected):
|
||||
self.restore_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
self.delete_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
self.edit_comment_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
|
||||
def _get_active_subframe(self):
|
||||
return self.system_backups_frame if self.current_view_index == 0 else self.user_backups_frame
|
||||
|
||||
def _restore_selected(self):
|
||||
self._get_active_subframe()._restore_selected()
|
||||
|
||||
def _delete_selected(self):
|
||||
self._get_active_subframe()._delete_selected()
|
||||
|
||||
def _edit_comment(self):
|
||||
self._get_active_subframe()._edit_comment()
|
||||
|
||||
def _switch_view(self, index):
|
||||
self.current_view_index = index
|
||||
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
|
||||
self.app.config_manager.set_setting(config_key, index)
|
||||
self.update_nav_buttons(index)
|
||||
|
||||
if index == 0:
|
||||
self.system_backups_frame.grid()
|
||||
self.user_backups_frame.grid_remove()
|
||||
else:
|
||||
self.user_backups_frame.grid()
|
||||
self.system_backups_frame.grid_remove()
|
||||
self.update_button_state(False)
|
||||
|
||||
def update_nav_buttons(self, active_index):
|
||||
for i, button in enumerate(self.nav_buttons):
|
||||
if i == active_index:
|
||||
button.configure(style="Toolbutton")
|
||||
self.nav_progress_bars[i].pack(side=tk.BOTTOM, fill=tk.X)
|
||||
self.nav_progress_bars[i]['value'] = 100
|
||||
else:
|
||||
button.configure(style="Gray.Toolbutton")
|
||||
self.nav_progress_bars[i].pack_forget()
|
||||
|
||||
def show(self, backup_path):
|
||||
app_logger.log(
|
||||
f"BackupContentFrame: show called with path {backup_path}")
|
||||
self.grid(row=2, column=0, sticky="nsew")
|
||||
if backup_path and self.backup_path != backup_path:
|
||||
self.backup_path = backup_path
|
||||
self.system_backups_frame.show(backup_path)
|
||||
self.user_backups_frame.show(backup_path)
|
||||
|
||||
self.base_backup_path = backup_path
|
||||
|
||||
# Check if the destination is encrypted and trigger mount if necessary
|
||||
is_encrypted = self.backup_manager.encryption_manager.is_encrypted(
|
||||
backup_path)
|
||||
if is_encrypted and not self.backup_manager.encryption_manager.is_mounted(backup_path):
|
||||
app_logger.log(
|
||||
"Encrypted destination is not mounted. Attempting to mount.")
|
||||
mount_point = self.backup_manager.encryption_manager.mount(
|
||||
backup_path)
|
||||
if not mount_point:
|
||||
app_logger.log("Mount failed. Cannot display backup content.")
|
||||
MessageDialog(
|
||||
message_type="error", title=Msg.STR["error"], text=Msg.STR["err_unlock_failed"])
|
||||
# Clear views and return if mount fails
|
||||
self.system_backups_frame.show(backup_path, [])
|
||||
self.user_backups_frame.show(backup_path, [])
|
||||
return
|
||||
# Refresh header status after successful mount
|
||||
self.app.header_frame.refresh_status()
|
||||
|
||||
pybackup_dir = os.path.join(backup_path, "pybackup")
|
||||
|
||||
if not os.path.isdir(pybackup_dir):
|
||||
app_logger.log(
|
||||
f"Backup path {pybackup_dir} does not exist or is not a directory.")
|
||||
# Clear views if path is invalid
|
||||
self.system_backups_frame.show(backup_path, [])
|
||||
self.user_backups_frame.show(backup_path, [])
|
||||
return
|
||||
|
||||
all_backups = self.backup_manager.list_all_backups(backup_path)
|
||||
if all_backups:
|
||||
system_backups, user_backups = all_backups
|
||||
self.system_backups_frame.show(backup_path, system_backups)
|
||||
self.user_backups_frame.show(backup_path, user_backups)
|
||||
else:
|
||||
# Handle case where inspection returns None (e.g. encrypted and mount_if_needed=False)
|
||||
self.system_backups_frame.show(backup_path, [])
|
||||
self.user_backups_frame.show(backup_path, [])
|
||||
|
||||
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
|
||||
last_view = self.app.config_manager.get_setting(config_key, 0)
|
||||
self._switch_view(last_view)
|
||||
|
||||
def hide(self):
|
||||
self.grid_remove()
|
||||
|
||||
def show_system_backups(self):
|
||||
self.system_backups_frame.grid()
|
||||
self.user_backups_frame.grid_remove()
|
||||
def show_deletion_status(self, text: str):
|
||||
app_logger.log(f"Showing deletion status: {text}")
|
||||
self.deletion_status_label.config(text=text)
|
||||
self.deletion_animated_icon.start()
|
||||
self.deletion_status_frame.pack(side=tk.LEFT, padx=15)
|
||||
|
||||
def show_user_backups(self):
|
||||
self.user_backups_frame.grid()
|
||||
self.system_backups_frame.grid_remove()
|
||||
def hide_deletion_status(self):
|
||||
app_logger.log("Hiding deletion status text.")
|
||||
self.deletion_status_label.config(text="")
|
||||
self.deletion_animated_icon.stop("DISABLE")
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# pyimage/ui/drawing.py
|
||||
import tkinter as tk
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
import os
|
||||
import threading
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
@@ -234,54 +234,60 @@ class Drawing:
|
||||
self.app.after(50, self.update_target_projection)
|
||||
return
|
||||
|
||||
projected_total_used = self.app.destination_used_bytes + self.app.source_size_bytes
|
||||
projected_total_percentage = projected_total_used / \
|
||||
self.app.destination_total_bytes
|
||||
# Determine required space, considering compression
|
||||
required_space = self.app.source_size_bytes
|
||||
if self.app.compressed_var.get():
|
||||
required_space *= 2 # Double the space for compression process
|
||||
|
||||
info_font = (AppConfig.UI_CONFIG["font_family"], 12, "bold")
|
||||
|
||||
if projected_total_percentage >= 0.95:
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(
|
||||
), fill="#ff0000", outline="") # Red bar
|
||||
elif projected_total_percentage >= 0.90:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(
|
||||
), fill="#ff8c00", outline="") # Orange bar
|
||||
projected_total_used = self.app.destination_used_bytes + required_space
|
||||
|
||||
if self.app.destination_total_bytes > 0:
|
||||
projected_total_percentage = projected_total_used / self.app.destination_total_bytes
|
||||
else:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
used_percentage = self.app.destination_used_bytes / \
|
||||
self.app.destination_total_bytes
|
||||
used_width = canvas_width * used_percentage
|
||||
canvas.create_rectangle(
|
||||
0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
|
||||
projected_percentage = self.app.source_size_bytes / \
|
||||
self.app.destination_total_bytes
|
||||
projected_width = canvas_width * projected_percentage
|
||||
canvas.create_rectangle(used_width, 0, used_width + projected_width,
|
||||
canvas.winfo_height(), fill="#ff8c00", outline="")
|
||||
projected_total_percentage = 0
|
||||
|
||||
info_font = (AppConfig.UI_CONFIG["font_family"], 10, "bold")
|
||||
info_messages = []
|
||||
if self.app.source_larger_than_partition:
|
||||
info_messages.append(
|
||||
Msg.STR["warning_source_larger_than_partition"])
|
||||
|
||||
if projected_total_percentage >= 0.95:
|
||||
# First, check for critical space issues
|
||||
if projected_total_used > self.app.destination_total_bytes or projected_total_percentage >= 0.95:
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(), fill="#D32F2F", outline="") # Red bar
|
||||
info_messages.append(Msg.STR["warning_not_enough_space"])
|
||||
|
||||
elif projected_total_percentage >= 0.90:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(), fill="#E8740C", outline="") # Orange bar
|
||||
info_messages.append(Msg.STR["warning_space_over_90_percent"])
|
||||
|
||||
else:
|
||||
# Only enable the button if the source is not larger than the partition itself
|
||||
if not self.app.source_larger_than_partition:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
else:
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
|
||||
if not info_messages: # If no warnings, show other messages or default text
|
||||
if self.app.is_first_backup:
|
||||
used_percentage = self.app.destination_used_bytes / self.app.destination_total_bytes
|
||||
used_width = canvas_width * used_percentage
|
||||
canvas.create_rectangle(0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
|
||||
# Draw the projected part only if there is space
|
||||
projected_percentage = self.app.source_size_bytes / self.app.destination_total_bytes
|
||||
projected_width = canvas_width * projected_percentage
|
||||
canvas.create_rectangle(used_width, 0, used_width + projected_width, canvas.winfo_height(), fill="#50E6FF", outline="")
|
||||
|
||||
# Add other informational messages if no critical warnings are present
|
||||
if not info_messages:
|
||||
if self.app.source_larger_than_partition:
|
||||
info_messages.append(Msg.STR["warning_source_larger_than_partition"])
|
||||
elif self.app.is_first_backup:
|
||||
info_messages.append(Msg.STR["ready_for_first_backup"])
|
||||
elif self.app.mode == "backup":
|
||||
info_messages.append(Msg.STR["backup_mode_info"])
|
||||
else:
|
||||
info_messages.append(Msg.STR["restore_mode_info"])
|
||||
|
||||
self.app.info_label.config(
|
||||
text="\n".join(info_messages), font=info_font)
|
||||
self.app.info_label.config(text="\n".join(info_messages), font=info_font)
|
||||
|
||||
self.app.target_size_label.config(
|
||||
text=f"{projected_total_used / (1024**3):.2f} GB / {self.app.destination_total_bytes / (1024**3):.2f} GB")
|
||||
|
113
pyimage_ui/encryption_frame.py
Normal file
113
pyimage_ui/encryption_frame.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from shared_libs.message import MessageDialog
|
||||
import keyring
|
||||
|
||||
|
||||
class EncryptionFrame(ttk.Frame):
|
||||
def __init__(self, parent, app, encryption_manager, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
self.app = app
|
||||
self.encryption_manager = encryption_manager
|
||||
self.username = None
|
||||
|
||||
self.columnconfigure(0, weight=1)
|
||||
|
||||
ttk.Label(self, text="Encryption Settings", font=("Ubuntu", 16, "bold")).grid(
|
||||
row=0, column=0, pady=10, sticky="w")
|
||||
|
||||
# Keyring status
|
||||
self.keyring_status_label = ttk.Label(self, text="")
|
||||
self.keyring_status_label.grid(
|
||||
row=1, column=0, sticky="ew", padx=10, pady=5)
|
||||
|
||||
self.keyring_usage_label = ttk.Label(self, text="")
|
||||
self.keyring_usage_label.grid(row=4, column=0, sticky="ew", padx=10, pady=5)
|
||||
|
||||
self.check_keyring_availability()
|
||||
|
||||
# Password section
|
||||
password_frame = ttk.LabelFrame(self, text="Password Management")
|
||||
password_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=10)
|
||||
password_frame.columnconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(password_frame, text="Password:").grid(
|
||||
row=0, column=0, padx=5, pady=5, sticky="w")
|
||||
self.password_entry = ttk.Entry(password_frame, show="*")
|
||||
self.password_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
|
||||
|
||||
self.save_to_keyring_var = tk.BooleanVar()
|
||||
self.save_to_keyring_cb = ttk.Checkbutton(
|
||||
password_frame, text="Save password to system keyring", variable=self.save_to_keyring_var)
|
||||
self.save_to_keyring_cb.grid(
|
||||
row=1, column=0, columnspan=2, padx=5, pady=5, sticky="w")
|
||||
|
||||
set_password_button = ttk.Button(
|
||||
password_frame, text="Set Session Password", command=self.set_session_password)
|
||||
set_password_button.grid(row=2, column=0, padx=5, pady=5)
|
||||
|
||||
clear_password_button = ttk.Button(
|
||||
password_frame, text="Clear Session Password", command=self.clear_session_password)
|
||||
clear_password_button.grid(row=2, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
self.status_message_label = ttk.Label(self, text="", foreground="blue")
|
||||
self.status_message_label.grid(row=3, column=0, sticky="ew", padx=10, pady=5)
|
||||
|
||||
def set_context(self, username):
|
||||
self.username = username
|
||||
self.update_keyring_status()
|
||||
|
||||
def check_keyring_availability(self):
|
||||
try:
|
||||
kr = keyring.get_keyring()
|
||||
if kr is None:
|
||||
self.keyring_status_label.config(
|
||||
text="No system keyring found. Passwords will not be saved.", foreground="orange")
|
||||
self.save_to_keyring_cb.config(state="disabled")
|
||||
else:
|
||||
self.keyring_status_label.config(
|
||||
text="System keyring is available.", foreground="green")
|
||||
except keyring.errors.NoKeyringError:
|
||||
self.keyring_status_label.config(
|
||||
text="No system keyring found. Passwords will not be saved.", foreground="orange")
|
||||
self.save_to_keyring_cb.config(state="disabled")
|
||||
|
||||
def set_session_password(self):
|
||||
password = self.password_entry.get()
|
||||
if not password:
|
||||
self.status_message_label.config(text="Password cannot be empty.", foreground="red")
|
||||
return
|
||||
|
||||
self.encryption_manager.set_session_password(password, self.save_to_keyring_var.get())
|
||||
|
||||
if self.save_to_keyring_var.get():
|
||||
if not self.username:
|
||||
self.status_message_label.config(text="Please select a backup destination first.", foreground="orange")
|
||||
return
|
||||
if self.encryption_manager.set_password_in_keyring(self.username, password):
|
||||
self.status_message_label.config(text="Password set for this session and saved to keyring.", foreground="green")
|
||||
self.update_keyring_status()
|
||||
else:
|
||||
self.status_message_label.config(text="Password set for this session, but failed to save to keyring.", foreground="orange")
|
||||
else:
|
||||
self.status_message_label.config(text="Password set for this session.", foreground="green")
|
||||
|
||||
|
||||
def clear_session_password(self):
|
||||
self.encryption_manager.clear_session_password()
|
||||
self.password_entry.delete(0, tk.END)
|
||||
self.status_message_label.config(text="Session password cleared.", foreground="green")
|
||||
if self.username:
|
||||
self.encryption_manager.delete_password_from_keyring(self.username)
|
||||
self.update_keyring_status()
|
||||
|
||||
def update_keyring_status(self):
|
||||
if not self.username:
|
||||
self.keyring_usage_label.config(text="Select a backup destination to see keyring status.", foreground="blue")
|
||||
return
|
||||
|
||||
if self.encryption_manager.get_password_from_keyring(self.username):
|
||||
self.keyring_usage_label.config(text=f'Password for "{self.username}" is stored in the keyring.', foreground="green")
|
||||
else:
|
||||
self.keyring_usage_label.config(text=f'No password for "{self.username}" found in the keyring.', foreground="orange")
|
||||
|
@@ -1,14 +1,16 @@
|
||||
import tkinter as tk
|
||||
import os
|
||||
|
||||
from pbp_app_config import Msg
|
||||
from core.pbp_app_config import Msg
|
||||
from shared_libs.common_tools import IconManager
|
||||
|
||||
|
||||
class HeaderFrame(tk.Frame):
|
||||
def __init__(self, container, image_manager, **kwargs):
|
||||
def __init__(self, container, image_manager, encryption_manager, app, **kwargs):
|
||||
super().__init__(container, bg="#455A64", **kwargs)
|
||||
|
||||
self.image_manager = image_manager
|
||||
self.encryption_manager = encryption_manager
|
||||
self.app = app
|
||||
|
||||
# Configure grid weights for internal layout
|
||||
self.columnconfigure(1, weight=1) # Make the middle column expand
|
||||
@@ -48,18 +50,54 @@ class HeaderFrame(tk.Frame):
|
||||
subtitle_label.grid(row=1, column=1, sticky="w",
|
||||
padx=(5, 20), pady=(0, 10))
|
||||
|
||||
# Right side: Placeholder for future info or buttons
|
||||
# Right side: Keyring status
|
||||
right_frame = tk.Frame(self, bg="#455A64")
|
||||
right_frame.grid(row=0, column=2, rowspan=2, sticky="nsew")
|
||||
right_frame.columnconfigure(0, weight=1)
|
||||
right_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# Example of content for the right side (can be removed or replaced)
|
||||
# info_label = tk.Label(
|
||||
# right_frame,
|
||||
# text="Some Info Here",
|
||||
# font=("Helvetica", 10),
|
||||
# fg="#ecf0f1",
|
||||
# bg="#455A64",
|
||||
# )
|
||||
# info_label.grid(row=0, column=0, sticky="ne", padx=(10, 10), pady=(10, 0))
|
||||
self.keyring_status_label = tk.Label(
|
||||
right_frame,
|
||||
text="",
|
||||
font=("Helvetica", 10, "bold"),
|
||||
bg="#455A64",
|
||||
)
|
||||
self.keyring_status_label.grid(row=0, column=0, sticky="ne", padx=(10, 10), pady=(10, 0))
|
||||
|
||||
self.refresh_status()
|
||||
|
||||
def refresh_status(self):
|
||||
"""Checks the keyring status based on the current destination and updates the label."""
|
||||
dest_path = self.app.destination_path
|
||||
if not dest_path or not self.encryption_manager.is_encrypted(dest_path):
|
||||
self.keyring_status_label.config(text="") # Clear status if not encrypted
|
||||
return
|
||||
|
||||
username = os.path.basename(dest_path.rstrip('/'))
|
||||
|
||||
if self.encryption_manager.is_mounted(dest_path):
|
||||
status_text = "Key: In Use"
|
||||
auth_method = getattr(self.encryption_manager, 'auth_method', None)
|
||||
if auth_method == 'keyring':
|
||||
status_text += " (Keyring)"
|
||||
elif auth_method == 'keyfile':
|
||||
status_text += " (Keyfile)"
|
||||
self.keyring_status_label.config(
|
||||
text=status_text,
|
||||
fg="#2E8B57" # SeaGreen
|
||||
)
|
||||
elif self.encryption_manager.is_key_in_keyring(username):
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Available (Keyring)",
|
||||
fg="#FFD700" # Gold
|
||||
)
|
||||
elif os.path.exists(self.encryption_manager.get_key_file_path(dest_path)):
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Available (Keyfile)",
|
||||
fg="#FFD700" # Gold
|
||||
)
|
||||
else:
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Not Available",
|
||||
fg="#A9A9A9" # DarkGray
|
||||
)
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import os
|
||||
import shutil
|
||||
from shared_libs.message import MessageDialog
|
||||
from pbp_app_config import Msg
|
||||
from core.pbp_app_config import Msg
|
||||
|
||||
|
||||
class Navigation:
|
||||
@@ -141,7 +141,8 @@ class Navigation:
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.scheduler_frame.hide()
|
||||
self.app.settings_frame.hide()
|
||||
self.app.backup_content_frame.hide()
|
||||
self.app.backup_content_frame.grid_remove()
|
||||
|
||||
|
||||
# Show the main content frames
|
||||
self.app.canvas_frame.grid()
|
||||
@@ -185,7 +186,8 @@ class Navigation:
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.scheduler_frame.hide()
|
||||
self.app.settings_frame.hide()
|
||||
self.app.backup_content_frame.hide()
|
||||
self.app.backup_content_frame.grid_remove()
|
||||
|
||||
self.app.canvas_frame.grid()
|
||||
self.app.source_size_frame.grid()
|
||||
self.app.target_size_frame.grid()
|
||||
@@ -222,7 +224,7 @@ class Navigation:
|
||||
self.app.canvas_frame.grid_remove()
|
||||
self.app.scheduler_frame.hide()
|
||||
self.app.settings_frame.hide()
|
||||
self.app.backup_content_frame.hide()
|
||||
self.app.backup_content_frame.grid_remove()
|
||||
self.app.source_size_frame.grid_remove()
|
||||
self.app.target_size_frame.grid_remove()
|
||||
self.app.restore_size_frame_before.grid_remove()
|
||||
@@ -239,7 +241,8 @@ class Navigation:
|
||||
self.app.canvas_frame.grid_remove()
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.settings_frame.hide()
|
||||
self.app.backup_content_frame.hide()
|
||||
self.app.backup_content_frame.grid_remove()
|
||||
|
||||
self.app.source_size_frame.grid_remove()
|
||||
self.app.target_size_frame.grid_remove()
|
||||
self.app.restore_size_frame_before.grid_remove()
|
||||
@@ -255,8 +258,9 @@ class Navigation:
|
||||
|
||||
self.app.canvas_frame.grid_remove()
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.backup_content_frame.hide()
|
||||
self.app.backup_content_frame.grid_remove()
|
||||
self.app.scheduler_frame.hide()
|
||||
|
||||
self.app.source_size_frame.grid_remove()
|
||||
self.app.target_size_frame.grid_remove()
|
||||
self.app.restore_size_frame_before.grid_remove()
|
||||
@@ -276,13 +280,29 @@ class Navigation:
|
||||
self.toggle_mode("backup", 0)
|
||||
return
|
||||
|
||||
# Mount the destination if it is encrypted and not already mounted
|
||||
if self.app.backup_manager.encryption_manager.is_encrypted(self.app.destination_path):
|
||||
if not self.app.backup_manager.encryption_manager.is_mounted(self.app.destination_path):
|
||||
mount_point = self.app.backup_manager.encryption_manager.mount(self.app.destination_path)
|
||||
if not mount_point:
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR.get("err_mount_failed", "Mounting failed."))
|
||||
self.toggle_mode("backup", 0)
|
||||
return
|
||||
|
||||
self.app.header_frame.refresh_status()
|
||||
|
||||
self.app.canvas_frame.grid_remove()
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.scheduler_frame.hide()
|
||||
self.app.settings_frame.hide()
|
||||
|
||||
self.app.source_size_frame.grid_remove()
|
||||
self.app.target_size_frame.grid_remove()
|
||||
self.app.restore_size_frame_before.grid_remove()
|
||||
self.app.restore_size_frame_after.grid_remove()
|
||||
self.app.backup_content_frame.show(self.app.destination_path)
|
||||
self.app.top_bar.grid()
|
||||
self._update_task_bar_visibility("scheduler")
|
||||
|
||||
|
||||
|
63
pyimage_ui/password_dialog.py
Normal file
63
pyimage_ui/password_dialog.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
class PasswordDialog(tk.Toplevel):
|
||||
def __init__(self, parent, title="Password Required", confirm=True):
|
||||
super().__init__(parent)
|
||||
self.title(title)
|
||||
self.parent = parent
|
||||
self.password = None
|
||||
self.save_to_keyring = tk.BooleanVar()
|
||||
self.confirm = confirm
|
||||
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
|
||||
ttk.Label(self, text="Please enter the password for the encrypted backup:").pack(padx=20, pady=10)
|
||||
self.password_entry = ttk.Entry(self, show="*")
|
||||
self.password_entry.pack(padx=20, pady=5, fill="x", expand=True)
|
||||
self.password_entry.focus_set()
|
||||
|
||||
if self.confirm:
|
||||
ttk.Label(self, text="Confirm password:").pack(padx=20, pady=10)
|
||||
self.confirm_entry = ttk.Entry(self, show="*")
|
||||
self.confirm_entry.pack(padx=20, pady=5, fill="x", expand=True)
|
||||
|
||||
self.save_to_keyring_cb = ttk.Checkbutton(self, text="Save password to system keyring", variable=self.save_to_keyring)
|
||||
self.save_to_keyring_cb.pack(padx=20, pady=10)
|
||||
|
||||
button_frame = ttk.Frame(self)
|
||||
button_frame.pack(pady=10)
|
||||
|
||||
ok_button = ttk.Button(button_frame, text="OK", command=self.on_ok)
|
||||
ok_button.pack(side="left", padx=5)
|
||||
cancel_button = ttk.Button(button_frame, text="Cancel", command=self.on_cancel)
|
||||
cancel_button.pack(side="left", padx=5)
|
||||
|
||||
self.bind("<Return>", lambda event: self.on_ok())
|
||||
self.bind("<Escape>", lambda event: self.on_cancel())
|
||||
|
||||
self.wait_window(self)
|
||||
|
||||
def on_ok(self):
|
||||
password = self.password_entry.get()
|
||||
|
||||
if not password:
|
||||
messagebox.showerror("Error", "Password cannot be empty.", parent=self)
|
||||
return
|
||||
|
||||
if self.confirm:
|
||||
confirm = self.confirm_entry.get()
|
||||
if password != confirm:
|
||||
messagebox.showerror("Error", "Passwords do not match.", parent=self)
|
||||
return
|
||||
|
||||
self.password = password
|
||||
self.destroy()
|
||||
|
||||
def on_cancel(self):
|
||||
self.password = None
|
||||
self.destroy()
|
||||
|
||||
def get_password(self):
|
||||
return self.password, self.save_to_keyring.get()
|
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from shared_libs.message import MessageDialog
|
||||
from pbp_app_config import Msg
|
||||
from core.pbp_app_config import Msg
|
||||
|
||||
|
||||
class SchedulerFrame(ttk.Frame):
|
||||
|
@@ -3,8 +3,10 @@ from tkinter import ttk
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
from pyimage_ui.advanced_settings_frame import AdvancedSettingsFrame
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from shared_libs.message import MessageDialog
|
||||
|
||||
|
||||
class SettingsFrame(ttk.Frame):
|
||||
@@ -18,12 +20,12 @@ class SettingsFrame(ttk.Frame):
|
||||
self.user_exclude_patterns = []
|
||||
|
||||
# --- Container for Treeviews ---
|
||||
trees_container = ttk.Frame(self)
|
||||
trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
self.trees_container = ttk.Frame(self)
|
||||
self.trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
|
||||
# --- Treeview for file/folder exclusion ---
|
||||
self.tree_frame = ttk.LabelFrame(
|
||||
trees_container, text=Msg.STR["user_defined_folder_settings"], padding=10)
|
||||
self.trees_container, text=Msg.STR["user_defined_folder_settings"], padding=10)
|
||||
self.tree_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
columns = ("included", "name", "path")
|
||||
@@ -43,7 +45,7 @@ class SettingsFrame(ttk.Frame):
|
||||
|
||||
# --- Treeview for hidden files (initially hidden) ---
|
||||
self.hidden_tree_frame = ttk.LabelFrame(
|
||||
trees_container, text=Msg.STR["hidden_files_and_folders"], padding=10)
|
||||
self.trees_container, text=Msg.STR["hidden_files_and_folders"], padding=10)
|
||||
self.hidden_tree = ttk.Treeview(
|
||||
self.hidden_tree_frame, columns=columns, show="headings")
|
||||
self.hidden_tree.heading("included", text=Msg.STR["in_backup"])
|
||||
@@ -60,11 +62,11 @@ class SettingsFrame(ttk.Frame):
|
||||
self.hidden_tree_frame.pack_forget() # Initially hidden
|
||||
|
||||
# --- Action Buttons ---
|
||||
button_frame = ttk.Frame(self)
|
||||
button_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
self.button_frame = ttk.Frame(self)
|
||||
self.button_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
self.show_hidden_button = ttk.Button(
|
||||
button_frame, command=self._toggle_hidden_files_view, style="TButton.Borderless.Round")
|
||||
self.button_frame, command=self._toggle_hidden_files_view, style="TButton.Borderless.Round")
|
||||
self.show_hidden_button.pack(side=tk.LEFT)
|
||||
self.unhide_icon = self.master.master.master.image_manager.get_icon(
|
||||
'hide')
|
||||
@@ -72,23 +74,56 @@ class SettingsFrame(ttk.Frame):
|
||||
'unhide')
|
||||
self.show_hidden_button.config(image=self.unhide_icon)
|
||||
|
||||
add_to_exclude_button = ttk.Button(
|
||||
self.button_frame, text=Msg.STR["add_to_exclude_list"], command=self._add_to_exclude_list)
|
||||
add_to_exclude_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
apply_button = ttk.Button(
|
||||
button_frame, text=Msg.STR["apply"], command=self._apply_changes)
|
||||
self.button_frame, text=Msg.STR["apply"], command=self._apply_changes)
|
||||
apply_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
cancel_button = ttk.Button(button_frame, text=Msg.STR["cancel"],
|
||||
cancel_button = ttk.Button(self.button_frame, text=Msg.STR["cancel"],
|
||||
command=lambda: self.navigation.toggle_mode("backup", 0))
|
||||
cancel_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
advanced_button = ttk.Button(
|
||||
button_frame, text=Msg.STR["advanced"], command=self._open_advanced_settings)
|
||||
self.button_frame, text=Msg.STR["advanced"], command=self._open_advanced_settings)
|
||||
advanced_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
reset_button = ttk.Button(
|
||||
button_frame, text=Msg.STR["default_settings"], command=self.actions.reset_to_default_settings)
|
||||
self.button_frame, text=Msg.STR["default_settings"], command=self.actions.reset_to_default_settings)
|
||||
reset_button.pack(side=tk.RIGHT)
|
||||
|
||||
self.hidden_files_visible = False
|
||||
self.advanced_settings_frame_instance = None # To hold the instance of AdvancedSettingsFrame
|
||||
|
||||
def _add_to_exclude_list(self) -> bool:
|
||||
result = MessageDialog("ask", Msg.STR["exclude_dialog_text"], title=Msg.STR["add_to_exclude_list"], buttons=[
|
||||
Msg.STR["add_folder_button"], Msg.STR["add_file_button"]]).show()
|
||||
|
||||
path = None
|
||||
if result:
|
||||
dialog = CustomFileDialog(
|
||||
self, mode="dir", title=Msg.STR["add_to_exclude_list"])
|
||||
self.wait_window(dialog)
|
||||
path = dialog.get_result()
|
||||
dialog.destroy()
|
||||
else:
|
||||
dialog = CustomFileDialog(
|
||||
self, filetypes=[("All Files", "*.*")], title=Msg.STR["add_to_exclude_list"])
|
||||
self.wait_window(dialog)
|
||||
path = dialog.get_result()
|
||||
dialog.destroy()
|
||||
|
||||
if path:
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'a') as f:
|
||||
if os.path.isdir(path):
|
||||
f.write(f"\n{path}/*")
|
||||
else:
|
||||
f.write(f"\n{path}")
|
||||
|
||||
self.load_and_display_excludes()
|
||||
self._load_hidden_files()
|
||||
|
||||
def show(self):
|
||||
self.grid(row=2, column=0, sticky="nsew")
|
||||
@@ -98,27 +133,27 @@ class SettingsFrame(ttk.Frame):
|
||||
self.grid_remove()
|
||||
|
||||
def _load_exclude_patterns(self):
|
||||
|
||||
generated_patterns = []
|
||||
all_patterns = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.GENERATED_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
generated_patterns = [
|
||||
line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
|
||||
self.user_exclude_patterns = []
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
self.user_exclude_patterns = [
|
||||
line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
|
||||
return generated_patterns, self.user_exclude_patterns
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
|
||||
return all_patterns
|
||||
|
||||
def load_and_display_excludes(self):
|
||||
# Clear existing items
|
||||
for i in self.tree.get_children():
|
||||
self.tree.delete(i)
|
||||
|
||||
_, self.user_exclude_patterns = self._load_exclude_patterns()
|
||||
exclude_patterns = self._load_exclude_patterns()
|
||||
|
||||
home_dir = Path.home()
|
||||
|
||||
@@ -126,7 +161,7 @@ class SettingsFrame(ttk.Frame):
|
||||
for item in home_dir.iterdir():
|
||||
if not item.name.startswith('.'):
|
||||
item_path_str = str(item.absolute())
|
||||
is_excluded = f"{item_path_str}/*" in self.user_exclude_patterns
|
||||
is_excluded = f"{item_path_str}/*" in exclude_patterns or item_path_str in exclude_patterns
|
||||
included_text = Msg.STR["no"] if is_excluded else Msg.STR["yes"]
|
||||
items_to_display.append(
|
||||
(included_text, item.name, item_path_str))
|
||||
@@ -183,16 +218,16 @@ class SettingsFrame(ttk.Frame):
|
||||
else:
|
||||
new_excludes.append(path)
|
||||
|
||||
# Load existing patterns
|
||||
existing_patterns = []
|
||||
# Load existing user patterns
|
||||
existing_user_patterns = []
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
existing_patterns = [
|
||||
existing_user_patterns = [
|
||||
line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
|
||||
# Preserve patterns that are not managed by this view
|
||||
preserved_patterns = []
|
||||
for pattern in existing_patterns:
|
||||
for pattern in existing_user_patterns:
|
||||
clean_pattern = pattern.replace('/*', '')
|
||||
if clean_pattern not in tree_paths:
|
||||
preserved_patterns.append(pattern)
|
||||
@@ -200,12 +235,6 @@ class SettingsFrame(ttk.Frame):
|
||||
# Combine preserved patterns with new excludes from this view
|
||||
final_excludes = list(set(preserved_patterns + new_excludes))
|
||||
|
||||
# Handle backup destination separately to ensure it's always excluded
|
||||
if self.master.master.master.destination_path:
|
||||
backup_root_to_exclude = f"/{self.master.master.master.destination_path.strip('/').split('/')[0]}/*"
|
||||
if backup_root_to_exclude not in final_excludes:
|
||||
final_excludes.append(backup_root_to_exclude)
|
||||
|
||||
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'w') as f:
|
||||
for path in final_excludes:
|
||||
f.write(f"{path}\n")
|
||||
@@ -216,12 +245,30 @@ class SettingsFrame(ttk.Frame):
|
||||
self._load_hidden_files()
|
||||
|
||||
def _open_advanced_settings(self):
|
||||
advanced_settings_window = AdvancedSettingsFrame(
|
||||
self.master,
|
||||
config_manager=self.master.master.master.config_manager,
|
||||
app_instance=self.master.master.master
|
||||
)
|
||||
advanced_settings_window.grab_set()
|
||||
# Hide main settings UI elements
|
||||
self.trees_container.pack_forget() # Hide the container for treeviews
|
||||
self.button_frame.pack_forget()
|
||||
|
||||
# Create AdvancedSettingsFrame if not already created
|
||||
if not self.advanced_settings_frame_instance:
|
||||
self.advanced_settings_frame_instance = AdvancedSettingsFrame(
|
||||
self, # Parent is now self (SettingsFrame)
|
||||
config_manager=self.master.master.master.config_manager,
|
||||
app_instance=self.master.master.master,
|
||||
show_main_settings_callback=self._show_main_settings
|
||||
)
|
||||
|
||||
# Pack the AdvancedSettingsFrame
|
||||
self.advanced_settings_frame_instance.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
def _show_main_settings(self):
|
||||
# Hide advanced settings frame
|
||||
if self.advanced_settings_frame_instance:
|
||||
self.advanced_settings_frame_instance.pack_forget()
|
||||
|
||||
# Show main settings UI elements
|
||||
self.trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Re-pack the container for treeviews
|
||||
self.button_frame.pack(fill=tk.X, padx=10, pady=10) # Re-pack the button frame
|
||||
|
||||
def _toggle_hidden_files_view(self):
|
||||
self.hidden_files_visible = not self.hidden_files_visible
|
||||
@@ -240,7 +287,7 @@ class SettingsFrame(ttk.Frame):
|
||||
for i in self.hidden_tree.get_children():
|
||||
self.hidden_tree.delete(i)
|
||||
|
||||
_, self.user_exclude_patterns = self._load_exclude_patterns()
|
||||
exclude_patterns = self._load_exclude_patterns()
|
||||
|
||||
home_dir = Path.home()
|
||||
|
||||
@@ -248,7 +295,7 @@ class SettingsFrame(ttk.Frame):
|
||||
for item in home_dir.iterdir():
|
||||
if item.name.startswith('.'):
|
||||
item_path_str = str(item.absolute())
|
||||
is_excluded = f"{item_path_str}/*" in self.user_exclude_patterns or f"{item_path_str}" in self.user_exclude_patterns
|
||||
is_excluded = f"{item_path_str}/*" in exclude_patterns or f"{item_path_str}" in exclude_patterns
|
||||
included_text = Msg.STR["no"] if is_excluded else Msg.STR["yes"]
|
||||
items_to_display.append(
|
||||
(included_text, item.name, item_path_str))
|
||||
|
@@ -2,129 +2,164 @@ import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
|
||||
from pbp_app_config import Msg
|
||||
from core.pbp_app_config import Msg
|
||||
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
|
||||
|
||||
|
||||
class SystemBackupContentFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, **kwargs):
|
||||
def __init__(self, master, backup_manager, actions, parent_view, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
self.actions = actions
|
||||
self.parent_view = parent_view
|
||||
self.system_backups_list = []
|
||||
self.backup_path = None
|
||||
|
||||
# --- Backup Content List View ---
|
||||
self.content_frame = ttk.LabelFrame(
|
||||
self, text=Msg.STR["backup_content"], padding=10)
|
||||
self.content_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.tag_colors = [
|
||||
("full_blue", "#0078D7", "inc_blue", "#50E6FF"),
|
||||
("full_orange", "#E8740C", "inc_orange", "#FFB366"),
|
||||
("full_green", "#107C10", "inc_green", "#50E680"),
|
||||
("full_purple", "#8B107C", "inc_purple", "#D46EE5"),
|
||||
]
|
||||
|
||||
columns = ("date", "type", "size", "comment", "folder_name")
|
||||
columns = ("date", "time", "type", "size", "comment")
|
||||
self.content_tree = ttk.Treeview(
|
||||
self.content_frame, columns=columns, show="headings")
|
||||
self.content_tree.heading(
|
||||
"date", text=Msg.STR["date"])
|
||||
self.content_tree.heading(
|
||||
"type", text=Msg.STR["type"])
|
||||
self.content_tree.heading(
|
||||
"size", text=Msg.STR["size"])
|
||||
self.content_tree.heading(
|
||||
"comment", text=Msg.STR["comment"])
|
||||
self.content_tree.heading(
|
||||
"folder_name", text=Msg.STR["folder"])
|
||||
self, columns=columns, show="headings")
|
||||
self.content_tree.heading("date", text=Msg.STR["date"])
|
||||
self.content_tree.heading("time", text=Msg.STR["time"])
|
||||
self.content_tree.heading("type", text=Msg.STR["type"])
|
||||
self.content_tree.heading("size", text=Msg.STR["size"])
|
||||
self.content_tree.heading("comment", text=Msg.STR["comment"])
|
||||
|
||||
self.content_tree.column("date", width=120, anchor="w")
|
||||
self.content_tree.column("type", width=80, anchor="center")
|
||||
self.content_tree.column("date", width=100, anchor="w")
|
||||
self.content_tree.column("time", width=80, anchor="center")
|
||||
self.content_tree.column("type", width=120, anchor="center")
|
||||
self.content_tree.column("size", width=100, anchor="e")
|
||||
self.content_tree.column("comment", width=200, anchor="w")
|
||||
self.content_tree.column("folder_name", width=250, anchor="w")
|
||||
self.content_tree.column("comment", width=300, anchor="w")
|
||||
|
||||
self.content_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
self.content_tree.bind("<<TreeviewSelect>>", self._on_item_select)
|
||||
|
||||
list_button_frame = ttk.Frame(self.content_frame)
|
||||
list_button_frame.pack(pady=10)
|
||||
|
||||
self.restore_button = ttk.Button(list_button_frame, text=Msg.STR["restore"],
|
||||
command=self._restore_selected, state="disabled")
|
||||
self.restore_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.delete_button = ttk.Button(list_button_frame, text=Msg.STR["delete"],
|
||||
command=self._delete_selected, state="disabled")
|
||||
self.delete_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.edit_comment_button = ttk.Button(list_button_frame, text="Kommentar bearbeiten",
|
||||
command=self._edit_comment, state="disabled")
|
||||
self.edit_comment_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def show(self, backup_path):
|
||||
if backup_path and self.backup_path != backup_path:
|
||||
self.backup_path = backup_path
|
||||
self._load_backup_content()
|
||||
|
||||
def hide(self):
|
||||
self.grid_remove()
|
||||
def show(self, backup_path, system_backups):
|
||||
self.backup_path = backup_path
|
||||
self.system_backups_list = system_backups
|
||||
self._load_backup_content()
|
||||
|
||||
def _load_backup_content(self):
|
||||
for i in self.content_tree.get_children():
|
||||
self.content_tree.delete(i)
|
||||
|
||||
if not self.backup_path or not os.path.isdir(self.backup_path):
|
||||
if not self.system_backups_list:
|
||||
return
|
||||
|
||||
# Use the new method to get structured system backup data
|
||||
system_backups = self.backup_manager.list_system_backups(
|
||||
self.backup_path)
|
||||
color_index = -1
|
||||
for i, backup_info in enumerate(self.system_backups_list):
|
||||
if backup_info.get("backup_type_base") == "Full":
|
||||
color_index = (color_index + 1) % len(self.tag_colors)
|
||||
full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index]
|
||||
self.content_tree.tag_configure(
|
||||
full_tag, foreground=full_color)
|
||||
self.content_tree.tag_configure(
|
||||
inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
|
||||
current_tag = full_tag
|
||||
else:
|
||||
_, _, inc_tag, _ = self.tag_colors[color_index]
|
||||
current_tag = inc_tag
|
||||
|
||||
for backup_info in system_backups:
|
||||
self.content_tree.insert("", "end", values=(
|
||||
backup_info.get("date", "N/A"),
|
||||
backup_info.get("time", "N/A"),
|
||||
backup_info.get("type", "N/A"),
|
||||
backup_info.get("size", "N/A"),
|
||||
backup_info.get("comment", ""),
|
||||
backup_info.get("folder_name", "N/A")
|
||||
))
|
||||
self._on_item_select(None) # Disable buttons initially
|
||||
), tags=(current_tag,), iid=backup_info.get("folder_name"))
|
||||
|
||||
self._on_item_select(None)
|
||||
|
||||
def _on_item_select(self, event):
|
||||
selected_item = self.content_tree.focus()
|
||||
is_selected = True if selected_item else False
|
||||
self.restore_button.config(state="normal" if is_selected else "disabled")
|
||||
self.delete_button.config(state="normal" if is_selected else "disabled")
|
||||
self.edit_comment_button.config(state="normal" if is_selected else "disabled")
|
||||
is_selected = True if self.content_tree.focus() else False
|
||||
self.parent_view.update_button_state(is_selected)
|
||||
|
||||
def _edit_comment(self):
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
selected_item_id = self.content_tree.focus()
|
||||
if not selected_item_id:
|
||||
return
|
||||
|
||||
item_values = self.content_tree.item(selected_item)["values"]
|
||||
folder_name = item_values[4] # Assuming folder_name is the 5th value
|
||||
|
||||
# Construct the path to the info file
|
||||
pybackup_path = os.path.join(self.backup_path, "pybackup")
|
||||
info_file_path = os.path.join(pybackup_path, f"{folder_name}.txt")
|
||||
selected_backup = next((b for b in self.system_backups_list if b.get(
|
||||
"folder_name") == selected_item_id), None)
|
||||
if not selected_backup:
|
||||
return
|
||||
|
||||
is_encrypted = selected_backup.get('is_encrypted', False)
|
||||
info_file_name = f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt"
|
||||
info_file_path = os.path.join(
|
||||
self.backup_path, "pybackup", info_file_name)
|
||||
|
||||
# The file should exist, but we can handle cases where it might not.
|
||||
if not os.path.exists(info_file_path):
|
||||
# If for some reason the info file is missing, we can create an empty one.
|
||||
self.backup_manager.update_comment(info_file_path, "")
|
||||
|
||||
|
||||
CommentEditorDialog(self, info_file_path, self.backup_manager)
|
||||
self._load_backup_content() # Refresh list to show new comment
|
||||
self.parent_view.show(self.backup_path)
|
||||
|
||||
def _restore_selected(self):
|
||||
# Placeholder for restore logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
selected_item_id = self.content_tree.focus()
|
||||
if not selected_item_id:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Restoring {backup_name}...")
|
||||
|
||||
selected_backup = next((b for b in self.system_backups_list if b.get(
|
||||
"folder_name") == selected_item_id), None)
|
||||
|
||||
if not selected_backup:
|
||||
return
|
||||
|
||||
main_app = self.winfo_toplevel()
|
||||
restore_dest_path = main_app.config_manager.get_setting(
|
||||
"restore_destination_path", "/")
|
||||
|
||||
if not restore_dest_path:
|
||||
return
|
||||
|
||||
self.backup_manager.start_restore(
|
||||
source_path=selected_backup['full_path'],
|
||||
dest_path=restore_dest_path,
|
||||
is_compressed=selected_backup['is_compressed']
|
||||
)
|
||||
|
||||
def _delete_selected(self):
|
||||
# Placeholder for delete logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
selected_item_id = self.content_tree.focus()
|
||||
if not selected_item_id:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Deleting {backup_name}...")
|
||||
|
||||
selected_backup = next((b for b in self.system_backups_list if b.get(
|
||||
"folder_name") == selected_item_id), None)
|
||||
|
||||
if not selected_backup:
|
||||
return
|
||||
|
||||
folder_to_delete = selected_backup['full_path']
|
||||
is_encrypted = selected_backup['is_encrypted']
|
||||
password = None
|
||||
|
||||
if is_encrypted:
|
||||
username = os.path.basename(self.backup_path.rstrip('/'))
|
||||
# Get password in the UI thread before starting the background task
|
||||
password = self.backup_manager.encryption_manager.get_password(username, confirm=False)
|
||||
if not password:
|
||||
self.actions.logger.log("Password entry cancelled, aborting deletion.")
|
||||
return
|
||||
|
||||
info_file_to_delete = os.path.join(
|
||||
self.backup_path, "pybackup", f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt")
|
||||
|
||||
self.actions._set_ui_state(False)
|
||||
self.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
|
||||
|
||||
self.backup_manager.start_delete_backup(
|
||||
path_to_delete=folder_to_delete,
|
||||
info_file_path=info_file_to_delete,
|
||||
is_encrypted=is_encrypted,
|
||||
is_system=True,
|
||||
base_dest_path=self.backup_path,
|
||||
password=password,
|
||||
queue=self.winfo_toplevel().queue
|
||||
)
|
||||
|
@@ -1,117 +1,150 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from pbp_app_config import Msg
|
||||
from core.pbp_app_config import Msg
|
||||
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
|
||||
|
||||
from shared_libs.message import MessageDialog
|
||||
|
||||
class UserBackupContentFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, **kwargs):
|
||||
def __init__(self, master, backup_manager, actions, parent_view, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
self.actions = actions
|
||||
self.parent_view = parent_view
|
||||
self.user_backups_list = []
|
||||
self.backup_path = None
|
||||
|
||||
# --- Backup Content List View ---
|
||||
self.content_frame = ttk.LabelFrame(
|
||||
self, text=Msg.STR["backup_content"], padding=10)
|
||||
self.content_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.tag_colors = [
|
||||
("full_blue", "#0078D7", "inc_blue", "#50E6FF"),
|
||||
("full_orange", "#E8740C", "inc_orange", "#FFB366"),
|
||||
("full_green", "#107C10", "inc_green", "#50E680"),
|
||||
("full_purple", "#8B107C", "inc_purple", "#D46EE5"),
|
||||
]
|
||||
|
||||
columns = ("date", "size", "comment", "folder_name")
|
||||
self.content_tree = ttk.Treeview(
|
||||
self.content_frame, columns=columns, show="headings")
|
||||
self.content_tree.heading(
|
||||
"date", text=Msg.STR["date"])
|
||||
self.content_tree.heading(
|
||||
"size", text=Msg.STR["size"])
|
||||
self.content_tree.heading(
|
||||
"comment", text=Msg.STR["comment"])
|
||||
self.content_tree.heading(
|
||||
"folder_name", text=Msg.STR["folder"])
|
||||
columns = ("date", "time", "type", "size", "comment", "folder_name")
|
||||
self.content_tree = ttk.Treeview(self, columns=columns, show="headings")
|
||||
self.content_tree.heading("date", text=Msg.STR["date"])
|
||||
self.content_tree.heading("time", text=Msg.STR["time"])
|
||||
self.content_tree.heading("type", text=Msg.STR["type"])
|
||||
self.content_tree.heading("size", text=Msg.STR["size"])
|
||||
self.content_tree.heading("comment", text=Msg.STR["comment"])
|
||||
self.content_tree.heading("folder_name", text=Msg.STR["folder"])
|
||||
|
||||
self.content_tree.column("date", width=120, anchor="w")
|
||||
self.content_tree.column("date", width=100, anchor="w")
|
||||
self.content_tree.column("time", width=80, anchor="center")
|
||||
self.content_tree.column("type", width=120, anchor="center")
|
||||
self.content_tree.column("size", width=100, anchor="e")
|
||||
self.content_tree.column("comment", width=200, anchor="w")
|
||||
self.content_tree.column("folder_name", width=250, anchor="w")
|
||||
self.content_tree.column("comment", width=250, anchor="w")
|
||||
self.content_tree.column("folder_name", width=200, anchor="w")
|
||||
|
||||
self.content_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
self.content_tree.bind("<<TreeviewSelect>>", self._on_item_select)
|
||||
|
||||
list_button_frame = ttk.Frame(self.content_frame)
|
||||
list_button_frame.pack(pady=10)
|
||||
self.restore_button = ttk.Button(list_button_frame, text=Msg.STR["restore"],
|
||||
command=self._restore_selected, state="disabled")
|
||||
self.restore_button.pack(side=tk.LEFT, padx=5)
|
||||
self.delete_button = ttk.Button(list_button_frame, text=Msg.STR["delete"],
|
||||
command=self._delete_selected, state="disabled")
|
||||
self.delete_button.pack(side=tk.LEFT, padx=5)
|
||||
self.edit_comment_button = ttk.Button(list_button_frame, text="Kommentar bearbeiten",
|
||||
command=self._edit_comment, state="disabled")
|
||||
self.edit_comment_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def show(self, backup_path):
|
||||
if backup_path and self.backup_path != backup_path:
|
||||
self.backup_path = backup_path
|
||||
self._load_backup_content()
|
||||
|
||||
def hide(self):
|
||||
self.grid_remove()
|
||||
def show(self, backup_path, user_backups):
|
||||
self.backup_path = backup_path
|
||||
self.user_backups_list = user_backups
|
||||
self._load_backup_content()
|
||||
|
||||
def _load_backup_content(self):
|
||||
for i in self.content_tree.get_children():
|
||||
self.content_tree.delete(i)
|
||||
|
||||
if not self.backup_path or not os.path.isdir(self.backup_path):
|
||||
if not self.user_backups_list:
|
||||
return
|
||||
|
||||
user_backups = self.backup_manager.list_user_backups(self.backup_path)
|
||||
color_index = -1
|
||||
for i, backup_info in enumerate(self.user_backups_list):
|
||||
if backup_info.get("backup_type_base") == "Full":
|
||||
color_index = (color_index + 1) % len(self.tag_colors)
|
||||
full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index]
|
||||
self.content_tree.tag_configure(
|
||||
full_tag, foreground=full_color)
|
||||
self.content_tree.tag_configure(
|
||||
inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
|
||||
current_tag = full_tag
|
||||
else:
|
||||
_, _, inc_tag, _ = self.tag_colors[color_index]
|
||||
current_tag = inc_tag
|
||||
|
||||
for backup_info in user_backups:
|
||||
self.content_tree.insert("", "end", values=(
|
||||
backup_info.get("date", "N/A"),
|
||||
backup_info.get("time", "N/A"),
|
||||
backup_info.get("type", "N/A"),
|
||||
backup_info.get("size", "N/A"),
|
||||
backup_info.get("comment", ""),
|
||||
backup_info.get("folder_name", "N/A")
|
||||
))
|
||||
), tags=(current_tag,), iid=backup_info.get("folder_name"))
|
||||
self._on_item_select(None)
|
||||
|
||||
def _on_item_select(self, event):
|
||||
selected_item = self.content_tree.focus()
|
||||
is_selected = True if selected_item else False
|
||||
self.restore_button.config(state="normal" if is_selected else "disabled")
|
||||
self.delete_button.config(state="normal" if is_selected else "disabled")
|
||||
self.edit_comment_button.config(state="normal" if is_selected else "disabled")
|
||||
is_selected = True if self.content_tree.focus() else False
|
||||
self.parent_view.update_button_state(is_selected)
|
||||
|
||||
def _edit_comment(self):
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
selected_item_id = self.content_tree.focus()
|
||||
if not selected_item_id:
|
||||
return
|
||||
|
||||
item_values = self.content_tree.item(selected_item)["values"]
|
||||
folder_name = item_values[3] # Assuming folder_name is the 4th value
|
||||
|
||||
# Construct the path to the info file
|
||||
info_file_path = os.path.join(self.backup_path, f"{folder_name}.txt")
|
||||
selected_backup = next((b for b in self.user_backups_list if b.get(
|
||||
"folder_name") == selected_item_id), None)
|
||||
if not selected_backup:
|
||||
return
|
||||
|
||||
is_encrypted = selected_backup.get('is_encrypted', False)
|
||||
info_file_name = f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt"
|
||||
info_file_path = os.path.join(
|
||||
self.backup_path, "pybackup", info_file_name)
|
||||
|
||||
if not os.path.exists(info_file_path):
|
||||
self.backup_manager.update_comment(info_file_path, "")
|
||||
|
||||
|
||||
CommentEditorDialog(self, info_file_path, self.backup_manager)
|
||||
self._load_backup_content() # Refresh list to show new comment
|
||||
self.parent_view.show(self.backup_path)
|
||||
|
||||
def _restore_selected(self):
|
||||
# Placeholder for restore logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
selected_item_id = self.content_tree.focus()
|
||||
if not selected_item_id:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Restoring {backup_name}...")
|
||||
|
||||
MessageDialog(master=self, message_type="info", title="Info", text="User restore not implemented yet.")
|
||||
|
||||
def _delete_selected(self):
|
||||
# Placeholder for delete logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
selected_item_id = self.content_tree.focus()
|
||||
if not selected_item_id:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Deleting {backup_name}...")
|
||||
|
||||
selected_backup = next((b for b in self.user_backups_list if b.get(
|
||||
"folder_name") == selected_item_id), None)
|
||||
|
||||
if not selected_backup:
|
||||
return
|
||||
|
||||
folder_to_delete = selected_backup['full_path']
|
||||
is_encrypted = selected_backup['is_encrypted']
|
||||
password = None
|
||||
|
||||
if is_encrypted:
|
||||
username = os.path.basename(self.backup_path.rstrip('/'))
|
||||
# Get password in the UI thread before starting the background task
|
||||
password = self.backup_manager.encryption_manager.get_password(username, confirm=False)
|
||||
if not password:
|
||||
self.actions.logger.log("Password entry cancelled, aborting deletion.")
|
||||
return
|
||||
|
||||
info_file_to_delete = os.path.join(
|
||||
self.backup_path, "pybackup", f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt")
|
||||
|
||||
self.actions._set_ui_state(False)
|
||||
self.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
|
||||
|
||||
self.backup_manager.start_delete_backup(
|
||||
path_to_delete=folder_to_delete,
|
||||
info_file_path=info_file_to_delete,
|
||||
is_encrypted=is_encrypted,
|
||||
is_system=False,
|
||||
base_dest_path=self.backup_path,
|
||||
password=password,
|
||||
queue=self.winfo_toplevel().queue
|
||||
)
|
||||
|
@@ -1,149 +0,0 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
|
||||
from shared_libs.message import MessageDialog
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from pbp_app_config import Msg
|
||||
|
||||
|
||||
class ScheduleJobDialog(tk.Toplevel):
|
||||
def __init__(self, parent, backup_manager):
|
||||
super().__init__(parent)
|
||||
self.parent = parent
|
||||
self.backup_manager = backup_manager
|
||||
self.result = None
|
||||
|
||||
self.title(Msg.STR["add_job_title"])
|
||||
self.geometry("500x400")
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
|
||||
self.backup_type = tk.StringVar(value="system")
|
||||
self.destination = tk.StringVar()
|
||||
self.user_sources = {
|
||||
Msg.STR["cat_images"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_documents"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_music"]: tk.BooleanVar(value=False),
|
||||
Msg.STR["cat_videos"]: tk.BooleanVar(value=False)
|
||||
}
|
||||
self.frequency = tk.StringVar(value="daily")
|
||||
|
||||
self._create_widgets()
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
||||
self.wait_window()
|
||||
|
||||
def _create_widgets(self):
|
||||
main_frame = ttk.Frame(self, padding=10)
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Backup Type
|
||||
type_frame = ttk.LabelFrame(
|
||||
main_frame, text=Msg.STR["backup_type"], padding=10)
|
||||
type_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Radiobutton(type_frame, text=Msg.STR["system_backup_menu"], variable=self.backup_type,
|
||||
value="system", command=self._toggle_user_sources).pack(anchor=tk.W)
|
||||
ttk.Radiobutton(type_frame, text=Msg.STR["user_backup_menu"], variable=self.backup_type,
|
||||
value="user", command=self._toggle_user_sources).pack(anchor=tk.W)
|
||||
|
||||
# Destination
|
||||
dest_frame = ttk.LabelFrame(
|
||||
main_frame, text=Msg.STR["dest_folder"], padding=10)
|
||||
dest_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Entry(dest_frame, textvariable=self.destination, state="readonly",
|
||||
width=50).pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
ttk.Button(dest_frame, text=Msg.STR["browse"], command=self._select_destination).pack(
|
||||
side=tk.RIGHT)
|
||||
|
||||
# User Sources (initially hidden)
|
||||
self.user_sources_frame = ttk.LabelFrame(
|
||||
main_frame, text=Msg.STR["source_folders"], padding=10)
|
||||
for name, var in self.user_sources.items():
|
||||
ttk.Checkbutton(self.user_sources_frame, text=name,
|
||||
variable=var).pack(anchor=tk.W)
|
||||
self._toggle_user_sources() # Set initial visibility
|
||||
|
||||
# Frequency
|
||||
freq_frame = ttk.LabelFrame(
|
||||
main_frame, text=Msg.STR["frequency"], padding=10)
|
||||
freq_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_daily"],
|
||||
variable=self.frequency, value="daily").pack(anchor=tk.W)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_weekly"],
|
||||
variable=self.frequency, value="weekly").pack(anchor=tk.W)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_monthly"],
|
||||
variable=self.frequency, value="monthly").pack(anchor=tk.W)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.pack(pady=10)
|
||||
ttk.Button(button_frame, text=Msg.STR["save"], command=self._on_save).pack(
|
||||
side=tk.LEFT, padx=5)
|
||||
ttk.Button(button_frame, text=Msg.STR["cancel"], command=self._on_cancel).pack(
|
||||
side=tk.LEFT, padx=5)
|
||||
|
||||
def _toggle_user_sources(self):
|
||||
if self.backup_type.get() == "user":
|
||||
self.user_sources_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
else:
|
||||
self.user_sources_frame.pack_forget()
|
||||
|
||||
def _select_destination(self):
|
||||
dialog = CustomFileDialog(
|
||||
self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
self.wait_window(dialog)
|
||||
result = dialog.get_result()
|
||||
if result:
|
||||
self.destination.set(result)
|
||||
|
||||
def _on_save(self):
|
||||
dest = self.destination.get()
|
||||
if not dest:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
return
|
||||
|
||||
job_type = self.backup_type.get()
|
||||
job_frequency = self.frequency.get()
|
||||
job_sources = []
|
||||
|
||||
if job_type == "user":
|
||||
job_sources = [name for name,
|
||||
var in self.user_sources.items() if var.get()]
|
||||
if not job_sources:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_source_folder"])
|
||||
return
|
||||
|
||||
# Construct the CLI command
|
||||
script_path = os.path.abspath(os.path.join(
|
||||
os.path.dirname(__file__), "main_app.py"))
|
||||
command = f"python3 {script_path} --backup-type {job_type} --destination \"{dest}\""
|
||||
if job_type == "user":
|
||||
command += f" --sources "
|
||||
for s in job_sources:
|
||||
command += f'\"{s}\" '
|
||||
|
||||
# Construct the cron job comment
|
||||
comment = f"{self.backup_manager.app_tag}; type:{job_type}; freq:{job_frequency}; dest:{dest}"
|
||||
if job_type == "user":
|
||||
comment += f"; sources:{','.join(job_sources)}"
|
||||
|
||||
self.result = {
|
||||
"command": command,
|
||||
"comment": comment,
|
||||
"type": job_type,
|
||||
"frequency": job_frequency,
|
||||
"destination": dest,
|
||||
"sources": job_sources
|
||||
}
|
||||
self.destroy()
|
||||
|
||||
def _on_cancel(self):
|
||||
self.result = None
|
||||
self.destroy()
|
||||
|
||||
def show(self):
|
||||
self.parent.wait_window(self)
|
||||
return self.result
|
Reference in New Issue
Block a user