Compare commits

...

33 Commits

Author SHA1 Message Date
4a700194c3 add lvm for img encrypt zo automatic resize for new encrypt backup 2025-09-07 01:54:16 +02:00
4d70e0eee0 feat: Erweiterte Einstellungen in main SettingsFrame integrieren und Layoutprobleme beheben. 2025-09-06 18:31:57 +02:00
2097573cbc advance settings edit part one 2025-09-06 18:14:41 +02:00
069d2ea94d feat: Implementierung von Papierkorb- und reinen Synchronisierungsoptionen für Benutzer-Backups 2025-09-06 17:33:22 +02:00
a843a875c6 feat: Implement incremental user backup logic and display
This commit introduces the core logic for handling incremental user backups.

Changes include:
- Updated `core/backup_manager.py`:
    - Modified `_list_user_backups_from_path` to parse new naming convention for user backups (including `_full` and `_incremental`).
    - Enhanced `_find_latest_backup` to filter by source name for user backups.
    - Adjusted `_run_backup_path` to dynamically determine backup mode (full/incremental) for user backups and apply `--link-dest` accordingly.
- Updated `pyimage_ui/user_backup_content_frame.py`:
    - Added `tag_colors` attribute for visual differentiation.
    - Included 'type' column in the Treeview.
    - Modified `_load_backup_content` to apply coloring based on backup type.
2025-09-06 16:47:49 +02:00
e1b12227d0 feat: Improve encrypted backup management and UI feedback
This commit introduces significant improvements to how encrypted backups are handled,
focusing on user experience and system integration.

- Persistent Mounts: Encrypted backup containers now remain mounted across UI view changes,
  eliminating repeated password prompts when navigating the application. The container is
  automatically unmounted when the destination changes or the application closes.
- Key Management Fallback: The mounting process now intelligently falls back from
  keyring to keyfile, and finally to a user password prompt if previous methods fail.
- Enhanced UI Status: The header now provides detailed feedback on the encryption key
  status, indicating whether a key is available (via keyring or keyfile) and if the
  container is currently in use.
- Reduced pkexec Prompts: By keeping containers mounted, the number of system-level
  pkexec authentication prompts is drastically reduced, improving workflow.
- Bug Fixes:
    - Corrected a SyntaxError in encryption_manager.py related to string escaping.
    - Fixed an AttributeError in header_frame.py by restoring the is_key_in_keyring method.
    - Addressed a TclError on application shutdown by safely destroying Tkinter widgets.
2025-09-06 15:39:59 +02:00
739c18f2a9 fix multipasswort entry for encryptrd backups view 2025-09-06 15:30:22 +02:00
abc0f4e8cf feat: Inhibit screensaver during backup operations
Adds logic to prevent the screensaver or screen lock from activating while a backup is in progress.

- Uses a D-Bus call to `org.freedesktop.ScreenSaver.Inhibit` to request a lock.
- The lock is activated when a backup starts.
- The lock is reliably released when the backup finishes, fails, or is cancelled.
2025-09-06 12:56:18 +02:00
452a56b813 feat: Implement auto-scaling encrypted containers and fix UI workflow
Refactors the encryption mechanism to use a flexible LVM-on-a-loop-device backend instead of a fixed-size file. This resolves issues with containers running out of space.

- Implements auto-resizing of the container when a backup fails due to lack of space.
- Implements transparent inspection of encrypted containers, allowing the UI to display their contents (full/incremental backups) just like unencrypted ones.
- Fixes deletion of encrypted backups by ensuring the container is unlocked before deletion.
- Fixes a bug where deleting unencrypted user backups incorrectly required root privileges.
- Fixes a UI freeze caused by calling a password dialog from a non-UI thread during deletion.
- Simplifies the UI by removing the now-obsolete "Show Encrypted Backups" button.
- Changes the default directory for encrypted user backups to `user_encrypt`.
2025-09-06 12:46:36 +02:00
0359b37ff8 Refactor: Implement new backup structure, fix paths, and resolve runtime errors. 2025-09-06 00:49:35 +02:00
77ac4f5c4f Fix: Automatisches Erkennen von vollständigen Backups, auch bei Verschlüsselung.
Die Logik zur Erkennung von vollständigen Backups wurde verbessert.
Das Programm prüft nun korrekt, ob bereits ein vollständiges Backup
vorhanden ist und schlägt in diesem Fall automatisch ein inkrementelles
Backup vor. Dies funktioniert jetzt auch für verschlüsselte Backups.
2025-09-05 16:23:19 +02:00
a19699287d add animated icon in Backup Content 2025-09-05 16:16:07 +02:00
e2fe8f9b5c fix path to encryption container 2025-09-05 15:37:50 +02:00
827f3a1e08 feat: Implement CLI script and key file support for automated backups
- Introduces `pybackup-cli.py` as a command-line interface for non-interactive backups.
- Adds support for LUKS key files in `EncryptionManager` for passwordless container operations.
- Updates `BackupManager` to pass key file arguments to encryption routines.
- Modifies `AdvancedSettingsFrame` to provide a GUI for creating and managing key files.
- Integrates `pybackup-cli.py` and key file options into `schedule_job_dialog.py` for cronjob generation.
2025-09-05 01:48:49 +02:00
4b6062981a fix: Resolve issues with encrypted backup view and logic
- Fixes a bug where the backup list would not display "orphan" incremental backups in the encrypted view.
- Fixes a bug where system backups were incorrectly shown in the user backup list.
- Prevents the app from asking for system permissions to lock an encrypted container on exit if it is not currently mounted.
- Fixes pathing inconsistencies for the encryption manager to ensure mapper names are created consistently.
- Adds debug logging to the backup list functions to help diagnose future issues.
2025-09-05 00:53:44 +02:00
2d685e1d97 refactor: Rework encrypted backup UI and logic
- Centralizes backup content view logic into a single BackupContentFrame.
- Removes the separate, now obsolete EncryptedBackupContentFrame.
- Adds a toggle button within the BackupContentFrame to switch between viewing normal and encrypted backups.
- Centralizes the Restore, Delete, and Edit Comment buttons into a single button bar in BackupContentFrame.
- Corrects the path resolution logic to find backups and encrypted containers within the /pybackup subdirectory.
- Fixes UI bugs where action buttons would disappear when switching tabs.
2025-09-04 23:22:12 +02:00
0b9c58410f 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.
2025-09-04 14:20:57 +02:00
76a27e12b2 ebcrypted backup part 3 2025-09-04 13:43:37 +02:00
3f5bac2d7e encrypt backups part zwo 2025-09-04 02:02:06 +02:00
adf78124a4 encrypt backups part one 2025-09-04 01:49:56 +02:00
158bc6ec97 encrypted backups part one 2025-09-04 01:49:24 +02:00
9a7470f017 feat: Verbesserung der Backup-Größenberechnung und des UI-Feedbacks
- Refactor '_execute_rsync', um die 'Gesamtgröße' aus der rsync-Ausgabe zu parsen und zurückzugeben, was eine genauere Größe für vollständige Backups liefert.
- Verbessertes Logging im Backup-Manager, um die Ausführung von rsync und die Logik zur Größenberechnung besser zu verfolgen.
- Implementierung eines live aktualisierenden Dauer-Timers auf der Haupt-UI, der die verstrichene Zeit während eines Backup-Vorgangs anzeigt.
- Die Zusammenfassung am Ende des Backups verwendet nun die genaueren Größeninformationen.
2025-09-03 17:45:31 +02:00
c35c5c52a4 fix checkbox enabele fullbackup works a Fullbackup 2025-09-02 17:31:22 +02:00
988b0e8d1d fix(app): Behebt das Hängenbleiben der UI und die falsche Grössenberechnung
Mehrere grundlegende Probleme in der Anwendungslogik wurden behoben:

- **UI-Verarbeitungsschleife:** Die Verarbeitung von Nachrichten aus Hintergrund-Threads wurde komplett überarbeitet. Zuvor führten zwei konkurrierende Schleifen zu einer Race Condition, bei der Nachrichten verloren gingen. Jetzt gibt es eine einzige, zentrale Verarbeitungsschleife, die Nachrichten in Stapeln verarbeitet. Dies behebt das Problem, dass die Benutzeroberfläche nach dem Löschen oder dem Abschluss eines Backups im "in Arbeit"-Zustand hängen blieb.

- **Backup-Grössenberechnung:** Die Ermittlung der Grösse von inkrementellen Backups wurde robuster gestaltet.
    - Die rsync-Ausgabe wird nun zuverlässig auf Englisch erzwungen, um Parsing-Fehler in anderen System-Locales zu vermeiden.
    - Die Grösse wird nun aus der `sent... received...` Zusammenfassungszeile von rsync ausgelesen, was auch bei Backups ohne Datenänderungen einen Wert ungleich Null liefert.
    - Es wird nun korrekt zwischen Voll-Backups (Anzeige der Gesamtgrösse) und inkrementellen Backups (Anzeige der Übertragungsgrösse) unterschieden.

- **Sonstige Korrekturen:**
    - Eine fehlende Übersetzung für die manuelle Ausschlussliste wurde hinzugefügt.
    - Ein überflüssiger Aufruf zum Starten der Verarbeitungsschleife wurde entfernt.
2025-09-02 13:59:06 +02:00
05500f0303 feat(ui): Refactor backup and advanced settings navigation
- Update button styles in Backup Content and Advanced Settings to match the main application navigation.
- Implement a progress bar indicator for the active tab.
- Realign buttons in Advanced Settings to the left.
- Add a dynamic informational label for the "Manual Excludes" section.
2025-09-01 20:36:24 +02:00
16b8a92a24 fix: Improve backup detection and UI updates
- Ensure backup content view updates after backup completion or deletion.
- Fix "Please wait" animation not stopping after backup deletion.
- Refactor backup type selection logic for more reliable UI updates.
- Correct backup detection after resetting settings.
- Ensure correct size is written to info file for all backup types.
2025-09-01 19:10:15 +02:00
058dc1e951 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.
2025-09-01 16:16:55 +02:00
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
7c765019ff Fix: BackupContentFrame layout and style access
Corrected the parenting of BackupContentFrame in main_app.py to ensure it's a child of content_frame, aligning its layout behavior with SettingsFrame.

Resolved AttributeError in BackupContentFrame by updating style lookup to use the top-level window's style object, ensuring correct background color retrieval.
2025-09-01 02:00:13 +02:00
b6b05633a7 feat: Implement compressed backups and restore
Implements a new feature for creating compressed full backups and restoring from them.

- Backups can now be created as compressed .tar.gz archives.
- This option is only available for full backups to maintain the efficiency of incremental backups.
- The UI now forces a full backup when compression is selected.
- The backup list correctly identifies and labels compressed backups.
- The restore process can handle both compressed and uncompressed backups.

fix: Improve backup process feedback and reliability

- Fixes a critical bug where backups could be overwritten if created on the same day. Backup names now include a timestamp to ensure uniqueness.
- Improves UI feedback during compressed backups by showing distinct stages (transfer, compress) and using an indeterminate progress bar during the compression phase.
- Disables the cancel button during the non-cancellable compression stage.
- Fixes a bug where the incremental backup size was written to the info file for full backups. The correct total size is now used.
2025-08-31 23:57:00 +02:00
f69e77cdd2 feat: Implement backup deletion and improve exclusion list
This commit introduces two main improvements:

1.  **Backup Deletion:** The user can now delete system and user backups from the "Backup Content" view.
2.  **Improved Exclusion List:** The `.cache` directory is now excluded from backups by default, which should prevent "file has vanished" warnings from rsync.
2025-08-31 19:54:14 +02:00
ed9700aca1 fix change restore to Backup content 2025-08-31 17:43:50 +02:00
974c8295f1 feat: Verbesserung der Backup-Typ-Auswahl und Korrektur der Füllstandsanzeige
2
   3 Dieses Commit enthält zwei wesentliche Verbesserungen:
   4
   5 1.  **Flexible Auswahl des Backup-Typs:** Der Benutzer kann jetzt manuell die
     Erstellung eines vollständigen Backups auswählen, auch wenn bereits ein früheres
     vollständiges Backup vorhanden ist. Die Anwendung wechselt in diesem Fall nicht mehr
     automatisch zu einem inkrementellen Backup.
   6
   7 2.  **Korrektur der Füllstandsanzeige:** Die Füllstandsanzeige zeigt jetzt die
     voraussichtliche Backup-Größe sowohl für vollständige als auch für inkrementelle
     Backups korrekt an. Dies wurde erreicht, indem sichergestellt wurde, dass die
     Quellgröße in allen Fällen korrekt berechnet und an die Benutzeroberfläche übergeben
     wird.
2025-08-31 17:37:05 +02:00
23 changed files with 3339 additions and 1245 deletions

View File

