Compare commits
14 Commits
313f3b9654
...
f65e7feb67
Author | SHA1 | Date | |
---|---|---|---|
f65e7feb67 | |||
9e88ac8bb5 | |||
8132c5cef9 | |||
269da49a1a | |||
5f5cd78ee0 | |||
e1ed313a7d | |||
4c4b8bf0cd | |||
eae09b8694 | |||
7ab227626b | |||
8f0150c293 | |||
841bc82915 | |||
94530fd626 | |||
6af66d5211 | |||
bf3eaaf451 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,11 +2,13 @@ import subprocess
|
||||
import os
|
||||
import threading
|
||||
import re
|
||||
import datetime
|
||||
import signal
|
||||
from typing import Callable, Optional, List, Dict, Any
|
||||
import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pathlib import Path
|
||||
from crontab import CronTab
|
||||
import tempfile
|
||||
import stat
|
||||
|
||||
|
||||
class BackupManager:
|
||||
@@ -18,26 +20,149 @@ class BackupManager:
|
||||
self.logger = logger
|
||||
self.process = None
|
||||
self.app_tag = "# Py-Backup Job"
|
||||
self.is_system_process = False
|
||||
|
||||
def pause_backup(self):
|
||||
if self.process and self.process.poll() is None:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGSTOP)
|
||||
self.logger.log("Backup paused.")
|
||||
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
|
||||
|
||||
def resume_backup(self):
|
||||
if self.process and self.process.poll() is None:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGCONT)
|
||||
self.logger.log("Backup resumed.")
|
||||
# Make the script executable
|
||||
os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP |
|
||||
stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
|
||||
def start_backup(self, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool = False, exclude_files: Optional[List[Path]] = None, on_progress: Optional[Callable[[int], None]] = None, on_completion: Optional[Callable[[], None]] = None, on_error: Optional[Callable[[], None]] = None):
|
||||
"""Starts a generic backup process for a specific path."""
|
||||
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=(
|
||||
source_path, dest_path, is_system, is_dry_run, exclude_files, on_progress, on_completion, on_error))
|
||||
queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def _run_backup_path(self, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool, exclude_files: Optional[List[Path]], on_progress: Optional[Callable[[int], None]], on_completion: Optional[Callable[[], None]], on_error: Optional[Callable[[], None]]):
|
||||
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}'...")
|
||||
|
||||
@@ -45,63 +170,145 @@ class BackupManager:
|
||||
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', '-aAXH'])
|
||||
command.extend(['pkexec', 'rsync', '-aAXHv'])
|
||||
else:
|
||||
command.extend(['rsync', '-a'])
|
||||
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(command, exclude_files, on_progress, on_error)
|
||||
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:
|
||||
if on_completion:
|
||||
on_completion()
|
||||
self.process = None
|
||||
queue.put(('completion', None))
|
||||
|
||||
def _execute_rsync(self, command: List[str], exclude_files: Optional[List[Path]] = None, on_progress: Optional[Callable[[int], None]] = None, on_error: Optional[Callable[[], None]] = None):
|
||||
if exclude_files:
|
||||
for exclude_file in exclude_files:
|
||||
command.insert(1, f"--exclude-from={exclude_file}")
|
||||
def _create_info_file(self, dest_path: str, filename: str, source_size: int):
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, preexec_fn=os.setsid)
|
||||
# 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")
|
||||
|
||||
progress_regex = re.compile(r'\s*(\d+)%')
|
||||
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, ''):
|
||||
self.logger.log(line.strip())
|
||||
match = progress_regex.search(line)
|
||||
if match and on_progress:
|
||||
stripped_line = line.strip()
|
||||
self.logger.log(stripped_line)
|
||||
|
||||
match = progress_regex.search(stripped_line)
|
||||
if match:
|
||||
percentage = int(match.group(1))
|
||||
on_progress(percentage)
|
||||
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()}")
|
||||
if on_error:
|
||||
on_error()
|
||||
queue.put(('error', None))
|
||||
|
||||
except FileNotFoundError:
|
||||
self.logger.log(
|
||||
"Error: 'rsync' command not found. Please ensure it is installed and in your PATH.")
|
||||
if on_error:
|
||||
on_error()
|
||||
queue.put(('error', None))
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred: {e}")
|
||||
if on_error:
|
||||
on_error()
|
||||
queue.put(('error', None))
|
||||
|
||||
def get_scheduled_jobs(self) -> List[Dict[str, Any]]:
|
||||
jobs_list = []
|
||||
@@ -173,4 +380,174 @@ class BackupManager:
|
||||
full_path = os.path.join(base_backup_path, item)
|
||||
if os.path.isdir(full_path):
|
||||
backups.append(item)
|
||||
return sorted(backups, reverse=True)
|
||||
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}")
|
||||
|
@@ -1,74 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
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)
|
||||
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()
|
Binary file not shown.
@@ -3,7 +3,7 @@ import os
|
||||
import fnmatch
|
||||
import shutil
|
||||
import re
|
||||
from app_config import AppConfig
|
||||
from pbp_app_config import AppConfig
|
||||
from shared_libs.logger import app_logger
|
||||
|
||||
|
||||
@@ -80,13 +80,34 @@ class DataProcessing:
|
||||
if not stop_event.is_set():
|
||||
self.app.queue.put((button_text, total_size, mode))
|
||||
|
||||
def get_user_folder_size_threaded(self, path, button_text, stop_event, mode='backup'):
|
||||
"""Calculates folder size without applying any exclusion lists."""
|
||||
total_size = 0
|
||||
for dirpath, dirnames, filenames in os.walk(path, topdown=True):
|
||||
if stop_event.is_set():
|
||||
return # Stop the calculation
|
||||
|
||||
for f in filenames:
|
||||
if stop_event.is_set():
|
||||
return # Stop the calculation
|
||||
|
||||
fp = os.path.join(dirpath, f)
|
||||
if not os.path.islink(fp):
|
||||
try:
|
||||
total_size += os.path.getsize(fp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not stop_event.is_set():
|
||||
self.app.queue.put((button_text, total_size, mode))
|
||||
|
||||
def process_queue(self):
|
||||
try:
|
||||
message = self.app.queue.get_nowait()
|
||||
button_text, folder_size, mode_when_started = message
|
||||
|
||||
if mode_when_started != self.app.mode:
|
||||
return # Discard stale result from a different mode
|
||||
return # Discard stale result from a different mode
|
||||
|
||||
if self.app.mode == 'restore':
|
||||
self.app.restore_destination_folder_size_bytes = folder_size
|
||||
@@ -99,7 +120,7 @@ class DataProcessing:
|
||||
self.app.left_canvas_animation.stop()
|
||||
self.app.left_canvas_animation.destroy()
|
||||
self.app.left_canvas_animation = None
|
||||
|
||||
|
||||
if not self.app.right_canvas_data.get('calculating', False):
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
|
||||
@@ -125,7 +146,7 @@ class DataProcessing:
|
||||
percentage = (folder_size / total_disk_size) * 100
|
||||
else:
|
||||
percentage = 0
|
||||
|
||||
|
||||
self.app.source_size_canvas.delete("all")
|
||||
fill_width = (
|
||||
self.app.source_size_canvas.winfo_width() / 100) * percentage
|
||||
|
164
main_app.py
164
main_app.py
@@ -1,15 +1,17 @@
|
||||
#!/usr/bin/python3
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
from queue import Queue
|
||||
import datetime
|
||||
from queue import Queue, Empty
|
||||
|
||||
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 config_manager import ConfigManager
|
||||
from shared_libs.config_manager import ConfigManager
|
||||
from backup_manager import BackupManager
|
||||
from app_config import AppConfig, Msg, _
|
||||
from 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
|
||||
@@ -71,12 +73,16 @@ class MainApplication(tk.Tk):
|
||||
self.backup_manager = BackupManager(app_logger)
|
||||
self.queue = Queue()
|
||||
self.image_manager = IconManager()
|
||||
|
||||
self.config_manager = ConfigManager(AppConfig.SETTINGS_FILE)
|
||||
self.data_processing = DataProcessing(self)
|
||||
self.drawing = Drawing(self)
|
||||
self.navigation = Navigation(self)
|
||||
self.actions = Actions(self)
|
||||
|
||||
self.backup_is_running = False
|
||||
self.start_time = None
|
||||
|
||||
self.calculation_thread = None
|
||||
self.calculation_stop_event = None
|
||||
self.source_larger_than_partition = False
|
||||
@@ -112,8 +118,8 @@ class MainApplication(tk.Tk):
|
||||
lx_backup_label.image = lx_backup_icon
|
||||
lx_backup_label.pack(pady=10)
|
||||
|
||||
sidebar_buttons_frame = ttk.Frame(sidebar, style="Custom.TFrame")
|
||||
sidebar_buttons_frame.pack(pady=20)
|
||||
self.sidebar_buttons_frame = ttk.Frame(sidebar, style="Custom.TFrame")
|
||||
self.sidebar_buttons_frame.pack(pady=20)
|
||||
|
||||
self.buttons_map = {
|
||||
"Computer": {"icon": "computer_extralarge"},
|
||||
@@ -124,17 +130,17 @@ class MainApplication(tk.Tk):
|
||||
}
|
||||
|
||||
for text, data in self.buttons_map.items():
|
||||
button = ttk.Button(sidebar_buttons_frame, text=text, style="Sidebar.TButton",
|
||||
button = ttk.Button(self.sidebar_buttons_frame, text=text, style="Sidebar.TButton",
|
||||
command=lambda t=text: self.actions.on_sidebar_button_click(t))
|
||||
button.pack(fill=tk.X, pady=10)
|
||||
|
||||
schedule_dialog_button = ttk.Button(
|
||||
sidebar_buttons_frame, text=Msg.STR["scheduling"], command=lambda: self.navigation.toggle_scheduler_frame(3), style="Sidebar.TButton")
|
||||
schedule_dialog_button.pack(fill=tk.X, pady=10)
|
||||
self.schedule_dialog_button = ttk.Button(
|
||||
self.sidebar_buttons_frame, text=Msg.STR["scheduling"], command=lambda: self.navigation.toggle_scheduler_frame(3), style="Sidebar.TButton")
|
||||
self.schedule_dialog_button.pack(fill=tk.X, pady=10)
|
||||
|
||||
settings_button = ttk.Button(
|
||||
sidebar_buttons_frame, text=Msg.STR["settings"], command=lambda: self.navigation.toggle_settings_frame(4), style="Sidebar.TButton")
|
||||
settings_button.pack(fill=tk.X, pady=10)
|
||||
self.settings_button = ttk.Button(
|
||||
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.grid(row=0, column=0, sticky="nsew")
|
||||
@@ -145,7 +151,7 @@ class MainApplication(tk.Tk):
|
||||
top_nav_frame = ttk.Frame(self.top_bar)
|
||||
top_nav_frame.pack(side=tk.LEFT)
|
||||
|
||||
nav_buttons_defs = [
|
||||
self.nav_buttons_defs = [
|
||||
(Msg.STR["backup_menu"], lambda: self.navigation.toggle_mode(
|
||||
"backup", 0, trigger_calculation=True)),
|
||||
(Msg.STR["restore"], lambda: self.navigation.toggle_mode(
|
||||
@@ -162,7 +168,7 @@ class MainApplication(tk.Tk):
|
||||
self.nav_buttons = []
|
||||
self.nav_progress_bars = []
|
||||
|
||||
for i, (text, command) in enumerate(nav_buttons_defs):
|
||||
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,
|
||||
@@ -174,7 +180,7 @@ class MainApplication(tk.Tk):
|
||||
progress_bar.pack_forget()
|
||||
self.nav_progress_bars.append(progress_bar)
|
||||
|
||||
if i < len(nav_buttons_defs) - 1:
|
||||
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)
|
||||
|
||||
@@ -209,11 +215,7 @@ class MainApplication(tk.Tk):
|
||||
self._setup_log_window()
|
||||
self._setup_scheduler_frame()
|
||||
self._setup_settings_frame()
|
||||
|
||||
self.backup_content_frame = BackupContentFrame(
|
||||
self.content_frame, self.backup_manager, padding=10)
|
||||
self.backup_content_frame.grid(row=2, column=0, sticky="nsew")
|
||||
self.backup_content_frame.grid_remove()
|
||||
self._setup_backup_content_frame()
|
||||
|
||||
self._setup_task_bar()
|
||||
|
||||
@@ -251,7 +253,8 @@ class MainApplication(tk.Tk):
|
||||
|
||||
self.restore_size_frame_before = ttk.LabelFrame(
|
||||
self.content_frame, text="Before Restoration", padding=10)
|
||||
self.restore_size_frame_before.grid(row=4, column=0, sticky="ew", padx=10, pady=5)
|
||||
self.restore_size_frame_before.grid(
|
||||
row=4, column=0, sticky="ew", padx=10, pady=5)
|
||||
self.restore_size_frame_before.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.restore_size_canvas_before = tk.Canvas(
|
||||
@@ -266,7 +269,8 @@ class MainApplication(tk.Tk):
|
||||
|
||||
self.restore_size_frame_after = ttk.LabelFrame(
|
||||
self.content_frame, text="After Restoration", padding=10)
|
||||
self.restore_size_frame_after.grid(row=5, column=0, sticky="ew", padx=10, pady=5)
|
||||
self.restore_size_frame_after.grid(
|
||||
row=5, column=0, sticky="ew", padx=10, pady=5)
|
||||
self.restore_size_frame_after.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.restore_size_canvas_after = tk.Canvas(
|
||||
@@ -296,10 +300,12 @@ class MainApplication(tk.Tk):
|
||||
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")
|
||||
backup_source_path = self.config_manager.get_setting(
|
||||
"backup_source_path")
|
||||
if backup_source_path and os.path.isdir(backup_source_path):
|
||||
folder_name = next((name for name, path_obj in AppConfig.FOLDER_PATHS.items() if str(path_obj) == backup_source_path), None)
|
||||
|
||||
folder_name = next((name for name, path_obj in AppConfig.FOLDER_PATHS.items(
|
||||
) if str(path_obj) == backup_source_path), None)
|
||||
|
||||
if folder_name:
|
||||
icon_name = self.buttons_map[folder_name]['icon']
|
||||
else:
|
||||
@@ -388,6 +394,12 @@ class MainApplication(tk.Tk):
|
||||
self.settings_frame.grid(row=2, column=0, sticky="nsew")
|
||||
self.settings_frame.grid_remove()
|
||||
|
||||
def _setup_backup_content_frame(self):
|
||||
self.backup_content_frame = BackupContentFrame(
|
||||
self.content_frame, self.backup_manager, 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.info_checkbox_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.info_checkbox_frame.grid(row=3, column=0, sticky="ew")
|
||||
@@ -396,6 +408,19 @@ 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.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.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)
|
||||
|
||||
checkbox_frame = ttk.Frame(self.info_checkbox_frame)
|
||||
checkbox_frame.pack(fill=tk.X, pady=5)
|
||||
self.vollbackup_var = tk.BooleanVar()
|
||||
@@ -427,29 +452,36 @@ class MainApplication(tk.Tk):
|
||||
|
||||
self.action_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.action_frame.grid(row=6, column=0, sticky="ew")
|
||||
self.action_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
bg_color = self.style.lookup('TFrame', 'background')
|
||||
|
||||
backup_animation_type = self.config_manager.get_setting(
|
||||
"backup_animation_type", "counter_arc")
|
||||
|
||||
initial_animation_type = "blink"
|
||||
if backup_animation_type == "line":
|
||||
initial_animation_type = "line"
|
||||
|
||||
self.animated_icon = AnimatedIcon(
|
||||
self.action_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type=initial_animation_type)
|
||||
self.animated_icon.pack(side=tk.LEFT, padx=5)
|
||||
self.animated_icon.grid(row=0, column=0, rowspan=2, padx=5)
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.animated_icon.animation_type = backup_animation_type
|
||||
|
||||
progress_container = ttk.Frame(self.action_frame)
|
||||
progress_container.grid(row=0, column=1, sticky="ew", padx=5)
|
||||
progress_container.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.current_file_label = ttk.Label(
|
||||
progress_container, text="", anchor="w")
|
||||
self.current_file_label.grid(row=0, column=0, sticky="ew")
|
||||
|
||||
self.task_progress = ttk.Progressbar(
|
||||
self.action_frame, orient="horizontal", length=100, mode="determinate")
|
||||
self.task_progress.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
|
||||
progress_container, orient="horizontal", length=100, mode="determinate")
|
||||
self.task_progress.grid(row=1, column=0, sticky="ew", pady=(5, 0))
|
||||
|
||||
self.start_pause_button = ttk.Button(
|
||||
self.action_frame, text=Msg.STR["start"], command=self.actions.toggle_start_pause, state="disabled")
|
||||
self.start_pause_button.pack(side=tk.RIGHT, padx=5)
|
||||
self.action_frame, text=Msg.STR["start"], command=self.actions.toggle_start_cancel, state="disabled")
|
||||
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."""
|
||||
@@ -480,9 +512,77 @@ 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()
|
||||
self.left_canvas_animation = None
|
||||
if self.right_canvas_animation:
|
||||
self.right_canvas_animation.stop()
|
||||
self.right_canvas_animation.destroy()
|
||||
self.right_canvas_animation = None
|
||||
if self.animated_icon:
|
||||
self.animated_icon.stop()
|
||||
self.animated_icon.destroy()
|
||||
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:
|
||||
message = self.queue.get_nowait()
|
||||
|
||||
# Check if it's a backup status message (2-element tuple)
|
||||
if 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
|
||||
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 == 'error':
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.start_pause_button["text"] = "Start"
|
||||
self.backup_is_running = False
|
||||
elif message_type == 'completion':
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.start_pause_button["text"] = "Start"
|
||||
self.task_progress["value"] = 0
|
||||
self.current_file_label.config(text="Backup finished.")
|
||||
|
||||
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
|
||||
else:
|
||||
# This message is not for us (likely for DataProcessing), put it back and yield.
|
||||
self.queue.put(message)
|
||||
break
|
||||
|
||||
except Empty:
|
||||
pass # Queue is empty, do nothing
|
||||
|
||||
# Reschedule the queue check
|
||||
self.after(100, self._process_backup_queue)
|
||||
|
||||
def quit(self):
|
||||
self.on_closing()
|
||||
|
||||
|
@@ -237,6 +237,8 @@ class Msg:
|
||||
"path": _("Path"),
|
||||
"date": _("Date"),
|
||||
"size": _("Size"),
|
||||
"type": _("Type"),
|
||||
"folder": _("Folder"),
|
||||
"backup_content": _("Backup Content"),
|
||||
"scheduled_jobs": _("Scheduled Jobs"),
|
||||
"err_no_job_selected": _("No job selected."),
|
||||
@@ -251,6 +253,8 @@ class Msg:
|
||||
"incremental": _("Incremental"),
|
||||
"test_run": _("Test run"),
|
||||
"start": _("Start"),
|
||||
"cancel_backup": _("Cancel"),
|
||||
"backup_cancelled_and_deleted_msg": _("Backup cancelled and partially completed backup deleted."),
|
||||
"info_text_placeholder": _("Info text about the current view."),
|
||||
|
||||
# Menus
|
||||
@@ -314,6 +318,7 @@ class Msg:
|
||||
"compressed": _("Compressed"),
|
||||
"encrypted": _("Encrypted"),
|
||||
"bypass_security": _("Bypass security"),
|
||||
"comment": _("Kommentar"),
|
||||
"force_full_backup": _("Always force full backup"),
|
||||
"force_incremental_backup": _("Always force incremental backup (except first)"),
|
||||
"force_compression": _("Always compress backup"),
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,13 +3,14 @@ import tkinter as tk
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import datetime
|
||||
import locale
|
||||
from typing import Optional
|
||||
|
||||
from shared_libs.message import MessageDialog
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from app_config import AppConfig, Msg, _
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
from shared_libs.logger import app_logger
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
from shared_libs.common_tools import message_box_animation
|
||||
|
||||
|
||||
@@ -17,44 +18,73 @@ class Actions:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def _check_for_first_backup(self):
|
||||
# This logic only applies if no override from advanced settings is active
|
||||
if self.app.full_backup_cb.cget('state') != 'normal':
|
||||
self.app.is_first_backup = False # Ensure this is reset if controls are disabled
|
||||
return
|
||||
|
||||
# A "first backup" situation exists if the destination is selected but empty.
|
||||
# This applies to both Computer and User folder backups.
|
||||
is_first_backup_situation = (self.app.destination_path and
|
||||
os.path.isdir(self.app.destination_path) and
|
||||
not os.listdir(self.app.destination_path))
|
||||
|
||||
self.app.is_first_backup = is_first_backup_situation
|
||||
|
||||
if is_first_backup_situation:
|
||||
# It's the first backup, so it MUST be a full backup.
|
||||
self.app.vollbackup_var.set(True)
|
||||
self.app.inkrementell_var.set(False)
|
||||
# Disable controls so user cannot change this.
|
||||
self.app.full_backup_cb.config(state="disabled")
|
||||
self.app.incremental_cb.config(state="disabled")
|
||||
else:
|
||||
# A backup already exists, so the user can choose.
|
||||
# Re-enable controls.
|
||||
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")
|
||||
# We don't set the value here, preserving the user's last choice.
|
||||
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.
|
||||
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")
|
||||
else:
|
||||
# Case 2: No full backup exists. Force a 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")
|
||||
|
||||
def on_sidebar_button_click(self, button_text):
|
||||
if self.app.backup_is_running:
|
||||
app_logger.log("Action blocked: Backup is in progress.")
|
||||
return
|
||||
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
if not self.app.canvas_frame.winfo_viewable():
|
||||
self.app.navigation.toggle_mode(
|
||||
self.app.mode, trigger_calculation=False)
|
||||
|
||||
self.app.log_window.clear_log()
|
||||
folder_path = AppConfig.FOLDER_PATHS.get(button_text)
|
||||
|
||||
# Reverse map from translated UI string to canonical key
|
||||
REVERSE_FOLDER_MAP = {
|
||||
"Computer": "Computer",
|
||||
Msg.STR["cat_documents"]: "Documents",
|
||||
Msg.STR["cat_images"]: "Pictures",
|
||||
Msg.STR["cat_music"]: "Music",
|
||||
Msg.STR["cat_videos"]: "Videos",
|
||||
}
|
||||
canonical_key = REVERSE_FOLDER_MAP.get(button_text, button_text)
|
||||
|
||||
folder_path = AppConfig.FOLDER_PATHS.get(canonical_key)
|
||||
|
||||
if not folder_path or not folder_path.exists():
|
||||
print(f"Folder not found for {button_text}")
|
||||
print(
|
||||
f"Folder not found for {canonical_key} (Path: {folder_path})")
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
return
|
||||
|
||||
@@ -127,24 +157,37 @@ class Actions:
|
||||
|
||||
self.app.drawing.start_backup_calculation_display()
|
||||
|
||||
# CRITICAL: Only load exclude patterns for "Computer"
|
||||
exclude_patterns = []
|
||||
if button_text == "Computer":
|
||||
exclude_patterns = self.app.data_processing.load_exclude_patterns()
|
||||
|
||||
self.app.calculation_stop_event = threading.Event()
|
||||
self.app.calculation_thread = threading.Thread(target=self.app.data_processing.get_folder_size_threaded, args=(
|
||||
folder_path, button_text, self.app.calculation_stop_event, exclude_patterns, self.app.mode))
|
||||
|
||||
# Decide which calculation method to use based on the source
|
||||
if button_text == "Computer":
|
||||
# For system backup, use exclusions
|
||||
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)
|
||||
|
||||
self.app.calculation_thread = threading.Thread(
|
||||
target=target_method, args=args)
|
||||
self.app.calculation_thread.daemon = True
|
||||
self.app.calculation_thread.start()
|
||||
|
||||
if self.app.mode == 'backup':
|
||||
self._check_for_first_backup()
|
||||
self._update_backup_type_controls()
|
||||
else: # restore mode
|
||||
self.app.config_manager.set_setting(
|
||||
"restore_destination_path", folder_path)
|
||||
|
||||
def on_right_canvas_click(self, event):
|
||||
if self.app.backup_is_running:
|
||||
app_logger.log("Action blocked: Backup is in progress.")
|
||||
return
|
||||
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
self.app.after(100, self._open_source_or_destination_dialog)
|
||||
|
||||
@@ -199,7 +242,7 @@ class Actions:
|
||||
current_source = self.app.left_canvas_data.get('folder')
|
||||
if current_source:
|
||||
self.on_sidebar_button_click(current_source)
|
||||
self._check_for_first_backup()
|
||||
self._update_backup_type_controls()
|
||||
|
||||
elif self.app.mode == "restore":
|
||||
self.app.right_canvas_data.update({
|
||||
@@ -258,22 +301,171 @@ class Actions:
|
||||
MessageDialog(master=self.app, message_type="info",
|
||||
title=Msg.STR["settings_reset_title"], text=Msg.STR["settings_reset_text"])
|
||||
|
||||
def toggle_start_pause(self):
|
||||
bg_color = self.app.style.lookup('TFrame', 'background')
|
||||
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
|
||||
|
||||
try:
|
||||
value = float(parts[0])
|
||||
unit = parts[1].upper()
|
||||
|
||||
if unit == 'B':
|
||||
return int(value)
|
||||
elif unit == 'KB':
|
||||
return int(value * (1024**1))
|
||||
elif unit == 'MB':
|
||||
return int(value * (1024**2))
|
||||
elif unit == 'GB':
|
||||
return int(value * (1024**3))
|
||||
elif unit == 'TB':
|
||||
return int(value * (1024**4))
|
||||
else:
|
||||
return 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def _set_ui_state(self, enable: bool):
|
||||
# Sidebar Buttons
|
||||
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")
|
||||
|
||||
# Right Canvas (Destination/Restore Source)
|
||||
if enable:
|
||||
self.app.right_canvas.bind(
|
||||
"<Button-1>", self.app.actions.on_right_canvas_click)
|
||||
self.app.right_canvas.config(cursor="hand2")
|
||||
else:
|
||||
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.compressed_cb,
|
||||
self.app.encrypted_cb,
|
||||
self.app.test_run_cb,
|
||||
self.app.bypass_security_cb
|
||||
]
|
||||
for cb in checkboxes:
|
||||
cb.config(state="disabled")
|
||||
|
||||
def toggle_start_cancel(self):
|
||||
# If a backup is already running, we must be cancelling.
|
||||
if self.app.backup_is_running:
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
|
||||
delete_path = getattr(self.app, 'current_backup_path', None)
|
||||
is_system_backup = self.app.backup_manager.is_system_process
|
||||
|
||||
if is_system_backup:
|
||||
if delete_path:
|
||||
self.app.backup_manager.cancel_and_delete_privileged_backup(
|
||||
delete_path)
|
||||
app_logger.log(
|
||||
Msg.STR["backup_cancelled_and_deleted_msg"])
|
||||
self.app.info_label.config(
|
||||
text=Msg.STR["backup_cancelled_and_deleted_msg"])
|
||||
else:
|
||||
self.app.backup_manager.cancel_backup()
|
||||
app_logger.log(
|
||||
"Backup cancelled, but directory could not be deleted (path unknown).")
|
||||
self.app.info_label.config(
|
||||
text="Backup cancelled, but directory could not be deleted (path unknown).")
|
||||
else:
|
||||
self.app.backup_manager.cancel_backup()
|
||||
if delete_path:
|
||||
try:
|
||||
app_logger.log(
|
||||
f"Attempting to delete non-privileged path: {delete_path}")
|
||||
if os.path.isdir(delete_path):
|
||||
shutil.rmtree(delete_path)
|
||||
app_logger.log(
|
||||
Msg.STR["backup_cancelled_and_deleted_msg"])
|
||||
self.app.info_label.config(
|
||||
text=Msg.STR["backup_cancelled_and_deleted_msg"])
|
||||
except Exception as e:
|
||||
app_logger.log(f"Error deleting backup directory: {e}")
|
||||
self.app.info_label.config(
|
||||
text=f"Error deleting backup directory: {e}")
|
||||
else:
|
||||
app_logger.log(
|
||||
"Backup cancelled, but no path found to delete.")
|
||||
self.app.info_label.config(
|
||||
text="Backup cancelled, but no path found to delete.")
|
||||
|
||||
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.start_pause_button["text"] = Msg.STR["cancel_backup"]
|
||||
self.app.update_idletasks()
|
||||
|
||||
if self.app.start_pause_button["text"] == "Start":
|
||||
self.app.log_window.clear_log()
|
||||
self.app.start_pause_button["text"] = "Pause"
|
||||
|
||||
self.app.animated_icon.destroy()
|
||||
backup_animation_type = self.app.config_manager.get_setting(
|
||||
"backup_animation_type", "counter_arc")
|
||||
self.app.animated_icon = AnimatedIcon(
|
||||
self.app.action_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type=backup_animation_type)
|
||||
self.app.animated_icon.pack(
|
||||
side=tk.LEFT, padx=5, before=self.app.task_progress)
|
||||
self._set_ui_state(False)
|
||||
|
||||
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")
|
||||
@@ -281,54 +473,36 @@ class Actions:
|
||||
self._start_system_backup("incremental")
|
||||
else:
|
||||
pass
|
||||
elif self.app.start_pause_button["text"] == "Pause":
|
||||
self.app.start_pause_button["text"] = "Resume"
|
||||
|
||||
self.app.animated_icon.destroy()
|
||||
self.app.animated_icon = AnimatedIcon(
|
||||
self.app.action_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type="blink")
|
||||
self.app.animated_icon.pack(
|
||||
side=tk.LEFT, padx=5, before=self.app.task_progress)
|
||||
|
||||
self.app.animated_icon.start()
|
||||
self.app.backup_manager.pause_backup()
|
||||
|
||||
elif self.app.start_pause_button["text"] == "Resume":
|
||||
self.app.start_pause_button["text"] = "Pause"
|
||||
|
||||
self.app.animated_icon.destroy()
|
||||
backup_animation_type = self.app.config_manager.get_setting(
|
||||
"backup_animation_type", "counter_arc")
|
||||
self.app.animated_icon = AnimatedIcon(
|
||||
self.app.action_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type=backup_animation_type)
|
||||
self.app.animated_icon.pack(
|
||||
side=tk.LEFT, padx=5, before=self.app.task_progress)
|
||||
|
||||
self.app.animated_icon.start()
|
||||
self.app.backup_manager.resume_backup()
|
||||
|
||||
def on_backup_error(self):
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
self.app.start_pause_button["text"] = "Start"
|
||||
|
||||
def on_backup_completion(self):
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
self.app.start_pause_button["text"] = "Start"
|
||||
self.app.task_progress["value"] = 0
|
||||
|
||||
def _start_system_backup(self, mode):
|
||||
dest = self.app.destination_path
|
||||
if not dest:
|
||||
base_dest = self.app.destination_path
|
||||
if not base_dest:
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"]).show()
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
return
|
||||
|
||||
if dest.startswith("/home"):
|
||||
if base_dest.startswith("/home"):
|
||||
with message_box_animation(self.app.animated_icon):
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["system_backup_in_home_error"])
|
||||
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
|
||||
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)
|
||||
|
||||
exclude_file_paths = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.GENERATED_EXCLUDE_LIST_PATH)
|
||||
@@ -337,7 +511,13 @@ class Actions:
|
||||
|
||||
is_dry_run = self.app.testlauf_var.get()
|
||||
self.app.backup_manager.start_backup(
|
||||
"/", dest, True, is_dry_run=is_dry_run, exclude_files=exclude_file_paths, on_progress=self.update_task_progress, on_completion=self.on_backup_completion, on_error=self.on_backup_error)
|
||||
queue=self.app.queue,
|
||||
source_path="/",
|
||||
dest_path=final_dest,
|
||||
is_system=True,
|
||||
is_dry_run=is_dry_run,
|
||||
exclude_files=exclude_file_paths,
|
||||
source_size=source_size_bytes)
|
||||
|
||||
def _start_user_backup(self, sources):
|
||||
dest = self.app.destination_path
|
||||
@@ -349,7 +529,8 @@ class Actions:
|
||||
is_dry_run = self.app.testlauf_var.get()
|
||||
for source in sources:
|
||||
self.app.backup_manager.start_backup(
|
||||
source, dest, False, is_dry_run=is_dry_run, exclude_files=None, on_progress=self.update_task_progress, on_completion=self.on_backup_completion, on_error=self.on_backup_error)
|
||||
|
||||
def update_task_progress(self, percentage):
|
||||
self.app.task_progress["value"] = percentage
|
||||
queue=self.app.queue,
|
||||
source_path=source,
|
||||
dest_path=dest,
|
||||
is_system=False,
|
||||
is_dry_run=is_dry_run)
|
||||
|
@@ -2,9 +2,8 @@ import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
from pathlib import Path
|
||||
import fnmatch
|
||||
|
||||
from app_config import AppConfig, Msg
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
from pyimage_ui.shared_logic import enforce_backup_type_exclusivity
|
||||
|
||||
@@ -171,12 +170,26 @@ class AdvancedSettingsFrame(tk.Toplevel):
|
||||
items_to_display[backup_root_path_str] = (
|
||||
Msg.STR["no"], backup_root_path.name, backup_root_path_str)
|
||||
|
||||
restore_src_path = self.config_manager.get_setting(
|
||||
"restore_source_path")
|
||||
if restore_src_path and Path(restore_src_path).is_dir():
|
||||
restore_root_path = Path(
|
||||
f"/{str(Path(restore_src_path)).strip('/').split('/')[0]}")
|
||||
restore_root_path_str = str(restore_root_path.absolute())
|
||||
items_to_display[restore_root_path_str] = (
|
||||
Msg.STR["no"], restore_root_path.name, restore_root_path_str)
|
||||
|
||||
for item_path_str in sorted(items_to_display.keys()):
|
||||
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
|
||||
if 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_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
|
||||
item_path_str == str(Path(f"/{str(Path(restore_src_path)).strip('/').split('/')[0]}").absolute()))
|
||||
|
||||
if is_backup_dest or is_restore_src:
|
||||
tags = ("backup_dest_exclude", tag)
|
||||
else:
|
||||
tags = (tag,)
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from app_config import Msg
|
||||
from pbp_app_config import Msg
|
||||
from pyimage_ui.system_backup_content_frame import SystemBackupContentFrame
|
||||
from pyimage_ui.user_backup_content_frame import UserBackupContentFrame
|
||||
|
||||
|
||||
class BackupContentFrame(ttk.Frame):
|
||||
@@ -14,74 +13,44 @@ class BackupContentFrame(ttk.Frame):
|
||||
|
||||
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)
|
||||
# --- 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
|
||||
|
||||
columns = ("name", "date", "size")
|
||||
self.content_tree = ttk.Treeview(
|
||||
self.content_frame, columns=columns, show="headings")
|
||||
self.content_tree.heading(
|
||||
"name", text=Msg.STR["name"])
|
||||
self.content_tree.heading(
|
||||
"date", text=Msg.STR["date"])
|
||||
self.content_tree.heading(
|
||||
"size", text=Msg.STR["size"])
|
||||
for col in columns:
|
||||
self.content_tree.column(col, width=150, anchor="center")
|
||||
self.content_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
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)
|
||||
|
||||
list_button_frame = ttk.Frame(self.content_frame)
|
||||
list_button_frame.pack(pady=10)
|
||||
ttk.Button(list_button_frame, text=Msg.STR["restore"],
|
||||
command=self._restore_selected).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(list_button_frame, text=Msg.STR["delete"],
|
||||
command=self._delete_selected).pack(side=tk.LEFT, padx=5)
|
||||
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)
|
||||
|
||||
# --- Content Frames ---
|
||||
self.system_backups_frame = SystemBackupContentFrame(
|
||||
self, backup_manager)
|
||||
self.user_backups_frame = UserBackupContentFrame(self, backup_manager)
|
||||
|
||||
self.system_backups_frame.grid(row=1, column=0, sticky=tk.NSEW)
|
||||
self.user_backups_frame.grid(row=1, column=0, sticky=tk.NSEW)
|
||||
|
||||
self.show_system_backups() # Show system backups by default
|
||||
|
||||
def show(self, 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._load_backup_content()
|
||||
self.system_backups_frame.show(backup_path)
|
||||
self.user_backups_frame.show(backup_path)
|
||||
|
||||
def hide(self):
|
||||
self.grid_remove()
|
||||
|
||||
def _load_backup_content(self):
|
||||
for i in self.content_tree.get_children():
|
||||
self.content_tree.delete(i)
|
||||
def show_system_backups(self):
|
||||
self.system_backups_frame.grid()
|
||||
self.user_backups_frame.grid_remove()
|
||||
|
||||
if not self.backup_path or not os.path.isdir(self.backup_path):
|
||||
return
|
||||
|
||||
# Use BackupManager to list backups
|
||||
backups = self.backup_manager.list_backups(self.backup_path)
|
||||
|
||||
for backup_name in backups:
|
||||
backup_date = ""
|
||||
match = re.match(r"(\d{4}-\d{2}-\d{2})", backup_name)
|
||||
if match:
|
||||
backup_date = match.group(1)
|
||||
|
||||
backup_size = ""
|
||||
|
||||
self.content_tree.insert("", "end", values=(
|
||||
backup_name, backup_date, backup_size
|
||||
))
|
||||
|
||||
def _restore_selected(self):
|
||||
# Placeholder for restore logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Restoring {backup_name}...")
|
||||
|
||||
def _delete_selected(self):
|
||||
# Placeholder for delete logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Deleting {backup_name}...")
|
||||
def show_user_backups(self):
|
||||
self.user_backups_frame.grid()
|
||||
self.system_backups_frame.grid_remove()
|
||||
|
38
pyimage_ui/comment_editor_dialog.py
Normal file
38
pyimage_ui/comment_editor_dialog.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
class CommentEditorDialog(tk.Toplevel):
|
||||
def __init__(self, master, info_file_path, backup_manager):
|
||||
super().__init__(master)
|
||||
self.info_file_path = info_file_path
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
self.title("Kommentar bearbeiten")
|
||||
self.geometry("400x300")
|
||||
|
||||
main_frame = ttk.Frame(self, padding=10)
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self.text_widget = tk.Text(main_frame, wrap="word", height=10, width=40)
|
||||
self.text_widget.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
||||
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.pack(fill=tk.X)
|
||||
|
||||
ttk.Button(button_frame, text="Speichern & Schließen", command=self._save_and_close).pack(side=tk.RIGHT)
|
||||
ttk.Button(button_frame, text="Abbrechen", command=self.destroy).pack(side=tk.RIGHT, padx=5)
|
||||
|
||||
self._load_comment()
|
||||
|
||||
self.transient(master)
|
||||
self.grab_set()
|
||||
self.wait_window(self)
|
||||
|
||||
def _load_comment(self):
|
||||
comment = self.backup_manager.get_comment(self.info_file_path)
|
||||
self.text_widget.insert("1.0", comment)
|
||||
|
||||
def _save_and_close(self):
|
||||
new_comment = self.text_widget.get("1.0", tk.END).strip()
|
||||
self.backup_manager.update_comment(self.info_file_path, new_comment)
|
||||
self.destroy()
|
@@ -1,18 +1,21 @@
|
||||
# pyimage/ui/drawing.py
|
||||
import tkinter as tk
|
||||
from app_config import AppConfig, Msg
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
import os
|
||||
import threading
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
|
||||
|
||||
class Drawing:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def _start_calculating_animation(self, canvas):
|
||||
self._stop_calculating_animation(canvas) # Stop animation for the specific canvas
|
||||
animation_type = self.app.config_manager.get_setting("calculation_animation_type", "double_arc")
|
||||
|
||||
# Stop animation for the specific canvas
|
||||
self._stop_calculating_animation(canvas)
|
||||
animation_type = self.app.config_manager.get_setting(
|
||||
"calculation_animation_type", "double_arc")
|
||||
|
||||
if canvas == self.app.left_canvas:
|
||||
self.app.left_canvas_animation = AnimatedIcon(
|
||||
canvas, width=20, height=20, animation_type=animation_type, use_pillow=True)
|
||||
@@ -23,7 +26,7 @@ class Drawing:
|
||||
self.app.right_canvas_animation.start()
|
||||
|
||||
def _stop_calculating_animation(self, canvas=None):
|
||||
if canvas is None: # Stop all
|
||||
if canvas is None: # Stop all
|
||||
if self.app.left_canvas_animation:
|
||||
self.app.left_canvas_animation.stop()
|
||||
self.app.left_canvas_animation.destroy()
|
||||
@@ -208,7 +211,7 @@ class Drawing:
|
||||
self.app.right_canvas_data['calculating'] = False
|
||||
self._stop_calculating_animation(self.app.right_canvas)
|
||||
self.redraw_right_canvas_restore()
|
||||
|
||||
|
||||
if not self.app.left_canvas_data.get('calculating', False):
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
|
||||
@@ -314,14 +317,16 @@ class Drawing:
|
||||
self.app.after(50, self.update_restore_projection_after)
|
||||
return
|
||||
|
||||
destination_folder_bytes = getattr(self.app, 'restore_destination_folder_size_bytes', 0)
|
||||
destination_folder_bytes = getattr(
|
||||
self.app, 'restore_destination_folder_size_bytes', 0)
|
||||
source_bytes = getattr(self.app, 'restore_source_size_bytes', 0)
|
||||
|
||||
size_diff_bytes = source_bytes - destination_folder_bytes
|
||||
projected_total_used = self.app.destination_used_bytes + size_diff_bytes
|
||||
|
||||
if self.app.destination_total_bytes > 0:
|
||||
projected_total_percentage = projected_total_used / self.app.destination_total_bytes
|
||||
projected_total_percentage = projected_total_used / \
|
||||
self.app.destination_total_bytes
|
||||
else:
|
||||
projected_total_percentage = 0
|
||||
|
||||
@@ -333,7 +338,8 @@ class Drawing:
|
||||
fill="#ff8c00", outline="") # Orange bar
|
||||
else:
|
||||
if self.app.destination_total_bytes > 0:
|
||||
used_percentage = self.app.destination_used_bytes / self.app.destination_total_bytes
|
||||
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="")
|
||||
@@ -344,17 +350,19 @@ class Drawing:
|
||||
canvas.create_rectangle(used_width, 0, used_width + projected_width,
|
||||
canvas.winfo_height(), fill="#ff8c00", outline="")
|
||||
elif size_diff_bytes < 0:
|
||||
final_used_width = canvas_width * (projected_total_used / self.app.destination_total_bytes)
|
||||
final_used_width = canvas_width * \
|
||||
(projected_total_used / self.app.destination_total_bytes)
|
||||
canvas.delete("all")
|
||||
canvas.create_rectangle(
|
||||
0, 0, final_used_width, canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
|
||||
size_diff_gb = size_diff_bytes / (1024**3)
|
||||
diff_text = f"+{size_diff_gb:.2f} GB" if size_diff_gb >= 0 else f"{size_diff_gb:.2f} GB"
|
||||
|
||||
|
||||
diff_color = "blue"
|
||||
|
||||
self.app.restore_size_label_diff.config(text=diff_text, foreground=diff_color)
|
||||
self.app.restore_size_label_diff.config(
|
||||
text=diff_text, foreground=diff_color)
|
||||
|
||||
self.app.restore_size_label_after.config(
|
||||
text=f"{projected_total_used / (1024**3):.2f} GB / {self.app.destination_total_bytes / (1024**3):.2f} GB")
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from app_config import AppConfig, Msg
|
||||
from pbp_app_config import Msg
|
||||
from shared_libs.common_tools import IconManager
|
||||
|
||||
|
||||
@@ -12,8 +11,8 @@ class HeaderFrame(tk.Frame):
|
||||
self.image_manager = image_manager
|
||||
|
||||
# Configure grid weights for internal layout
|
||||
self.columnconfigure(1, weight=1) # Make the middle column expand
|
||||
self.rowconfigure(0, weight=1) # Make the top row expand
|
||||
self.columnconfigure(1, weight=1) # Make the middle column expand
|
||||
self.rowconfigure(0, weight=1) # Make the top row expand
|
||||
|
||||
# Left side: Icon and Main Title/Subtitle
|
||||
left_frame = tk.Frame(self, bg="#455A64")
|
||||
@@ -23,7 +22,8 @@ class HeaderFrame(tk.Frame):
|
||||
|
||||
icon_label = tk.Label(
|
||||
left_frame,
|
||||
image=self.image_manager.get_icon("backup_extralarge"), # Using a generic backup icon
|
||||
image=self.image_manager.get_icon(
|
||||
"backup_extralarge"), # Using a generic backup icon
|
||||
bg="#455A64",
|
||||
)
|
||||
icon_label.grid(row=0, column=0, sticky="e", padx=10, pady=5)
|
||||
@@ -35,7 +35,8 @@ class HeaderFrame(tk.Frame):
|
||||
fg="#ffffff",
|
||||
bg="#455A64",
|
||||
)
|
||||
title_label.grid(row=0, column=1, sticky="w", padx=(5, 20), pady=(15, 5))
|
||||
title_label.grid(row=0, column=1, sticky="w",
|
||||
padx=(5, 20), pady=(15, 5))
|
||||
|
||||
subtitle_label = tk.Label(
|
||||
self,
|
||||
@@ -44,7 +45,8 @@ class HeaderFrame(tk.Frame):
|
||||
fg="#bdc3c7",
|
||||
bg="#455A64",
|
||||
)
|
||||
subtitle_label.grid(row=1, column=1, sticky="w", padx=(5, 20), pady=(0, 10))
|
||||
subtitle_label.grid(row=1, column=1, sticky="w",
|
||||
padx=(5, 20), pady=(0, 10))
|
||||
|
||||
# Right side: Placeholder for future info or buttons
|
||||
right_frame = tk.Frame(self, bg="#455A64")
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import os
|
||||
import shutil
|
||||
from shared_libs.message import MessageDialog
|
||||
from app_config import Msg
|
||||
from pbp_app_config import Msg
|
||||
|
||||
|
||||
class Navigation:
|
||||
@@ -10,6 +10,7 @@ class Navigation:
|
||||
self.app = app
|
||||
|
||||
def _cancel_calculation(self):
|
||||
|
||||
if self.app.calculation_thread and self.app.calculation_thread.is_alive():
|
||||
self.app.calculation_stop_event.set()
|
||||
if self.app.right_calculation_thread and self.app.right_calculation_thread.is_alive():
|
||||
@@ -134,6 +135,34 @@ class Navigation:
|
||||
self.app.drawing.redraw_right_canvas()
|
||||
|
||||
def toggle_mode(self, mode=None, active_index=None, trigger_calculation=True):
|
||||
if self.app.backup_is_running:
|
||||
# If a backup is running, we only want to switch the view to the main backup screen.
|
||||
# We don't reset anything.
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.scheduler_frame.hide()
|
||||
self.app.settings_frame.hide()
|
||||
self.app.backup_content_frame.hide()
|
||||
|
||||
# Show the main content frames
|
||||
self.app.canvas_frame.grid()
|
||||
self.app.top_bar.grid()
|
||||
# Ensures action_frame is visible
|
||||
self._update_task_bar_visibility("backup")
|
||||
|
||||
# Restore visibility of size frames based on the current mode
|
||||
if self.app.mode == 'backup':
|
||||
self.app.source_size_frame.grid()
|
||||
self.app.target_size_frame.grid()
|
||||
else: # restore
|
||||
self.app.restore_size_frame_before.grid()
|
||||
self.app.restore_size_frame_after.grid()
|
||||
|
||||
# Update the top nav button highlight
|
||||
if active_index is not None:
|
||||
self.app.drawing.update_nav_buttons(active_index)
|
||||
return
|
||||
|
||||
# --- Original logic if no backup is running ---
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
self._cancel_calculation()
|
||||
|
||||
@@ -253,7 +282,6 @@ class Navigation:
|
||||
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()
|
||||
|
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from shared_libs.message import MessageDialog
|
||||
from app_config import Msg
|
||||
from pbp_app_config import Msg
|
||||
|
||||
|
||||
class SchedulerFrame(ttk.Frame):
|
||||
|
@@ -2,9 +2,8 @@ import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
from pathlib import Path
|
||||
import fnmatch
|
||||
|
||||
from app_config import AppConfig, Msg
|
||||
from pbp_app_config import AppConfig, Msg
|
||||
from pyimage_ui.advanced_settings_frame import AdvancedSettingsFrame
|
||||
|
||||
|
||||
@@ -15,7 +14,7 @@ class SettingsFrame(ttk.Frame):
|
||||
self.navigation = navigation
|
||||
self.actions = actions
|
||||
|
||||
self.app_config = AppConfig()
|
||||
self.pbp_app_config = AppConfig()
|
||||
self.user_exclude_patterns = []
|
||||
|
||||
# --- Container for Treeviews ---
|
||||
@@ -99,6 +98,7 @@ class SettingsFrame(ttk.Frame):
|
||||
self.grid_remove()
|
||||
|
||||
def _load_exclude_patterns(self):
|
||||
|
||||
generated_patterns = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.GENERATED_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
|
130
pyimage_ui/system_backup_content_frame.py
Normal file
130
pyimage_ui/system_backup_content_frame.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
|
||||
from pbp_app_config import Msg
|
||||
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
|
||||
|
||||
|
||||
class SystemBackupContentFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
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)
|
||||
|
||||
columns = ("date", "type", "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(
|
||||
"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("type", width=80, 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.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 _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):
|
||||
return
|
||||
|
||||
# Use the new method to get structured system backup data
|
||||
system_backups = self.backup_manager.list_system_backups(
|
||||
self.backup_path)
|
||||
|
||||
for backup_info in system_backups:
|
||||
self.content_tree.insert("", "end", values=(
|
||||
backup_info.get("date", "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
|
||||
|
||||
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")
|
||||
|
||||
def _edit_comment(self):
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
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")
|
||||
|
||||
# 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
|
||||
|
||||
def _restore_selected(self):
|
||||
# Placeholder for restore logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Restoring {backup_name}...")
|
||||
|
||||
def _delete_selected(self):
|
||||
# Placeholder for delete logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Deleting {backup_name}...")
|
117
pyimage_ui/user_backup_content_frame.py
Normal file
117
pyimage_ui/user_backup_content_frame.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
|
||||
from pbp_app_config import Msg
|
||||
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
|
||||
|
||||
|
||||
class UserBackupContentFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
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)
|
||||
|
||||
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"])
|
||||
|
||||
self.content_tree.column("date", width=120, anchor="w")
|
||||
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.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 _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):
|
||||
return
|
||||
|
||||
user_backups = self.backup_manager.list_user_backups(self.backup_path)
|
||||
|
||||
for backup_info in user_backups:
|
||||
self.content_tree.insert("", "end", values=(
|
||||
backup_info.get("date", "N/A"),
|
||||
backup_info.get("size", "N/A"),
|
||||
backup_info.get("comment", ""),
|
||||
backup_info.get("folder_name", "N/A")
|
||||
))
|
||||
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")
|
||||
|
||||
def _edit_comment(self):
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
def _restore_selected(self):
|
||||
# Placeholder for restore logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Restoring {backup_name}...")
|
||||
|
||||
def _delete_selected(self):
|
||||
# Placeholder for delete logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Deleting {backup_name}...")
|
@@ -4,7 +4,8 @@ import os
|
||||
|
||||
from shared_libs.message import MessageDialog
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from app_config import AppConfig, Msg
|
||||
from pbp_app_config import Msg
|
||||
|
||||
|
||||
class ScheduleJobDialog(tk.Toplevel):
|
||||
def __init__(self, parent, backup_manager):
|
||||
@@ -38,35 +39,49 @@ class ScheduleJobDialog(tk.Toplevel):
|
||||
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 = 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)
|
||||
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 = 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)
|
||||
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)
|
||||
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
|
||||
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 = 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)
|
||||
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)
|
||||
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":
|
||||
@@ -75,7 +90,8 @@ class ScheduleJobDialog(tk.Toplevel):
|
||||
self.user_sources_frame.pack_forget()
|
||||
|
||||
def _select_destination(self):
|
||||
dialog = CustomFileDialog(self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
dialog = CustomFileDialog(
|
||||
self, mode="dir", title=Msg.STR["select_dest_folder_title"])
|
||||
self.wait_window(dialog)
|
||||
result = dialog.get_result()
|
||||
if result:
|
||||
@@ -84,7 +100,8 @@ class ScheduleJobDialog(tk.Toplevel):
|
||||
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"])
|
||||
MessageDialog(master=self, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
return
|
||||
|
||||
job_type = self.backup_type.get()
|
||||
@@ -92,23 +109,26 @@ class ScheduleJobDialog(tk.Toplevel):
|
||||
job_sources = []
|
||||
|
||||
if job_type == "user":
|
||||
job_sources = [name for name, var in self.user_sources.items() if var.get()]
|
||||
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"])
|
||||
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"))
|
||||
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 ";
|
||||
command += f" --sources "
|
||||
for s in job_sources:
|
||||
command += f'\"{s}\" ';
|
||||
|
||||
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)}"
|
||||
comment += f"; sources:{','.join(job_sources)}"
|
||||
|
||||
self.result = {
|
||||
"command": command,
|
||||
@@ -126,4 +146,4 @@ class ScheduleJobDialog(tk.Toplevel):
|
||||
|
||||
def show(self):
|
||||
self.parent.wait_window(self)
|
||||
return self.result
|
||||
return self.result
|
||||
|
Reference in New Issue
Block a user