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.
This commit is contained in:
2025-09-04 14:20:57 +02:00
parent 76a27e12b2
commit 0b9c58410f
7 changed files with 271 additions and 99 deletions

View File

@@ -326,7 +326,7 @@ set -e
size_str = f"{display_size:.2f} {power_labels[n]}"
else:
size_str = "0 B"
date_str = datetime.datetime.now().strftime("%d. %B %Y, %H:%M:%S")
date_str = datetime.datetime.now().strftime("%d-%m-%Y %H:%M:%S")
info_content = (
f"Backup-Datum: {date_str}\n"
@@ -590,11 +590,47 @@ set -e
if not os.path.isdir(pybackup_path):
return system_backups
# Check for a single encrypted container first
encrypted_container_path = os.path.join(pybackup_path, "pybackup_encrypted.luks")
if os.path.exists(encrypted_container_path):
try:
stat_info = os.stat(encrypted_container_path)
m_time = datetime.datetime.fromtimestamp(stat_info.st_mtime)
file_size = stat_info.st_size
# Format size
power = 1024
n = 0
power_labels = {0: 'B', 1: 'KB', 2: 'MB', 3: 'GB', 4: 'TB'}
display_size = file_size
while display_size >= power and n < len(power_labels) - 1:
display_size /= power
n += 1
size_str = f"{display_size:.2f} {power_labels[n]}"
system_backups.append({
"date": m_time.strftime('%d-%m-%Y'),
"time": m_time.strftime('%H:%M:%S'),
"type": "Encrypted Container",
"size": size_str,
"folder_name": "pybackup_encrypted.luks",
"full_path": encrypted_container_path,
"comment": "Container for all encrypted backups",
"is_compressed": False,
"is_encrypted": True,
"backup_type_base": "Full" # Treat container as a single full entity
})
return system_backups # Return immediately
except Exception as e:
self.logger.log(f"Could not stat encrypted container file: {e}")
# Fall through to normal processing if stat fails
# Proceed with individual folder scan if no container is found
name_regex = re.compile(
r"^(\d{1,2}-\w+-\d{4})_(\d{6})_system_(full|incremental)(\.tar\.gz|\.luks)?$", re.IGNORECASE)
r"^(\d{2}-\d{2}-\d{4})_(\d{2}:\d{2}:\d{2})_system_(full|incremental)(\.tar\.gz)?$", re.IGNORECASE)
for item in os.listdir(pybackup_path):
if item.endswith('.txt'):
if item.endswith('.txt') or item.endswith('.luks'):
continue
match = name_regex.match(item)
@@ -603,16 +639,15 @@ set -e
full_path = os.path.join(pybackup_path, item)
date_str = match.group(1)
time_str = match.group(2)
backup_type_base = match.group(3).capitalize()
extension = match.group(4)
is_compressed = (extension == ".tar.gz")
is_encrypted = (extension == ".luks")
is_encrypted = False # Individual folders are not encrypted in this logic
backup_type = backup_type_base
if is_compressed:
backup_type += " (Compressed)"
elif is_encrypted:
backup_type += " (Encrypted)"
backup_size = "N/A"
comment = ""
@@ -623,11 +658,11 @@ set -e
with open(info_file_path, 'r') as f:
for line in f:
if line.strip().lower().startswith("originalgröße:"):
size_match = re.search(r":\s*(.*?)\s*(" , line)
if size_match:
backup_size = size_match.group(1).strip()
size_part = line.split(":", 1)[1].strip()
if '(' in size_part:
backup_size = size_part.split('(')[0].strip()
else:
backup_size = line.split(":")[1].strip()
backup_size = size_part
elif line.strip().lower().startswith("kommentar:"):
comment = line.split(":", 1)[1].strip()
except Exception as e:
@@ -636,22 +671,25 @@ set -e
system_backups.append({
"date": date_str,
"time": time_str,
"type": backup_type,
"size": backup_size,
"folder_name": item,
"full_path": full_path,
"comment": comment,
"is_compressed": is_compressed,
"is_encrypted": is_encrypted
"is_encrypted": is_encrypted,
"backup_type_base": backup_type_base
})
try:
# Sort chronologically, oldest first
system_backups.sort(key=lambda x: datetime.datetime.strptime(
x['date'], '%d-%B-%Y'), reverse=True)
f"{x['date']} {x['time']}", '%d-%m-%Y %H:%M:%S'), reverse=False)
except ValueError:
self.logger.log(
"Could not sort backups by date due to format mismatch.")
system_backups.sort(key=lambda x: x['folder_name'], reverse=True)
"Could not sort backups by date and time due to format mismatch.")
system_backups.sort(key=lambda x: x['folder_name'], reverse=False)
return system_backups
@@ -671,18 +709,25 @@ set -e
if os.path.exists(info_file_path):
backup_size = "N/A"
backup_date = "N/A"
backup_time = "N/A"
comment = ""
try:
with open(info_file_path, 'r') as f:
for line in f:
if line.strip().lower().startswith("originalgröße:"):
size_match = re.search(r":\s*(.*?)\s*(" , line)
if size_match:
backup_size = size_match.group(1).strip()
size_part = line.split(":", 1)[1].strip()
if '(' in size_part:
backup_size = size_part.split('(')[0].strip()
else:
backup_size = line.split(":")[1].strip()
backup_size = size_part
elif line.strip().lower().startswith("backup-datum:"):
backup_date = line.split(":", 1)[1].strip()
full_date_str = line.split(":", 1)[1].strip()
date_parts = full_date_str.split()
if len(date_parts) >= 2:
backup_date = date_parts[0]
backup_time = date_parts[1]
else:
backup_date = full_date_str
elif line.strip().lower().startswith("kommentar:"):
comment = line.split(":", 1)[1].strip()
except Exception as e:
@@ -691,13 +736,15 @@ set -e
user_backups.append({
"date": backup_date,
"time": backup_time,
"size": backup_size,
"folder_name": item,
"full_path": full_path,
"comment": comment
})
user_backups.sort(key=lambda x: x['folder_name'], reverse=True)
# Sort chronologically, oldest first
user_backups.sort(key=lambda x: f"{x['date']} {x['time']}", reverse=False)
return user_backups
def get_comment(self, info_file_path: str) -> str:

