532 lines
28 KiB
Python
532 lines
28 KiB
Python
# BackupApp/backup_app/gui/main_window.py
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, simpledialog, messagebox, Text, StringVar, Frame, Label
|
|
from pathlib import Path
|
|
import threading
|
|
from datetime import datetime
|
|
|
|
# Importi specifici del progetto BackupApp
|
|
from backuptools.config import settings as app_settings
|
|
from backuptools.core import file_scanner
|
|
from backuptools.core import backup_operations
|
|
from backuptools.gui import dialogs
|
|
|
|
# --- Import Version Info FOR THE WRAPPER ITSELF ---
|
|
try:
|
|
# Use absolute import based on package name
|
|
from backuptools import _version as wrapper_version
|
|
|
|
WRAPPER_APP_VERSION_STRING = f"{wrapper_version.__version__} ({wrapper_version.GIT_BRANCH}/{wrapper_version.GIT_COMMIT_HASH[:7]})"
|
|
WRAPPER_BUILD_INFO = f"Wrapper Built: {wrapper_version.BUILD_TIMESTAMP}"
|
|
except ImportError:
|
|
# This might happen if you run the wrapper directly from source
|
|
# without generating its _version.py first (if you use that approach for the wrapper itself)
|
|
WRAPPER_APP_VERSION_STRING = "(Dev Wrapper)"
|
|
WRAPPER_BUILD_INFO = "Wrapper build time unknown"
|
|
# --- End Import Version Info ---
|
|
|
|
# --- Constants for Version Generation ---
|
|
DEFAULT_VERSION = "0.0.0+unknown"
|
|
DEFAULT_COMMIT = "Unknown"
|
|
DEFAULT_BRANCH = "Unknown"
|
|
# --- End Constants ---
|
|
|
|
class BackupApplicationWindow:
|
|
def __init__(self, root: tk.Tk):
|
|
self.root = root
|
|
self.root.title(f"Backup Manager Pro - {WRAPPER_APP_VERSION_STRING}")
|
|
self.root.geometry("750x600") # Potrebbe servire un po' più di altezza per il Text widget
|
|
self.root.minsize(650, 500)
|
|
|
|
# --- Application State Variables ---
|
|
self.config_data: dict = app_settings.load_application_data()
|
|
|
|
self.source_dir_var = StringVar(value=self.config_data.get("source_dir", ""))
|
|
self.dest_dir_var = StringVar(value=self.config_data.get("dest_dir", ""))
|
|
|
|
self.current_exclusions: list[str] = list(self.config_data.get("exclusions", []))
|
|
|
|
# self.exclusions_input_var non è più necessaria, useremo direttamente il Text widget
|
|
|
|
self.profiles: dict = self.config_data.get("profiles", {})
|
|
self.current_profile_var = StringVar()
|
|
|
|
self._scanned_included_files: list[file_scanner.FileDetail] = []
|
|
self._scanned_excluded_files: list[file_scanner.FileDetail] = []
|
|
self._included_ext_stats: dict = {}
|
|
self._excluded_ext_stats: dict = {}
|
|
self._confirmation_dialog_active = False
|
|
|
|
self._setup_styles()
|
|
self._create_widgets()
|
|
self._load_initial_profile_or_defaults()
|
|
self._update_profile_dropdown()
|
|
|
|
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
|
|
|
|
def _setup_styles(self) -> None:
|
|
style = ttk.Style(self.root)
|
|
style.configure("Accent.TButton", font=('Arial', 10, 'bold'), padding=5)
|
|
style.configure("TLabel", padding=2)
|
|
style.configure("TEntry", padding=2) # Mantenuto per altri Entry
|
|
style.configure("TCombobox", padding=2)
|
|
# Stile per il Text widget (opzionale, ma può aiutare con il bordo)
|
|
style.configure("Exclusion.TText", borderwidth=1, relief="solid")
|
|
|
|
|
|
def _create_widgets(self) -> None:
|
|
main_frame = ttk.Frame(self.root, padding="10 10 10 10")
|
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
main_frame.columnconfigure(1, weight=1)
|
|
|
|
# --- Profile Section (invariata) ---
|
|
profile_frame = ttk.LabelFrame(main_frame, text="Profile Management", padding=10)
|
|
profile_frame.grid(row=0, column=0, columnspan=3, padx=5, pady=(0,10), sticky="ew")
|
|
profile_frame.columnconfigure(1, weight=1)
|
|
ttk.Label(profile_frame, text="Active Profile:").grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
|
self.profile_dropdown = ttk.Combobox(
|
|
profile_frame, textvariable=self.current_profile_var, state="readonly", width=35
|
|
)
|
|
self.profile_dropdown.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
|
|
self.profile_dropdown.bind("<<ComboboxSelected>>", self._on_profile_selected)
|
|
save_profile_btn = ttk.Button(profile_frame, text="Save Current as Profile", command=self._save_current_as_profile)
|
|
save_profile_btn.grid(row=0, column=2, padx=5, pady=5)
|
|
delete_profile_btn = ttk.Button(profile_frame, text="Delete Selected Profile", command=self._delete_selected_profile)
|
|
delete_profile_btn.grid(row=0, column=3, padx=5, pady=5)
|
|
|
|
# --- Source/Destination Directory (invariate) ---
|
|
ttk.Label(main_frame, text="Source Directory:").grid(row=1, column=0, padx=5, pady=5, sticky="w")
|
|
source_entry = ttk.Entry(main_frame, textvariable=self.source_dir_var, width=60)
|
|
source_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
|
|
ttk.Button(main_frame, text="Browse...", command=lambda: self._browse_directory(self.source_dir_var)).grid(row=1, column=2, padx=5, pady=5)
|
|
ttk.Label(main_frame, text="Destination Directory:").grid(row=2, column=0, padx=5, pady=5, sticky="w")
|
|
dest_entry = ttk.Entry(main_frame, textvariable=self.dest_dir_var, width=60)
|
|
dest_entry.grid(row=2, column=1, padx=5, pady=5, sticky="ew")
|
|
ttk.Button(main_frame, text="Browse...", command=lambda: self._browse_directory(self.dest_dir_var)).grid(row=2, column=2, padx=5, pady=5)
|
|
|
|
# --- MODIFICA SEZIONE ESCLUSIONI (Text Widget multiriga) ---
|
|
exclusions_lf = ttk.LabelFrame(main_frame, text="File Exclusions (comma-separated, auto-wraps)", padding=10)
|
|
exclusions_lf.grid(row=3, column=0, columnspan=3, padx=5, pady=10, sticky="nsew")
|
|
exclusions_lf.columnconfigure(0, weight=1) # Il Text widget si espande orizzontalmente
|
|
exclusions_lf.rowconfigure(0, weight=1) # Il Text widget si espande verticalmente
|
|
|
|
# Frame per contenere Text e Scrollbar
|
|
text_scroll_frame = ttk.Frame(exclusions_lf)
|
|
text_scroll_frame.grid(row=0, column=0, sticky="nsew", padx=(5,0), pady=5)
|
|
text_scroll_frame.columnconfigure(0, weight=1)
|
|
text_scroll_frame.rowconfigure(0, weight=1)
|
|
|
|
self.exclusions_text_widget = Text(
|
|
text_scroll_frame,
|
|
height=5, # Circa 5 righe visibili
|
|
width=60, # Larghezza indicativa, il wrap gestirà il resto
|
|
wrap=tk.WORD, # Va a capo per parole intere
|
|
relief=tk.SOLID,
|
|
borderwidth=1
|
|
# Potresti voler aggiungere un font specifico qui
|
|
# font=("Arial", 10)
|
|
)
|
|
self.exclusions_text_widget.grid(row=0, column=0, sticky="nsew")
|
|
|
|
exclusions_scrollbar = ttk.Scrollbar(text_scroll_frame, orient=tk.VERTICAL, command=self.exclusions_text_widget.yview)
|
|
exclusions_scrollbar.grid(row=0, column=1, sticky="ns")
|
|
self.exclusions_text_widget.config(yscrollcommand=exclusions_scrollbar.set)
|
|
|
|
# Bottone per salvare la lista di esclusioni dal Text widget
|
|
save_exclusions_btn = ttk.Button(exclusions_lf, text="Update Exclusions List", command=self._save_exclusions_from_input)
|
|
save_exclusions_btn.grid(row=0, column=1, padx=(5,5), pady=5, sticky="n") # sticky="n" per allinearlo in alto
|
|
# --- FINE MODIFICA SEZIONE ESCLUSIONI ---
|
|
|
|
# --- Description Section (invariata, ma LabelFrame ora è row 4) ---
|
|
desc_frame = ttk.LabelFrame(main_frame, text="Backup Description (optional)", padding=10)
|
|
desc_frame.grid(row=4, column=0, columnspan=3, padx=5, pady=5, sticky="nsew")
|
|
desc_frame.columnconfigure(0, weight=1)
|
|
# desc_frame.rowconfigure(0, weight=1) # Lascia che il Text widget interno determini l'altezza
|
|
|
|
self.description_text_widget = Text(desc_frame, height=3, width=60, relief=tk.SOLID, borderwidth=1)
|
|
self.description_text_widget.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
|
self.description_text_widget.insert("1.0", self.config_data.get("description", ""))
|
|
|
|
# --- Action Button, Progress Bar, Status Bar (invariati, ma le row potrebbero cambiare) ---
|
|
self.backup_button = ttk.Button(main_frame, text="Analyze and Create Backup", command=self._start_backup_process, style="Accent.TButton")
|
|
self.backup_button.grid(row=5, column=0, columnspan=3, pady=20)
|
|
|
|
self.progress_bar = ttk.Progressbar(main_frame, orient="horizontal", length=100, mode="determinate")
|
|
self.progress_bar.grid(row=6, column=0, columnspan=3, sticky="ew", padx=5, pady=(5,0))
|
|
|
|
status_bar_frame = ttk.Frame(main_frame, relief="sunken", padding=2)
|
|
status_bar_frame.grid(row=7, column=0, columnspan=3, sticky="ew", padx=5, pady=(2,5))
|
|
status_bar_frame.columnconfigure(0, weight=1)
|
|
status_bar_frame.columnconfigure(1, weight=0)
|
|
|
|
self.status_file_label = ttk.Label(status_bar_frame, text="File: Idle", anchor="w", width=60)
|
|
self.status_file_label.grid(row=0, column=0, sticky="ew", padx=5)
|
|
self.status_size_label = ttk.Label(status_bar_frame, text="Size: 0.00 MB / 0.00 MB", anchor="e", width=30)
|
|
self.status_size_label.grid(row=0, column=1, sticky="e", padx=5)
|
|
|
|
# Permetti alla riga delle esclusioni di espandersi se necessario
|
|
main_frame.rowconfigure(3, weight=1)
|
|
# main_frame.rowconfigure(4, weight=0) # Description non ha bisogno di espandersi molto
|
|
|
|
def _load_initial_profile_or_defaults(self) -> None:
|
|
last_active_profile = self.config_data.get("last_active_profile")
|
|
if last_active_profile and last_active_profile in self.profiles:
|
|
self.current_profile_var.set(last_active_profile)
|
|
self._load_profile_data(last_active_profile)
|
|
else:
|
|
self.source_dir_var.set(self.config_data.get("source_dir", ""))
|
|
self.dest_dir_var.set(self.config_data.get("dest_dir", ""))
|
|
self.current_exclusions = list(self.config_data.get("exclusions", []))
|
|
self._update_exclusions_display() # Aggiorna il Text widget
|
|
self.description_text_widget.delete("1.0", tk.END)
|
|
self.description_text_widget.insert("1.0", self.config_data.get("description", ""))
|
|
|
|
def _on_profile_selected(self, event=None) -> None:
|
|
profile_name = self.current_profile_var.get()
|
|
if profile_name:
|
|
self._load_profile_data(profile_name)
|
|
|
|
def _load_profile_data(self, profile_name: str) -> None:
|
|
profile_data = self.profiles.get(profile_name)
|
|
if profile_data:
|
|
self.source_dir_var.set(profile_data.get("source_dir", ""))
|
|
self.dest_dir_var.set(profile_data.get("dest_dir", ""))
|
|
self.current_exclusions = list(profile_data.get("exclusions", []))
|
|
self._update_exclusions_display() # Aggiorna il Text widget
|
|
self.description_text_widget.delete("1.0", tk.END)
|
|
self.description_text_widget.insert("1.0", profile_data.get("description", ""))
|
|
print(f"Info: Profile '{profile_name}' loaded.")
|
|
else:
|
|
messagebox.showwarning("Profile Load Error", f"Profile '{profile_name}' not found.", parent=self.root)
|
|
|
|
def _update_profile_dropdown(self) -> None:
|
|
profile_names = list(self.profiles.keys())
|
|
self.profile_dropdown["values"] = sorted(profile_names)
|
|
|
|
def _save_current_as_profile(self) -> None:
|
|
current_selected_profile = self.current_profile_var.get()
|
|
suggested_profile_name = current_selected_profile if current_selected_profile else ""
|
|
profile_name = simpledialog.askstring(
|
|
"Save Profile", "Enter profile name:",
|
|
initialvalue=suggested_profile_name, parent=self.root
|
|
)
|
|
if not profile_name: return
|
|
profile_name = profile_name.strip()
|
|
if not profile_name:
|
|
messagebox.showwarning("Invalid Name", "Profile name cannot be empty or just spaces.", parent=self.root)
|
|
return
|
|
|
|
if profile_name in self.profiles and profile_name != current_selected_profile:
|
|
if not messagebox.askyesno("Confirm Overwrite", f"Profile '{profile_name}' already exists. Overwrite?", parent=self.root):
|
|
return
|
|
|
|
if not self._save_exclusions_from_input(show_success_message=False):
|
|
messagebox.showerror("Exclusion Error", "Could not save profile: invalid exclusion patterns.", parent=self.root)
|
|
return
|
|
|
|
self.profiles[profile_name] = {
|
|
"source_dir": self.source_dir_var.get(),
|
|
"dest_dir": self.dest_dir_var.get(),
|
|
"exclusions": list(self.current_exclusions),
|
|
"description": self.description_text_widget.get("1.0", tk.END).strip()
|
|
}
|
|
self._update_profile_dropdown()
|
|
self.current_profile_var.set(profile_name)
|
|
self._save_app_config()
|
|
messagebox.showinfo("Profile Saved", f"Profile '{profile_name}' saved successfully.", parent=self.root)
|
|
|
|
def _delete_selected_profile(self) -> None:
|
|
profile_name = self.current_profile_var.get()
|
|
if not profile_name:
|
|
messagebox.showwarning("No Profile Selected", "Please select a profile to delete.", parent=self.root)
|
|
return
|
|
if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete profile '{profile_name}'?", parent=self.root):
|
|
if profile_name in self.profiles:
|
|
del self.profiles[profile_name]
|
|
self._update_profile_dropdown()
|
|
self.current_profile_var.set("")
|
|
self._clear_ui_fields(load_defaults=True)
|
|
self._save_app_config()
|
|
messagebox.showinfo("Profile Deleted", f"Profile '{profile_name}' deleted.", parent=self.root)
|
|
else:
|
|
messagebox.showerror("Error", "Profile not found.", parent=self.root)
|
|
|
|
def _save_app_config(self, is_closing: bool = False) -> None:
|
|
active_profile = self.current_profile_var.get()
|
|
current_description = self.description_text_widget.get("1.0", tk.END).strip()
|
|
|
|
# Tenta di aggiornare self.current_exclusions dal Text widget silenziosamente
|
|
try:
|
|
input_string = self.exclusions_text_widget.get("1.0", tk.END)
|
|
# Tratta newline come spazi per unirli, poi splitta per virgola
|
|
single_line_input = input_string.replace('\n', ' ').replace('\r', '')
|
|
raw_patterns = single_line_input.split(',')
|
|
processed_patterns = [p.strip() for p in raw_patterns if p.strip()]
|
|
self.current_exclusions = sorted(list(set(processed_patterns)))
|
|
except Exception:
|
|
print("Warning: Could not parse exclusions Text widget during general config save. Using previous list.")
|
|
pass
|
|
|
|
self.config_data["source_dir"] = self.source_dir_var.get()
|
|
self.config_data["dest_dir"] = self.dest_dir_var.get()
|
|
self.config_data["exclusions"] = list(self.current_exclusions)
|
|
self.config_data["description"] = current_description
|
|
self.config_data["profiles"] = self.profiles
|
|
if is_closing and active_profile:
|
|
self.config_data["last_active_profile"] = active_profile
|
|
elif is_closing:
|
|
self.config_data.pop("last_active_profile", None)
|
|
app_settings.save_application_data(self.config_data)
|
|
|
|
def _browse_directory(self, dir_var: StringVar) -> None:
|
|
current_path = dir_var.get()
|
|
initial_dir = current_path if Path(current_path).is_dir() else str(Path.home())
|
|
directory = filedialog.askdirectory(initialdir=initial_dir, parent=self.root)
|
|
if directory: dir_var.set(directory)
|
|
|
|
# --- NUOVI METODI E MODIFICHE PER GESTIONE ESCLUSIONI (Text Widget) ---
|
|
def _update_exclusions_display(self) -> None:
|
|
"""Updates the exclusions Text widget from self.current_exclusions list."""
|
|
self.exclusions_text_widget.config(state=tk.NORMAL) # Abilita per scrittura
|
|
self.exclusions_text_widget.delete("1.0", tk.END)
|
|
|
|
# Unisci la lista in una singola stringa separata da ", "
|
|
# Il Text widget con wrap=tk.WORD gestirà l'andare a capo.
|
|
display_text = ", ".join(self.current_exclusions)
|
|
self.exclusions_text_widget.insert("1.0", display_text)
|
|
# self.exclusions_text_widget.config(state=tk.DISABLED) # Se vuoi renderlo read-only dopo l'aggiornamento
|
|
|
|
def _save_exclusions_from_input(self, show_success_message: bool = True) -> bool:
|
|
"""
|
|
Reads exclusion patterns from the Text widget, validates them,
|
|
updates self.current_exclusions (internal list), and then updates the display.
|
|
Returns True if successful, False if validation fails (improbabile con validazione minima).
|
|
"""
|
|
# Ottieni tutto il testo dal widget
|
|
input_string = self.exclusions_text_widget.get("1.0", tk.END)
|
|
|
|
# Sostituisci i newline con spazi per trattarlo come una singola linea logica,
|
|
# poi splitta per virgola.
|
|
single_line_input = input_string.replace('\n', ' ').replace('\r', '') # Rimuovi i ritorni a capo
|
|
raw_patterns = single_line_input.split(',')
|
|
|
|
processed_patterns = []
|
|
for pattern in raw_patterns:
|
|
clean_pattern = pattern.strip()
|
|
if clean_pattern: # Solo se non è una stringa vuota dopo lo strip
|
|
processed_patterns.append(clean_pattern)
|
|
|
|
# Aggiorna la lista interna, rimuovi duplicati e ordina
|
|
self.current_exclusions = sorted(list(set(processed_patterns)))
|
|
|
|
# Aggiorna il Text widget per riflettere la formattazione pulita
|
|
self._update_exclusions_display()
|
|
|
|
if show_success_message:
|
|
messagebox.showinfo("Exclusions Updated", "Exclusion list has been updated based on your input.", parent=self.root)
|
|
return True # Con la validazione attuale, è difficile che fallisca se c'è input.
|
|
|
|
def _clear_ui_fields(self, load_defaults: bool = False) -> None:
|
|
if load_defaults:
|
|
self._load_initial_profile_or_defaults()
|
|
else:
|
|
self.source_dir_var.set("")
|
|
self.dest_dir_var.set("")
|
|
self.current_exclusions = []
|
|
self._update_exclusions_display() # Aggiorna il Text widget (lo mostrerà vuoto)
|
|
self.description_text_widget.delete("1.0", tk.END)
|
|
self.current_profile_var.set("")
|
|
|
|
|
|
# --- Backup Process Methods (invariati) ---
|
|
def _update_scan_progress(self, current_file_idx: int, total_files: int, current_file_path: str) -> None:
|
|
if total_files > 0: progress_percent = (current_file_idx + 1) / total_files * 100
|
|
else: progress_percent = 0
|
|
self.progress_bar["value"] = progress_percent
|
|
display_path = Path(current_file_path).name
|
|
self.status_file_label.config(text=f"Scanning: {display_path}")
|
|
self.status_size_label.config(text=f"File {current_file_idx+1}/{total_files}")
|
|
self.root.update_idletasks()
|
|
|
|
def _update_zip_progress(self, processed_mb: float, total_mb: float, arcname: str) -> None:
|
|
if total_mb > 0: progress_percent = (processed_mb / total_mb) * 100
|
|
else: progress_percent = 0
|
|
self.progress_bar["value"] = progress_percent
|
|
display_arcname = Path(arcname).name
|
|
self.status_file_label.config(text=f"Archiving: {display_arcname}")
|
|
self.status_size_label.config(text=f"{processed_mb:.2f} MB / {total_mb:.2f} MB")
|
|
self.root.update_idletasks()
|
|
|
|
def _reset_progress_status(self, message: str = "Idle") -> None:
|
|
self.progress_bar["value"] = 0
|
|
self.status_file_label.config(text=f"Status: {message}")
|
|
self.status_size_label.config(text="Size: 0.00 MB / 0.00 MB")
|
|
|
|
def _toggle_ui_elements(self, enabled: bool) -> None:
|
|
state_tk_text = tk.NORMAL if enabled else tk.DISABLED
|
|
state_ttk_widget = "normal" if enabled else "disabled"
|
|
|
|
def configure_state(widget, state_type):
|
|
if widget: # Controlla se il widget esiste
|
|
try:
|
|
widget.config(state=state_type)
|
|
except tk.TclError: pass
|
|
|
|
if hasattr(self, 'profile_dropdown'):
|
|
profile_frame = self.profile_dropdown.master
|
|
for widget in profile_frame.winfo_children():
|
|
if isinstance(widget, (ttk.Button, ttk.Combobox)):
|
|
configure_state(widget, state_ttk_widget)
|
|
|
|
if hasattr(self, 'backup_button'):
|
|
main_content_frame = self.backup_button.master
|
|
for child in main_content_frame.winfo_children():
|
|
if isinstance(child, ttk.Entry) and child != getattr(self, 'exclusions_entry_widget', None):
|
|
configure_state(child, state_ttk_widget)
|
|
elif isinstance(child, ttk.Button) and child.cget("text") == "Browse...":
|
|
configure_state(child, state_ttk_widget)
|
|
|
|
# Esclusioni: Text widget e il suo bottone "Update"
|
|
if hasattr(self, 'exclusions_text_widget'):
|
|
configure_state(self.exclusions_text_widget, state_tk_text) # Usa state_tk_text per Text widget
|
|
# Trova il bottone "Update Exclusions List"
|
|
exclusions_lf = self.exclusions_text_widget.master.master # text_scroll_frame -> exclusions_lf
|
|
for child in exclusions_lf.winfo_children():
|
|
if isinstance(child, ttk.Button) and "Exclusion" in child.cget("text"):
|
|
configure_state(child, state_ttk_widget)
|
|
break
|
|
|
|
if hasattr(self, 'backup_button'):
|
|
configure_state(self.backup_button, state_ttk_widget)
|
|
|
|
if hasattr(self, 'description_text_widget'):
|
|
configure_state(self.description_text_widget, state_tk_text)
|
|
|
|
|
|
def _start_backup_process(self) -> None:
|
|
source_dir = self.source_dir_var.get()
|
|
dest_dir = self.dest_dir_var.get()
|
|
|
|
if not source_dir or not Path(source_dir).is_dir():
|
|
messagebox.showerror("Input Error", "Invalid or missing source directory.", parent=self.root)
|
|
return
|
|
if not dest_dir :
|
|
messagebox.showerror("Input Error", "Destination directory cannot be empty.", parent=self.root)
|
|
return
|
|
|
|
dest_path_obj = Path(dest_dir)
|
|
if not dest_path_obj.is_dir():
|
|
try:
|
|
dest_path_obj.mkdir(parents=True, exist_ok=True)
|
|
print(f"Info: Destination directory '{dest_dir}' created.")
|
|
except OSError as e:
|
|
messagebox.showerror("Input Error", f"Invalid destination directory, and failed to create it: {e}", parent=self.root)
|
|
return
|
|
|
|
self._toggle_ui_elements(enabled=False)
|
|
self._reset_progress_status("Scanning files...")
|
|
scan_thread = threading.Thread(
|
|
target=self._execute_file_scan_thread,
|
|
args=(source_dir, list(self.current_exclusions))
|
|
)
|
|
scan_thread.start()
|
|
|
|
def _execute_file_scan_thread(self, source_dir: str, current_exclusions_copy: list[str]):
|
|
try:
|
|
(
|
|
self._scanned_included_files,
|
|
self._scanned_excluded_files,
|
|
count,
|
|
size_mb,
|
|
) = file_scanner.scan_directory_for_files(
|
|
source_dir, current_exclusions_copy, self._update_scan_progress_threadsafe
|
|
)
|
|
self._included_ext_stats = file_scanner.get_file_extension_stats(self._scanned_included_files)
|
|
self._excluded_ext_stats = file_scanner.get_file_extension_stats(self._scanned_excluded_files)
|
|
self.root.after(0, lambda: self._show_scan_confirmation(count, size_mb))
|
|
except ValueError as e:
|
|
self.root.after(0, lambda: messagebox.showerror("Scan Error", str(e), parent=self.root))
|
|
self.root.after(0, lambda: self._reset_progress_status("Scan failed"))
|
|
self.root.after(0, lambda: self._toggle_ui_elements(enabled=True))
|
|
except Exception as e:
|
|
self.root.after(0, lambda: messagebox.showerror("Unexpected Scan Error", f"An error occurred: {e}", parent=self.root))
|
|
self.root.after(0, lambda: self._reset_progress_status("Scan failed"))
|
|
self.root.after(0, lambda: self._toggle_ui_elements(enabled=True))
|
|
|
|
def _update_scan_progress_threadsafe(self, current_idx, total_files, file_path):
|
|
self.root.after(0, self._update_scan_progress, current_idx, total_files, file_path)
|
|
|
|
def _show_scan_confirmation(self, included_count: int, included_size_mb: float) -> None:
|
|
if included_count == 0:
|
|
messagebox.showinfo("No Files to Backup", "Scan found no files to include.", parent=self.root)
|
|
self._reset_progress_status("Scan complete - no files")
|
|
self._toggle_ui_elements(enabled=True)
|
|
return
|
|
|
|
self._confirmation_dialog_active = True
|
|
try:
|
|
dialogs.show_backup_confirmation_dialog(
|
|
parent=self.root,
|
|
included_files_count=included_count,
|
|
included_total_size_mb=included_size_mb,
|
|
on_proceed=self._proceed_with_actual_backup,
|
|
on_show_file_details=lambda: dialogs.show_file_details_dialog(self.root, self._scanned_included_files, self._scanned_excluded_files),
|
|
on_show_included_ext_stats=lambda: dialogs.show_extension_stats_dialog(self.root, self._included_ext_stats, "Included Files - Extension Stats"),
|
|
on_show_excluded_ext_stats=lambda: dialogs.show_extension_stats_dialog(self.root, self._excluded_ext_stats, "Excluded Files - Extension Stats")
|
|
)
|
|
finally:
|
|
self._confirmation_dialog_active = False
|
|
self._toggle_ui_elements(enabled=True)
|
|
self._reset_progress_status("Awaiting backup confirmation...")
|
|
|
|
def _proceed_with_actual_backup(self) -> None:
|
|
self._toggle_ui_elements(enabled=False)
|
|
self._reset_progress_status("Starting backup...")
|
|
source_dir = self.source_dir_var.get()
|
|
dest_dir = self.dest_dir_var.get()
|
|
description = self.description_text_widget.get("1.0", tk.END).strip()
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
source_basename = Path(source_dir).name
|
|
if not source_basename: source_basename = "drive_backup"
|
|
sanitized_basename = "".join(c if c.isalnum() or c in ('_', '-') else '_' for c in source_basename)
|
|
zip_filename = f"{timestamp}_{sanitized_basename}.zip"
|
|
zip_full_path = str(Path(dest_dir) / zip_filename)
|
|
zip_thread = threading.Thread(
|
|
target=self._execute_zip_thread,
|
|
args=(zip_full_path, source_dir, list(self._scanned_included_files), description)
|
|
)
|
|
zip_thread.start()
|
|
|
|
def _execute_zip_thread(self, zip_path_str: str, source_root_str: str,
|
|
files_to_zip: list[file_scanner.FileDetail], backup_description: str):
|
|
try:
|
|
backup_operations.create_backup_archive(
|
|
zip_file_path_str=zip_path_str,
|
|
source_root_dir_str=source_root_str,
|
|
included_file_details=files_to_zip,
|
|
backup_description=backup_description,
|
|
progress_callback=self._update_zip_progress_threadsafe
|
|
)
|
|
self.root.after(0, lambda: messagebox.showinfo("Backup Successful", f"Archive created:\n{zip_path_str}", parent=self.root))
|
|
self.root.after(0, lambda: self._reset_progress_status("Backup complete"))
|
|
except backup_operations.BackupError as e:
|
|
self.root.after(0, lambda: messagebox.showerror("Backup Failed", str(e), parent=self.root))
|
|
self.root.after(0, lambda: self._reset_progress_status("Backup failed"))
|
|
except Exception as e:
|
|
self.root.after(0, lambda: messagebox.showerror("Unexpected Backup Error", f"An error: {e}", parent=self.root))
|
|
self.root.after(0, lambda: self._reset_progress_status("Backup failed"))
|
|
finally:
|
|
self.root.after(0, lambda: self._toggle_ui_elements(enabled=True))
|
|
if self._scanned_included_files: self._scanned_included_files.clear()
|
|
if self._scanned_excluded_files: self._scanned_excluded_files.clear()
|
|
if self._included_ext_stats: self._included_ext_stats.clear()
|
|
if self._excluded_ext_stats: self._excluded_ext_stats.clear()
|
|
|
|
def _update_zip_progress_threadsafe(self, processed_mb, total_mb, arcname):
|
|
self.root.after(0, self._update_zip_progress, processed_mb, total_mb, arcname)
|
|
|
|
def _on_closing(self) -> None:
|
|
if messagebox.askokcancel("Quit", "Do you want to save current settings and quit?", parent=self.root):
|
|
self._save_app_config(is_closing=True)
|
|
self.root.destroy() |