Files
Py-Backup/pyimage_ui/actions.py
Désiré Werner Menrath fbfc6a7224 Refactor: Update various modules and add deletion functionality
This commit includes updates across several modules, including:
- backup_manager.py: Enhancements related to backup deletion and regex for backup naming.
- core/data_processing.py: Adjustments to UI state handling.
- pbp_app_config.py: Addition of new UI messages.
- pyimage_ui/actions.py: Refinements in UI actions.
- pyimage_ui/system_backup_content_frame.py: Integration of new deletion logic.
- pyimage_ui/user_backup_content_frame.py: Minor adjustments.

These changes collectively improve backup management, UI responsiveness, and prepare for new deletion features.
2025-09-01 02:02:15 +02:00

700 lines
30 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 _update_backup_type_controls(self):
"""
Updates the state of the Full/Incremental backup radio buttons based on
advanced settings and the content of the destination folder.
This logic only applies to 'Computer' backups.
"""
# Do nothing if the backup mode is not 'backup' or source is not 'Computer'
if self.app.mode != 'backup' or self.app.left_canvas_data.get('folder') != "Computer":
self.app.full_backup_cb.config(state="normal")
self.app.incremental_cb.config(state="normal")
return
# Respect that advanced settings might have already disabled the controls
# This check is based on the user's confirmation that this logic exists elsewhere
if self.app.full_backup_cb.cget('state') == 'disabled':
return
# --- Standard Logic ---
full_backup_exists = False
if self.app.destination_path and os.path.isdir(self.app.destination_path):
system_backups = self.app.backup_manager.list_system_backups(
self.app.destination_path)
for backup in system_backups:
if backup.get('type') == 'Full':
full_backup_exists = True
break
if full_backup_exists:
# Case 1: A full backup exists. Allow user to choose, default to incremental.
if not self.app.vollbackup_var.get(): # If user has not manually selected full backup
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")
self.app.accurate_size_cb.config(
state="normal") # Enable accurate calc
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")
self.app.accurate_size_cb.config(
state="disabled") # Disable accurate calc
def handle_backup_type_change(self, changed_var_name):
# This function is called when the user clicks on "Full" or "Incremental".
# It enforces that only one can be selected and updates the accurate size checkbox accordingly.
if changed_var_name == 'voll':
if self.app.vollbackup_var.get(): # if "Full" was checked
self.app.inkrementell_var.set(False)
elif changed_var_name == 'inkrementell':
if self.app.inkrementell_var.get(): # if "Incremental" was checked
self.app.vollbackup_var.set(False)
# Now, update the state of the accurate calculation checkbox
if self.app.vollbackup_var.get():
self.app.accurate_size_cb.config(state="disabled")
self.app.genaue_berechnung_var.set(False)
else:
# Only enable if it's an incremental backup of the system
if self.app.left_canvas_data.get('folder') == "Computer":
self.app.accurate_size_cb.config(state="normal")
else:
self.app.accurate_size_cb.config(state="disabled")
def handle_compression_change(self):
if self.app.compressed_var.get():
# Compression is enabled, force 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")
# Also disable accurate incremental size calculation, as it's irrelevant
self.app.accurate_size_cb.config(state="disabled")
self.app.genaue_berechnung_var.set(False)
else:
# Compression is disabled, restore normal logic
self.app.full_backup_cb.config(state="normal")
# The state of the incremental checkbox depends on whether a full backup exists
self._update_backup_type_controls()
def on_toggle_accurate_size_calc(self):
# This method is called when the user clicks the "Genaue inkrem. Größe" checkbox.
if not self.app.genaue_berechnung_var.get():
# Box was unchecked by user, but this shouldn't happen if disabled.
# Or it was unchecked automatically after a run. Do nothing.
return
# If a normal calculation is running, stop it.
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.")
# --- Start the accurate calculation ---
app_logger.log("Accurate incremental size calculation requested.")
self.app.accurate_calculation_running = True
# Lock the UI, keep Cancel enabled
self._set_ui_state(False, keep_cancel_enabled=True)
self.app.start_pause_button.config(text=Msg.STR["cancel_backup"])
# Immediately clear the projection canvases
self.app.drawing.reset_projection_canvases()
# Update canvas to show it's calculating and start animation
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()
# Get folder path from the current canvas data
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) # Unlock UI
self.app.genaue_berechnung_var.set(False) # Uncheck the box
self.app.accurate_calculation_running = False
self.app.animated_icon.stop("DISABLE")
return
def threaded_incremental_calc():
status = 'failure' # Default to 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)
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
)
# If rsync fails, size is 0, so status will be failure.
status = 'success' if size > 0 else 'failure'
except Exception as e:
app_logger.log(f"Error during threaded_incremental_calc: {e}")
status = 'failure'
finally:
# This message MUST be sent to unlock the UI and update the display.
# It is now handled by the main data_processing queue.
if self.app.accurate_calculation_running: # Only post if not cancelled
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 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 {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']
# Determine the correct description based on mode and selection
extra_info = ""
if button_text == "Computer":
if self.app.mode == 'backup':
extra_info = Msg.STR["system_backup_info"]
else: # restore
extra_info = Msg.STR["system_restore_info"]
else: # User folder
if self.app.mode == 'backup':
extra_info = Msg.STR["user_backup_info"]
else: # restore
extra_info = Msg.STR["user_restore_info"]
# CORRECTED LOGIC ORDER:
# 1. Update backup type checkboxes BEFORE starting the calculation.
if self.app.mode == 'backup':
self._update_backup_type_controls()
else: # restore mode
self.app.config_manager.set_setting(
"restore_destination_path", folder_path)
# 2. Unified logic for starting a calculation on the left canvas.
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()
# For system backup, now we default to the simple, fast (but for incremental inaccurate) full size calculation.
# The accurate calculation is now triggered explicitly by the new checkbox.
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:
# 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._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)
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":
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_path = path
self.app.destination_total_bytes = total
self.app.destination_used_bytes = used
size_str = f"{used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB"
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)
# Remove advanced settings
self.app.config_manager.remove_setting("backup_animation_type")
self.app.config_manager.remove_setting("calculation_animation_type")
self.app.config_manager.remove_setting("force_full_backup")
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")
# Update the main UI to reflect the cleared settings
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")
# Clear the canvases and reset the UI to its initial state for the current mode
self.app.backup_left_canvas_data.clear()
self.app.backup_right_canvas_data.clear()
self.app.restore_left_canvas_data.clear()
self.app.restore_right_canvas_data.clear()
with message_box_animation(self.app.animated_icon):
MessageDialog(master=self.app, message_type="info",
title=Msg.STR["settings_reset_title"], text=Msg.STR["settings_reset_text"])
def _parse_size_string_to_bytes(self, size_str: str) -> int:
"""Parses a size string like '38.61 GB' into bytes."""
if not size_str or size_str == Msg.STR["calculating_size"]:
return 0
parts = size_str.split()
if len(parts) != 2:
return 0 # Invalid format
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):
# 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):
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.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")
# Special handling for the cancel button during accurate calculation
if keep_cancel_enabled:
self.app.start_pause_button.config(state="normal")
def toggle_start_cancel(self):
# If an accurate calculation is running, cancel it.
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)
# Stop all animations and restore UI
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()
# Restore bottom action bar
self.app.task_progress.stop()
self.app.task_progress.config(mode="determinate", value=0)
self.app.info_label.config(
# Orange for cancelled
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 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()
self.app.log_window.clear_log()
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")
else:
self._start_system_backup("incremental")
else:
pass
def _start_system_backup(self, mode):
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
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-%B-%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)
# Store the path for potential deletion
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)
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)
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)