diff --git a/backup_manager.py b/backup_manager.py index a39f9ad..5d196bd 100644 --- a/backup_manager.py +++ b/backup_manager.py @@ -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') diff --git a/core/data_processing.py b/core/data_processing.py index 437a59e..2c7d81f 100644 --- a/core/data_processing.py +++ b/core/data_processing.py @@ -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") diff --git a/pbp_app_config.py b/pbp_app_config.py index 9c4321e..fcf8ec4 100644 --- a/pbp_app_config.py +++ b/pbp_app_config.py @@ -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."), - } \ No newline at end of file + } diff --git a/pyimage_ui/actions.py b/pyimage_ui/actions.py index fb88d3d..c3bbe0f 100644 --- a/pyimage_ui/actions.py +++ b/pyimage_ui/actions.py @@ -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() diff --git a/pyimage_ui/advanced_settings_frame.py b/pyimage_ui/advanced_settings_frame.py index cf65e1b..adcfe93 100644 --- a/pyimage_ui/advanced_settings_frame.py +++ b/pyimage_ui/advanced_settings_frame.py @@ -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 diff --git a/pyimage_ui/settings_frame.py b/pyimage_ui/settings_frame.py index ab52b4c..760446a 100644 --- a/pyimage_ui/settings_frame.py +++ b/pyimage_ui/settings_frame.py @@ -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))