@@ -1,553 +0,0 @@
import subprocess
import os
import threading
import re
import signal
import datetime
from typing import Optional, List, Dict, Any
from pathlib import Path
from crontab import CronTab
import tempfile
import stat
class BackupManager:
"""
Handles the logic for creating and managing backups using rsync.
"""
def __init__(self, logger):
self.logger = logger
self.process = None
self.app_tag = "# Py-Backup Job"
self.is_system_process = False
def _execute_as_root(self, script_content: str) -> bool:
"""Executes a shell script with root privileges using pkexec."""
script_path = ''
try:
# Use tempfile for secure temporary file creation
with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="pybackup_script_", suffix=".sh", dir="/tmp") as tmp_script:
tmp_script.write("#!/bin/bash\n\n")
tmp_script.write("set -e\n\n") # Exit on error
tmp_script.write(script_content)
script_path = tmp_script.name
# Make the script executable
os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP |
stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
command = ['pkexec', script_path]
self.logger.log(
f"Executing privileged command via script: {script_path}")
self.logger.log(
f"Script content:\n---\n{script_content}\n---")
result = subprocess.run(
command, capture_output=True, text=True, check=False)
if result.returncode == 0:
self.logger.log(
f"Privileged script executed successfully. Output:\n{result.stdout}")
return True
else:
self.logger.log(
f"Privileged script failed. Return code: {result.returncode}\nStderr: {result.stderr}\nStdout: {result.stdout}")
return False
except Exception as e:
self.logger.log(
f"Failed to set up or execute privileged command: {e}")
return False
finally:
if script_path and os.path.exists(script_path):
os.remove(script_path)
def cancel_and_delete_privileged_backup(self, delete_path: str):
"""Cancels a running system backup and deletes the target directory in one atomic pkexec call."""
if not self.process or self.process.poll() is not None:
self.logger.log("No active backup process to cancel.")
return
self.logger.log(
"Attempting to cancel backup and delete directory with root privileges...")
try:
pgid = os.getpgid(self.process.pid)
script_parts = [
f"echo 'Attempting to terminate process group {pgid}'",
f"kill -SIGTERM -- -{pgid} || echo 'Process group {pgid} not found or already terminated.'",
f"echo 'Attempting to delete directory {delete_path}'",
f'if [ -n "{delete_path}" ] && [ "{delete_path}" != "/" ]; then',
f' rm -rf "{delete_path}"',
f'fi'
]
script_content = "\n".join(script_parts)
if self._execute_as_root(script_content):
self.logger.log(
"Backup cancellation and deletion script succeeded.")
else:
self.logger.log(
"Backup cancellation and deletion script failed.")
except ProcessLookupError:
self.logger.log("Backup process already terminated before action.")
# Still try to delete the directory
self.delete_privileged_path(delete_path)
except Exception as e:
self.logger.log(
f"An error occurred during privileged cancel and delete: {e}")
def delete_privileged_path(self, path: str):
"""Deletes a given path using root privileges."""
self.logger.log(f"Requesting privileged deletion of: {path}")
if not path or path == "/":
self.logger.log("Invalid path for deletion provided.")
return
script_content = f'rm -rf "{path}"'
if self._execute_as_root(script_content):
self.logger.log(f"Successfully deleted path: {path}")
else:
self.logger.log(f"Failed to delete path: {path}")
def cancel_backup(self):
if self.process and self.process.poll() is None: # Check if process is still running
self.logger.log("Attempting to cancel backup...")
try:
pgid = os.getpgid(self.process.pid)
if self.is_system_process:
self.logger.log(
f"Cancelling system process with pgid {pgid} via privileged script.")
script_content = f"kill -SIGTERM -- -{pgid}"
self._execute_as_root(script_content)
else:
os.killpg(pgid, signal.SIGTERM)
self.logger.log("Backup process terminated.")
except ProcessLookupError:
self.logger.log(
"Backup process already terminated or not found.")
except Exception as e:
self.logger.log(f"Failed to terminate backup process: {e}")
else:
self.logger.log("No active backup process to cancel.")
def start_backup(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool = False, exclude_files: Optional[List[Path]] = None, source_size: int = 0):
"""Starts a generic backup process for a specific path, reporting to a queue."""
thread = threading.Thread(target=self._run_backup_path, args=(
queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size))
thread.daemon = True
thread.start()
def _find_latest_backup(self, base_backup_path: str) -> Optional[str]:
"""Finds the most recent backup directory in a given path."""
self.logger.log(f"Searching for latest backup in: {base_backup_path}")
backup_names = self.list_backups(base_backup_path)
if not backup_names:
self.logger.log("No previous backups found to link against.")
return None
latest_backup_name = backup_names[0]
latest_backup_path = os.path.join(base_backup_path, latest_backup_name)
if os.path.isdir(latest_backup_path):
self.logger.log(f"Found latest backup for --link-dest: {latest_backup_path}")
return latest_backup_path
self.logger.log(f"Latest backup entry '{latest_backup_name}' was not a directory. No link will be used.")
return None
def _run_backup_path(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool, exclude_files: Optional[List[Path]], source_size: int):
try:
self.is_system_process = is_system
self.logger.log(
f"Starting backup from '{source_path}' to '{dest_path}'...")
if os.path.isdir(source_path) and not source_path.endswith('/'):
source_path += '/'
parent_dest = os.path.dirname(dest_path)
# Ensure the parent directory exists. For system backups, rsync with pkexec will create the final destination.
# For user backups, this creates the destination.
if not os.path.exists(parent_dest):
os.makedirs(parent_dest, exist_ok=True)
latest_backup_path = self._find_latest_backup(parent_dest)
command = []
if is_system:
command.extend(['pkexec', 'rsync', '-aAXHv'])
else:
command.extend(['rsync', '-av'])
if latest_backup_path and not is_dry_run:
self.logger.log(f"Using --link-dest='{latest_backup_path}'")
command.append(f"--link-dest={latest_backup_path}")
command.extend(['--info=progress2'])
if exclude_files:
for exclude_file in exclude_files:
command.append(f"--exclude-from={exclude_file}")
if is_dry_run:
command.append('--dry-run')
command.extend([source_path, dest_path])
self._execute_rsync(queue, command)
if self.process:
self.logger.log(
f"Rsync process finished with return code: {self.process.returncode}")
if self.process.returncode == 0 and not is_dry_run:
# For user backups, the info file is named after the folder.
# For system backups, it's named after the folder inside 'pybackup'.
info_filename_base = os.path.basename(dest_path)
self._create_info_file(
dest_path, f"{info_filename_base}.txt", source_size)
else:
self.logger.log(
"Info file not created due to non-zero return code or dry run.")
else:
self.logger.log(
"Rsync process did not start or self.process is None.")
self.logger.log(
f"Backup to '{dest_path}' completed.")
finally:
self.process = None
queue.put(('completion', None))
def _create_info_file(self, dest_path: str, filename: str, source_size: int):
try:
# Info file is now stored in the parent directory of the backup folder.
parent_dir = os.path.dirname(dest_path)
info_file_path = os.path.join(parent_dir, filename)
original_bytes = source_size
if source_size > 0:
power = 1024
n = 0
power_labels = {0: 'B', 1: 'KB', 2: 'MB', 3: 'GB', 4: 'TB'}
display_size = original_bytes
while display_size >= power and n < len(power_labels) - 1:
display_size /= power
n += 1
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")
info_content = (
f"Backup-Datum: {date_str}\n"
f"Originalgröße: {size_str} ({original_bytes} Bytes)\n"
)
self.logger.log(
f"Attempting to write info file to {info_file_path} as current user.")
with open(info_file_path, 'w') as f:
f.write(info_content)
self.logger.log(
f"Successfully created metadata file: {info_file_path}")
except Exception as e:
self.logger.log(
f"Failed to create metadata file. Please check permissions for {os.path.dirname(info_file_path)}. Error: {e}")
def _execute_rsync(self, queue, command: List[str]):
try:
try:
self.process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, preexec_fn=os.setsid)
except FileNotFoundError:
self.logger.log(
"Error: 'pkexec' or 'rsync' command not found in PATH during Popen call.")
queue.put(('error', None))
return
except Exception as e:
self.logger.log(
f"Error starting rsync process with Popen: {e}")
queue.put(('error', None))
return
if self.process is None: # This check might be redundant if exceptions are caught, but good for safety
self.logger.log(
"Error: subprocess.Popen returned None for rsync process (after exception handling).")
queue.put(('error', None))
return # Exit early if process didn't start
progress_regex = re.compile(r'\s*(\d+)%\s+')
if self.process.stdout:
for line in iter(self.process.stdout.readline, ''):
stripped_line = line.strip()
self.logger.log(stripped_line)
match = progress_regex.search(stripped_line)
if match:
percentage = int(match.group(1))
queue.put(('progress', percentage))
else:
if stripped_line and not stripped_line.startswith(('sending incremental file list', 'sent', 'total size')):
queue.put(('file_update', stripped_line))
self.process.wait()
if self.process.stderr:
stderr_output = self.process.stderr.read()
if stderr_output:
self.logger.log(f"Rsync Error: {stderr_output.strip()}")
queue.put(('error', None))
except FileNotFoundError:
self.logger.log(
"Error: 'rsync' command not found. Please ensure it is installed and in your PATH.")
queue.put(('error', None))
except Exception as e:
self.logger.log(f"An unexpected error occurred: {e}")
queue.put(('error', None))
def get_scheduled_jobs(self) -> List[Dict[str, Any]]:
jobs_list = []
try:
user_cron = CronTab(user=True)
for job in user_cron:
if self.app_tag in job.comment:
details = self._parse_job_comment(job.comment)
if details:
jobs_list.append({
"id": job.comment,
"active": job.is_enabled(),
"type": details.get("type", "N/A"),
"frequency": details.get("freq", "N/A"),
"destination": details.get("dest", "N/A"),
"sources": details.get("sources", []),
"command": job.command
})
except Exception as e:
self.logger.log(f"Error loading cron jobs: {e}")
return jobs_list
def add_scheduled_job(self, job_details: Dict[str, Any]):
try:
user_cron = CronTab(user=True)
job = user_cron.new(
command=job_details["command"], comment=job_details["comment"])
if job_details["frequency"] == "daily":
job.day.every(1)
elif job_details["frequency"] == "weekly":
job.dow.every(1)
elif job_details["frequency"] == "monthly":
job.dom.every(1)
job.enable()
user_cron.write()
self.logger.log(
f"Job successfully added: {job_details['comment']}")
except Exception as e:
self.logger.log(f"Error adding cron job: {e}")
def remove_scheduled_job(self, job_id: str):
try:
user_cron = CronTab(user=True)
user_cron.remove_all(comment=job_id)
user_cron.write()
self.logger.log(f"Job successfully removed: {job_id}")
except Exception as e:
self.logger.log(f"Error removing cron job: {e}")
def _parse_job_comment(self, comment: str) -> Dict[str, Any]:
details = {}
parts = comment.split("; ")
for part in parts:
if ":" in part:
key, value = part.split(":", 1)
if key.strip() == "sources":
details[key.strip()] = [s.strip()
for s in value.split(",")]
else:
details[key.strip()] = value.strip()
return details
def list_backups(self, base_backup_path: str) -> List[str]:
backups = []
if os.path.isdir(base_backup_path):
for item in os.listdir(base_backup_path):
full_path = os.path.join(base_backup_path, item)
if os.path.isdir(full_path):
backups.append(item)
return sorted(backups, reverse=True)
def list_system_backups(self, base_backup_path: str) -> List[Dict[str, str]]:
"""Lists all system backups found in the pybackup subdirectory."""
system_backups = []
pybackup_path = os.path.join(base_backup_path, "pybackup")
if not os.path.isdir(pybackup_path):
return system_backups
# Regex to parse folder names like '6-März-2024_system_full'
name_regex = re.compile(
r"^(\d{1,2}-\w+-\d{4})_system_(full|incremental)$", re.IGNORECASE)
for item in os.listdir(pybackup_path):
full_path = os.path.join(pybackup_path, item)
if not os.path.isdir(full_path):
continue
match = name_regex.match(item)
if not match:
continue
date_str = match.group(1)
backup_type = match.group(2).capitalize()
backup_size = "N/A"
comment = ""
# NEW: Look for info file in the parent directory, named after the backup folder
info_file_path = os.path.join(pybackup_path, f"{item}.txt")
if os.path.exists(info_file_path):
try:
with open(info_file_path, 'r') as f:
for line in f:
if line.strip().lower().startswith("originalgröße:"):
# Extract size, e.g., "Originalgröße: 13.45 GB (...)"
size_match = re.search(r":\s*(.*)\s*((", line)
if size_match:
backup_size = size_match.group(1).strip()
else: # Fallback if format is just "Originalgröße: 13.45 GB"
backup_size = line.split(":")[1].strip()
elif line.strip().lower().startswith("kommentar:"):
comment = line.split(":", 1)[1].strip()
except Exception as e:
self.logger.log(
f"Could not read info file {info_file_path}: {e}")
system_backups.append({
"date": date_str,
"type": backup_type,
"size": backup_size,
"folder_name": item,
"full_path": full_path,
"comment": comment
})
# Sort by parsing the date from the folder name
try:
system_backups.sort(key=lambda x: datetime.datetime.strptime(
x['date'], '%d-%B-%Y'), reverse=True)
except ValueError:
self.logger.log(
"Could not sort backups by date due to format mismatch.")
# Fallback to simple string sort if date parsing fails
system_backups.sort(key=lambda x: x['folder_name'], reverse=True)
return system_backups
def list_user_backups(self, base_backup_path: str) -> List[Dict[str, str]]:
"""Lists all user backups found in the base backup path."""
user_backups = []
if not os.path.isdir(base_backup_path):
return user_backups
for item in os.listdir(base_backup_path):
full_path = os.path.join(base_backup_path, item)
if not os.path.isdir(full_path):
continue
# NEW: Look for info file in the parent directory, named after the backup folder
info_file_path = os.path.join(base_backup_path, f"{item}.txt")
# We identify a user backup by the presence of its corresponding info file.
if os.path.exists(info_file_path):
backup_size = "N/A"
backup_date = "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()
else:
backup_size = line.split(":")[1].strip()
elif line.strip().lower().startswith("backup-datum:"):
backup_date = line.split(":", 1)[1].strip()
elif line.strip().lower().startswith("kommentar:"):
comment = line.split(":", 1)[1].strip()
except Exception as e:
self.logger.log(
f"Could not read info file {info_file_path}: {e}")
user_backups.append({
"date": backup_date,
"size": backup_size,
"folder_name": item,
"full_path": full_path,
"comment": comment
})
user_backups.sort(key=lambda x: x['folder_name'], reverse=True)
return user_backups
def get_comment(self, info_file_path: str) -> str:
"""Reads an info file and returns the comment, if it exists."""
if not os.path.exists(info_file_path):
return ""
try:
with open(info_file_path, 'r') as f:
for line in f:
if line.strip().lower().startswith("kommentar:"):
return line.split(":", 1)[1].strip()
except Exception as e:
self.logger.log(f"Error reading comment from {info_file_path}: {e}")
return ""
def update_comment(self, info_file_path: str, new_comment: str):
"""Updates the comment in a given info file."""
try:
lines = []
comment_found = False
if os.path.exists(info_file_path):
with open(info_file_path, 'r') as f:
lines = f.readlines()
new_lines = []
for line in lines:
if line.strip().lower().startswith("kommentar:"):
if new_comment: # Update existing comment
new_lines.append(f"Kommentar: {new_comment}\n")
comment_found = True
# If new_comment is empty, the old line is effectively deleted
else:
new_lines.append(line)
if not comment_found and new_comment:
new_lines.append(f"Kommentar: {new_comment}\n")
with open(info_file_path, 'w') as f:
f.writelines(new_lines)
self.logger.log(f"Successfully updated comment in {info_file_path}")
except Exception as e:
self.logger.log(f"Error updating comment in {info_file_path}: {e}")
def test_pkexec_rsync(self, source_path: str, dest_path: str):
self.logger.log(f"Testing pkexec rsync command...")
command = ['pkexec', 'rsync', '-aAXHv', source_path, dest_path]
try:
result = subprocess.run(
command, capture_output=True, text=True, check=False)
self.logger.log(f"pkexec rsync return code: {result.returncode}")
self.logger.log(f"pkexec rsync stdout: {result.stdout.strip()}")
self.logger.log(f"pkexec rsync stderr: {result.stderr.strip()}")
except FileNotFoundError:
self.logger.log("Error: 'pkexec' or 'rsync' command not found.")
except Exception as e:
self.logger.log(
f"An unexpected error occurred during pkexec rsync test: {e}")

1072
core/backup_manager.py Normal file

File diff suppressed because it is too large Load Diff

83
core/config_manager.py Normal file
View File

@@ -0,0 +1,83 @@
import json
from pathlib import Path
from typing import Any, Dict
from json import JSONEncoder
class PathEncoder(JSONEncoder):
def default(self, obj):
if isinstance(obj, Path):
return str(obj)
return JSONEncoder.default(self, obj)
class ConfigManager:
"""Manages loading and saving of application settings in a JSON file."""
def __init__(self, file_path: Path):
"""
Initializes the ConfigManager.
Args:
file_path: The path to the configuration file.
"""
self.file_path = file_path
self.settings: Dict[str, Any] = {}
self.load()
def load(self) -> None:
"""Loads the settings from the JSON file."""
if self.file_path.exists():
try:
with open(self.file_path, 'r', encoding='utf-8') as f:
self.settings = json.load(f)
except (IOError, json.JSONDecodeError) as e:
print(f"Error loading config file {self.file_path}: {e}")
self.settings = {}
else:
self.settings = {}
def save(self) -> None:
"""Saves the current settings to the JSON file."""
try:
# Ensure the parent directory exists
self.file_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.file_path, 'w', encoding='utf-8') as f:
json.dump(self.settings, f, indent=4, cls=PathEncoder)
except IOError as e:
print(f"Error saving config file {self.file_path}: {e}")
def get_setting(self, key: str, default: Any = None) -> Any:
"""
Gets a setting value.
Args:
key: The key of the setting.
default: The default value to return if the key is not found.
Returns:
The value of the setting or the default value.
"""
return self.settings.get(key, default)
def set_setting(self, key: str, value: Any) -> None:
"""
Sets a setting value and immediately saves it to the file.
Args:
key: The key of the setting.
value: The value to set.
"""
self.settings[key] = value
self.save()
def remove_setting(self, key: str) -> None:
"""
Removes a setting from the configuration.
Args:
key: The key of the setting to remove.
"""
if key in self.settings:
del self.settings[key]
self.save()

View File