View File

@@ -622,9 +622,6 @@ class MainApplication(tk.Tk):
self.drawing.update_target_projection()
if self.mode == 'backup' and self.destination_path:
self.start_pause_button.config(state="normal")
if calc_type == 'accurate_incremental':
self.source_size_bytes = folder_size
self.drawing.update_target_projection()

View File

@@ -239,6 +239,7 @@ class Msg:
"name": _("Name"),
"path": _("Path"),
"date": _("Date"),
"time": _("Time"),
"size": _("Size"),
"type": _("Type"),
"folder": _("Folder"),

View File

@@ -38,7 +38,7 @@ class Actions:
system_backups = self.app.backup_manager.list_system_backups(
self.app.destination_path)
for backup in system_backups:
if backup.get('type') == 'Full':
if backup.get('backup_type_base') == 'Full':
full_backup_exists = True
break
@@ -619,8 +619,8 @@ class Actions:
"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")
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

View File

@@ -234,54 +234,60 @@ class Drawing:
self.app.after(50, self.update_target_projection)
return
projected_total_used = self.app.destination_used_bytes + self.app.source_size_bytes
projected_total_percentage = projected_total_used / \
self.app.destination_total_bytes
# Determine required space, considering compression
required_space = self.app.source_size_bytes
if self.app.compressed_var.get():
required_space *= 2 # Double the space for compression process
info_font = (AppConfig.UI_CONFIG["font_family"], 12, "bold")
if projected_total_percentage >= 0.95:
self.app.start_pause_button.config(state="disabled")
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(
), fill="#ff0000", outline="") # Red bar
elif projected_total_percentage >= 0.90:
self.app.start_pause_button.config(state="normal")
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(
), fill="#ff8c00", outline="") # Orange bar
projected_total_used = self.app.destination_used_bytes + required_space
if self.app.destination_total_bytes > 0:
projected_total_percentage = projected_total_used / self.app.destination_total_bytes
else:
self.app.start_pause_button.config(state="normal")
used_percentage = self.app.destination_used_bytes / \
self.app.destination_total_bytes
used_width = canvas_width * used_percentage
canvas.create_rectangle(
0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
projected_percentage = self.app.source_size_bytes / \
self.app.destination_total_bytes
projected_width = canvas_width * projected_percentage
canvas.create_rectangle(used_width, 0, used_width + projected_width,
canvas.winfo_height(), fill="#ff8c00", outline="")
projected_total_percentage = 0
info_font = (AppConfig.UI_CONFIG["font_family"], 10, "bold")
info_messages = []
if self.app.source_larger_than_partition:
info_messages.append(
Msg.STR["warning_source_larger_than_partition"])
if projected_total_percentage >= 0.95:
# First, check for critical space issues
if projected_total_used > self.app.destination_total_bytes or projected_total_percentage >= 0.95:
self.app.start_pause_button.config(state="disabled")
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(), fill="#D32F2F", outline="") # Red bar
info_messages.append(Msg.STR["warning_not_enough_space"])
elif projected_total_percentage >= 0.90:
self.app.start_pause_button.config(state="normal")
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(), fill="#E8740C", outline="") # Orange bar
info_messages.append(Msg.STR["warning_space_over_90_percent"])
else:
# Only enable the button if the source is not larger than the partition itself
if not self.app.source_larger_than_partition:
self.app.start_pause_button.config(state="normal")
else:
self.app.start_pause_button.config(state="disabled")
if not info_messages: # If no warnings, show other messages or default text
if self.app.is_first_backup:
used_percentage = self.app.destination_used_bytes / self.app.destination_total_bytes
used_width = canvas_width * used_percentage
canvas.create_rectangle(0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
# Draw the projected part only if there is space
projected_percentage = self.app.source_size_bytes / self.app.destination_total_bytes
projected_width = canvas_width * projected_percentage
canvas.create_rectangle(used_width, 0, used_width + projected_width, canvas.winfo_height(), fill="#50E6FF", outline="")
# Add other informational messages if no critical warnings are present
if not info_messages:
if self.app.source_larger_than_partition:
info_messages.append(Msg.STR["warning_source_larger_than_partition"])
elif self.app.is_first_backup:
info_messages.append(Msg.STR["ready_for_first_backup"])
elif self.app.mode == "backup":
info_messages.append(Msg.STR["backup_mode_info"])
else:
info_messages.append(Msg.STR["restore_mode_info"])
self.app.info_label.config(
text="\n".join(info_messages), font=info_font)
self.app.info_label.config(text="\n".join(info_messages), font=info_font)
self.app.target_size_label.config(
text=f"{projected_total_used / (1024**3):.2f} GB / {self.app.destination_total_bytes / (1024**3):.2f} GB")

View File

@@ -15,30 +15,38 @@ class SystemBackupContentFrame(ttk.Frame):
self.backup_path = None
# --- Color Tags ---
self.tag_colors = [
("full_blue", "#0078D7", "inc_blue", "#50E6FF"),
("full_orange", "#E8740C", "inc_orange", "#FFB366"),
("full_green", "#107C10", "inc_green", "#50E680"),
("full_purple", "#8B107C", "inc_purple", "#D46EE5"),
]
# --- Backup Content List View ---
self.content_frame = ttk.LabelFrame(
self, text=Msg.STR["backup_content"], padding=10)
self.content_frame.pack(fill=tk.BOTH, expand=True)
columns = ("date", "type", "size", "comment", "folder_name")
columns = ("date", "time", "type", "size", "comment")
self.content_tree = ttk.Treeview(
self.content_frame, columns=columns, show="headings")
self.content_tree.heading(
"date", text=Msg.STR["date"])
self.content_tree.heading(
"time", text=Msg.STR["time"])
self.content_tree.heading(
"type", text=Msg.STR["type"])
self.content_tree.heading(
"size", text=Msg.STR["size"])
self.content_tree.heading(
"comment", text=Msg.STR["comment"])
self.content_tree.heading(
"folder_name", text=Msg.STR["folder"])
self.content_tree.column("date", width=120, anchor="w")
self.content_tree.column("type", width=80, anchor="center")
self.content_tree.column("date", width=100, anchor="w")
self.content_tree.column("time", width=80, anchor="center")
self.content_tree.column("type", width=120, anchor="center")
self.content_tree.column("size", width=100, anchor="e")
self.content_tree.column("comment", width=200, anchor="w")
self.content_tree.column("folder_name", width=250, anchor="w")
self.content_tree.column("comment", width=300, anchor="w")
self.content_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
@@ -74,18 +82,32 @@ class SystemBackupContentFrame(ttk.Frame):
if not self.backup_path or not os.path.isdir(self.backup_path):
return
# Use the new method to get structured system backup data
system_backups = self.backup_manager.list_system_backups(
self.backup_path)
system_backups = self.backup_manager.list_system_backups(self.backup_path)
self.system_backups_list = system_backups # Store for later use
color_index = 0
for i, backup_info in enumerate(system_backups):
# Determine the color tag for the group
if backup_info.get("backup_type_base") == "Full":
if i > 0: # Not the very first backup
color_index = (color_index + 1) % len(self.tag_colors)
tag_name, color, _, _ = self.tag_colors[color_index]
self.content_tree.tag_configure(tag_name, foreground=color)
current_tag = tag_name
else: # Incremental
_, _, tag_name, color = self.tag_colors[color_index]
self.content_tree.tag_configure(tag_name, foreground=color)
current_tag = tag_name
for backup_info in system_backups:
self.content_tree.insert("", "end", values=(
backup_info.get("date", "N/A"),
backup_info.get("time", "N/A"),
backup_info.get("type", "N/A"),
backup_info.get("size", "N/A"),
backup_info.get("comment", ""),
backup_info.get("folder_name", "N/A")
))
), tags=(current_tag,), iid=backup_info.get("folder_name"))
self._on_item_select(None) # Disable buttons initially
def _on_item_select(self, event):
@@ -99,56 +121,42 @@ class SystemBackupContentFrame(ttk.Frame):
state="normal" if is_selected else "disabled")
def _edit_comment(self):
selected_item = self.content_tree.focus()
if not selected_item:
selected_item_id = self.content_tree.focus()
if not selected_item_id:
return
item_values = self.content_tree.item(selected_item)["values"]
folder_name = item_values[4] # Assuming folder_name is the 5th value
# Construct the path to the info file
pybackup_path = os.path.join(self.backup_path, "pybackup")
info_file_path = os.path.join(pybackup_path, f"{folder_name}.txt")
info_file_path = os.path.join(pybackup_path, f"{selected_item_id}.txt")
# The file should exist, but we can handle cases where it might not.
if not os.path.exists(info_file_path):
# If for some reason the info file is missing, we can create an empty one.
self.backup_manager.update_comment(info_file_path, "")
CommentEditorDialog(self, info_file_path, self.backup_manager)
self._load_backup_content() # Refresh list to show new comment
def _restore_selected(self):
selected_item = self.content_tree.focus()
if not selected_item:
selected_item_id = self.content_tree.focus()
if not selected_item_id:
return
item_values = self.content_tree.item(selected_item)["values"]
folder_name = item_values[4] # 5th column is folder_name
selected_backup = None
for backup in self.system_backups_list:
if backup.get("folder_name") == folder_name:
if backup.get("folder_name") == selected_item_id:
selected_backup = backup
break
if not selected_backup:
print(f"Error: Could not find backup info for {folder_name}")
print(f"Error: Could not find backup info for {selected_item_id}")
return
# We need to get the restore destination from the main app
# This is a bit tricky as this frame is isolated.
# We assume the main app has a way to provide this.
# Let's get it from the config manager, which should be accessible via the backup_manager's app instance.
try:
# Accessing the app instance through the master hierarchy
main_app = self.winfo_toplevel()
restore_dest_path = main_app.config_manager.get_setting(
"restore_destination_path", "/")
if not restore_dest_path:
print("Error: Restore destination not set.")
# Optionally, show a message box to the user
return
self.backup_manager.start_restore(
@@ -160,16 +168,13 @@ class SystemBackupContentFrame(ttk.Frame):
print("Could not access main application instance to get restore path.")
def _delete_selected(self):
selected_item = self.content_tree.focus()
if not selected_item:
selected_item_id = self.content_tree.focus()
if not selected_item_id:
return
item_values = self.content_tree.item(selected_item)["values"]
folder_name = item_values[4] # Assuming folder_name is the 5th value
# Construct the full path to the backup folder
pybackup_path = os.path.join(self.backup_path, "pybackup")
folder_to_delete = os.path.join(pybackup_path, folder_name)
folder_to_delete = os.path.join(pybackup_path, selected_item_id)
# Lock UI and show status
self.actions._set_ui_state(False) # Lock UI

View File

@@ -47,6 +47,63 @@ class UserBackupContentFrame(ttk.Frame):
self.delete_button = ttk.Button(list_button_frame, text=Msg.STR["delete"],
command=self._delete_selected, state="disabled")
self.delete_button.pack(side=tk.LEFT, padx=5)
import tkinter as tk
from tkinter import ttk
import os
from pbp_app_config import Msg
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
class UserBackupContentFrame(ttk.Frame):
def __init__(self, master, backup_manager, actions, **kwargs):
super().__init__(master, **kwargs)
self.backup_manager = backup_manager
self.actions = actions
self.user_backups_list = []
self.backup_path = None
# --- Backup Content List View ---
self.content_frame = ttk.LabelFrame(
self, text=Msg.STR["backup_content"], padding=10)
self.content_frame.pack(fill=tk.BOTH, expand=True)
columns = ("date", "time", "size", "comment", "folder_name")
self.content_tree = ttk.Treeview(
self.content_frame, columns=columns, show="headings")
self.content_tree.heading(
"date", text=Msg.STR["date"])
self.content_tree.heading(
"time", text=Msg.STR["time"])
self.content_tree.heading(
"size", text=Msg.STR["size"])
self.content_tree.heading(
"comment", text=Msg.STR["comment"])
self.content_tree.heading(
"folder_name", text=Msg.STR["folder"])
self.content_tree.column("date", width=100, anchor="w")
self.content_tree.column("time", width=80, anchor="center")
self.content_tree.column("size", width=100, anchor="e")
self.content_tree.column("comment", width=250, anchor="w")
self.content_tree.column("folder_name", width=200, anchor="w")
self.content_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.content_tree.bind("<<TreeviewSelect>>", self._on_item_select)
list_button_frame = ttk.Frame(self.content_frame)
list_button_frame.pack(pady=10)
self.restore_button = ttk.Button(list_button_frame, text=Msg.STR["restore"],
command=self._restore_selected, state="disabled")
self.restore_button.pack(side=tk.LEFT, padx=5)
self.delete_button = ttk.Button(list_button_frame, text=Msg.STR["delete"],
command=self._delete_selected, state="disabled")
self.delete_button.pack(side=tk.LEFT, padx=5)
self.edit_comment_button = ttk.Button(list_button_frame, text="Kommentar bearbeiten",
command=self._edit_comment, state="disabled")
self.edit_comment_button.pack(side=tk.LEFT, padx=5)
@@ -59,6 +116,65 @@ class UserBackupContentFrame(ttk.Frame):
def hide(self):
self.grid_remove()
def _load_backup_content(self):
for i in self.content_tree.get_children():
self.content_tree.delete(i)
if not self.backup_path or not os.path.isdir(self.backup_path):
return
self.user_backups_list = self.backup_manager.list_user_backups(self.backup_path)
for backup_info in self.user_backups_list:
self.content_tree.insert("", "end", values=(
backup_info.get("date", "N/A"),
backup_info.get("time", "N/A"),
backup_info.get("size", "N/A"),
backup_info.get("comment", ""),
backup_info.get("folder_name", "N/A")
), iid=backup_info.get("folder_name"))
self._on_item_select(None) # Disable buttons initially
def _on_item_select(self, event):
selected_item = self.content_tree.focus()
is_selected = True if selected_item else False
self.restore_button.config(
state="normal" if is_selected else "disabled")
self.delete_button.config(
state="normal" if is_selected else "disabled")
self.edit_comment_button.config(
state="normal" if is_selected else "disabled")
def _edit_comment(self):
selected_item_id = self.content_tree.focus()
if not selected_item_id:
return
info_file_path = os.path.join(self.backup_path, f"{selected_item_id}.txt")
if not os.path.exists(info_file_path):
self.backup_manager.update_comment(info_file_path, "")
CommentEditorDialog(self, info_file_path, self.backup_manager)
self._load_backup_content() # Refresh list to show new comment
def _restore_selected(self):
# This functionality needs to be implemented based on app requirements
pass
def _delete_selected(self):
# This functionality needs to be implemented based on app requirements
pass
self.edit_comment_button.pack(side=tk.LEFT, padx=5)
def show(self, backup_path):
if backup_path and self.backup_path != backup_path:
self.backup_path = backup_path
self._load_backup_content()
def hide(self):
self.grid_remove()
def _load_backup_content(self):
for i in self.content_tree.get_children():
self.content_tree.delete(i)