Files
Py-Backup/pyimage_ui/actions.py
Désiré Werner Menrath 0b9c58410f feat: Rework backup list and space calculation
- Improves the backup list display with chronological sorting, colored grouping for full/incremental backups, and dedicated time column.
- Changes backup folder naming to a consistent dd-mm-yyyy_HH:MM:SS format.
- Fixes bug where backup size was not displayed.
- Adds detection for encrypted backup containers, showing them correctly in the list.
- Hardens destination space check:
  - Considers extra space needed for compressed backups.
  - Disables start button if projected usage is > 95% or exceeds total disk space.
2025-09-04 14:20:57 +02:00

668 lines
27 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):
system_backups = self.app.backup_manager.list_system_backups(
self.app.destination_path)
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.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)