Files
Py-Backup/pyimage_ui/actions.py
Désiré Werner Menrath 77ac4f5c4f Fix: Automatisches Erkennen von vollständigen Backups, auch bei Verschlüsselung.
Die Logik zur Erkennung von vollständigen Backups wurde verbessert.
Das Programm prüft nun korrekt, ob bereits ein vollständiges Backup
vorhanden ist und schlägt in diesem Fall automatisch ein inkrementelles
Backup vor. Dies funktioniert jetzt auch für verschlüsselte Backups.
2025-09-05 16:23:19 +02:00

681 lines
28 KiB
Python

# pyimage_ui/actions.py
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 pbp_app_config import AppConfig, Msg
from shared_libs.logger import app_logger
from shared_libs.common_tools import message_box_animation
class Actions:
def __init__(self, app):
self.app = app
def _set_backup_type(self, backup_type: str):
if backup_type == "full":
self.app.vollbackup_var.set(True)
self.app.inkrementell_var.set(False)
elif backup_type == "incremental":
self.app.vollbackup_var.set(False)
self.app.inkrementell_var.set(True)
def _update_backup_type_controls(self):
if self.app.mode != 'backup' or self.app.left_canvas_data.get('folder') != "Computer":
return
if self.app.full_backup_cb.cget('state') == 'disabled':
return
full_backup_exists = False
if self.app.destination_path and os.path.isdir(self.app.destination_path):
pybackup_dir = os.path.join(self.app.destination_path, "pybackup")
encrypted_container = os.path.join(pybackup_dir, "pybackup_encrypted.luks")
if os.path.exists(encrypted_container):
# If an encrypted container exists, we check for any directory inside the pybackup folder.
# The presence of any item other than the .luks file and its .txt info file suggests a backup has been made.
if os.path.isdir(pybackup_dir):
for item in os.listdir(pybackup_dir):
if not item.endswith(('.luks', '.txt')) and os.path.isdir(os.path.join(pybackup_dir, item)):
full_backup_exists = True
break
else:
# For non-encrypted backups, check for a directory that represents a full backup.
system_backups = self.app.backup_manager.list_system_backups(pybackup_dir)
for backup in system_backups:
if backup.get('backup_type_base') == 'Full':
full_backup_exists = True
break
if full_backup_exists:
self._set_backup_type("incremental")
else:
self._set_backup_type("full")
def _refresh_backup_options_ui(self):
# Reset enabled/disabled state for all potentially affected controls
self.app.full_backup_cb.config(state="normal")
self.app.incremental_cb.config(state="normal")
self.app.compressed_cb.config(state="normal")
self.app.encrypted_cb.config(state="normal")
self.app.accurate_size_cb.config(state="normal")
# Apply logic: Encryption and Compression are mutually exclusive
if self.app.encrypted_var.get():
self.app.compressed_var.set(False)
self.app.compressed_cb.config(state="disabled")
if self.app.compressed_var.get():
self.app.encrypted_var.set(False)
self.app.encrypted_cb.config(state="disabled")
# Compression forces full backup
self.app.vollbackup_var.set(True)
self.app.inkrementell_var.set(False)
self.app.full_backup_cb.config(state="disabled")
self.app.incremental_cb.config(state="disabled")
self.app.accurate_size_cb.config(state="disabled")
self.app.genaue_berechnung_var.set(False)
# After setting the states, determine the final full/incremental choice
self._update_backup_type_controls()
def handle_backup_type_change(self, changed_var_name):
if changed_var_name == 'voll':
if self.app.vollbackup_var.get():
self._set_backup_type("full")
elif changed_var_name == 'inkrementell':
if self.app.inkrementell_var.get():
self._set_backup_type("incremental")
def handle_compression_change(self):
self._refresh_backup_options_ui()
def handle_encryption_change(self):
self._refresh_backup_options_ui()
def on_toggle_accurate_size_calc(self):
if not self.app.genaue_berechnung_var.get():
return
if self.app.calculation_thread and self.app.calculation_thread.is_alive():
self.app.calculation_stop_event.set()
app_logger.log("Stopping previous size calculation.")
app_logger.log("Accurate incremental size calculation requested.")
self.app.accurate_calculation_running = True
self._set_ui_state(False, keep_cancel_enabled=True)
self.app.start_pause_button.config(text=Msg.STR["cancel_backup"])
self.app.drawing.reset_projection_canvases()
self.app.info_label.config(
text=Msg.STR["please_wait"], foreground="#0078d7")
self.app.task_progress.config(mode="indeterminate")
self.app.task_progress.start()
self.app.left_canvas_data.update({
'size': Msg.STR["calculating_size"],
'calculating': True,
})
self.app.drawing.start_backup_calculation_display()
self.app.animated_icon.start()
folder_path = self.app.left_canvas_data.get('path_display')
button_text = self.app.left_canvas_data.get('folder')
if not folder_path or not button_text:
app_logger.log(
"Cannot start accurate calculation, source folder info missing.")
self._set_ui_state(True)
self.app.genaue_berechnung_var.set(False)
self.app.accurate_calculation_running = False
self.app.animated_icon.stop("DISABLE")
return
def threaded_incremental_calc():
status = 'failure'
size = 0
try:
exclude_file_paths = []
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
exclude_file_paths.append(
AppConfig.GENERATED_EXCLUDE_LIST_PATH)
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
exclude_file_paths.append(AppConfig.USER_EXCLUDE_LIST_PATH)
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
exclude_file_paths.append(AppConfig.MANUAL_EXCLUDE_LIST_PATH)
base_dest = self.app.destination_path
correct_parent_dir = os.path.join(base_dest, "pybackup")
dummy_dest_for_calc = os.path.join(
correct_parent_dir, "dummy_name")
size = self.app.data_processing.get_incremental_backup_size(
source_path=folder_path,
dest_path=dummy_dest_for_calc,
is_system=True,
exclude_files=exclude_file_paths
)
status = 'success' if size > 0 else 'failure'
except Exception as e:
app_logger.log(f"Error during threaded_incremental_calc: {e}")
status = 'failure'
finally:
if self.app.accurate_calculation_running:
self.app.queue.put(
(button_text, size, self.app.mode, 'accurate_incremental', status))
self.app.calculation_thread = threading.Thread(
target=threaded_incremental_calc)
self.app.calculation_thread.daemon = True
self.app.calculation_thread.start()
def on_sidebar_button_click(self, button_text):
if self.app.backup_is_running or self.app.accurate_calculation_running:
app_logger.log(
"Action blocked: Backup or accurate calculation is in progress.")
return
self.app.drawing.reset_projection_canvases()
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()
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 {canonical_key} (Path: {folder_path})")
self.app.start_pause_button.config(state="disabled")
return
if self.app.mode == 'restore':
try:
total, used, free = shutil.disk_usage(str(folder_path))
self.app.destination_total_bytes = total
self.app.destination_used_bytes = used
except FileNotFoundError:
app_logger.log(f"Path not found for disk usage: {folder_path}")
return
icon_name = self.app.buttons_map[button_text]['icon']
extra_info = ""
if button_text == "Computer":
if self.app.mode == 'backup':
extra_info = Msg.STR["system_backup_info"]
else:
extra_info = Msg.STR["system_restore_info"]
else:
if self.app.mode == 'backup':
extra_info = Msg.STR["user_backup_info"]
else:
extra_info = Msg.STR["user_restore_info"]
if self.app.mode == 'backup':
self._update_backup_type_controls()
else:
self.app.config_manager.set_setting(
"restore_destination_path", folder_path)
self._start_left_canvas_calculation(
button_text, str(folder_path), icon_name, extra_info)
def _start_left_canvas_calculation(self, button_text, folder_path, icon_name, extra_info):
self.app.start_pause_button.config(state="disabled")
if self.app.mode == 'backup' and not self.app.destination_path:
self.app.left_canvas_data.update({
'icon': icon_name,
'folder': button_text,
'path_display': folder_path,
'size': Msg.STR["select_destination_first"],
'calculating': False,
'extra_info': extra_info
})
self.app.drawing.redraw_left_canvas()
return
if self.app.mode == 'restore' and not self.app.right_canvas_data.get('path_display'):
self.app.left_canvas_data.update({
'icon': icon_name,
'folder': button_text,
'path_display': folder_path,
'size': Msg.STR["select_source_first"],
'calculating': False,
'extra_info': extra_info
})
self.app.drawing.redraw_left_canvas()
return
if self.app.calculation_thread and self.app.calculation_thread.is_alive():
self.app.calculation_stop_event.set()
data_dict = self.app.left_canvas_data
data_dict.update({
'icon': icon_name,
'folder': button_text,
'path_display': folder_path,
'size': Msg.STR["calculating_size"],
'calculating': True,
'extra_info': extra_info
})
self.app.drawing.start_backup_calculation_display()
self.app.calculation_stop_event = threading.Event()
if button_text == "Computer":
app_logger.log(
"Using default (full) size calculation for system backup display.")
exclude_patterns = self.app.data_processing.load_exclude_patterns()
target_method = self.app.data_processing.get_folder_size_threaded
args = (folder_path, button_text, self.app.calculation_stop_event,
exclude_patterns, self.app.mode)
else:
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._update_backup_type_controls()
else:
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)
def _open_source_or_destination_dialog(self):
title = Msg.STR["select_dest_folder_title"] if self.app.mode == 'backup' else Msg.STR["select_restore_source_title"]
path = self._get_path_from_dialog(title)
if path:
self._update_right_canvas_info(path)
def _get_path_from_dialog(self, title) -> Optional[str]:
self.app.update_idletasks()
dialog = CustomFileDialog(self.app, mode="dir", title=title)
self.app.wait_window(dialog)
path = dialog.get_result()
dialog.destroy()
return path
def _update_right_canvas_info(self, path):
try:
if self.app.mode == "backup":
self.app.destination_path = path
backup_root_to_exclude = f"/{path.strip('/').split('/')[0]}"
try:
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r+') as f:
lines = f.readlines()
f.seek(0)
if not any(backup_root_to_exclude in line for line in lines):
lines.append(f"\n{backup_root_to_exclude}\n")
f.writelines(lines)
except FileNotFoundError:
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'w') as f:
f.write(f"{backup_root_to_exclude}\n")
except IOError as e:
app_logger.log(f"Error updating exclusion list: {e}")
total, used, free = shutil.disk_usage(path)
self.app.destination_total_bytes = total
self.app.destination_used_bytes = used
size_str = f"{used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB"
self.app.right_canvas_data.update({
'folder': os.path.basename(path.rstrip('/')),
'path_display': path,
'size': size_str
})
self.app.config_manager.set_setting(
"backup_destination_path", path)
self.app.header_frame.refresh_status() # Refresh keyring status
self.app.drawing.redraw_right_canvas()
self.app.drawing.update_target_projection()
current_source = self.app.left_canvas_data.get('folder')
if current_source:
self.on_sidebar_button_click(current_source)
self._update_backup_type_controls()
elif self.app.mode == "restore":
self.app.right_canvas_data.update({
'folder': os.path.basename(path.rstrip('/')),
'path_display': path,
'size': ''
})
self.app.config_manager.set_setting(
"restore_source_path", path)
self.app.drawing.calculate_restore_folder_size()
self.app.start_pause_button.config(state="normal")
except FileNotFoundError:
with message_box_animation(self.app.calculating_animation):
MessageDialog(master=self.app, message_type="error",
title=Msg.STR["error"], text=Msg.STR["path_not_found"].format(path=path)).show()
def reset_to_default_settings(self):
try:
AppConfig.create_default_user_excludes()
except OSError as e:
app_logger.log(f"Error creating default user exclude list: {e}")
self.app.config_manager.set_setting("backup_destination_path", None)
self.app.config_manager.set_setting("restore_source_path", None)
self.app.config_manager.set_setting("restore_destination_path", None)
self.app.config_manager.remove_setting("backup_animation_type")
self.app.config_manager.remove_setting("calculation_animation_type")
self.app.config_manager.remove_setting("force_full_backup")
self.app.config_manager.remove_setting("force_incremental_backup")
self.app.config_manager.remove_setting("force_compression")
self.app.config_manager.remove_setting("force_encryption")
self.app.update_backup_options_from_config()
AppConfig.generate_and_write_final_exclude_list()
app_logger.log("Settings have been reset to default values.")
settings_frame = self.app.settings_frame
if settings_frame:
settings_frame.load_and_display_excludes()
settings_frame._load_hidden_files()
self.app.destination_path = None
self.app.start_pause_button.config(state="disabled")
self.app.backup_left_canvas_data.clear()
self.app.backup_right_canvas_data.clear()
self.app.restore_left_canvas_data.clear()
self.app.restore_right_canvas_data.clear()
current_source = self.app.left_canvas_data.get('folder')
if current_source:
self.on_sidebar_button_click(current_source)
self.app.backup_content_frame.system_backups_frame._load_backup_content()
self.app.backup_content_frame.user_backups_frame._load_backup_content()
with message_box_animation(self.app.animated_icon):
MessageDialog(master=self.app, message_type="info",
title=Msg.STR["settings_reset_title"], text=Msg.STR["settings_reset_text"])
def _parse_size_string_to_bytes(self, size_str: str) -> int:
if not size_str or size_str == Msg.STR["calculating_size"]:
return 0
parts = size_str.split()
if len(parts) != 2:
return 0
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, keep_cancel_enabled: bool = False, allow_log_and_backup_toggle: bool = False):
for text, data in self.app.buttons_map.items():
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
self.app.schedule_dialog_button.config(
state="normal" if enable else "disabled")
self.app.settings_button.config(
state="normal" if enable else "disabled")
self.app.mode_button.config(state="normal" if enable else "disabled")
for i, button in enumerate(self.app.nav_buttons):
if allow_log_and_backup_toggle and self.app.nav_buttons_defs[i][0] in [Msg.STR["log"], Msg.STR["backup_menu"]]:
continue
button.config(state="normal" if enable else "disabled")
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="")
if enable:
self.app.update_backup_options_from_config()
self.app.actions._update_backup_type_controls()
else:
checkboxes = [
self.app.full_backup_cb,
self.app.incremental_cb,
self.app.accurate_size_cb,
self.app.compressed_cb,
self.app.encrypted_cb,
self.app.test_run_cb,
self.app.bypass_security_cb
]
for cb in checkboxes:
cb.config(state="disabled")
if keep_cancel_enabled:
self.app.start_pause_button.config(state="normal")
def toggle_start_cancel(self):
if self.app.accurate_calculation_running:
app_logger.log("Accurate size calculation cancelled by user.")
self.app.accurate_calculation_running = False
self.app.genaue_berechnung_var.set(False)
self.app.animated_icon.stop("DISABLE")
if self.app.left_canvas_animation:
self.app.left_canvas_animation.stop()
self.app.left_canvas_data['calculating'] = False
self.app.drawing.redraw_left_canvas()
self.app.task_progress.stop()
self.app.task_progress.config(mode="determinate", value=0)
self.app.info_label.config(
text=Msg.STR["accurate_calc_cancelled"], foreground="#E8740C")
self.app.start_pause_button.config(text=Msg.STR["start"])
self._set_ui_state(True)
return
if self.app.backup_is_running:
self.app.animated_icon.stop("DISABLE")
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
self.app.backup_is_running = False
self.app.start_pause_button["text"] = Msg.STR["start"]
self._set_ui_state(True)
else:
if self.app.start_pause_button['state'] == 'disabled':
return
self.app.backup_is_running = True
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...")
self.app._update_duration()
self.app.start_pause_button["text"] = Msg.STR["cancel_backup"]
self.app.update_idletasks()
self.app.log_window.clear_log()
self._set_ui_state(False, allow_log_and_backup_toggle=True)
self.app.animated_icon.start()
if self.app.mode == "backup":
source_size_bytes = self.app.source_size_bytes
if self.app.vollbackup_var.get():
self._start_system_backup("full", source_size_bytes)
else:
self._start_system_backup("incremental", source_size_bytes)
else:
pass
def _start_system_backup(self, mode, source_size_bytes):
base_dest = self.app.destination_path
if not base_dest:
MessageDialog(master=self.app, message_type="error",
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
return
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
is_encrypted = self.app.encrypted_var.get()
password = None
if is_encrypted:
username = os.path.basename(base_dest.rstrip('/'))
password = self.app.backup_manager.encryption_manager.get_password(username, confirm=True)
if not password:
app_logger.log("Encryption enabled, but no password provided. Aborting backup.")
self.app.backup_is_running = False
self.app.start_pause_button["text"] = Msg.STR["start"]
self._set_ui_state(True)
return
try:
locale.setlocale(locale.LC_TIME, 'de_DE.UTF-8')
except locale.Error:
app_logger.log(
"Could not set locale to de_DE.UTF-8. Using default.")
now = datetime.datetime.now()
date_str = now.strftime("%d-%m-%Y")
time_str = now.strftime("%H:%M:%S")
folder_name = f"{date_str}_{time_str}_system_{mode}"
final_dest = os.path.join(base_dest, "pybackup", folder_name)
self.app.current_backup_path = final_dest
source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0)
exclude_file_paths = []
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
exclude_file_paths.append(AppConfig.GENERATED_EXCLUDE_LIST_PATH)
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
exclude_file_paths.append(AppConfig.USER_EXCLUDE_LIST_PATH)
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
exclude_file_paths.append(AppConfig.MANUAL_EXCLUDE_LIST_PATH)
is_dry_run = self.app.testlauf_var.get()
is_compressed = self.app.compressed_var.get()
self.app.backup_manager.start_backup(
queue=self.app.queue,
source_path="/",
dest_path=final_dest,
is_system=True,
is_dry_run=is_dry_run,
exclude_files=exclude_file_paths,
source_size=source_size_bytes,
is_compressed=is_compressed,
is_encrypted=is_encrypted,
mode=mode,
password=password)
def _start_user_backup(self, sources):
dest = self.app.destination_path
if not dest:
MessageDialog(master=self.app, message_type="error",
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
return
is_dry_run = self.app.testlauf_var.get()
for source in sources:
self.app.backup_manager.start_backup(
queue=self.app.queue,
source_path=source,
dest_path=dest,
is_system=False,
is_dry_run=is_dry_run)