feat: Add manual exclude list functionality

- Create a separate file for manual excludes (`rsync-manual-excludes.conf`) that is not cleared on reset.
- Add a button to the settings frame to add files/folders to the manual exclude list.
- Update the backup and calculation logic to use the manual exclude list.
- Ensure the UI reflects the combined exclude lists.
This commit is contained in:
2025-09-01 16:16:55 +02:00
parent fbfc6a7224
commit 058dc1e951
6 changed files with 90 additions and 25 deletions

View File

@@ -10,6 +10,8 @@ from crontab import CronTab
import tempfile
import stat
from pbp_app_config import AppConfig
class BackupManager:
"""
@@ -272,6 +274,9 @@ set -e
for exclude_file in exclude_files:
command.append(f"--exclude-from={exclude_file}")
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
command.append(f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
if is_dry_run:
command.append('--dry-run')

View File

@@ -43,6 +43,20 @@ class DataProcessing:
except IOError as e:
app_logger.log(f"Error loading user-defined exclusion list: {e}")
try:
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
manual_patterns = [
line.strip() for line in f if line.strip() and not line.startswith('#')]
all_patterns.update(manual_patterns)
app_logger.log(
f"Loaded manual exclusion patterns: {manual_patterns}")
except FileNotFoundError:
app_logger.log(
f"Manual exclusion list not found: {AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
except IOError as e:
app_logger.log(f"Error loading manual exclusion list: {e}")
final_patterns = sorted(list(all_patterns))
app_logger.log(f"Combined exclusion patterns: {final_patterns}")
return final_patterns
@@ -144,6 +158,9 @@ class DataProcessing:
for exclude_file in exclude_files:
command.append(f"--exclude-from={exclude_file}")
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
command.append(f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
# The destination for a dry run can be a dummy path, but it must exist.
# Let's use a temporary directory.
dummy_dest = os.path.join(parent_dest, "dry_run_dest")

View File

@@ -15,6 +15,8 @@ class AppConfig:
GENERATED_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / "rsync-generated-excludes.conf"
USER_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / \
"rsync-user-excludes.conf" # Single file
MANUAL_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / \
"rsync-manual-excludes.conf" # Single file
APP_ICONS_DIR: Path = Path(__file__).parent / "lx-icons"
# --- Application Info ---
@@ -267,6 +269,10 @@ class Msg:
"accurate_size_failed": _("Failed to calculate size. See log for details."),
"please_wait": _("Please wait, calculating..."),
"accurate_calc_cancelled": _("Calculate size cancelled."),
"add_to_exclude_list": _("Add to exclude list"),
"exclude_dialog_text": _("Do you want to add a folder or a file?"),
"add_folder_button": _("Folder"),
"add_file_button": _("File"),
# Menus
"file_menu": _("File"),
@@ -338,4 +344,4 @@ class Msg:
"force_compression": _("Always compress backup"),
"force_encryption": _("Always encrypt backup"),
"encryption_note_system_backup": _("Note: For system backups, encryption only applies to files directly within the /home directory. Folders are not automatically encrypted unless explicitly included in the backup."),
}
}

View File

@@ -157,6 +157,8 @@ class Actions:
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")
@@ -668,6 +670,8 @@ class Actions:
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()

View File

@@ -303,7 +303,12 @@ class AdvancedSettingsFrame(tk.Toplevel):
user_patterns = []
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r') as f:
user_patterns = [
line.strip() for line in f if line.strip() and not line.startswith('#')]
user_patterns.extend(
[line.strip() for line in f if line.strip() and not line.startswith('#')])
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
user_patterns.extend(
[line.strip() for line in f if line.strip() and not line.startswith('#')])
return generated_patterns, user_patterns

View File

@@ -5,6 +5,8 @@ from pathlib import Path
from pbp_app_config import AppConfig, Msg
from pyimage_ui.advanced_settings_frame import AdvancedSettingsFrame
from shared_libs.custom_file_dialog import CustomFileDialog
from shared_libs.message import MessageDialog
class SettingsFrame(ttk.Frame):
@@ -72,6 +74,10 @@ class SettingsFrame(ttk.Frame):
'unhide')
self.show_hidden_button.config(image=self.unhide_icon)
add_to_exclude_button = ttk.Button(
button_frame, text=Msg.STR["add_to_exclude_list"], command=self._add_to_exclude_list)
add_to_exclude_button.pack(side=tk.LEFT, padx=5)
apply_button = ttk.Button(
button_frame, text=Msg.STR["apply"], command=self._apply_changes)
apply_button.pack(side=tk.LEFT, padx=5)
@@ -90,6 +96,34 @@ class SettingsFrame(ttk.Frame):
self.hidden_files_visible = False
def _add_to_exclude_list(self) -> bool:
result = MessageDialog("ask", Msg.STR["exclude_dialog_text"], title=Msg.STR["add_to_exclude_list"], buttons=[
Msg.STR["add_folder_button"], Msg.STR["add_file_button"]]).show()
path = None
if result:
dialog = CustomFileDialog(
self, mode="dir", title=Msg.STR["add_to_exclude_list"])
self.wait_window(dialog)
path = dialog.get_result()
dialog.destroy()
else:
dialog = CustomFileDialog(
self, filetypes=[("All Files", "*.*")], title=Msg.STR["add_to_exclude_list"])
self.wait_window(dialog)
path = dialog.get_result()
dialog.destroy()
if path:
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'a') as f:
if os.path.isdir(path):
f.write(f"\n{path}/*")
else:
f.write(f"\n{path}")
self.load_and_display_excludes()
self._load_hidden_files()
def show(self):
self.grid(row=2, column=0, sticky="nsew")
self.load_and_display_excludes()
@@ -98,27 +132,27 @@ class SettingsFrame(ttk.Frame):
self.grid_remove()
def _load_exclude_patterns(self):
generated_patterns = []
all_patterns = []
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
with open(AppConfig.GENERATED_EXCLUDE_LIST_PATH, 'r') as f:
generated_patterns = [
line.strip() for line in f if line.strip() and not line.startswith('#')]
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
self.user_exclude_patterns = []
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r') as f:
self.user_exclude_patterns = [
line.strip() for line in f if line.strip() and not line.startswith('#')]
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
return generated_patterns, self.user_exclude_patterns
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
return all_patterns
def load_and_display_excludes(self):
# Clear existing items
for i in self.tree.get_children():
self.tree.delete(i)
_, self.user_exclude_patterns = self._load_exclude_patterns()
exclude_patterns = self._load_exclude_patterns()
home_dir = Path.home()
@@ -126,7 +160,7 @@ class SettingsFrame(ttk.Frame):
for item in home_dir.iterdir():
if not item.name.startswith('.'):
item_path_str = str(item.absolute())
is_excluded = f"{item_path_str}/*" in self.user_exclude_patterns
is_excluded = f"{item_path_str}/*" in exclude_patterns or item_path_str in exclude_patterns
included_text = Msg.STR["no"] if is_excluded else Msg.STR["yes"]
items_to_display.append(
(included_text, item.name, item_path_str))
@@ -183,16 +217,16 @@ class SettingsFrame(ttk.Frame):
else:
new_excludes.append(path)
# Load existing patterns
existing_patterns = []
# Load existing user patterns
existing_user_patterns = []
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r') as f:
existing_patterns = [
existing_user_patterns = [
line.strip() for line in f if line.strip() and not line.startswith('#')]
# Preserve patterns that are not managed by this view
preserved_patterns = []
for pattern in existing_patterns:
for pattern in existing_user_patterns:
clean_pattern = pattern.replace('/*', '')
if clean_pattern not in tree_paths:
preserved_patterns.append(pattern)
@@ -200,12 +234,6 @@ class SettingsFrame(ttk.Frame):
# Combine preserved patterns with new excludes from this view
final_excludes = list(set(preserved_patterns + new_excludes))
# Handle backup destination separately to ensure it's always excluded
if self.master.master.master.destination_path:
backup_root_to_exclude = f"/{self.master.master.master.destination_path.strip('/').split('/')[0]}/*"
if backup_root_to_exclude not in final_excludes:
final_excludes.append(backup_root_to_exclude)
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'w') as f:
for path in final_excludes:
f.write(f"{path}\n")
@@ -240,7 +268,7 @@ class SettingsFrame(ttk.Frame):
for i in self.hidden_tree.get_children():
self.hidden_tree.delete(i)
_, self.user_exclude_patterns = self._load_exclude_patterns()
exclude_patterns = self._load_exclude_patterns()
home_dir = Path.home()
@@ -248,7 +276,7 @@ class SettingsFrame(ttk.Frame):
for item in home_dir.iterdir():
if item.name.startswith('.'):
item_path_str = str(item.absolute())
is_excluded = f"{item_path_str}/*" in self.user_exclude_patterns or f"{item_path_str}" in self.user_exclude_patterns
is_excluded = f"{item_path_str}/*" in exclude_patterns or f"{item_path_str}" in exclude_patterns
included_text = Msg.STR["no"] if is_excluded else Msg.STR["yes"]
items_to_display.append(
(included_text, item.name, item_path_str))