@@ -3,7 +3,9 @@ import os
import fnmatch
import shutil
import re
from pbp_app_config import AppConfig
import subprocess
from queue import Empty
from core.pbp_app_config import AppConfig, Msg
from shared_libs.logger import app_logger
@@ -41,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
@@ -101,63 +117,90 @@ class DataProcessing:
if not stop_event.is_set():
self.app.queue.put((button_text, total_size, mode))
def process_queue(self):
def get_incremental_backup_size(self, source_path: str, dest_path: str, is_system: bool, exclude_files: list = None) -> int:
"""
Calculates the approximate size of an incremental backup using rsync's dry-run feature.
This is much faster than walking the entire file tree.
"""
app_logger.log(f"Calculating incremental backup size for source: {source_path}")
parent_dest = os.path.dirname(dest_path)
if not os.path.exists(parent_dest):
# If the parent destination doesn't exist, there are no previous backups to link to.
# In this case, the incremental size is the full size of the source.
# We can use the existing full-size calculation method.
# This is a simplified approach for the estimation.
# A more accurate one would run rsync without --link-dest.
app_logger.log("Destination parent does not exist, cannot calculate incremental size. Returning 0.")
return 0
# Find the latest backup to link against
try:
message = self.app.queue.get_nowait()
button_text, folder_size, mode_when_started = message
backups = sorted([d for d in os.listdir(parent_dest) if os.path.isdir(os.path.join(parent_dest, d))], reverse=True)
if not backups:
app_logger.log("No previous backups found. Incremental size is full size.")
return 0 # Or trigger a full size calculation
latest_backup_path = os.path.join(parent_dest, backups[0])
except FileNotFoundError:
app_logger.log("Could not list backups, assuming no prior backups exist.")
return 0
if mode_when_started != self.app.mode:
return # Discard stale result from a different mode
if self.app.mode == 'restore':
self.app.restore_destination_folder_size_bytes = folder_size
else: # backup mode
self.app.source_size_bytes = folder_size
command = []
if is_system:
command.extend(['pkexec', 'rsync', '-aAXHvn', '--stats'])
else:
command.extend(['rsync', '-avn', '--stats'])
current_folder_name = self.app.left_canvas_data.get('folder')
if current_folder_name == button_text:
if self.app.left_canvas_animation:
self.app.left_canvas_animation.stop()
self.app.left_canvas_animation.destroy()
self.app.left_canvas_animation = None
command.append(f"--link-dest={latest_backup_path}")
if not self.app.right_canvas_data.get('calculating', False):
self.app.start_pause_button.config(state="normal")
if exclude_files:
for exclude_file in exclude_files:
command.append(f"--exclude-from={exclude_file}")
size_in_gb = folder_size / (1024**3)
if size_in_gb >= 1:
size_str = f"{size_in_gb:.2f} GB"
else:
size_str = f"{folder_size / (1024*1024):.2f} MB"
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
command.append(f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
self.app.left_canvas_data['size'] = size_str
self.app.left_canvas_data['calculating'] = False
self.app.drawing.redraw_left_canvas()
# 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")
os.makedirs(dummy_dest, exist_ok=True)
if self.app.mode == 'backup':
total_disk_size, _, _ = shutil.disk_usage(
AppConfig.FOLDER_PATHS[button_text])
if folder_size > total_disk_size:
self.app.source_larger_than_partition = True
else:
self.app.source_larger_than_partition = False
command.extend([source_path, dummy_dest])
app_logger.log(f"Executing rsync dry-run command: {' '.join(command)}")
if total_disk_size > 0:
percentage = (folder_size / total_disk_size) * 100
else:
percentage = 0
try:
result = subprocess.run(command, capture_output=True, text=True, check=False)
# Clean up the dummy directory
shutil.rmtree(dummy_dest)
self.app.source_size_canvas.delete("all")
fill_width = (
self.app.source_size_canvas.winfo_width() / 100) * percentage
self.app.source_size_canvas.create_rectangle(
0, 0, fill_width, self.app.source_size_canvas.winfo_height(), fill="#0078d7", outline="")
self.app.source_size_label.config(
text=f"{folder_size / (1024**3):.2f} GB / {total_disk_size / (1024**3):.2f} GB")
if result.returncode != 0:
app_logger.log(f"Rsync dry-run failed with code {result.returncode}: {result.stderr}")
return 0
self.app.drawing.update_target_projection()
output = result.stdout + "\n" + result.stderr
# The regex now accepts dots as thousands separators (e.g., 1.234.567).
match = re.search(r"Total transferred file size: ([\d,.]+) bytes", output)
if match:
# Remove both dots and commas before converting to an integer.
size_str = match.group(1).replace(',', '').replace('.', '')
size_bytes = int(size_str)
app_logger.log(f"Estimated incremental backup size: {size_bytes} bytes")
return size_bytes
else:
app_logger.log("Could not find 'Total transferred file size' in rsync output.")
# Log the output just in case something changes in the future
app_logger.log(f"Full rsync output for debugging:\n{output}")
return 0
except FileNotFoundError:
app_logger.log("Error: 'rsync' or 'pkexec' command not found.")
return 0
except Exception as e:
pass
finally:
self.app.after(100, self.process_queue)
app_logger.log(f"An unexpected error occurred during incremental size calculation: {e}")
return 0
# The queue processing logic has been moved to main_app.py
# to fix a race condition and ensure all queue messages are handled correctly.

346
core/encryption_manager.py Normal file
View File

@@ -0,0 +1,346 @@
import keyring
import keyring.errors
from keyring.backends import SecretService
import os
import shutil
import subprocess
import tempfile
import stat
import re
from typing import Optional, List
from core.pbp_app_config import AppConfig
from pyimage_ui.password_dialog import PasswordDialog
class EncryptionManager:
def __init__(self, logger, app=None):
try:
keyring.set_keyring(SecretService.Keyring())
except Exception as e:
logger.log(f"Failed to set keyring backend to SecretService: {e}")
self.logger = logger
self.app = app
self.service_id = "py-backup-encryption"
self.session_password = None
self.mounted_destinations = set()
self.auth_method = None
self.is_mounting = False
def get_key_file_path(self, base_dest_path: str) -> str:
"""Generates the standard path for the key file for a given destination."""
key_filename = f"keyfile_{os.path.basename(base_dest_path.rstrip('/'))}.key"
return os.path.join(AppConfig.CONFIG_DIR, key_filename)
def get_password_from_keyring(self, username: str) -> Optional[str]:
try:
return keyring.get_password(self.service_id, username)
except keyring.errors.InitError as e:
self.logger.log(f"Could not initialize keyring: {e}")
return None
except Exception as e:
self.logger.log(f"Could not get password from keyring: {e}")
return None
def is_key_in_keyring(self, username: str) -> bool:
try:
return self.get_password_from_keyring(username) is not None
except Exception as e:
self.logger.log(f"Could not check password in keyring: {e}")
return False
def set_password_in_keyring(self, username: str, password: str) -> bool:
try:
keyring.set_password(self.service_id, username, password)
self.logger.log(f"Password for {username} stored in keyring.")
return True
except Exception as e:
self.logger.log(f"Could not set password in keyring: {e}")
return False
def get_password(self, username: str, confirm: bool = True) -> Optional[str]:
if self.session_password:
return self.session_password
password = self.get_password_from_keyring(username)
if password:
self.session_password = password
return password
dialog = PasswordDialog(
self.app, title=f"Enter password for {username}", confirm=confirm)
password, save_to_keyring = dialog.get_password()
if password and save_to_keyring:
self.set_password_in_keyring(username, password)
if password:
self.session_password = password
return password
def is_encrypted(self, base_dest_path: str) -> bool:
if os.path.basename(base_dest_path) == "pybackup":
pybackup_dir = base_dest_path
else:
pybackup_dir = os.path.join(base_dest_path, "pybackup")
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
return os.path.exists(container_path)
def is_mounted(self, base_dest_path: str) -> bool:
if os.path.basename(base_dest_path) == "pybackup":
pybackup_dir = base_dest_path
else:
pybackup_dir = os.path.join(base_dest_path, "pybackup")
mount_point = os.path.join(pybackup_dir, "encrypted")
return os.path.ismount(mount_point)
def mount(self, base_dest_path: str, queue=None, size_gb: int = 0) -> Optional[str]:
if self.is_mounting:
self.logger.log("Mount process already in progress. Aborting new request.")
return None
self.is_mounting = True
try:
if self.is_mounted(base_dest_path):
self.mounted_destinations.add(base_dest_path)
pybackup_dir = os.path.join(base_dest_path, "pybackup")
return os.path.join(pybackup_dir, "encrypted")
username = os.path.basename(base_dest_path.rstrip('/'))
# Use a dummy queue if none is provided
if queue is None:
from queue import Queue
queue = Queue()
# 1. Try keyring
password = self.get_password_from_keyring(username)
if password:
self.logger.log("Found password in keyring. Attempting to mount.")
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, password=password)
if mount_point:
self.auth_method = "keyring"
self.mounted_destinations.add(base_dest_path)
return mount_point
else:
# If mounting with keyring key fails, stop here and report error.
self.logger.log("Mounting with keyring password failed. Aborting mount attempt.")
return None
# 2. Try key file
key_file_path = self.get_key_file_path(base_dest_path)
if os.path.exists(key_file_path):
self.logger.log(f"Found key file at {key_file_path}. Attempting to mount.")
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, key_file=key_file_path)
if mount_point:
self.auth_method = "keyfile"
self.mounted_destinations.add(base_dest_path)
return mount_point
# 3. Prompt for password
self.logger.log("No password in keyring or key file found. Prompting user.")
password = self.get_password(username, confirm=False)
if not password:
self.logger.log("No password provided, cannot mount container.")
self.auth_method = None
return None
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, password=password)
if mount_point:
self.auth_method = "password"
self.mounted_destinations.add(base_dest_path)
return mount_point
finally:
self.is_mounting = False
def unmount(self, base_dest_path: str):
if base_dest_path in self.mounted_destinations:
self._unmount_encrypted_backup(base_dest_path)
self.mounted_destinations.remove(base_dest_path)
def unmount_all(self):
self.logger.log(f"Unmounting all mounted backup destinations: {self.mounted_destinations}")
# Create a copy for safe iteration
for path in list(self.mounted_destinations):
self.unmount(path)
def _unmount_encrypted_backup(self, base_dest_path: str):
""" Gently unmounts the container without destroying LVM structures. """
pybackup_dir = os.path.join(base_dest_path, "pybackup")
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
mount_point = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
mapper_name = f"pybackup_luks_{base_name}"
self.logger.log(f"Unmounting encrypted LVM backup for {base_dest_path}")
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
script = f"""
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
if mountpoint -q {mount_point}; then
umount {mount_point} || echo "Umount failed, continuing..."
fi
if [ -e /dev/mapper/{mapper_name} ]; then
cryptsetup luksClose {mapper_name} || echo "luksClose failed, continuing..."
fi
if vgdisplay {vg_name} >/dev/null 2>&1; then
vgchange -an {vg_name} || echo "Could not deactivate volume group {vg_name}."
fi
if [ -n "$LOOP_DEVICE" ] && losetup $LOOP_DEVICE >/dev/null 2>&1; then
losetup -d $LOOP_DEVICE || echo "Could not detach loop device $LOOP_DEVICE."
fi
"""
if not self._execute_as_root(script):
self.logger.log("Encrypted LVM backup unmount script failed.")
def _setup_encrypted_backup(self, queue, base_dest_path: str, size_gb: int, password: Optional[str] = None, key_file: Optional[str] = None) -> Optional[str]:
self.logger.log(f"Setting up LVM-based encrypted container at {base_dest_path}")
for tool in ["cryptsetup", "losetup", "pvcreate", "vgcreate", "lvcreate", "lvextend", "resize2fs"]:
if not shutil.which(tool):
self.logger.log(f"Error: Required tool '{tool}' is not installed.")
queue.put(('error', f"Required tool '{tool}' is not installed."))
return None
pybackup_dir = os.path.join(base_dest_path, "pybackup")
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
mount_point = encrypted_dir
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
lv_name = "backup_lv"
mapper_name = f"pybackup_luks_{base_name}"
lv_path = f"/dev/{vg_name}/{lv_name}"
if not password and not key_file:
self.logger.log("No password or key file provided for encryption.")
queue.put(('error', "No password or key file provided for encryption."))
return None
if os.path.ismount(mount_point):
self.logger.log(f"Mount point {mount_point} already in use. Assuming it's correctly mounted.")
return mount_point
if os.path.exists(container_path):
auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -" if password else f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
script = f"""
mkdir -p {user_encrypt_dir}
LOOP_DEVICE=$(losetup -f --show {container_path})
pvscan --cache
vgchange -ay {vg_name}
{auth_part}
mount /dev/mapper/{mapper_name} {mount_point}
echo "LOOP_DEVICE_PATH=$LOOP_DEVICE"
"""
if not self._execute_as_root(script):
self.logger.log("Failed to unlock existing LVM container.")
self._destroy_encrypted_structures(base_dest_path)
return None
self.logger.log(f"Encrypted LVM container unlocked and mounted at {mount_point}")
return mount_point
else:
format_auth_part = f"echo -n '{password}' | cryptsetup luksFormat {lv_path} -" if password else f"cryptsetup luksFormat {lv_path} --key-file {key_file}"
open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -" if password else f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
script = f"""
mkdir -p {encrypted_dir}
mkdir -p {user_encrypt_dir}
fallocate -l {size_gb}G {container_path}
LOOP_DEVICE=$(losetup -f --show {container_path})
pvcreate $LOOP_DEVICE
vgcreate {vg_name} $LOOP_DEVICE
lvcreate -n {lv_name} -l 100%FREE {vg_name}
{format_auth_part}
{open_auth_part}
mkfs.ext4 /dev/mapper/{mapper_name}
mount /dev/mapper/{mapper_name} {mount_point}
echo "LOOP_DEVICE_PATH=$LOOP_DEVICE"
"""
if not self._execute_as_root(script):
self.logger.log("Failed to create and setup LVM-based encrypted container.")
self._destroy_encrypted_structures(base_dest_path)
if os.path.exists(container_path):
self._execute_as_root(f"rm -f {container_path}")
queue.put(('error', "Failed to setup LVM-based encrypted container."))
return None
self.logger.log(f"Encrypted LVM container is ready and mounted at {mount_point}")
return mount_point
def _destroy_encrypted_structures(self, base_dest_path: str):
pybackup_dir = os.path.join(base_dest_path, "pybackup")
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
mount_point = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
mapper_name = f"pybackup_luks_{base_name}"
self.logger.log(f"Cleaning up encrypted LVM backup for {base_dest_path}")
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
lv_name = "backup_lv"
lv_path = f"/dev/{vg_name}/{lv_name}"
script = f"""
set -x # Log executed commands
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
if mountpoint -q {mount_point}; then
umount {mount_point} || echo "Umount failed, continuing cleanup..."
fi
if [ -e /dev/mapper/{mapper_name} ]; then
cryptsetup luksClose {mapper_name} || echo "luksClose failed, continuing cleanup..."
fi
# Deactivate and remove all LVM structures associated with the VG
if vgdisplay {vg_name} >/dev/null 2>&1; then
lvchange -an {lv_path} >/dev/null 2>&1 || echo "lvchange failed, continuing..."
vgchange -an {vg_name} || echo "vgchange -an failed, continuing cleanup..."
lvremove -f {vg_name} || echo "lvremove failed, continuing cleanup..."
vgremove -f {vg_name} || echo "vgremove failed, continuing cleanup..."
fi
if [ -n "$LOOP_DEVICE" ] && losetup $LOOP_DEVICE >/dev/null 2>&1; then
pvremove -f $LOOP_DEVICE || echo "pvremove failed, continuing cleanup..."
losetup -d $LOOP_DEVICE || echo "losetup -d failed, continuing cleanup..."
fi
"""
if not self._execute_as_root(script):
self.logger.log("Encrypted LVM backup cleanup script failed.")
def _execute_as_root(self, script_content: str) -> bool:
script_path = ''
try:
with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="pybackup_script_", suffix=".sh", dir="/tmp") as tmp_script:
tmp_script.write("#!/bin/bash\n\n")
tmp_script.write("set -e\n\n")
tmp_script.write(script_content)
script_path = tmp_script.name
os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP |
stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
command = ['pkexec', script_path]
sanitized_script_content = re.sub(
r"echo -n \'.*?\'", "echo -n \'[REDACTED]\'", script_content)
self.logger.log(
f"Executing privileged command via script: {script_path}")
self.logger.log(
f"Script content:\n---\n{sanitized_script_content}\n---")
result = subprocess.run(
command, capture_output=True, text=True, check=False)
if result.returncode == 0:
self.logger.log(
f"Privileged script executed successfully. Output:\n{result.stdout}")
return True
else:
self.logger.log(
f"Privileged script failed. Return code: {result.returncode}\nStderr: {result.stderr}\nStdout: {result.stdout}")
return False
except Exception as e:
self.logger.log(
f"Failed to set up or execute privileged command: {e}")
return False
finally:
if script_path and os.path.exists(script_path):
os.remove(script_path)

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 ---
@@ -86,6 +88,7 @@ class AppConfig:
**/root/.gvfs
/snap/*
/home/*/.gvfs
/home/*/.cache/*
/var/cache/pacman/pkg/*
/var/cache/apt/archives/*
/var/cache/yum/*
@@ -236,6 +239,7 @@ class Msg:
"name": _("Name"),
"path": _("Path"),
"date": _("Date"),
"time": _("Time"),
"size": _("Size"),
"type": _("Type"),
"folder": _("Folder"),
@@ -256,6 +260,23 @@ class Msg:
"cancel_backup": _("Cancel"),
"backup_cancelled_and_deleted_msg": _("Backup cancelled and partially completed backup deleted."),
"info_text_placeholder": _("Info text about the current view."),
"backup_finished_successfully": _("Backup finished successfully."),
"backup_finished_with_warnings": _("Backup finished with warnings. See log for details."),
"backup_failed": _("Backup failed. See log for details."),
"backup_cancelled_by_user": _("Backup was cancelled by the user."),
"accurate_size_cb_label": _("Accurate inkrem. size"),
"accurate_size_info_label": _("(Calculation may take longer)"),
"accurate_size_success": _("Accurate size calculated successfully."),
"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"),
"system_excludes": _("System Excludes"),
"manual_excludes": _("Manual Excludes"),
"manual_excludes_info": _("Here, manually add files or folders to be excluded from the backup. Each entry should be on a new line."),
# Menus
"file_menu": _("File"),
@@ -278,6 +299,8 @@ class Msg:
"cat_documents": _("Documents"),
"cat_music": _("Music"),
"cat_videos": _("Videos"),
"show_encrypted_backups": _("Show Encrypted Backups"),
"show_normal_backups": _("Show Normal Backups"),
# Browser View
"backup_location": _("Backup Location"),
@@ -294,11 +317,16 @@ class Msg:
"err_no_dest_folder": _("Please select a destination folder."),
"err_no_source_folder": _("Please select at least one source folder."),
"err_no_backup_selected": _("Please select a backup from the list."),
"err_unlock_failed": _("Failed to unlock the container. Please check the password and try again."),
"err_encrypted_not_mounted": _("Encrypted container is not unlocked. Please unlock it first from the header bar."),
"confirm_user_restore_title": _("Confirm User Data Restore"),
"confirm_user_restore_msg": _("Do you really want to restore the backup of '{backup_name}' to its original location? \n\nAny newer files may be overwritten."),
"confirm_user_restore_msg": _("Do you really want to restore the backup of '{backup_name}' to its original location? Any newer files may be overwritten."),
"confirm_delete_title": _("Confirm Deletion"),
"confirm_delete_text": _("Do you really want to delete the backup '{folder_name}'? This action cannot be undone."),
"final_warning_system_restore_title": _("FINAL WARNING"),
"final_warning_system_restore_msg": _("ATTENTION: You are about to restore the system. This process cannot be safely interrupted. All changes since the backup will be lost. \n\nThe computer will automatically restart upon completion. \n\nREALLY PROCEED?"),
"btn_continue": _("PROCEED"),
"deleting_backup_in_progress": _("Deletion in progress... Please wait."),
"select_restore_source_title": _("Select Restore Source"),
"select_restore_destination_title": _("Select Restore Destination"),
@@ -315,6 +343,7 @@ class Msg:
"projected_usage_label": _("Projected usage after backup"),
"header_title": _("Lx Tools Py-Backup"),
"header_subtitle": _("Simple GUI for rsync"),
"encrypted_backup_content": _("Encrypted Backups"),
"compressed": _("Compressed"),
"encrypted": _("Encrypted"),
"bypass_security": _("Bypass security"),
@@ -323,5 +352,15 @@ class Msg:
"force_incremental_backup": _("Always force incremental backup (except first)"),
"force_compression": _("Always compress backup"),
"force_encryption": _("Always encrypt backup"),
"use_trash_bin": _("Papierkorb verwenden"),
"no_trash_bin": _("Kein Papierkorb (reine Synchronisierung)"),
"sync_mode_pure_sync": _("Synchronisierungsmodus: Reine Synchronisierung (Dateien werden gelöscht)"),
"sync_mode_trash_bin": _("Synchronisierungsmodus: Papierkorb verwenden (Gelöschte Dateien werden verschoben)"),
"sync_mode_no_delete": _("Synchronisierungsmodus: Keine Löschung (Dateien bleiben im Ziel)"),
"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."),
"keyfile_settings": _("Keyfile Settings"), # New
"backup_defaults_title": _("Backup Defaults"), # New
"automation_settings_title": _("Automation Settings"), # New
"create_add_key_file": _("Create/Add Key File"), # New
"key_file_not_created": _("Key file not created."), # New
}

23
core/user_utils.py Normal file
View File

@@ -0,0 +1,23 @@
import os
import pwd
def is_admin_user() -> bool:
"""Checks if the current user is a member of the 'sudo' or 'wheel' group."""
try:
# Get current user's groups
groups = [g.gr_name for g in pwd.getpwall() if os.getuid() in g.gr_mem]
# Also get groups current user is in by gid
groups.extend([g.gr_name for g in pwd.getgrall()
if os.getgid() == g.gr_gid])
# Common admin groups
admin_groups = ["sudo", "wheel", "admin"]
for group in admin_groups:
if group in groups:
return True
return False
except Exception:
# Fallback if group lookup fails for some reason
return False

View File

@@ -4,23 +4,24 @@ from tkinter import ttk
import os
import datetime
from queue import Queue, Empty
import shutil
from shared_libs.log_window import LogWindow
from shared_libs.logger import app_logger
from shared_libs.animated_icon import AnimatedIcon
from shared_libs.common_tools import IconManager
from shared_libs.config_manager import ConfigManager
from backup_manager import BackupManager
from pbp_app_config import AppConfig, Msg
from core.config_manager import ConfigManager
from core.backup_manager import BackupManager
from core.pbp_app_config import AppConfig, Msg
from pyimage_ui.scheduler_frame import SchedulerFrame
from pyimage_ui.backup_content_frame import BackupContentFrame
from pyimage_ui.header_frame import HeaderFrame
from pyimage_ui.settings_frame import SettingsFrame
from core.data_processing import DataProcessing
from pyimage_ui.drawing import Drawing
from pyimage_ui.navigation import Navigation
from pyimage_ui.actions import Actions
from pyimage_ui.shared_logic import enforce_backup_type_exclusivity
class MainApplication(tk.Tk):
@@ -48,6 +49,8 @@ class MainApplication(tk.Tk):
self.style.layout("Toolbutton"))
self.style.configure("Gray.Toolbutton", foreground="gray")
self.style.configure("Green.Sidebar.TButton", foreground="green")
main_frame = ttk.Frame(self)
main_frame.grid(row=0, column=0, sticky="nsew")
self.grid_rowconfigure(0, weight=1)
@@ -70,7 +73,7 @@ class MainApplication(tk.Tk):
self.content_frame.grid_rowconfigure(6, weight=0)
self.content_frame.grid_columnconfigure(0, weight=1)
self.backup_manager = BackupManager(app_logger)
self.backup_manager = BackupManager(app_logger, self)
self.queue = Queue()
self.image_manager = IconManager()
@@ -80,12 +83,14 @@ class MainApplication(tk.Tk):
self.navigation = Navigation(self)
self.actions = Actions(self)
self.mode = "backup" # Default mode
self.backup_is_running = False
self.start_time = None
self.calculation_thread = None
self.calculation_stop_event = None
self.source_larger_than_partition = False
self.accurate_calculation_running = False
self.is_first_backup = False
self.left_canvas_animation = None
@@ -142,7 +147,8 @@ class MainApplication(tk.Tk):
self.sidebar_buttons_frame, text=Msg.STR["settings"], command=lambda: self.navigation.toggle_settings_frame(4), style="Sidebar.TButton")
self.settings_button.pack(fill=tk.X, pady=10)
self.header_frame = HeaderFrame(self.content_frame, self.image_manager)
self.header_frame = HeaderFrame(
self.content_frame, self.image_manager, self.backup_manager.encryption_manager, self)
self.header_frame.grid(row=0, column=0, sticky="nsew")
self.top_bar = ttk.Frame(self.content_frame)
@@ -292,14 +298,13 @@ class MainApplication(tk.Tk):
self.restore_size_frame_after.grid_remove()
self._load_state_and_initialize()
self.update_backup_options_from_config() # Add this call
self.update_backup_options_from_config()
self.protocol("WM_DELETE_WINDOW", self.on_closing)
def _load_state_and_initialize(self):
"""Loads saved state from config and initializes the UI."""
# self.log_window.clear_log()
last_mode = self.config_manager.get_setting("last_mode", "backup")
# Pre-load data from config before initializing the UI
backup_source_path = self.config_manager.get_setting(
"backup_source_path")
if backup_source_path and os.path.isdir(backup_source_path):
@@ -309,9 +314,8 @@ class MainApplication(tk.Tk):
if folder_name:
icon_name = self.buttons_map[folder_name]['icon']
else:
# Handle custom folder path
folder_name = os.path.basename(backup_source_path.rstrip('/'))
icon_name = 'folder_extralarge' # A generic folder icon
icon_name = 'folder_extralarge'
self.backup_left_canvas_data.update({
'icon': icon_name,
@@ -322,7 +326,7 @@ class MainApplication(tk.Tk):
backup_dest_path = self.config_manager.get_setting(
"backup_destination_path")
if backup_dest_path and os.path.isdir(backup_dest_path):
self.destination_path = backup_dest_path # Still needed for some logic
self.destination_path = backup_dest_path
total, used, free = shutil.disk_usage(backup_dest_path)
self.backup_right_canvas_data.update({
'folder': os.path.basename(backup_dest_path.rstrip('/')),
@@ -332,6 +336,23 @@ class MainApplication(tk.Tk):
self.destination_total_bytes = total
self.destination_used_bytes = used
if hasattr(self, 'header_frame'):
self.header_frame.refresh_status()
container_path = os.path.join(
backup_dest_path, "pybackup_encrypted.luks")
if os.path.exists(container_path):
username = os.path.basename(backup_dest_path.rstrip('/'))
password = self.backup_manager.encryption_manager.get_password_from_keyring(
username)
if password:
self.backup_manager.encryption_manager.unlock_container(
backup_dest_path, password)
app_logger.log(
"Automatically unlocked encrypted container.")
if hasattr(self, 'header_frame'):
self.header_frame.refresh_status()
restore_src_path = self.config_manager.get_setting(
"restore_source_path")
if restore_src_path and os.path.isdir(restore_src_path):
@@ -343,7 +364,6 @@ class MainApplication(tk.Tk):
restore_dest_path = self.config_manager.get_setting(
"restore_destination_path")
if restore_dest_path and os.path.isdir(restore_dest_path):
# Find the corresponding button_text for the path
folder_name = ""
for name, path_obj in AppConfig.FOLDER_PATHS.items():
if str(path_obj) == restore_dest_path:
@@ -356,23 +376,20 @@ class MainApplication(tk.Tk):
'path_display': restore_dest_path,
})
# Initialize UI for the last active mode
self.navigation.initialize_ui_for_mode(last_mode)
# Trigger calculations if needed
if last_mode == 'backup':
self.after(100, self.actions.on_sidebar_button_click,
self.backup_left_canvas_data.get('folder', 'Computer'))
elif last_mode == 'restore':
# Trigger calculation for the right canvas (source) if a path is set
if restore_src_path:
self.drawing.calculate_restore_folder_size()
# Trigger calculation for the left canvas (destination) based on last selection
restore_dest_folder = self.restore_left_canvas_data.get(
'folder', 'Computer')
self.after(100, self.actions.on_sidebar_button_click,
restore_dest_folder)
self.data_processing.process_queue()
self._process_queue()
self._update_sync_mode_display() # Call after loading state
def _setup_log_window(self):
self.log_frame = ttk.Frame(self.content_frame)
@@ -396,11 +413,19 @@ class MainApplication(tk.Tk):
def _setup_backup_content_frame(self):
self.backup_content_frame = BackupContentFrame(
self.content_frame, self.backup_manager, padding=10)
self.content_frame, self.backup_manager, self.actions, self, padding=10)
self.backup_content_frame.grid(row=2, column=0, sticky="nsew")
self.backup_content_frame.grid_remove()
def _setup_task_bar(self):
self.vollbackup_var = tk.BooleanVar()
self.inkrementell_var = tk.BooleanVar()
self.genaue_berechnung_var = tk.BooleanVar()
self.testlauf_var = tk.BooleanVar()
self.compressed_var = tk.BooleanVar()
self.encrypted_var = tk.BooleanVar()
self.bypass_security_var = tk.BooleanVar()
self.info_checkbox_frame = ttk.Frame(self.content_frame, padding=10)
self.info_checkbox_frame.grid(row=3, column=0, sticky="ew")
@@ -408,40 +433,51 @@ class MainApplication(tk.Tk):
self.info_checkbox_frame, text=Msg.STR["info_text_placeholder"])
self.info_label.pack(anchor=tk.W, fill=tk.X, pady=5)
# Frame for time info
self.sync_mode_label = ttk.Label(
self.info_checkbox_frame, text="", foreground="blue")
self.sync_mode_label.pack(anchor=tk.W, fill=tk.X, pady=2)
self.time_info_frame = ttk.Frame(self.info_checkbox_frame)
self.time_info_frame.pack(anchor=tk.W, fill=tk.X, pady=5)
self.start_time_label = ttk.Label(self.time_info_frame, text="Start: --:--:--")
self.start_time_label = ttk.Label(
self.time_info_frame, text="Start: --:--:--")
self.start_time_label.pack(side=tk.LEFT, padx=5)
self.end_time_label = ttk.Label(self.time_info_frame, text="Ende: --:--:--")
self.duration_label = ttk.Label(
self.time_info_frame, text="Dauer: --:--:--")
self.duration_label.pack(side=tk.LEFT, padx=5)
self.end_time_label = ttk.Label(
self.time_info_frame, text="Ende: --:--:--")
self.end_time_label.pack(side=tk.LEFT, padx=5)
self.duration_label = ttk.Label(self.time_info_frame, text="Dauer: --:--:--")
self.duration_label.pack(side=tk.LEFT, padx=5)
accurate_size_frame = ttk.Frame(self.time_info_frame)
accurate_size_frame.pack(side=tk.LEFT, padx=20)
self.accurate_size_cb = ttk.Checkbutton(accurate_size_frame, text=Msg.STR["accurate_size_cb_label"],
variable=self.genaue_berechnung_var, command=self.actions.on_toggle_accurate_size_calc)
self.accurate_size_cb.pack(side=tk.LEFT, padx=5)
accurate_size_info_label = ttk.Label(
accurate_size_frame, text=Msg.STR["accurate_size_info_label"], foreground="gray")
accurate_size_info_label.pack(side=tk.LEFT)
checkbox_frame = ttk.Frame(self.info_checkbox_frame)
checkbox_frame.pack(fill=tk.X, pady=5)
self.vollbackup_var = tk.BooleanVar()
self.inkrementell_var = tk.BooleanVar()
self.testlauf_var = tk.BooleanVar()
self.compressed_var = tk.BooleanVar()
self.encrypted_var = tk.BooleanVar()
self.bypass_security_var = tk.BooleanVar()
self.full_backup_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["full_backup"],
variable=self.vollbackup_var, command=lambda: enforce_backup_type_exclusivity(self.vollbackup_var, self.inkrementell_var, self.vollbackup_var.get()))
variable=self.vollbackup_var, command=lambda: self.actions.handle_backup_type_change('voll'))
self.full_backup_cb.pack(side=tk.LEFT, padx=5)
self.incremental_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["incremental"],
variable=self.inkrementell_var, command=lambda: enforce_backup_type_exclusivity(self.inkrementell_var, self.vollbackup_var, self.inkrementell_var.get()))
variable=self.inkrementell_var, command=lambda: self.actions.handle_backup_type_change('inkrementell'))
self.incremental_cb.pack(side=tk.LEFT, padx=5)
self.compressed_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["compressed"],
variable=self.compressed_var)
variable=self.compressed_var, command=self.actions.handle_compression_change)
self.compressed_cb.pack(side=tk.LEFT, padx=5)
self.encrypted_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["encrypted"],
variable=self.encrypted_var)
variable=self.encrypted_var, command=self.actions.handle_encryption_change)
self.encrypted_cb.pack(side=tk.LEFT, padx=5)
self.test_run_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["test_run"],
variable=self.testlauf_var)
@@ -484,10 +520,10 @@ class MainApplication(tk.Tk):
self.start_pause_button.grid(row=0, column=2, rowspan=2, padx=5)
def on_closing(self):
"""Handles window closing events and saves the app state."""
self.backup_manager.encryption_manager.unmount_all()
self.config_manager.set_setting("last_mode", self.mode)
# Save paths from the data dictionaries
if self.backup_left_canvas_data.get('path_display'):
self.config_manager.set_setting(
"backup_source_path", self.backup_left_canvas_data['path_display'])
@@ -512,7 +548,6 @@ class MainApplication(tk.Tk):
else:
self.config_manager.set_setting("restore_source_path", None)
# Stop any ongoing animations before destroying the application
if self.left_canvas_animation:
self.left_canvas_animation.stop()
self.left_canvas_animation.destroy()
@@ -527,71 +562,176 @@ class MainApplication(tk.Tk):
self.animated_icon = None
app_logger.log(Msg.STR["app_quit"])
self.destroy()
def _process_backup_queue(self):
"""Processes messages from the backup thread queue to update the UI safely."""
try:
while True:
self.destroy()
except tk.TclError:
pass # App is already destroyed
def _process_queue(self):
try:
for _ in range(100):
message = self.queue.get_nowait()
# Check if it's a backup status message (2-element tuple)
if isinstance(message, tuple) and len(message) == 2:
if isinstance(message, tuple) and len(message) in [3, 5]:
calc_type, status = None, None
if len(message) == 5:
button_text, folder_size, mode_when_started, calc_type, status = message
else:
button_text, folder_size, mode_when_started = message
if mode_when_started != self.mode:
if calc_type == 'accurate_incremental':
self.actions._set_ui_state(True)
self.genaue_berechnung_var.set(False)
self.accurate_calculation_running = False
self.animated_icon.stop("DISABLE")
else:
current_folder_name = self.left_canvas_data.get(
'folder')
if current_folder_name == button_text:
if self.left_canvas_animation:
self.left_canvas_animation.stop()
self.left_canvas_animation.destroy()
self.left_canvas_animation = None
size_in_gb = folder_size / (1024**3)
size_str = f"{size_in_gb:.2f} GB" if size_in_gb >= 1 else f"{folder_size / (1024*1024):.2f} MB"
self.left_canvas_data['size'] = size_str
self.left_canvas_data['total_bytes'] = folder_size
self.left_canvas_data['calculating'] = False
self.drawing.redraw_left_canvas()
self.source_size_bytes = folder_size
if self.mode == 'backup':
if button_text in AppConfig.FOLDER_PATHS:
total_disk_size, _, _ = shutil.disk_usage(
AppConfig.FOLDER_PATHS[button_text])
if folder_size > total_disk_size:
self.source_larger_than_partition = True
else:
self.source_larger_than_partition = False
percentage = (
folder_size / total_disk_size) * 100 if total_disk_size > 0 else 0
self.source_size_canvas.delete("all")
fill_width = (
self.source_size_canvas.winfo_width() / 100) * percentage
self.source_size_canvas.create_rectangle(
0, 0, fill_width, self.source_size_canvas.winfo_height(), fill="#0078d7", outline="")
self.source_size_label.config(
text=f"{folder_size / (1024**3):.2f} GB / {total_disk_size / (1024**3):.2f} GB")
self.drawing.update_target_projection()
if calc_type == 'accurate_incremental':
self.source_size_bytes = folder_size
self.drawing.update_target_projection()
self.animated_icon.stop("DISABLE")
self.task_progress.stop()
self.task_progress.config(
mode="determinate", value=0)
self.actions._set_ui_state(True)
self.genaue_berechnung_var.set(False)
self.accurate_calculation_running = False
self.start_pause_button.config(
text=Msg.STR["start"])
if status == 'success':
self.info_label.config(
text=Msg.STR["accurate_size_success"], foreground="#0078d7")
self.current_file_label.config(text="")
else:
self.info_label.config(
text=Msg.STR["accurate_size_failed"], foreground="#D32F2F")
self.current_file_label.config(text="")
elif isinstance(message, tuple) and len(message) == 2:
message_type, value = message
if message_type == 'progress':
self.task_progress["value"] = value
self.info_label.config(text=f"Fortschritt: {value}%") # Update progress text
self.info_label.config(text=f"Fortschritt: {value}%")
elif message_type == 'file_update':
# Truncate long text to avoid window resizing issues
max_len = 120
if len(value) > max_len:
value = "..." + value[-max_len:]
self.current_file_label.config(text=value)
elif message_type == 'status_update':
self.info_label.config(text=value)
elif message_type == 'progress_mode':
self.task_progress.config(mode=value)
if value == 'indeterminate':
self.task_progress.start()
else:
self.task_progress.stop()
elif message_type == 'cancel_button_state':
self.start_pause_button.config(state=value)
elif message_type == 'deletion_complete':
self.actions._set_ui_state(True)
self.backup_content_frame.hide_deletion_status()
self.backup_content_frame.system_backups_frame._load_backup_content()
self.backup_content_frame.user_backups_frame._load_backup_content()
elif message_type == 'error':
self.animated_icon.stop("DISABLE")
self.start_pause_button["text"] = "Start"
self.backup_is_running = False
elif message_type == 'completion':
status_info = value
status = 'error'
if isinstance(status_info, dict):
status = status_info.get('status', 'error')
elif status_info is None:
status = 'success'
if status == 'success':
self.info_label.config(
text=Msg.STR["backup_finished_successfully"])
elif status == 'warning':
self.info_label.config(
text=Msg.STR["backup_finished_with_warnings"])
elif status == 'error':
self.info_label.config(
text=Msg.STR["backup_failed"])
elif status == 'cancelled':
pass
self.animated_icon.stop("DISABLE")
self.start_pause_button["text"] = "Start"
self.task_progress["value"] = 0
self.current_file_label.config(text="Backup finished.")
self.current_file_label.config(text="")
if self.start_time:
end_time = datetime.datetime.now()
duration = end_time - self.start_time
total_seconds = int(duration.total_seconds())
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
duration_str = f"{hours:02}:{minutes:02}:{seconds:02}"
end_str = end_time.strftime("%H:%M:%S")
self.end_time_label.config(text=f"Ende: {end_str}")
self.duration_label.config(text=f"Dauer: {duration_str}")
self.start_time = None
self.backup_is_running = False
self.actions._set_ui_state(True) # Re-enable UI
self.actions._set_ui_state(True)
self.backup_content_frame.system_backups_frame._load_backup_content()
elif message_type == 'completion_accurate':
pass
else:
# This message is not for us (likely for DataProcessing), put it back and yield.
self.queue.put(message)
break
app_logger.log(f"Unknown message in queue: {message}")
except Empty:
pass # Queue is empty, do nothing
pass
finally:
self.after(100, self._process_queue)
# Reschedule the queue check
self.after(100, self._process_backup_queue)
def _update_duration(self):
if self.backup_is_running and self.start_time:
duration = datetime.datetime.now() - self.start_time
total_seconds = int(duration.total_seconds())
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
duration_str = f"{hours:02}:{minutes:02}:{seconds:02}"
self.duration_label.config(text=f"Dauer: {duration_str}")
self.after(1000, self._update_duration)
def quit(self):
self.on_closing()
def update_backup_options_from_config(self):
"""
Reads the 'force' settings from the config and updates the main UI checkboxes.
A 'force' setting overrides the user's selection and disables the control.
"""
# Full/Incremental Logic
force_full = self.config_manager.get_setting(
"force_full_backup", False)
force_incremental = self.config_manager.get_setting(
@@ -608,25 +748,45 @@ class MainApplication(tk.Tk):
self.full_backup_cb.config(state="disabled")
self.incremental_cb.config(state="disabled")
# Compression Logic
force_compression = self.config_manager.get_setting(
"force_compression", False)
if force_compression:
self.compressed_var.set(True)
self.compressed_cb.config(state="disabled")
# Encryption Logic
force_encryption = self.config_manager.get_setting(
"force_encryption", False)
if force_encryption:
self.encrypted_var.set(True)
self.encrypted_cb.config(state="disabled")
self.actions._refresh_backup_options_ui()
# Update sync mode display after options are loaded
self._update_sync_mode_display()
def _update_sync_mode_display(self):
use_trash_bin = self.config_manager.get_setting("use_trash_bin", False)
no_trash_bin = self.config_manager.get_setting("no_trash_bin", False)
if self.left_canvas_data.get('folder') == "Computer":
# Not applicable for system backups
self.sync_mode_label.config(text="")
return
if no_trash_bin:
self.sync_mode_label.config(
text=Msg.STR["sync_mode_pure_sync"], foreground="red")
elif use_trash_bin:
self.sync_mode_label.config(
text=Msg.STR["sync_mode_trash_bin"], foreground="orange")
else:
self.sync_mode_label.config(
text=Msg.STR["sync_mode_no_delete"], foreground="green")
if __name__ == "__main__":
import argparse
import sys
import shutil
parser = argparse.ArgumentParser(description="Py-Backup Application.")
parser.add_argument(

96
pybackup-cli.py Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/python3
import argparse
import sys
import os
from queue import Queue
import threading
from core.backup_manager import BackupManager
from shared_libs.logger import app_logger
from core.pbp_app_config import AppConfig
# A simple logger for the CLI that just prints to the console
class CliLogger:
def log(self, message):
print(f"[CLI] {message}")
def init_logger(self, log_method):
pass # Not needed for CLI
def main():
parser = argparse.ArgumentParser(description="Py-Backup Command-Line Interface.")
parser.add_argument("--backup-type", choices=['user', 'system'], required=True, help="Type of backup to perform.")
parser.add_argument("--destination", required=True, help="Destination directory for the backup.")
parser.add_argument("--source", help="Source directory for user backup. Required for --backup-type user.")
parser.add_argument("--mode", choices=['full', 'incremental'], default='incremental', help="Mode for system backup.")
parser.add_argument("--encrypted", action='store_true', help="Flag to indicate the backup should be encrypted.")
parser.add_argument("--key-file", help="Path to the key file for unlocking an encrypted container.")
parser.add_argument("--password", help="Password for the encrypted container (use with caution). If --key-file is not provided, this will be used.")
parser.add_argument("--compressed", action='store_true', help="Flag to indicate the backup should be compressed.")
args = parser.parse_args()
if args.backup_type == 'user' and not args.source:
parser.error("--source is required for --backup-type 'user'.")
if args.encrypted and not (args.key_file or args.password):
parser.error("For encrypted backups, either --key-file or --password must be provided.")
cli_logger = CliLogger()
backup_manager = BackupManager(cli_logger)
queue = Queue() # Dummy queue for now, might be used for progress later
source_path = "/" # Default for system backup
if args.backup_type == 'user':
source_path = args.source
if not os.path.isdir(source_path):
cli_logger.log(f"Error: Source path '{source_path}' does not exist or is not a directory.")
sys.exit(1)
# Determine password or key_file to pass
auth_password = None
auth_key_file = None
if args.encrypted:
if args.key_file:
auth_key_file = args.key_file
if not os.path.exists(auth_key_file):
cli_logger.log(f"Error: Key file '{auth_key_file}' does not exist.")
sys.exit(1)
elif args.password:
auth_password = args.password
cli_logger.log(f"Starting backup with the following configuration:")
cli_logger.log(f" Type: {args.backup_type}")
cli_logger.log(f" Source: {source_path}")
cli_logger.log(f" Destination: {args.destination}")
cli_logger.log(f" Mode: {args.mode}")
cli_logger.log(f" Encrypted: {args.encrypted}")
cli_logger.log(f" Compressed: {args.compressed}")
if auth_key_file:
cli_logger.log(f" Auth Method: Key File ({auth_key_file})")
elif auth_password:
cli_logger.log(f" Auth Method: Password (REDACTED)")
# Call the backup manager
backup_thread = backup_manager.start_backup(
queue=queue,
source_path=source_path,
dest_path=args.destination,
is_system=(args.backup_type == 'system'),
is_dry_run=False,
exclude_files=None, # Excludes are handled by AppConfig.MANUAL_EXCLUDE_LIST_PATH
source_size=0, # Not accurately calculable in CLI without scanning, set to 0
is_compressed=args.compressed,
is_encrypted=args.encrypted,
mode=args.mode,
password=auth_password,
key_file=auth_key_file
)
# Wait for the backup thread to complete
backup_thread.join()
cli_logger.log("CLI backup process finished.")
if __name__ == "__main__":
main()

View File

@@ -9,7 +9,7 @@ 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 core.pbp_app_config import AppConfig, Msg
from shared_libs.logger import app_logger
from shared_libs.common_tools import message_box_animation
@@ -18,49 +18,178 @@ 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.
def _set_backup_type(self, backup_type: str):
if backup_type == "full":
self.app.vollbackup_var.set(True)
self.app.inkrementell_var.set(False)
elif backup_type == "incremental":
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")
def _update_backup_type_controls(self):
# Only applies to system backups in backup mode
if self.app.mode != 'backup' or self.app.left_canvas_data.get('folder') != "Computer":
self._set_backup_type("full") # Default for user backups
self.app.full_backup_cb.config(state='disabled')
self.app.incremental_cb.config(state='disabled')
return
else:
# Case 2: No full backup exists. Force a full backup.
# Re-enable if we switch back to system backup
self.app.full_backup_cb.config(state='normal')
self.app.incremental_cb.config(state='normal')
# If controls are forced by advanced settings, do nothing
if self.app.full_backup_cb.cget('state') == 'disabled' and self.app.incremental_cb.cget('state') == 'disabled':
return
full_backup_exists = False
if self.app.destination_path and os.path.isdir(self.app.destination_path):
pybackup_dir = os.path.join(self.app.destination_path, "pybackup")
if not os.path.isdir(pybackup_dir):
self._set_backup_type("full")
return
is_encrypted_backup = self.app.encrypted_var.get()
system_backups = self.app.backup_manager.list_system_backups(
self.app.destination_path, mount_if_needed=False)
if system_backups is None: # Encrypted, but not inspected
full_backup_exists = True # Assume one exists to be safe
else:
for backup in system_backups:
# Match the encryption status and check if it's a full backup
if backup.get('is_encrypted') == is_encrypted_backup and backup.get('backup_type_base') == 'Full':
full_backup_exists = True
break
if full_backup_exists:
self._set_backup_type("incremental")
else:
self._set_backup_type("full")
def _refresh_backup_options_ui(self):
# Reset enabled/disabled state for all potentially affected controls
self.app.full_backup_cb.config(state="normal")
self.app.incremental_cb.config(state="normal")
self.app.compressed_cb.config(state="normal")
self.app.encrypted_cb.config(state="normal")
self.app.accurate_size_cb.config(state="normal")
# Apply logic: Encryption and Compression are mutually exclusive
if self.app.encrypted_var.get():
self.app.compressed_var.set(False)
self.app.compressed_cb.config(state="disabled")
if self.app.compressed_var.get():
self.app.encrypted_var.set(False)
self.app.encrypted_cb.config(state="disabled")
# Compression forces 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")
self.app.genaue_berechnung_var.set(False)
# After setting the states, determine the final full/incremental choice
self._update_backup_type_controls()
def handle_backup_type_change(self, changed_var_name):
if changed_var_name == 'voll':
if self.app.vollbackup_var.get():
self._set_backup_type("full")
elif changed_var_name == 'inkrementell':
if self.app.inkrementell_var.get():
self._set_backup_type("incremental")
def handle_compression_change(self):
self._refresh_backup_options_ui()
def handle_encryption_change(self):
self._refresh_backup_options_ui()
def on_toggle_accurate_size_calc(self):
if not self.app.genaue_berechnung_var.get():
return
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.")
app_logger.log("Accurate incremental size calculation requested.")
self.app.accurate_calculation_running = True
self._set_ui_state(False, keep_cancel_enabled=True)
self.app.start_pause_button.config(text=Msg.STR["cancel_backup"])
self.app.drawing.reset_projection_canvases()
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()
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)
self.app.genaue_berechnung_var.set(False)
self.app.accurate_calculation_running = False
self.app.animated_icon.stop("DISABLE")
return
def threaded_incremental_calc():
status = '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)
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")
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
)
status = 'success' if size > 0 else 'failure'
except Exception as e:
app_logger.log(f"Error during threaded_incremental_calc: {e}")
status = 'failure'
finally:
if self.app.accurate_calculation_running:
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:
app_logger.log("Action blocked: Backup is in progress.")
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()
@@ -68,9 +197,8 @@ class Actions:
self.app.navigation.toggle_mode(
self.app.mode, trigger_calculation=False)
self.app.log_window.clear_log()
# 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",
@@ -99,22 +227,28 @@ class Actions:
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
else:
extra_info = Msg.STR["system_restore_info"]
else: # User folder
else:
if self.app.mode == 'backup':
extra_info = Msg.STR["user_backup_info"]
else: # restore
else:
extra_info = Msg.STR["user_restore_info"]
# Unified logic for starting a calculation on the left canvas
if self.app.mode == 'backup':
self._update_backup_type_controls()
else:
self.app.config_manager.set_setting(
"restore_destination_path", folder_path)
self._start_left_canvas_calculation(
button_text, str(folder_path), icon_name, extra_info)
# Update sync mode display when source changes
self.app._update_sync_mode_display()
def _start_left_canvas_calculation(self, button_text, folder_path, icon_name, extra_info):
self.app.start_pause_button.config(state="disabled")
@@ -159,15 +293,14 @@ class Actions:
self.app.calculation_stop_event = threading.Event()
# Decide which calculation method to use based on the source
if button_text == "Computer":
# For system backup, use exclusions
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)
@@ -179,7 +312,7 @@ class Actions:
if self.app.mode == 'backup':
self._update_backup_type_controls()
else: # restore mode
else:
self.app.config_manager.set_setting(
"restore_destination_path", folder_path)
@@ -208,6 +341,13 @@ class Actions:
def _update_right_canvas_info(self, path):
try:
if self.app.mode == "backup":
# Unmount previous destination if it was mounted
if self.app.destination_path:
self.app.backup_manager.encryption_manager.unmount(
self.app.destination_path)
self.app.destination_path = path
backup_root_to_exclude = f"/{path.strip('/').split('/')[0]}"
try:
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r+') as f:
@@ -223,7 +363,6 @@ class Actions:
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"
@@ -235,9 +374,9 @@ class Actions:
})
self.app.config_manager.set_setting(
"backup_destination_path", path)
self.app.header_frame.refresh_status() # Refresh keyring status
self.app.drawing.redraw_right_canvas()
self.app.drawing.update_target_projection()
self.app.start_pause_button.config(state="normal")
current_source = self.app.left_canvas_data.get('folder')
if current_source:
@@ -270,7 +409,6 @@ class Actions:
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")
@@ -278,7 +416,6 @@ class Actions:
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()
@@ -291,29 +428,34 @@ class Actions:
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()
current_source = self.app.left_canvas_data.get('folder')
if current_source:
self.on_sidebar_button_click(current_source)
self.app.backup_content_frame.system_backups_frame._load_backup_content()
self.app.backup_content_frame.user_backups_frame._load_backup_content()
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
return 0
try:
value = float(parts[0])
unit = parts[1].upper()
if unit == 'B':
return int(value)
elif unit == 'KB':
@@ -329,38 +471,25 @@ class Actions:
except ValueError:
return 0
def _set_ui_state(self, enable: bool):
# Sidebar Buttons
def _set_ui_state(self, enable: bool, keep_cancel_enabled: bool = False, allow_log_and_backup_toggle: bool = False):
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):
# Keep "Backup" and "Log" always enabled
if (
self.app.nav_buttons_defs[i][0] == Msg.STR["backup_menu"] or
self.app.nav_buttons_defs[i][0] == Msg.STR["log"]):
button.config(state="normal")
else:
button.config(state="normal" if enable else "disabled")
if allow_log_and_backup_toggle and self.app.nav_buttons_defs[i][0] in [Msg.STR["log"], Msg.STR["backup_menu"]]:
continue
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)
@@ -369,17 +498,14 @@ class Actions:
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,
@@ -388,8 +514,29 @@ class Actions:
for cb in checkboxes:
cb.config(state="disabled")
if keep_cancel_enabled:
self.app.start_pause_button.config(state="normal")
def toggle_start_cancel(self):
# If a backup is already running, we must be cancelling.
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)
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()
self.app.task_progress.stop()
self.app.task_progress.config(mode="determinate", value=0)
self.app.info_label.config(
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 self.app.backup_is_running:
self.app.animated_icon.stop("DISABLE")
@@ -435,46 +582,54 @@ class Actions:
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._update_duration()
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.log_window.clear_log()
self._set_ui_state(False, allow_log_and_backup_toggle=True)
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")
source_folder = self.app.left_canvas_data.get('folder')
source_size_bytes = self.app.source_size_bytes
if not source_folder:
app_logger.log(
"No source folder selected, aborting backup.")
self.app.backup_is_running = False
self.app.start_pause_button["text"] = Msg.STR["start"]
self._set_ui_state(True)
return
if source_folder == "Computer":
mode = "full" if self.app.vollbackup_var.get() else "incremental"
self._start_system_backup(mode, source_size_bytes)
else:
self._start_system_backup("incremental")
else:
self._start_user_backup()
else: # restore mode
# Restore logic would go here
pass
def _start_system_backup(self, mode):
def _start_system_backup(self, mode, source_size_bytes):
base_dest = self.app.destination_path
if not base_dest:
MessageDialog(master=self.app, message_type="error",
@@ -487,29 +642,47 @@ class Actions:
title=Msg.STR["error"], text=Msg.STR["system_backup_in_home_error"])
return
is_encrypted = self.app.encrypted_var.get()
password = None
if is_encrypted:
username = os.path.basename(base_dest.rstrip('/'))
password = self.app.backup_manager.encryption_manager.get_password(
username, confirm=True)
if not password:
app_logger.log(
"Encryption enabled, but no password provided. Aborting backup.")
self.app.backup_is_running = False
self.app.start_pause_button["text"] = Msg.STR["start"]
self._set_ui_state(True)
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.")
date_str = datetime.datetime.now().strftime("%d-%B-%Y")
folder_name = f"{date_str}_system_{mode}"
final_dest = os.path.join(base_dest, "pybackup", folder_name)
# Store the path for potential deletion
now = datetime.datetime.now()
date_str = now.strftime("%d-%m-%Y")
time_str = now.strftime("%H:%M:%S")
folder_name = f"{date_str}_{time_str}_system_{mode}"
# The backup_manager will add /pybackup/
final_dest = os.path.join(base_dest, folder_name)
self.app.current_backup_path = final_dest
# Get source size from canvas data and parse it
size_display_str = self.app.left_canvas_data.get('size', '0 B')
source_size_bytes = self._parse_size_string_to_bytes(size_display_str)
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)
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()
self.app.backup_manager.start_backup(
queue=self.app.queue,
source_path="/",
@@ -517,20 +690,75 @@ class Actions:
is_system=True,
is_dry_run=is_dry_run,
exclude_files=exclude_file_paths,
source_size=source_size_bytes)
source_size=source_size_bytes,
is_compressed=is_compressed,
is_encrypted=is_encrypted,
mode=mode)
def _start_user_backup(self, sources):
dest = self.app.destination_path
if not dest:
def _start_user_backup(self):
base_dest = self.app.destination_path
source_path = self.app.left_canvas_data.get('path_display')
source_name = self.app.left_canvas_data.get('folder')
source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0)
if not base_dest or not source_path:
MessageDialog(master=self.app, message_type="error",
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
self.app.backup_is_running = False
self.app.start_pause_button["text"] = Msg.STR["start"]
self._set_ui_state(True)
return
is_encrypted = self.app.encrypted_var.get()
password = None
if is_encrypted:
username = os.path.basename(base_dest.rstrip('/'))
password = self.app.backup_manager.encryption_manager.get_password(
username, confirm=True)
if not password:
app_logger.log(
"Encryption enabled, but no password provided. Aborting backup.")
self.app.backup_is_running = False
self.app.start_pause_button["text"] = Msg.STR["start"]
self._set_ui_state(True)
return
# Determine mode for user backup based on UI selection
mode = "full" if self.app.vollbackup_var.get() else "incremental"
now = datetime.datetime.now()
date_str = now.strftime("%d-%m-%Y")
time_str = now.strftime("%H:%M:%S")
folder_name = f"{date_str}_{time_str}_user_{source_name}_{mode}"
final_dest = os.path.join(base_dest, folder_name)
self.app.current_backup_path = final_dest
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)
is_compressed = self.app.compressed_var.get()
use_trash_bin = self.app.config_manager.get_setting(
"use_trash_bin", False)
no_trash_bin = self.app.config_manager.get_setting(
"no_trash_bin", False)
self.app.backup_manager.start_backup(
queue=self.app.queue,
source_path=source_path,
dest_path=final_dest,
is_system=False,
is_dry_run=is_dry_run,
exclude_files=None,
source_size=source_size_bytes,
is_compressed=is_compressed,
is_encrypted=is_encrypted,
mode=mode,
use_trash_bin=use_trash_bin,
no_trash_bin=no_trash_bin)
now = datetime.datetime.now()
date_str = now.strftime("%d-%m-%Y")
time_str = now.strftime("%H:%M:%S")
folder_name = f"{date_str}_{time_str}_user_{source_name}_{mode}"
final_dest = os.path.join(base_dest, folder_name)
self.app.current_backup_path = final_dest

View File

@@ -3,28 +3,67 @@ from tkinter import ttk
import os
from pathlib import Path
from pbp_app_config import AppConfig, Msg
from core.pbp_app_config import AppConfig, Msg
from shared_libs.animated_icon import AnimatedIcon
from pyimage_ui.shared_logic import enforce_backup_type_exclusivity
from shared_libs.message import MessageDialog
from pyimage_ui.password_dialog import PasswordDialog
class AdvancedSettingsFrame(tk.Toplevel):
def __init__(self, master, config_manager, app_instance, **kwargs):
class AdvancedSettingsFrame(ttk.Frame):
def __init__(self, master, config_manager, app_instance, show_main_settings_callback, **kwargs):
super().__init__(master, **kwargs)
self.title(Msg.STR["advanced_settings_title"])
self.show_main_settings_callback = show_main_settings_callback
self.config_manager = config_manager
self.app_instance = app_instance
self.current_view_index = 0
# --- Warning Label ---
warning_label = ttk.Label(
self, text=Msg.STR["advanced_settings_warning"], wraplength=780, justify="center")
warning_label.pack(pady=10, fill=tk.X, padx=10)
self.info_label = ttk.Label(
self, text=Msg.STR["advanced_settings_warning"], wraplength=780, justify="left")
self.info_label.pack(pady=10, fill=tk.X, padx=10)
nav_frame = ttk.Frame(self)
nav_frame.pack(fill=tk.X, padx=10, pady=5)
top_nav_frame = ttk.Frame(nav_frame)
top_nav_frame.pack(side=tk.LEFT)
self.nav_buttons_defs = [
(Msg.STR["system_excludes"], lambda: self._switch_view(0)),
(Msg.STR["manual_excludes"], lambda: self._switch_view(1)),
# New button for Keyfile/Automation
(Msg.STR["keyfile_settings"], lambda: self._switch_view(2)),
(Msg.STR["animation_settings_title"],
lambda: self._switch_view(3)), # Animation settings
(Msg.STR["backup_defaults_title"],
lambda: self._switch_view(4)), # Backup Defaults
]
self.nav_buttons = []
self.nav_progress_bars = []
for i, (text, command) in enumerate(self.nav_buttons_defs):
button_frame = ttk.Frame(top_nav_frame)
button_frame.pack(side=tk.LEFT, padx=5)
button = ttk.Button(button_frame, text=text,
command=command, style="TButton.Borderless.Round")
button.pack(side=tk.TOP)
self.nav_buttons.append(button)
progress_bar = ttk.Progressbar(
button_frame, orient="horizontal", length=50, mode="determinate", style="Small.Horizontal.TProgressbar")
progress_bar.pack_forget()
self.nav_progress_bars.append(progress_bar)
if i < len(self.nav_buttons_defs) - 1:
ttk.Separator(top_nav_frame, orient=tk.VERTICAL).pack(
side=tk.LEFT, fill=tk.Y, padx=2)
view_container = ttk.Frame(self)
view_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# --- Treeview for system folder exclusion ---
self.tree_frame = ttk.LabelFrame(
self, text=Msg.STR["exclude_system_folders"], padding=10)
self.tree_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
view_container, text=Msg.STR["exclude_system_folders"], padding=10)
columns = ("included", "name", "path")
self.tree = ttk.Treeview(
@@ -36,76 +75,225 @@ class AdvancedSettingsFrame(tk.Toplevel):
self.tree.column("name", anchor="center")
self.tree.column("included", width=100, anchor="center")
self.tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.tree.tag_configure("backup_dest_exclude", foreground="gray")
self.tree.bind("<Button-1>", self._toggle_include_status)
# --- Animation Settings ---
animation_frame = ttk.LabelFrame(
self, text=Msg.STR["animation_settings_title"], padding=10)
animation_frame.pack(fill=tk.X, padx=10, pady=5)
self.manual_excludes_frame = ttk.LabelFrame(
view_container, text=Msg.STR["manual_excludes"], padding=10)
self.manual_excludes_listbox = tk.Listbox(
self.manual_excludes_frame, selectmode=tk.MULTIPLE)
self.manual_excludes_listbox.pack(
fill=tk.BOTH, expand=True, padx=5, pady=5)
delete_button = ttk.Button(
self.manual_excludes_frame, text=Msg.STR["delete"], command=self._delete_manual_exclude)
delete_button.pack(pady=5)
self.animation_settings_frame = ttk.LabelFrame(
view_container, text=Msg.STR["animation_settings_title"], padding=10)
animation_types = ["counter_arc", "double_arc", "line", "blink"]
ttk.Label(animation_frame, text=Msg.STR["backup_animation_label"]).grid(
ttk.Label(self.animation_settings_frame, text=Msg.STR["backup_animation_label"]).grid(
row=0, column=0, sticky="w", pady=2)
self.backup_anim_var = tk.StringVar()
self.backup_anim_combo = ttk.Combobox(
animation_frame, textvariable=self.backup_anim_var, values=animation_types, state="readonly")
self.animation_settings_frame, textvariable=self.backup_anim_var, values=animation_types, state="readonly")
self.backup_anim_combo.grid(row=0, column=1, sticky="ew", padx=5)
ttk.Label(animation_frame, text=Msg.STR["calc_animation_label"]).grid(
ttk.Label(self.animation_settings_frame, text=Msg.STR["calc_animation_label"]).grid(
row=1, column=0, sticky="w", pady=2)
self.calc_anim_var = tk.StringVar()
self.calc_anim_combo = ttk.Combobox(
animation_frame, textvariable=self.calc_anim_var, values=animation_types, state="readonly")
self.animation_settings_frame, textvariable=self.calc_anim_var, values=animation_types, state="readonly")
self.calc_anim_combo.grid(row=1, column=1, sticky="ew", padx=5)
reset_button = ttk.Button(
animation_frame, text=Msg.STR["default_settings"], command=self._reset_animation_settings)
self.animation_settings_frame, text=Msg.STR["default_settings"], command=self._reset_animation_settings)
reset_button.grid(row=0, column=2, rowspan=2, padx=10)
animation_frame.columnconfigure(1, weight=1)
self.animation_settings_frame.columnconfigure(1, weight=1)
# --- Backup Default Settings ---
defaults_frame = ttk.LabelFrame(
self, text="Backup Defaults", padding=10)
defaults_frame.pack(fill=tk.X, padx=10, pady=5)
self.backup_defaults_frame = ttk.LabelFrame(
view_container, text=Msg.STR["backup_defaults_title"], padding=10)
self.force_full_var = tk.BooleanVar()
self.force_incremental_var = tk.BooleanVar()
self.force_compression_var = tk.BooleanVar()
self.force_encryption_var = tk.BooleanVar()
ttk.Checkbutton(defaults_frame, text=Msg.STR["force_full_backup"], variable=self.force_full_var, command=lambda: enforce_backup_type_exclusivity(
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_full_backup"], variable=self.force_full_var, command=lambda: enforce_backup_type_exclusivity(
self.force_full_var, self.force_incremental_var, self.force_full_var.get())).pack(anchor=tk.W)
ttk.Checkbutton(defaults_frame, text=Msg.STR["force_incremental_backup"], variable=self.force_incremental_var, command=lambda: enforce_backup_type_exclusivity(
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_incremental_backup"], variable=self.force_incremental_var, command=lambda: enforce_backup_type_exclusivity(
self.force_incremental_var, self.force_full_var, self.force_incremental_var.get())).pack(anchor=tk.W)
ttk.Checkbutton(defaults_frame, text=Msg.STR["force_compression"],
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_compression"],
variable=self.force_compression_var).pack(anchor=tk.W)
ttk.Checkbutton(defaults_frame, text=Msg.STR["force_encryption"],
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_encryption"],
variable=self.force_encryption_var).pack(anchor=tk.W)
ttk.Separator(defaults_frame, orient=tk.HORIZONTAL).pack(
ttk.Separator(self.backup_defaults_frame, orient=tk.HORIZONTAL).pack(
fill=tk.X, pady=5)
encryption_note = ttk.Label(
defaults_frame, text=Msg.STR["encryption_note_system_backup"], wraplength=750, justify="left")
self.backup_defaults_frame, text=Msg.STR["encryption_note_system_backup"], wraplength=750, justify="left")
encryption_note.pack(anchor=tk.W, pady=5)
# --- Keyfile/Automation Settings ---
self.keyfile_settings_frame = ttk.LabelFrame(
view_container, text=Msg.STR["automation_settings_title"], padding=10)
key_file_button = ttk.Button(
self.keyfile_settings_frame, text=Msg.STR["create_add_key_file"], command=self._create_key_file)
key_file_button.grid(row=0, column=0, padx=5, pady=5)
self.key_file_status_var = tk.StringVar(
value=Msg.STR["key_file_not_created"])
key_file_status_label = ttk.Label(
self.keyfile_settings_frame, textvariable=self.key_file_status_var, foreground="gray")
key_file_status_label.grid(row=0, column=1, padx=5, pady=5, sticky="w")
sudoers_info_text = (f"To run automated backups, an administrator must create a file in /etc/sudoers.d/\n"
f"with the following content (replace 'punix' with the correct username):\n"
# Path needs to be updated
f"punix ALL=(ALL) NOPASSWD: /path/to/pybackup-cli.py")
sudoers_info_label = ttk.Label(
self.keyfile_settings_frame, text=sudoers_info_text, justify="left")
sudoers_info_label.grid(
row=1, column=0, columnspan=2, sticky="w", padx=5, pady=5)
self.keyfile_settings_frame.columnconfigure(1, weight=1)
# --- Action Buttons ---
button_frame = ttk.Frame(self)
button_frame.pack(pady=10)
ttk.Button(button_frame, text=Msg.STR["apply"], command=self._apply_changes).pack(
side=tk.LEFT, padx=5)
ttk.Button(button_frame, text=Msg.STR["cancel"], command=self.destroy).pack(
ttk.Button(button_frame, text=Msg.STR["cancel"], command=self._cancel_changes).pack(
side=tk.LEFT, padx=5)
# Initial packing of frames (all hidden except the first one by _switch_view)
# Initially packed, then hidden by _switch_view
self.tree_frame.pack(fill=tk.BOTH, expand=True)
# Initially packed, then hidden by _switch_view
self.manual_excludes_frame.pack(fill=tk.BOTH, expand=True)
# Initially packed, then hidden by _switch_view
self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True)
# Initially packed, then hidden by _switch_view
self.animation_settings_frame.pack(fill=tk.BOTH, expand=True)
# Initially packed, then hidden by _switch_view
self.backup_defaults_frame.pack(fill=tk.BOTH, expand=True)
self._load_system_folders()
self._load_animation_settings()
self._load_backup_defaults()
self._load_manual_excludes()
self._update_key_file_status()
self._switch_view(self.current_view_index)
def _create_key_file(self):
if not self.app_instance.destination_path:
MessageDialog(self, message_type="error", title="Error",
text="Please select a backup destination first.")
return
pybackup_dir = os.path.join(
self.app_instance.destination_path, "pybackup")
container_path = os.path.join(pybackup_dir, "pybackup_encrypted.luks")
if not os.path.exists(container_path):
MessageDialog(self, message_type="error", title="Error",
text="No encrypted container found at the destination.")
return
# Prompt for the existing password to authorize adding a new key
password_dialog = PasswordDialog(
self, title="Enter Existing Password", confirm=False)
password, _ = password_dialog.get_password()
if not password:
return # User cancelled
key_file_path = self.app_instance.backup_manager.encryption_manager.create_and_add_key_file(
self.app_instance.destination_path, password)
if key_file_path:
MessageDialog(self, message_type="info", title="Success",
text=f"Key file created and added successfully!\nPath: {key_file_path}")
else:
MessageDialog(self, message_type="error", title="Error",
text="Failed to create or add key file. See log for details.")
self._update_key_file_status()
def _update_key_file_status(self):
if not self.app_instance.destination_path:
self.key_file_status_var.set(
"Key file status unknown (no destination set).")
return
key_file_path = self.app_instance.backup_manager.encryption_manager.get_key_file_path(
self.app_instance.destination_path)
if os.path.exists(key_file_path):
self.key_file_status_var.set(f"Key file exists: {key_file_path}")
else:
self.key_file_status_var.set(
"Key file has not been created for this destination.")
def _switch_view(self, index):
self.current_view_index = index
self.update_nav_buttons(index)
# Hide all frames first
self.tree_frame.pack_forget()
self.manual_excludes_frame.pack_forget()
self.keyfile_settings_frame.pack_forget()
self.animation_settings_frame.pack_forget()
self.backup_defaults_frame.pack_forget()
# Show the selected frame and update info label
if index == 0:
self.tree_frame.pack(fill=tk.BOTH, expand=True)
self.info_label.config(text=Msg.STR["advanced_settings_warning"])
elif index == 1:
self.manual_excludes_frame.pack(fill=tk.BOTH, expand=True)
self.info_label.config(text=Msg.STR["manual_excludes_info"])
elif index == 2:
self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True)
# Use automation title for info
self.info_label.config(text=Msg.STR["automation_settings_title"])
elif index == 3:
self.animation_settings_frame.pack(fill=tk.BOTH, expand=True)
self.info_label.config(text=Msg.STR["animation_settings_title"])
elif index == 4:
self.backup_defaults_frame.pack(fill=tk.BOTH, expand=True)
self.info_label.config(text=Msg.STR["backup_defaults_title"])
def update_nav_buttons(self, active_index):
for i, button in enumerate(self.nav_buttons):
if i == active_index:
button.configure(style="Toolbutton")
self.nav_progress_bars[i].pack(side=tk.BOTTOM, fill=tk.X)
self.nav_progress_bars[i]['value'] = 100
else:
button.configure(style="Gray.Toolbutton")
self.nav_progress_bars[i].pack_forget()
def _load_manual_excludes(self):
self.manual_excludes_listbox.delete(0, tk.END)
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
for line in f:
line = line.strip()
if line:
self.manual_excludes_listbox.insert(tk.END, line)
def _delete_manual_exclude(self):
selected_indices = self.manual_excludes_listbox.curselection()
for i in reversed(selected_indices):
self.manual_excludes_listbox.delete(i)
def _reset_animation_settings(self):
self.config_manager.remove_setting("backup_animation_type")
@@ -183,7 +371,6 @@ class AdvancedSettingsFrame(tk.Toplevel):
item_values = items_to_display[item_path_str]
tag = "yes" if item_values[0] == Msg.STR["yes"] else "no"
# Special tag for the backup destination, which is always excluded and read-only
is_backup_dest = (self.app_instance and self.app_instance.destination_path and
item_path_str == str(Path(f"/{self.app_instance.destination_path.strip('/').split('/')[0]}").absolute()))
is_restore_src = (restore_src_path and
@@ -225,10 +412,8 @@ class AdvancedSettingsFrame(tk.Toplevel):
if self.app_instance:
self.app_instance.update_backup_options_from_config()
# Destroy the old icon
self.app_instance.animated_icon.destroy()
# Create a new one
bg_color = self.app_instance.style.lookup('TFrame', 'background')
backup_animation_type = self.backup_anim_var.get()
@@ -239,11 +424,9 @@ class AdvancedSettingsFrame(tk.Toplevel):
self.app_instance.animated_icon = AnimatedIcon(
self.app_instance.action_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type=initial_animation_type)
# Pack it in the correct order
self.app_instance.animated_icon.pack(
side=tk.LEFT, padx=5, before=self.app_instance.task_progress)
# Set the correct state
self.app_instance.animated_icon.stop("DISABLE")
self.app_instance.animated_icon.animation_type = backup_animation_type
@@ -285,7 +468,14 @@ class AdvancedSettingsFrame(tk.Toplevel):
for path in final_excludes:
f.write(f"{path}\n")
self.destroy()
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'w') as f:
for item in self.manual_excludes_listbox.get(0, tk.END):
f.write(f"{item}\n")
# Instead of destroying the Toplevel, hide this frame and show main settings
self.pack_forget()
if self.show_main_settings_callback:
self.show_main_settings_callback()
if self.app_instance:
current_source = self.app_instance.left_canvas_data.get('folder')
@@ -293,6 +483,12 @@ class AdvancedSettingsFrame(tk.Toplevel):
self.app_instance.actions.on_sidebar_button_click(
current_source)
def _cancel_changes(self):
# Hide this frame and show main settings without applying changes
self.pack_forget()
if self.show_main_settings_callback:
self.show_main_settings_callback()
def _load_exclude_patterns(self):
generated_patterns = []
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
@@ -303,7 +499,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

@@ -1,56 +1,206 @@
import tkinter as tk
from tkinter import ttk
from pbp_app_config import Msg
import os
from shared_libs.animated_icon import AnimatedIcon
from core.pbp_app_config import Msg
from pyimage_ui.system_backup_content_frame import SystemBackupContentFrame
from pyimage_ui.user_backup_content_frame import UserBackupContentFrame
from shared_libs.logger import app_logger
from shared_libs.message import MessageDialog
class BackupContentFrame(ttk.Frame):
def __init__(self, master, backup_manager, **kwargs):
def __init__(self, master, backup_manager, actions, app, **kwargs):
super().__init__(master, **kwargs)
app_logger.log("BackupContentFrame: __init__ called")
self.backup_manager = backup_manager
self.actions = actions
self.app = app
self.backup_path = None
self.base_backup_path = None
self.current_view_index = 0
self.viewing_encrypted = False
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
# --- Header with buttons ---
header_frame = ttk.Frame(self)
header_frame.grid(row=0, column=0, sticky=tk.NSEW)
self.grid_rowconfigure(1, weight=1) # Row for content frames
self.grid_columnconfigure(0, weight=1) # Column for content frames
self.system_button = ttk.Button(
header_frame, text=Msg.STR["system_backup_info"], command=self.show_system_backups)
self.system_button.pack(side=tk.LEFT, padx=5)
top_nav_frame = ttk.Frame(header_frame)
top_nav_frame.pack(side=tk.LEFT)
self.user_button = ttk.Button(
header_frame, text=Msg.STR["user_backup_info"], command=self.show_user_backups)
self.user_button.pack(side=tk.LEFT, padx=5)
self.nav_buttons_defs = [
(Msg.STR["system_backup_info"], lambda: self._switch_view(0)),
(Msg.STR["user_backup_info"], lambda: self._switch_view(1)),
]
self.nav_buttons = []
self.nav_progress_bars = []
for i, (text, command) in enumerate(self.nav_buttons_defs):
button_frame = ttk.Frame(top_nav_frame)
button_frame.pack(side=tk.LEFT, padx=5)
button = ttk.Button(button_frame, text=text,
command=command, style="TButton.Borderless.Round")
button.pack(side=tk.TOP)
self.nav_buttons.append(button)
progress_bar = ttk.Progressbar(
button_frame, orient="horizontal", length=50, mode="determinate", style="Small.Horizontal.TProgressbar")
progress_bar.pack_forget()
self.nav_progress_bars.append(progress_bar)
if i < len(self.nav_buttons_defs) - 1:
ttk.Separator(top_nav_frame, orient=tk.VERTICAL).pack(
side=tk.LEFT, fill=tk.Y, padx=2)
# Deletion Status UI
self.deletion_status_frame = ttk.Frame(header_frame)
self.deletion_status_frame.pack(side=tk.LEFT, padx=15)
bg_color = self.winfo_toplevel().style.lookup('TFrame', 'background')
self.deletion_animated_icon = AnimatedIcon(
self.deletion_status_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type="blink")
self.deletion_animated_icon.pack(side=tk.LEFT, padx=5)
self.deletion_animated_icon.stop("DISABLE")
self.deletion_status_label = ttk.Label(
self.deletion_status_frame, text="", font=("Ubuntu", 10, "bold"))
self.deletion_status_label.pack(side=tk.LEFT, padx=5)
content_container = ttk.Frame(self)
content_container.grid(row=1, column=0, sticky="nsew")
content_container.grid_rowconfigure(0, weight=1)
content_container.grid_columnconfigure(0, weight=1)
# --- Content Frames ---
self.system_backups_frame = SystemBackupContentFrame(
self, backup_manager)
self.user_backups_frame = UserBackupContentFrame(self, backup_manager)
content_container, backup_manager, actions, parent_view=self)
self.user_backups_frame = UserBackupContentFrame(
content_container, backup_manager, actions, parent_view=self)
self.system_backups_frame.grid(row=0, column=0, sticky=tk.NSEW)
self.user_backups_frame.grid(row=0, column=0, sticky=tk.NSEW)
self.system_backups_frame.grid(row=1, column=0, sticky=tk.NSEW)
self.user_backups_frame.grid(row=1, column=0, sticky=tk.NSEW)
action_button_frame = ttk.Frame(self, padding=10)
action_button_frame.grid(row=2, column=0, sticky="ew")
self.show_system_backups() # Show system backups by default
self.restore_button = ttk.Button(
action_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(
action_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(
action_button_frame, text=Msg.STR["comment"], command=self._edit_comment, state="disabled")
self.edit_comment_button.pack(side=tk.LEFT, padx=5)
self._switch_view(0)
def update_button_state(self, is_selected):
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 _get_active_subframe(self):
return self.system_backups_frame if self.current_view_index == 0 else self.user_backups_frame
def _restore_selected(self):
self._get_active_subframe()._restore_selected()
def _delete_selected(self):
self._get_active_subframe()._delete_selected()
def _edit_comment(self):
self._get_active_subframe()._edit_comment()
def _switch_view(self, index):
self.current_view_index = index
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
self.app.config_manager.set_setting(config_key, index)
self.update_nav_buttons(index)
if index == 0:
self.system_backups_frame.grid()
self.user_backups_frame.grid_remove()
else:
self.user_backups_frame.grid()
self.system_backups_frame.grid_remove()
self.update_button_state(False)
def update_nav_buttons(self, active_index):
for i, button in enumerate(self.nav_buttons):
if i == active_index:
button.configure(style="Toolbutton")
self.nav_progress_bars[i].pack(side=tk.BOTTOM, fill=tk.X)
self.nav_progress_bars[i]['value'] = 100
else:
button.configure(style="Gray.Toolbutton")
self.nav_progress_bars[i].pack_forget()
def show(self, backup_path):
app_logger.log(
f"BackupContentFrame: show called with path {backup_path}")
self.grid(row=2, column=0, sticky="nsew")
if backup_path and self.backup_path != backup_path:
self.backup_path = backup_path
self.system_backups_frame.show(backup_path)
self.user_backups_frame.show(backup_path)
self.base_backup_path = backup_path
# Check if the destination is encrypted and trigger mount if necessary
is_encrypted = self.backup_manager.encryption_manager.is_encrypted(
backup_path)
if is_encrypted and not self.backup_manager.encryption_manager.is_mounted(backup_path):
app_logger.log(
"Encrypted destination is not mounted. Attempting to mount.")
mount_point = self.backup_manager.encryption_manager.mount(
backup_path)
if not mount_point:
app_logger.log("Mount failed. Cannot display backup content.")
MessageDialog(
message_type="error", title=Msg.STR["error"], text=Msg.STR["err_unlock_failed"])
# Clear views and return if mount fails
self.system_backups_frame.show(backup_path, [])
self.user_backups_frame.show(backup_path, [])
return
# Refresh header status after successful mount
self.app.header_frame.refresh_status()
pybackup_dir = os.path.join(backup_path, "pybackup")
if not os.path.isdir(pybackup_dir):
app_logger.log(
f"Backup path {pybackup_dir} does not exist or is not a directory.")
# Clear views if path is invalid
self.system_backups_frame.show(backup_path, [])
self.user_backups_frame.show(backup_path, [])
return
all_backups = self.backup_manager.list_all_backups(backup_path)
if all_backups:
system_backups, user_backups = all_backups
self.system_backups_frame.show(backup_path, system_backups)
self.user_backups_frame.show(backup_path, user_backups)
else:
# Handle case where inspection returns None (e.g. encrypted and mount_if_needed=False)
self.system_backups_frame.show(backup_path, [])
self.user_backups_frame.show(backup_path, [])
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
last_view = self.app.config_manager.get_setting(config_key, 0)
self._switch_view(last_view)
def hide(self):
self.grid_remove()
def show_system_backups(self):
self.system_backups_frame.grid()
self.user_backups_frame.grid_remove()
def show_deletion_status(self, text: str):
app_logger.log(f"Showing deletion status: {text}")
self.deletion_status_label.config(text=text)
self.deletion_animated_icon.start()
self.deletion_status_frame.pack(side=tk.LEFT, padx=15)
def show_user_backups(self):
self.user_backups_frame.grid()
self.system_backups_frame.grid_remove()
def hide_deletion_status(self):
app_logger.log("Hiding deletion status text.")
self.deletion_status_label.config(text="")
self.deletion_animated_icon.stop("DISABLE")

View File

@@ -1,6 +1,6 @@
# pyimage/ui/drawing.py
import tkinter as tk
from pbp_app_config import AppConfig, Msg
from core.pbp_app_config import AppConfig, Msg
import os
import threading
from shared_libs.animated_icon import AnimatedIcon
@@ -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

@@ -0,0 +1,113 @@
import tkinter as tk
from tkinter import ttk
from shared_libs.message import MessageDialog
import keyring
class EncryptionFrame(ttk.Frame):
def __init__(self, parent, app, encryption_manager, **kwargs):
super().__init__(parent, **kwargs)
self.app = app
self.encryption_manager = encryption_manager
self.username = None
self.columnconfigure(0, weight=1)
ttk.Label(self, text="Encryption Settings", font=("Ubuntu", 16, "bold")).grid(
row=0, column=0, pady=10, sticky="w")
# Keyring status
self.keyring_status_label = ttk.Label(self, text="")
self.keyring_status_label.grid(
row=1, column=0, sticky="ew", padx=10, pady=5)
self.keyring_usage_label = ttk.Label(self, text="")
self.keyring_usage_label.grid(row=4, column=0, sticky="ew", padx=10, pady=5)
self.check_keyring_availability()
# Password section
password_frame = ttk.LabelFrame(self, text="Password Management")
password_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=10)
password_frame.columnconfigure(1, weight=1)
ttk.Label(password_frame, text="Password:").grid(
row=0, column=0, padx=5, pady=5, sticky="w")
self.password_entry = ttk.Entry(password_frame, show="*")
self.password_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.save_to_keyring_var = tk.BooleanVar()
self.save_to_keyring_cb = ttk.Checkbutton(
password_frame, text="Save password to system keyring", variable=self.save_to_keyring_var)
self.save_to_keyring_cb.grid(
row=1, column=0, columnspan=2, padx=5, pady=5, sticky="w")
set_password_button = ttk.Button(
password_frame, text="Set Session Password", command=self.set_session_password)
set_password_button.grid(row=2, column=0, padx=5, pady=5)
clear_password_button = ttk.Button(
password_frame, text="Clear Session Password", command=self.clear_session_password)
clear_password_button.grid(row=2, column=1, padx=5, pady=5, sticky="w")
self.status_message_label = ttk.Label(self, text="", foreground="blue")
self.status_message_label.grid(row=3, column=0, sticky="ew", padx=10, pady=5)
def set_context(self, username):
self.username = username
self.update_keyring_status()
def check_keyring_availability(self):
try:
kr = keyring.get_keyring()
if kr is None:
self.keyring_status_label.config(
text="No system keyring found. Passwords will not be saved.", foreground="orange")
self.save_to_keyring_cb.config(state="disabled")
else:
self.keyring_status_label.config(
text="System keyring is available.", foreground="green")
except keyring.errors.NoKeyringError:
self.keyring_status_label.config(
text="No system keyring found. Passwords will not be saved.", foreground="orange")
self.save_to_keyring_cb.config(state="disabled")
def set_session_password(self):
password = self.password_entry.get()
if not password:
self.status_message_label.config(text="Password cannot be empty.", foreground="red")
return
self.encryption_manager.set_session_password(password, self.save_to_keyring_var.get())
if self.save_to_keyring_var.get():
if not self.username:
self.status_message_label.config(text="Please select a backup destination first.", foreground="orange")
return
if self.encryption_manager.set_password_in_keyring(self.username, password):
self.status_message_label.config(text="Password set for this session and saved to keyring.", foreground="green")
self.update_keyring_status()
else:
self.status_message_label.config(text="Password set for this session, but failed to save to keyring.", foreground="orange")
else:
self.status_message_label.config(text="Password set for this session.", foreground="green")
def clear_session_password(self):
self.encryption_manager.clear_session_password()
self.password_entry.delete(0, tk.END)
self.status_message_label.config(text="Session password cleared.", foreground="green")
if self.username:
self.encryption_manager.delete_password_from_keyring(self.username)
self.update_keyring_status()
def update_keyring_status(self):
if not self.username:
self.keyring_usage_label.config(text="Select a backup destination to see keyring status.", foreground="blue")
return
if self.encryption_manager.get_password_from_keyring(self.username):
self.keyring_usage_label.config(text=f'Password for "{self.username}" is stored in the keyring.', foreground="green")
else:
self.keyring_usage_label.config(text=f'No password for "{self.username}" found in the keyring.', foreground="orange")

View File

@@ -1,14 +1,16 @@
import tkinter as tk
import os
from pbp_app_config import Msg
from core.pbp_app_config import Msg
from shared_libs.common_tools import IconManager
class HeaderFrame(tk.Frame):
def __init__(self, container, image_manager, **kwargs):
def __init__(self, container, image_manager, encryption_manager, app, **kwargs):
super().__init__(container, bg="#455A64", **kwargs)
self.image_manager = image_manager
self.encryption_manager = encryption_manager
self.app = app
# Configure grid weights for internal layout
self.columnconfigure(1, weight=1) # Make the middle column expand
@@ -48,18 +50,54 @@ class HeaderFrame(tk.Frame):
subtitle_label.grid(row=1, column=1, sticky="w",
padx=(5, 20), pady=(0, 10))
# Right side: Placeholder for future info or buttons
# Right side: Keyring status
right_frame = tk.Frame(self, bg="#455A64")
right_frame.grid(row=0, column=2, rowspan=2, sticky="nsew")
right_frame.columnconfigure(0, weight=1)
right_frame.rowconfigure(0, weight=1)
# Example of content for the right side (can be removed or replaced)
# info_label = tk.Label(
# right_frame,
# text="Some Info Here",
# font=("Helvetica", 10),
# fg="#ecf0f1",
# bg="#455A64",
# )
# info_label.grid(row=0, column=0, sticky="ne", padx=(10, 10), pady=(10, 0))
self.keyring_status_label = tk.Label(
right_frame,
text="",
font=("Helvetica", 10, "bold"),
bg="#455A64",
)
self.keyring_status_label.grid(row=0, column=0, sticky="ne", padx=(10, 10), pady=(10, 0))
self.refresh_status()
def refresh_status(self):
"""Checks the keyring status based on the current destination and updates the label."""
dest_path = self.app.destination_path
if not dest_path or not self.encryption_manager.is_encrypted(dest_path):
self.keyring_status_label.config(text="") # Clear status if not encrypted
return
username = os.path.basename(dest_path.rstrip('/'))
if self.encryption_manager.is_mounted(dest_path):
status_text = "Key: In Use"
auth_method = getattr(self.encryption_manager, 'auth_method', None)
if auth_method == 'keyring':
status_text += " (Keyring)"
elif auth_method == 'keyfile':
status_text += " (Keyfile)"
self.keyring_status_label.config(
text=status_text,
fg="#2E8B57" # SeaGreen
)
elif self.encryption_manager.is_key_in_keyring(username):
self.keyring_status_label.config(
text="Key: Available (Keyring)",
fg="#FFD700" # Gold
)
elif os.path.exists(self.encryption_manager.get_key_file_path(dest_path)):
self.keyring_status_label.config(
text="Key: Available (Keyfile)",
fg="#FFD700" # Gold
)
else:
self.keyring_status_label.config(
text="Key: Not Available",
fg="#A9A9A9" # DarkGray
)

View File

@@ -2,7 +2,7 @@
import os
import shutil
from shared_libs.message import MessageDialog
from pbp_app_config import Msg
from core.pbp_app_config import Msg
class Navigation:
@@ -141,7 +141,8 @@ class Navigation:
self.app.log_frame.grid_remove()
self.app.scheduler_frame.hide()
self.app.settings_frame.hide()
self.app.backup_content_frame.hide()
self.app.backup_content_frame.grid_remove()
# Show the main content frames
self.app.canvas_frame.grid()
@@ -185,7 +186,8 @@ class Navigation:
self.app.log_frame.grid_remove()
self.app.scheduler_frame.hide()
self.app.settings_frame.hide()
self.app.backup_content_frame.hide()
self.app.backup_content_frame.grid_remove()
self.app.canvas_frame.grid()
self.app.source_size_frame.grid()
self.app.target_size_frame.grid()
@@ -222,7 +224,7 @@ class Navigation:
self.app.canvas_frame.grid_remove()
self.app.scheduler_frame.hide()
self.app.settings_frame.hide()
self.app.backup_content_frame.hide()
self.app.backup_content_frame.grid_remove()
self.app.source_size_frame.grid_remove()
self.app.target_size_frame.grid_remove()
self.app.restore_size_frame_before.grid_remove()
@@ -239,7 +241,8 @@ class Navigation:
self.app.canvas_frame.grid_remove()
self.app.log_frame.grid_remove()
self.app.settings_frame.hide()
self.app.backup_content_frame.hide()
self.app.backup_content_frame.grid_remove()
self.app.source_size_frame.grid_remove()
self.app.target_size_frame.grid_remove()
self.app.restore_size_frame_before.grid_remove()
@@ -255,8 +258,9 @@ class Navigation:
self.app.canvas_frame.grid_remove()
self.app.log_frame.grid_remove()
self.app.backup_content_frame.hide()
self.app.backup_content_frame.grid_remove()
self.app.scheduler_frame.hide()
self.app.source_size_frame.grid_remove()
self.app.target_size_frame.grid_remove()
self.app.restore_size_frame_before.grid_remove()
@@ -276,13 +280,29 @@ class Navigation:
self.toggle_mode("backup", 0)
return
# Mount the destination if it is encrypted and not already mounted
if self.app.backup_manager.encryption_manager.is_encrypted(self.app.destination_path):
if not self.app.backup_manager.encryption_manager.is_mounted(self.app.destination_path):
mount_point = self.app.backup_manager.encryption_manager.mount(self.app.destination_path)
if not mount_point:
MessageDialog(master=self.app, message_type="error",
title=Msg.STR["error"], text=Msg.STR.get("err_mount_failed", "Mounting failed."))
self.toggle_mode("backup", 0)
return
self.app.header_frame.refresh_status()
self.app.canvas_frame.grid_remove()
self.app.log_frame.grid_remove()
self.app.scheduler_frame.hide()
self.app.settings_frame.hide()
self.app.source_size_frame.grid_remove()
self.app.target_size_frame.grid_remove()
self.app.restore_size_frame_before.grid_remove()
self.app.restore_size_frame_after.grid_remove()
self.app.backup_content_frame.show(self.app.destination_path)
self.app.top_bar.grid()
self._update_task_bar_visibility("scheduler")

View File

@@ -0,0 +1,63 @@
import tkinter as tk
from tkinter import ttk, messagebox
class PasswordDialog(tk.Toplevel):
def __init__(self, parent, title="Password Required", confirm=True):
super().__init__(parent)
self.title(title)
self.parent = parent
self.password = None
self.save_to_keyring = tk.BooleanVar()
self.confirm = confirm
self.transient(parent)
self.grab_set()
ttk.Label(self, text="Please enter the password for the encrypted backup:").pack(padx=20, pady=10)
self.password_entry = ttk.Entry(self, show="*")
self.password_entry.pack(padx=20, pady=5, fill="x", expand=True)
self.password_entry.focus_set()
if self.confirm:
ttk.Label(self, text="Confirm password:").pack(padx=20, pady=10)
self.confirm_entry = ttk.Entry(self, show="*")
self.confirm_entry.pack(padx=20, pady=5, fill="x", expand=True)
self.save_to_keyring_cb = ttk.Checkbutton(self, text="Save password to system keyring", variable=self.save_to_keyring)
self.save_to_keyring_cb.pack(padx=20, pady=10)
button_frame = ttk.Frame(self)
button_frame.pack(pady=10)
ok_button = ttk.Button(button_frame, text="OK", command=self.on_ok)
ok_button.pack(side="left", padx=5)
cancel_button = ttk.Button(button_frame, text="Cancel", command=self.on_cancel)
cancel_button.pack(side="left", padx=5)
self.bind("<Return>", lambda event: self.on_ok())
self.bind("<Escape>", lambda event: self.on_cancel())
self.wait_window(self)
def on_ok(self):
password = self.password_entry.get()
if not password:
messagebox.showerror("Error", "Password cannot be empty.", parent=self)
return
if self.confirm:
confirm = self.confirm_entry.get()
if password != confirm:
messagebox.showerror("Error", "Passwords do not match.", parent=self)
return
self.password = password
self.destroy()
def on_cancel(self):
self.password = None
self.destroy()
def get_password(self):
return self.password, self.save_to_keyring.get()

View File

@@ -4,7 +4,7 @@ import os
from shared_libs.custom_file_dialog import CustomFileDialog
from shared_libs.message import MessageDialog
from pbp_app_config import Msg
from core.pbp_app_config import Msg
class SchedulerFrame(ttk.Frame):

View File

@@ -3,8 +3,10 @@ from tkinter import ttk
import os
from pathlib import Path
from pbp_app_config import AppConfig, Msg
from core.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):
@@ -18,12 +20,12 @@ class SettingsFrame(ttk.Frame):
self.user_exclude_patterns = []
# --- Container for Treeviews ---
trees_container = ttk.Frame(self)
trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
self.trees_container = ttk.Frame(self)
self.trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# --- Treeview for file/folder exclusion ---
self.tree_frame = ttk.LabelFrame(
trees_container, text=Msg.STR["user_defined_folder_settings"], padding=10)
self.trees_container, text=Msg.STR["user_defined_folder_settings"], padding=10)
self.tree_frame.pack(fill=tk.BOTH, expand=True)
columns = ("included", "name", "path")
@@ -43,7 +45,7 @@ class SettingsFrame(ttk.Frame):
# --- Treeview for hidden files (initially hidden) ---
self.hidden_tree_frame = ttk.LabelFrame(
trees_container, text=Msg.STR["hidden_files_and_folders"], padding=10)
self.trees_container, text=Msg.STR["hidden_files_and_folders"], padding=10)
self.hidden_tree = ttk.Treeview(
self.hidden_tree_frame, columns=columns, show="headings")
self.hidden_tree.heading("included", text=Msg.STR["in_backup"])
@@ -60,11 +62,11 @@ class SettingsFrame(ttk.Frame):
self.hidden_tree_frame.pack_forget() # Initially hidden
# --- Action Buttons ---
button_frame = ttk.Frame(self)
button_frame.pack(fill=tk.X, padx=10, pady=10)
self.button_frame = ttk.Frame(self)
self.button_frame.pack(fill=tk.X, padx=10, pady=10)
self.show_hidden_button = ttk.Button(
button_frame, command=self._toggle_hidden_files_view, style="TButton.Borderless.Round")
self.button_frame, command=self._toggle_hidden_files_view, style="TButton.Borderless.Round")
self.show_hidden_button.pack(side=tk.LEFT)
self.unhide_icon = self.master.master.master.image_manager.get_icon(
'hide')
@@ -72,23 +74,56 @@ class SettingsFrame(ttk.Frame):
'unhide')
self.show_hidden_button.config(image=self.unhide_icon)
add_to_exclude_button = ttk.Button(
self.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)
self.button_frame, text=Msg.STR["apply"], command=self._apply_changes)
apply_button.pack(side=tk.LEFT, padx=5)
cancel_button = ttk.Button(button_frame, text=Msg.STR["cancel"],
cancel_button = ttk.Button(self.button_frame, text=Msg.STR["cancel"],
command=lambda: self.navigation.toggle_mode("backup", 0))
cancel_button.pack(side=tk.LEFT, padx=5)
advanced_button = ttk.Button(
button_frame, text=Msg.STR["advanced"], command=self._open_advanced_settings)
self.button_frame, text=Msg.STR["advanced"], command=self._open_advanced_settings)
advanced_button.pack(side=tk.LEFT, padx=5)
reset_button = ttk.Button(
button_frame, text=Msg.STR["default_settings"], command=self.actions.reset_to_default_settings)
self.button_frame, text=Msg.STR["default_settings"], command=self.actions.reset_to_default_settings)
reset_button.pack(side=tk.RIGHT)
self.hidden_files_visible = False
self.advanced_settings_frame_instance = None # To hold the instance of AdvancedSettingsFrame
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")
@@ -98,27 +133,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 +161,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 +218,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 +235,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")
@@ -216,12 +245,30 @@ class SettingsFrame(ttk.Frame):
self._load_hidden_files()
def _open_advanced_settings(self):
advanced_settings_window = AdvancedSettingsFrame(
self.master,
config_manager=self.master.master.master.config_manager,
app_instance=self.master.master.master
)
advanced_settings_window.grab_set()
# Hide main settings UI elements
self.trees_container.pack_forget() # Hide the container for treeviews
self.button_frame.pack_forget()
# Create AdvancedSettingsFrame if not already created
if not self.advanced_settings_frame_instance:
self.advanced_settings_frame_instance = AdvancedSettingsFrame(
self, # Parent is now self (SettingsFrame)
config_manager=self.master.master.master.config_manager,
app_instance=self.master.master.master,
show_main_settings_callback=self._show_main_settings
)
# Pack the AdvancedSettingsFrame
self.advanced_settings_frame_instance.pack(fill=tk.BOTH, expand=True)
def _show_main_settings(self):
# Hide advanced settings frame
if self.advanced_settings_frame_instance:
self.advanced_settings_frame_instance.pack_forget()
# Show main settings UI elements
self.trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Re-pack the container for treeviews
self.button_frame.pack(fill=tk.X, padx=10, pady=10) # Re-pack the button frame
def _toggle_hidden_files_view(self):
self.hidden_files_visible = not self.hidden_files_visible
@@ -240,7 +287,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 +295,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))

View File

@@ -2,129 +2,164 @@ import tkinter as tk
from tkinter import ttk
import os
from pbp_app_config import Msg
from core.pbp_app_config import Msg
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
class SystemBackupContentFrame(ttk.Frame):
def __init__(self, master, backup_manager, **kwargs):
def __init__(self, master, backup_manager, actions, parent_view, **kwargs):
super().__init__(master, **kwargs)
self.backup_manager = backup_manager
self.actions = actions
self.parent_view = parent_view
self.system_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)
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"),
]
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(
"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, 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.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)
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)
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 show(self, backup_path, system_backups):
self.backup_path = backup_path
self.system_backups_list = system_backups
self._load_backup_content()
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):
if not self.system_backups_list:
return
# Use the new method to get structured system backup data
system_backups = self.backup_manager.list_system_backups(
self.backup_path)
color_index = -1
for i, backup_info in enumerate(self.system_backups_list):
if backup_info.get("backup_type_base") == "Full":
color_index = (color_index + 1) % len(self.tag_colors)
full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index]
self.content_tree.tag_configure(
full_tag, foreground=full_color)
self.content_tree.tag_configure(
inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
current_tag = full_tag
else:
_, _, inc_tag, _ = self.tag_colors[color_index]
current_tag = inc_tag
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")
))
self._on_item_select(None) # Disable buttons initially
), tags=(current_tag,), iid=backup_info.get("folder_name"))
self._on_item_select(None)
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")
is_selected = True if self.content_tree.focus() else False
self.parent_view.update_button_state(is_selected)
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")
selected_backup = next((b for b in self.system_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup:
return
is_encrypted = selected_backup.get('is_encrypted', False)
info_file_name = f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt"
info_file_path = os.path.join(
self.backup_path, "pybackup", info_file_name)
# 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
self.parent_view.show(self.backup_path)
def _restore_selected(self):
# Placeholder for restore logic
selected_item = self.content_tree.focus()
if not selected_item:
selected_item_id = self.content_tree.focus()
if not selected_item_id:
return
backup_name = self.content_tree.item(selected_item)["values"][0]
print(f"Restoring {backup_name}...")
selected_backup = next((b for b in self.system_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup:
return
main_app = self.winfo_toplevel()
restore_dest_path = main_app.config_manager.get_setting(
"restore_destination_path", "/")
if not restore_dest_path:
return
self.backup_manager.start_restore(
source_path=selected_backup['full_path'],
dest_path=restore_dest_path,
is_compressed=selected_backup['is_compressed']
)
def _delete_selected(self):
# Placeholder for delete logic
selected_item = self.content_tree.focus()
if not selected_item:
selected_item_id = self.content_tree.focus()
if not selected_item_id:
return
backup_name = self.content_tree.item(selected_item)["values"][0]
print(f"Deleting {backup_name}...")
selected_backup = next((b for b in self.system_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup:
return
folder_to_delete = selected_backup['full_path']
is_encrypted = selected_backup['is_encrypted']
password = None
if is_encrypted:
username = os.path.basename(self.backup_path.rstrip('/'))
# Get password in the UI thread before starting the background task
password = self.backup_manager.encryption_manager.get_password(username, confirm=False)
if not password:
self.actions.logger.log("Password entry cancelled, aborting deletion.")
return
info_file_to_delete = os.path.join(
self.backup_path, "pybackup", f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt")
self.actions._set_ui_state(False)
self.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
self.backup_manager.start_delete_backup(
path_to_delete=folder_to_delete,
info_file_path=info_file_to_delete,
is_encrypted=is_encrypted,
is_system=True,
base_dest_path=self.backup_path,
password=password,
queue=self.winfo_toplevel().queue
)

View File

@@ -1,117 +1,150 @@
import tkinter as tk
from tkinter import ttk
import os
import shutil
from pbp_app_config import Msg
from core.pbp_app_config import Msg
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
from shared_libs.message import MessageDialog
class UserBackupContentFrame(ttk.Frame):
def __init__(self, master, backup_manager, **kwargs):
def __init__(self, master, backup_manager, actions, parent_view, **kwargs):
super().__init__(master, **kwargs)
self.backup_manager = backup_manager
self.actions = actions
self.parent_view = parent_view
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)
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"),
]
columns = ("date", "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(
"size", text=Msg.STR["size"])
self.content_tree.heading(
"comment", text=Msg.STR["comment"])
self.content_tree.heading(
"folder_name", text=Msg.STR["folder"])
columns = ("date", "time", "type", "size", "comment", "folder_name")
self.content_tree = ttk.Treeview(self, 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("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=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)
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 show(self, backup_path, user_backups):
self.backup_path = backup_path
self.user_backups_list = user_backups
self._load_backup_content()
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):
if not self.user_backups_list:
return
user_backups = self.backup_manager.list_user_backups(self.backup_path)
color_index = -1
for i, backup_info in enumerate(self.user_backups_list):
if backup_info.get("backup_type_base") == "Full":
color_index = (color_index + 1) % len(self.tag_colors)
full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index]
self.content_tree.tag_configure(
full_tag, foreground=full_color)
self.content_tree.tag_configure(
inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
current_tag = full_tag
else:
_, _, inc_tag, _ = self.tag_colors[color_index]
current_tag = inc_tag
for backup_info in user_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)
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")
is_selected = True if self.content_tree.focus() else False
self.parent_view.update_button_state(is_selected)
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[3] # Assuming folder_name is the 4th value
# Construct the path to the info file
info_file_path = os.path.join(self.backup_path, f"{folder_name}.txt")
selected_backup = next((b for b in self.user_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup:
return
is_encrypted = selected_backup.get('is_encrypted', False)
info_file_name = f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt"
info_file_path = os.path.join(
self.backup_path, "pybackup", info_file_name)
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
self.parent_view.show(self.backup_path)
def _restore_selected(self):
# Placeholder for restore logic
selected_item = self.content_tree.focus()
if not selected_item:
selected_item_id = self.content_tree.focus()
if not selected_item_id:
return
backup_name = self.content_tree.item(selected_item)["values"][0]
print(f"Restoring {backup_name}...")
MessageDialog(master=self, message_type="info", title="Info", text="User restore not implemented yet.")
def _delete_selected(self):
# Placeholder for delete logic
selected_item = self.content_tree.focus()
if not selected_item:
selected_item_id = self.content_tree.focus()
if not selected_item_id:
return
backup_name = self.content_tree.item(selected_item)["values"][0]
print(f"Deleting {backup_name}...")
selected_backup = next((b for b in self.user_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup:
return
folder_to_delete = selected_backup['full_path']
is_encrypted = selected_backup['is_encrypted']
password = None
if is_encrypted:
username = os.path.basename(self.backup_path.rstrip('/'))
# Get password in the UI thread before starting the background task
password = self.backup_manager.encryption_manager.get_password(username, confirm=False)
if not password:
self.actions.logger.log("Password entry cancelled, aborting deletion.")
return
info_file_to_delete = os.path.join(
self.backup_path, "pybackup", f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt")
self.actions._set_ui_state(False)
self.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
self.backup_manager.start_delete_backup(
path_to_delete=folder_to_delete,
info_file_path=info_file_to_delete,
is_encrypted=is_encrypted,
is_system=False,
base_dest_path=self.backup_path,
password=password,
queue=self.winfo_toplevel().queue
)

View File

@@ -1,149 +0,0 @@
import tkinter as tk
from tkinter import ttk
import os
from shared_libs.message import MessageDialog
from shared_libs.custom_file_dialog import CustomFileDialog
from pbp_app_config import Msg
class ScheduleJobDialog(tk.Toplevel):
def __init__(self, parent, backup_manager):
super().__init__(parent)
self.parent = parent
self.backup_manager = backup_manager
self.result = None
self.title(Msg.STR["add_job_title"])
self.geometry("500x400")
self.transient(parent)
self.grab_set()
self.backup_type = tk.StringVar(value="system")
self.destination = tk.StringVar()
self.user_sources = {
Msg.STR["cat_images"]: tk.BooleanVar(value=False),
Msg.STR["cat_documents"]: tk.BooleanVar(value=False),
Msg.STR["cat_music"]: tk.BooleanVar(value=False),
Msg.STR["cat_videos"]: tk.BooleanVar(value=False)
}
self.frequency = tk.StringVar(value="daily")
self._create_widgets()
self.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.wait_window()
def _create_widgets(self):
main_frame = ttk.Frame(self, padding=10)
main_frame.pack(fill=tk.BOTH, expand=True)
# Backup Type
type_frame = ttk.LabelFrame(
main_frame, text=Msg.STR["backup_type"], padding=10)
type_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Radiobutton(type_frame, text=Msg.STR["system_backup_menu"], variable=self.backup_type,
value="system", command=self._toggle_user_sources).pack(anchor=tk.W)
ttk.Radiobutton(type_frame, text=Msg.STR["user_backup_menu"], variable=self.backup_type,
value="user", command=self._toggle_user_sources).pack(anchor=tk.W)
# Destination
dest_frame = ttk.LabelFrame(
main_frame, text=Msg.STR["dest_folder"], padding=10)
dest_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Entry(dest_frame, textvariable=self.destination, state="readonly",
width=50).pack(side=tk.LEFT, fill=tk.X, expand=True)
ttk.Button(dest_frame, text=Msg.STR["browse"], command=self._select_destination).pack(
side=tk.RIGHT)
# User Sources (initially hidden)
self.user_sources_frame = ttk.LabelFrame(
main_frame, text=Msg.STR["source_folders"], padding=10)
for name, var in self.user_sources.items():
ttk.Checkbutton(self.user_sources_frame, text=name,
variable=var).pack(anchor=tk.W)
self._toggle_user_sources() # Set initial visibility
# Frequency
freq_frame = ttk.LabelFrame(
main_frame, text=Msg.STR["frequency"], padding=10)
freq_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_daily"],
variable=self.frequency, value="daily").pack(anchor=tk.W)
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_weekly"],
variable=self.frequency, value="weekly").pack(anchor=tk.W)
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_monthly"],
variable=self.frequency, value="monthly").pack(anchor=tk.W)
# Buttons
button_frame = ttk.Frame(main_frame)
button_frame.pack(pady=10)
ttk.Button(button_frame, text=Msg.STR["save"], command=self._on_save).pack(
side=tk.LEFT, padx=5)
ttk.Button(button_frame, text=Msg.STR["cancel"], command=self._on_cancel).pack(
side=tk.LEFT, padx=5)
def _toggle_user_sources(self):
if self.backup_type.get() == "user":
self.user_sources_frame.pack(fill=tk.X, padx=5, pady=5)
else:
self.user_sources_frame.pack_forget()
def _select_destination(self):
dialog = CustomFileDialog(
self, mode="dir", title=Msg.STR["select_dest_folder_title"])
self.wait_window(dialog)
result = dialog.get_result()
if result:
self.destination.set(result)
def _on_save(self):
dest = self.destination.get()
if not dest:
MessageDialog(master=self, message_type="error",
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
return
job_type = self.backup_type.get()
job_frequency = self.frequency.get()
job_sources = []
if job_type == "user":
job_sources = [name for name,
var in self.user_sources.items() if var.get()]
if not job_sources:
MessageDialog(master=self, message_type="error",
title=Msg.STR["error"], text=Msg.STR["err_no_source_folder"])
return
# Construct the CLI command
script_path = os.path.abspath(os.path.join(
os.path.dirname(__file__), "main_app.py"))
command = f"python3 {script_path} --backup-type {job_type} --destination \"{dest}\""
if job_type == "user":
command += f" --sources "
for s in job_sources:
command += f'\"{s}\" '
# Construct the cron job comment
comment = f"{self.backup_manager.app_tag}; type:{job_type}; freq:{job_frequency}; dest:{dest}"
if job_type == "user":
comment += f"; sources:{','.join(job_sources)}"
self.result = {
"command": command,
"comment": comment,
"type": job_type,
"frequency": job_frequency,
"destination": dest,
"sources": job_sources
}
self.destroy()
def _on_cancel(self):
self.result = None
self.destroy()
def show(self):
self.parent.wait_window(self)
return self.result