Compare commits

...

14 Commits

Author SHA1 Message Date
f65e7feb67 add new feature to edit comments on backup fix on correct backupzize on info-txt and dreate info text in parent backup dir 2025-08-31 02:33:59 +02:00
9e88ac8bb5 refactor: Metadaten-Speicherung zur Behebung von Berechtigungsproblemen
- Ändert die Logik zum Erstellen und Auflisten von Backups, um Metadaten-Dateien (z.B. 'backup_name.txt') im übergeordneten Verzeichnis des Backups statt darin zu speichern.
- Dies behebt den 'Permission denied'-Fehler beim Schreiben von Metadaten für System-Backups, die root gehören, da das übergeordnete Verzeichnis für den Benutzer beschreibbar ist.
- Ermöglicht vollständig unbeaufsichtigte/automatisierte Backups ohne eine zweite Passwortabfrage.
2025-08-31 01:12:10 +02:00
8132c5cef9 feat: Zeitanzeige & Fix für unbeaufsichtigte Backups
- Behebt das Problem, bei dem für die Metadaten-Datei eine zweite pkexec-Passwortabfrage nach einem langen Backup-Lauf erforderlich war. Dies ermöglicht nun unbeaufsichtigte/automatisierte Backups.
- Fügt eine neue UI-Anzeige für Startzeit, Endzeit und Dauer des Backups hinzu.
- Das Info-Label zeigt nun den prozentualen Fortschritt während des Backups an.
2025-08-30 22:32:52 +02:00
269da49a1a rename appconfig and mv configmanager to shared_libs 2025-08-30 19:08:56 +02:00
5f5cd78ee0 user dirs works final 2025-08-30 17:47:43 +02:00
e1ed313a7d user folder founds fix part one add german namefolder on app_config for works calculate size 2025-08-30 14:19:20 +02:00
4c4b8bf0cd disable checkboxes on running backup 2025-08-30 11:50:49 +02:00
eae09b8694 refactor(backup): Improve cancellation and UI state management
This commit refactors the backup cancellation process and hardens the UI state
management to resolve several race conditions and bugs.

Key changes:

Atomic Privileged Operations:
Introduced a helper method (`_execute_as_root`) to run shell commands via a
temporary script with a single `pkexec` invocation.
The backup cancellation for system backups now uses this method to atomically
terminate the rsync process and delete the partial backup directory, requiring only
one password authentication instead of two.
A general-purpose `delete_privileged_path` method is now available for other UI
actions, like deleting from the backup content view.
Robust State Handling:
Replaced unreliable state tracking based on UI widget text with a dedicated
`self.app.backup_is_running` boolean flag.
This flag is set immediately when a backup starts, preventing a race condition
that allowed a second backup to be initiated, which previously led to a
segmentation fault.
The `toggle_start_cancel` logic is now driven entirely by this reliable in
memory flag.
UI Hardening & Bug Fixes:
The `_set_ui_state` method now also disables clicks on the destination canvas
during a backup.
The `toggle_mode` method (for switching views) now checks the
`backup_is_running` flag to prevent resetting UI elements like projection canvases
when navigating away and back during a backup.
Fixed a bug where an invalid date format string (`% -d`) created incorrect
directory names, causing the deletion on cancel to fail.
Resolved a race condition where the backup process object was cleared
prematurely, leading to an `AttributeError` in the backup thread upon cancellation.
2025-08-30 11:35:26 +02:00
7ab227626b commit pkexec part one for remove cancel backup dir 2025-08-30 01:57:21 +02:00
8f0150c293 add user backup content 2025-08-29 20:37:54 +02:00
841bc82915 Textlabel works over progressbar 2025-08-29 15:05:55 +02:00
94530fd626 commit part one Systembackup create folders and metadata 2025-08-29 14:03:57 +02:00
6af66d5211 recover mode works add source on excludelist 2025-08-28 18:20:10 +02:00
bf3eaaf451 reformat and remove unused imports 2025-08-28 15:06:46 +02:00
35 changed files with 1293 additions and 358 deletions

Binary file not shown.

View File

@@ -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}")

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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,)

View File

@@ -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()

View 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()

View File

@@ -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")

View File

@@ -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")

View File

@@ -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()

View File

@@ -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):

View File

@@ -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:

View 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}...")

View 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}...")

View File

@@ -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