diff --git a/.gitignore b/.gitignore index 6439cd9..6b2111b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__/ +pitch.md + issues.txt diff --git a/README.md b/README.md index 7127895..3cbdde5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ Just run python main.py ``` +## CONTRIBUTION + +Check [CONTRIBUTION](https://github/com/SaveMyNode/savemynode/blob/main/CONTRIBUTION.md) + ## Product To better understand what this product's expectations are check out [mvp](https://github.com/SaveMyNode/savemynode/blob/main/product/mvp.md) diff --git a/main.py b/main.py index c54ab65..e563ea4 100644 --- a/main.py +++ b/main.py @@ -1,36 +1,117 @@ import gi +from gi.repository import GLib +import os +import cairo import subprocess gi.require_version("Gtk", "3.0") -from gi.repository import Gtk +from gi.repository import Gtk, Gdk +import re class SaveMyNodeApp(Gtk.Window): def __init__(self): super().__init__(title="SaveMyNode - File Recovery Tool") + self.stats_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.set_border_width(10) self.set_default_size(800, 600) - # Create a header bar + # Apply custom CSS + self.apply_theme() + + # Create a header bar with clickable title header_bar = Gtk.HeaderBar() - header_bar.set_title("SaveMyNode - File Recovery Tool") header_bar.set_show_close_button(True) self.set_titlebar(header_bar) - # Help button - help_button = Gtk.Button(label="?") - help_button.set_tooltip_text("Show manual") - help_button.set_size_request(40, 40) # Set size of the button - help_button.connect("clicked", self.on_help_button_clicked) - header_bar.pack_start(help_button) - - # Main box for other content - self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - self.add(self.main_box) - - # Create the sections - self.create_selection_section(self.main_box) - self.create_details_section(self.main_box) - self.create_recovery_section(self.main_box) - self.create_controls_section(self.main_box) + # Create an EventBox to make the label clickable + title_eventbox = Gtk.EventBox() + title_label = Gtk.Label(label="SaveMyNode - File Recovery Tool") + title_label.set_selectable(False) + title_label.set_name("title_label") + title_eventbox.add(title_label) + title_eventbox.connect("button-press-event", self.on_title_clicked) + header_bar.set_custom_title(title_eventbox) + + # Stack for switching between different views + self.stack = Gtk.Stack() + self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) + self.stack.set_transition_duration(500) + + # Add initial recovery screen to the stack + self.recovery_screen = self.create_recovery_screen() + self.stack.add_named(self.recovery_screen, "recovery") + + # Placeholder for statistics screen + self.stats_screen = self.create_statistics_screen() + self.stack.add_named(self.stats_screen, "statistics") + + # Create a stack switcher to toggle between screens + stack_switcher = Gtk.StackSwitcher() + stack_switcher.set_stack(self.stack) + header_bar.pack_end(stack_switcher) + + self.add(self.stack) + + def on_draw_partitions(self, widget, cr): + width = widget.get_allocated_width() + height = widget.get_allocated_height() + + # Example of updated partition data (you should use actual data here) + partitions = [ + {"name": "/dev/sda1", "size": 600, "used": 200, "color": (0.3, 0.7, 0.3)}, + {"name": "/dev/sda2", "size": 800, "used": 400, "color": (0.3, 0.3, 0.7)}, + {"name": "/dev/sda3", "size": 1200, "used": 600, "color": (0.7, 0.3, 0.3)} + ] + + total_size = sum(p["size"] for p in partitions) + + # Draw partitions + x = 0 + for partition in partitions: + partition_width = (partition["size"] / total_size) * width + used_width = (partition["used"] / partition["size"]) * partition_width + + # Draw used space + cr.set_source_rgb(*partition["color"]) + cr.rectangle(x, 0, used_width, height) + cr.fill() + + # Draw unused space + cr.set_source_rgb(0.9, 0.9, 0.9) # Light gray for unused space + cr.rectangle(x + used_width, 0, partition_width - used_width, height) + cr.fill() + + # Draw partition border + cr.set_source_rgb(0, 0, 0) + cr.rectangle(x, 0, partition_width, height) + cr.stroke() + + # Draw partition name + cr.set_source_rgb(0, 0, 0) + cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + cr.set_font_size(12) + + name = partition["name"] + while cr.text_extents(name)[2] > partition_width and len(name) > 3: + name = name[:-1] + + text_x = x + (partition_width - cr.text_extents(name)[2]) / 2 + text_y = height / 2 + cr.text_extents(name)[3] / 2 + cr.move_to(text_x, text_y) + cr.show_text(name) + + # Draw size information + size_text = f"{partition['size']}MB" + used_text = f"{partition['used']}MB used" + cr.set_font_size(10) + cr.move_to(x + 5, height - 25) + cr.show_text(size_text) + cr.move_to(x + 5, height - 10) + cr.show_text(used_text) + + x += partition_width + + return False + def apply_theme(self): css_provider = Gtk.CssProvider() css_provider.load_from_path("styles.css") @@ -38,18 +119,62 @@ def apply_theme(self): style_context = Gtk.StyleContext() style_context.add_provider_for_screen(screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + def create_recovery_screen(self): + """Creates the initial screen for file recovery.""" + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + self.create_selection_section(box) + self.create_details_section(box) + self.create_controls_section(box) + + return box + + def create_statistics_screen(self): + """Creates the screen to show drive statistics after recovery is selected.""" + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + # Back button to go back to recovery screen + back_button = Gtk.Button(label="Back") + back_button.set_halign(Gtk.Align.START) + back_button.connect("clicked", self.on_back_button_clicked) + box.pack_start(back_button, False, False, 10) + + + # Create a section for showing statistics + self.stats_frame = Gtk.Frame(label="Drive Statistics") + box.pack_start(self.stats_frame, True, True, 10) + + self.stats_textview = Gtk.TextView() + self.stats_textview.set_editable(False) + self.stats_textview.set_cursor_visible(False) + + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_vexpand(True) + scrolled_window.add(self.stats_textview) + self.stats_frame.add(scrolled_window) + + # Recovery buttons + self.create_recovery_options(box) + + return box + def create_selection_section(self, parent_box): frame = Gtk.Frame(label="Select Filesystem and Drive") parent_box.pack_start(frame, False, False, 10) + # Main horizontal box + main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + frame.add(main_box) + + # Box for selection widgets selection_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - frame.add(selection_box) + main_box.pack_start(selection_box, True, True, 0) filesystem_label = Gtk.Label(label="Filesystem:") selection_box.pack_start(filesystem_label, False, False, 0) self.filesystem_combo = Gtk.ComboBoxText() - self.filesystem_combo.append_text("Btrfs") + self.filesystem_combo.append_text("BTRFS") self.filesystem_combo.append_text("XFS") selection_box.pack_start(self.filesystem_combo, False, False, 0) @@ -60,15 +185,59 @@ def create_selection_section(self, parent_box): self.populate_drive_combo() selection_box.pack_start(self.drive_combo, False, False, 0) + # Create a spacer box + spacer_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + spacer_box.set_hexpand(True) + main_box.pack_start(spacer_box, True, True, 0) + + # Create the Clear button + clear_button = Gtk.Button(label="Clear All") + clear_button.connect("clicked", self.on_clear_button_clicked) + main_box.pack_end(clear_button, False, False, 10) + + def on_clear_button_clicked(self, widget): + # Clear filesystem combo box + self.filesystem_combo.set_active(-1) + + # Clear drive combo box + self.drive_combo.set_active(-1) + + + def populate_drive_combo(self): try: - result = subprocess.run(["lsblk", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT"], capture_output=True, text=True) + result = subprocess.run(["lsblk", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT", "--noheadings"], capture_output=True, text=True) if result.returncode == 0: output = result.stdout for line in output.splitlines()[1:]: self.drive_combo.append_text(line.strip()) except Exception as e: - print(f"Error populating drives: {e}") + self.show_error_message(f"Error populating drives: {e}") + + # def create_details_section(self, parent_box): + # frame = Gtk.Frame(label="Partition Details") + # parent_box.pack_start(frame, True, True, 10) + # + # vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + # frame.add(vbox) + # + # self.partition_drawing = Gtk.DrawingArea() + # self.partition_drawing.set_size_request(-1, 100) # Increased height for better visibility + # self.partition_drawing.connect("draw", self.on_draw_partitions) + # vbox.pack_start(self.partition_drawing, False, False, 0) + # + # self.details_treeview = Gtk.TreeView() + # self.create_columns() + # self.details_treeview.set_grid_lines(Gtk.TreeViewGridLines.BOTH) + # + # scrolled_window = Gtk.ScrolledWindow() + # scrolled_window.set_vexpand(True) + # scrolled_window.add(self.details_treeview) + # vbox.pack_start(scrolled_window, True, True, 0) + # + # self.details_textview = Gtk.TextView() + # self.details_textview.set_editable(False) + # self.details_textview.set_cursor_visible(False) def create_details_section(self, parent_box): frame = Gtk.Frame(label="Partition Details") @@ -85,77 +254,91 @@ def create_details_section(self, parent_box): self.refresh_partition_details() - def refresh_partition_details(self): + def create_columns(self): + columns = [ + ("Partition", 0), + ("File System", 1), + ("Label", 2), + ("Size", 3), + ("Used", 4), + ("Unused", 5), + ("Flags", 6) + ] + + for title, column_id in columns: + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(title, renderer, text=column_id) + column.set_resizable(True) + column.set_sort_column_id(column_id) + self.details_treeview.append_column(column) + + def refresh_partition_details(self, widget=None): try: - result = subprocess.run(["lsblk", "-o", "NAME,FSTYPE,LABEL,SIZE,TYPE,MOUNTPOINT"], capture_output=True, text=True) + result = subprocess.run(["lsblk", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT", "--noheadings"], capture_output=True, text=True) if result.returncode == 0: buffer = self.details_textview.get_buffer() buffer.set_text(result.stdout) else: - buffer = self.details_textview.get_buffer() - buffer.set_text("Error: Unable to retrieve partition details") + self.show_error_message("Unable to retrieve partition details") except Exception as e: - buffer = self.details_textview.get_buffer() - buffer.set_text(f"Error: {e}") + self.show_error_message(f"Error: {e}") - def create_recovery_section(self, parent_box): - frame = Gtk.Frame(label="File Recovery") - parent_box.pack_start(frame, False, False, 10) - recovery_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - frame.add(recovery_box) + def create_controls_section(self, parent_box): + button_box = Gtk.Box(spacing=10) + parent_box.pack_start(button_box, False, False, 10) - # Recovery Path - recovery_path_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - recovery_box.pack_start(recovery_path_box, False, False, 0) + self.start_button = Gtk.Button(label="Start Recovery", image=Gtk.Image.new_from_icon_name("media-playback-start", Gtk.IconSize.BUTTON)) + self.start_button.connect("clicked", self.on_start_recovery_clicked) + button_box.pack_start(self.start_button, False, False, 0) - recovery_path_label = Gtk.Label(label="Recovery Path:") - recovery_path_box.pack_start(recovery_path_label, False, False, 0) + refresh_button = Gtk.Button(label="Refresh Partition Details", image=Gtk.Image.new_from_icon_name("view-refresh", Gtk.IconSize.BUTTON)) + refresh_button.connect("clicked", self.refresh_partition_details) + button_box.pack_start(refresh_button, False, False, 0) - self.recovery_path_entry = Gtk.Entry() - recovery_path_box.pack_start(self.recovery_path_entry, True, True, 0) - recovery_path_button = Gtk.Button(label="Choose") - recovery_path_button.connect("clicked", self.on_recovery_path_button_clicked) - recovery_path_box.pack_start(recovery_path_button, False, False, 0) + def create_recovery_options(self, parent_box): + """Creates the buttons for recovery options on the statistics screen.""" + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + parent_box.pack_start(button_box, False, False, 10) - # Target Directory - target_directory_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - recovery_box.pack_start(target_directory_box, False, False, 0) + inode_recovery_button = Gtk.Button(label="Recover Latest Inode") + inode_recovery_button.connect("clicked", self.on_inode_recovery_clicked) + button_box.pack_start(inode_recovery_button, False, False, 0) - target_directory_label = Gtk.Label(label="Target Directory:") - target_directory_box.pack_start(target_directory_label, False, False, 0) + partition_recovery_button = Gtk.Button(label="Partition Recovery") + partition_recovery_button.connect("clicked", self.on_partition_recovery_clicked) + button_box.pack_start(partition_recovery_button, False, False, 0) - self.target_directory_entry = Gtk.Entry() - target_directory_box.pack_start(self.target_directory_entry, True, True, 0) + partition_recovery_button = Gtk.Button(label="Dry Run") + pattern = ".*" + restore_path = "/tmp" + depth = "2"; + dry_run_command = f'./dry-run.sh {depth} /dev/nvme0n1p7 {pattern} 0 {restore_path}' + partition_recovery_button.connect("clicked", lambda w: self.dry_run(dry_run_command)) + button_box.pack_start(partition_recovery_button, False, False, 0) - target_directory_button = Gtk.Button(label="Choose") - target_directory_button.connect("clicked", self.on_target_directory_button_clicked) - target_directory_box.pack_start(target_directory_button, False, False, 0) + def show_error_message(self, error_message): + """Displays a floating window with an error message.""" + dialog = Gtk.Dialog(title="Error", transient_for=self, modal=True) + dialog.set_default_size(400, 200) - self.recovery_log_textview = Gtk.TextView() - self.recovery_log_textview.set_editable(False) - self.recovery_log_textview.set_cursor_visible(False) + error_label = Gtk.Label(label=error_message) + error_label.set_name("error_label") - scrolled_window = Gtk.ScrolledWindow() - scrolled_window.set_size_request(550, 150) - scrolled_window.add(self.recovery_log_textview) - scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - recovery_box.pack_start(scrolled_window, False, False, 10) + # Add error message to the dialog content area + dialog.get_content_area().pack_start(error_label, True, True, 10) - def create_controls_section(self, parent_box): - button_box = Gtk.Box(spacing=10) - parent_box.pack_start(button_box, False, False, 10) + # Add a close button + close_button = dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE) + close_button.connect("clicked", lambda _: dialog.destroy()) - self.start_button = Gtk.Button(label="Start Recovery") - self.start_button.connect("clicked", self.on_start_recovery_clicked) - button_box.pack_start(self.start_button, False, False, 0) + dialog.show_all() - exit_button = Gtk.Button(label="Exit") - exit_button.connect("clicked", self.on_exit_clicked) - button_box.pack_start(exit_button, False, False, 0) + def on_title_clicked(self, widget, event): + self.show_manual() - def on_help_button_clicked(self, button): + def show_manual(self): dialog = Gtk.Dialog(title="Manual - SaveMyNode", transient_for=self, modal=True) dialog.set_default_size(600, 400) @@ -167,99 +350,431 @@ def on_help_button_clicked(self, button): manual_textview.get_buffer().set_text( " SaveMyNode Manual\n\n" "1. Select Filesystem and Drive:\n" - " - Choose the filesystem type (Btrfs or XFS) from the dropdown.\n" + " - Choose the filesystem type (BTRFS or XFS) from the dropdown.\n" " - Select the drive from the dropdown list.\n\n" "2. Partition Details:\n" " - This section displays the details of the partitions on the selected drive.\n\n" "3. File Recovery:\n" " - Specify the recovery path where files will be recovered from.\n" - " - Specify the target directory where files will be recovered to.\n" + " - Specify the target directory where files will be recovered to.\n" " - Click 'Choose' buttons to select directories using a file chooser dialog.\n\n" "4. Start Recovery:\n" " - Click 'Start Recovery' to begin the recovery process.\n\n" - "5. Exit:\n" - " - Click 'Exit' to close the application." + "5. Refresh Partition Details:\n" + " - Any changes to the partition table will be reflected." ) scrolled_window = Gtk.ScrolledWindow() scrolled_window.set_vexpand(True) - scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) scrolled_window.add(manual_textview) dialog.get_content_area().add(scrolled_window) - close_button = dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE) close_button.connect("clicked", lambda _: dialog.destroy()) dialog.show_all() - def on_recovery_path_button_clicked(self, button): - dialog = Gtk.FileChooserDialog( - title="Select Recovery Path", - parent=self, - action=Gtk.FileChooserAction.SELECT_FOLDER, - ) - dialog.add_buttons( - Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK + def on_back_button_clicked(self, button): + self.set_title("SaveMyNode - File Recovery Tool") + self.stack.set_visible_child_name("recovery") + + + def on_start_recovery_clicked(self, button): + filesystem_text = self.filesystem_combo.get_active_text() + drive_text = self.drive_combo.get_active_text() + + if not filesystem_text or not drive_text: + self.show_error_message("Please select both filesystem and drive.") + return + + # Switch to the statistics screen with smooth transition + self.set_title(f"Recovering from {filesystem_text} ({drive_text})") + self.stack.set_visible_child_name("statistics") + + # Provide instant feedback - Start Recovery Progress + progress_bar = Gtk.ProgressBar() + progress_bar.set_fraction(0.0) + self.stats_screen.pack_start(progress_bar, False, False, 10) + self.stats_screen.show_all() + self.update_stats_screen(drive_text) + + # Simulate progress (for example, 5 seconds of progress) + GLib.timeout_add(1000, self.simulate_recovery_progress, progress_bar) + + + def update_stats_screen(self, drive_text): + # Initialize a list to store cleaned drive information + clean_drive_text = [] + filesystem_text = self.filesystem_combo.get_active_text() + + # Extract and clean drive information from drive_text + drive_names = [] + for line in drive_text.splitlines(): + # Strip leading non-alphabetic characters until the first alphabetic character + clean_line = re.sub(r'^[^a-zA-Z]+', '', line).strip() + + # Split the line and check if it contains necessary parts + parts = clean_line.split() + if len(parts) > 2: + device_name = parts[0] + device_size = parts[2] + + # Check if the size is empty or null + if not device_size: + device_size = "Size information unavailable" + + # Add cleaned drive information to the list + clean_drive_text.append(f"/dev/{device_name} ({device_size})") + drive_names.append(f"/dev/{device_name}") + + # Join the cleaned drive text into a single string with new lines + driver_details = "\n".join(clean_drive_text) + + # Initialize space variables + total_space = "N/A" + used_space = "N/A" + free_space = "N/A" + + # Fetch space details for each drive using lsblk + for drive_name in drive_names: + try: + # Get total size of the drive + result = subprocess.run(["lsblk", "-b", "-o", "NAME,SIZE", "--noheadings", drive_name], capture_output=True, text=True) + if result.returncode == 0: + size_info = result.stdout.strip().split() + if len(size_info) == 2: + total_space = size_info[1] + + # Get used and free space for the drive + result = subprocess.run(["df", "-h", drive_name], capture_output=True, text=True) + if result.returncode == 0: + df_output = result.stdout.splitlines() + if len(df_output) > 1: + usage_info = df_output[1].split() + if len(usage_info) >= 4: + used_space = usage_info[2] + free_space = usage_info[3] + + except Exception as e: + self.show_error_message(f"Error retrieving drive statistics: {e}") + + # Create the main layout using Gtk.Grid for a well-organized structure + grid = Gtk.Grid() + grid.set_column_spacing(10) + grid.set_row_spacing(10) + grid.set_border_width(10) + + # Recovery statistics label and text area + stats_label = Gtk.Label(label="Recovery Statistics", halign=Gtk.Align.START) + grid.attach(stats_label, 0, 0, 1, 1) + + buffer = self.stats_textview.get_buffer() + buffer.set_text(f"Recovering in {filesystem_text} mode:\n\n" + f"Total Space: {total_space}\n" + f"Used: {used_space}\n" + f"Free: {free_space}\n\n" + f"Drive Details:\n{driver_details}") + stats_view = Gtk.TextView(buffer=buffer) + stats_view.set_editable(False) + stats_view.set_wrap_mode(Gtk.WrapMode.WORD) + grid.attach(stats_view, 0, 1, 2, 1) + + # File type selection section + file_types_label = Gtk.Label(label="Select file types:", halign=Gtk.Align.START) + grid.attach(file_types_label, 0, 2, 1, 1) + + file_types = ["Text Files (.txt)", "Images (.jpg, .png)", "Documents (.pdf, .docx)", + "Audio Files (.mp3, .wav)", "Videos (.mp4, .avi)", "Archives (.zip, .tar)"] + + file_types_text = ", ".join(file_types) # Join file types as a comma-separated string + file_types_display_label = Gtk.Label(label=f"File types: {file_types_text}", halign=Gtk.Align.START) + grid.attach(file_types_display_label, 0, 3, 2, 1) + + confirm_button = Gtk.Button(label="Start Recovery") + confirm_button.connect("clicked", self.on_confirm_recovery) + grid.attach(confirm_button, 0, 4, 1, 1) + + # Clear any existing children in the stats container and add the new grid + for child in self.stats_container.get_children(): + self.stats_container.remove(child) + + self.stats_container.pack_start(grid, True, True, 10) + self.stats_container.show_all() + + + + def on_confirm_recovery(self, button): + # Get the selected file types from the checkboxes + selected_file_types = [checkbox.get_label() for checkbox in self.file_type_checkboxes if checkbox.get_active()] + + if not selected_file_types: + self.show_error_message("Please select at least one file type.") + return + + # Build the text message with selected file types and success status + recovery_message = ( + f"Recovery started successfully!\n\n" + f"Selected file types:\n- {', '.join(selected_file_types)}" ) - dialog.set_default_size(800, 400) - response = dialog.run() - if response == Gtk.ResponseType.OK: - self.recovery_path_entry.set_text(dialog.get_filename()) - dialog.destroy() + # Retrieve the buffer from the stats textview (assuming stats_textview exists) + buffer = self.stats_textview.get_buffer() + buffer.set_text(recovery_message) - def on_target_directory_button_clicked(self, button): - dialog = Gtk.FileChooserDialog( - title="Select Target Directory", - parent=self, + # Ensure the stats screen is visible to the user + self.stack.set_visible_child_name("statistics") + + + def on_inode_recovery_clicked(self, button): + self.show_recovery_dialog("Inode Recovery", "Enter details for Inode Recovery") + + def on_partition_recovery_clicked(self, button): + # Step 1: Show file chooser dialog for restoration path + file_chooser = Gtk.FileChooserDialog( + title="Select Restoration Path", + transient_for=self, action=Gtk.FileChooserAction.SELECT_FOLDER, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK) ) - dialog.add_buttons( - Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK - ) - dialog.set_default_size(800, 400) + file_chooser.set_modal(True) + + response = file_chooser.run() - response = dialog.run() if response == Gtk.ResponseType.OK: - self.target_directory_entry.set_text(dialog.get_filename()) - dialog.destroy() + restoration_path = file_chooser.get_filename() + file_chooser.destroy() - def on_start_recovery_clicked(self, button): - drive_text = self.drive_combo.get_active_text() - if drive_text: - self.drive_path = drive_text.split()[0] - self.filesystem_type = self.filesystem_combo.get_active_text() - recovery_path = self.recovery_path_entry.get_text() - target_directory = self.target_directory_entry.get_text() - - if self.filesystem_type and self.drive_path and recovery_path and target_directory: - append_log(self.recovery_log_textview, f"Starting recovery from {recovery_path} to {target_directory} on {self.drive_path} ({self.filesystem_type})...") - if self.filesystem_type == "Btrfs": - recover_btrfs(self.drive_path, self.recovery_log_textview, recovery_path, target_directory) - elif self.filesystem_type == "XFS": - recover_xfs(self.drive_path, self.recovery_log_textview, recovery_path, target_directory) - else: - append_log(self.recovery_log_textview, "Error: Select a valid filesystem, drive, and specify both the recovery path and target directory.") + # Step 2: Show recovery dialog for selecting file types + self.show_recovery_dialog("Partition Recovery", "Select file types and proceed", restoration_path) else: - append_log(self.recovery_log_textview, "Error: No drive selected.") + file_chooser.destroy() - def on_exit_clicked(self, button): - Gtk.main_quit() + def show_recovery_dialog(self, title, action_desc, restoration_path): + dialog = Gtk.Dialog(title=title, transient_for=self, modal=True) + dialog.set_default_size(400, 300) + + # Create a VBox to hold the form fields + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + dialog.get_content_area().pack_start(vbox, True, True, 10) + + # Description Label + description_label = Gtk.Label(label=action_desc) + vbox.pack_start(description_label, False, False, 0) + + # Show the selected restoration path + restoration_path_label = Gtk.Label(label=f"Restoration Path: {restoration_path}") + vbox.pack_start(restoration_path_label, False, False, 0) + + # File Types (checkboxes) + file_types_label = Gtk.Label(label="Select file types:") + vbox.pack_start(file_types_label, False, False, 0) + + file_types_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + vbox.pack_start(file_types_box, False, False, 0) + + # Common file types (all selected by default) + file_types = ["Text Files (.txt)", "Images (.jpg, .png)", "Documents (.pdf, .docx)", + "Audio Files (.mp3, .wav)", "Videos (.mp4, .avi)", "Archives (.zip, .tar)"] + file_type_checkboxes = [] + + for file_type in file_types: + checkbox = Gtk.CheckButton(label=file_type) + checkbox.set_active(True) # All selected by default + file_type_checkboxes.append(checkbox) + file_types_box.pack_start(checkbox, False, False, 0) + + # Buttons + button_box = Gtk.Box(spacing=10) + dialog.get_action_area().pack_start(button_box, True, True, 0) + button_box.set_halign(Gtk.Align.CENTER) + + # Add OK and Cancel buttons + ok_button = Gtk.Button.new_with_label("OK") + ok_button.connect("clicked", lambda w: self.on_dialog_response(dialog, restoration_path, file_type_checkboxes)) + button_box.pack_start(ok_button, True, True, 0) + + cancel_button = Gtk.Button.new_with_label("Cancel") + cancel_button.connect("clicked", lambda w: dialog.destroy()) + button_box.pack_start(cancel_button, True, True, 0) + + dialog.show_all() + + def on_dialog_response(self, dialog, restoration_path, file_type_checkboxes): + # Get the selected file types from the checkboxes + selected_file_types = [checkbox for checkbox in file_type_checkboxes if checkbox.get_active()] + + # Validate the file types + if not selected_file_types: + self.show_error_message("You must select at least one file type.") + return + + # Create a new section in the stats screen for this recovery operation + recovery_frame = Gtk.Frame(label="Recovery Details") + recovery_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + recovery_frame.add(recovery_box) + + # Add the restoration path label + restoration_path_label = Gtk.Label(label=f"Restoration Path: {restoration_path}") + recovery_box.pack_start(restoration_path_label, False, False, 0) + + # Add a label for selected file types + file_types_label = Gtk.Label(label="Selected File Types:") + recovery_box.pack_start(file_types_label, False, False, 0) + + # Add checkboxes for the selected file types + for checkbox in selected_file_types: + file_type_checkbox = Gtk.CheckButton(label=checkbox.get_label()) + file_type_checkbox.set_active(True) + file_type_checkbox.set_sensitive(True) # Disable to indicate selection is locked + recovery_box.pack_start(file_type_checkbox, False, False, 0) -def append_log(textview, message): - buffer = textview.get_buffer() - buffer.insert(buffer.get_end_iter(), message + "\n") + # Pack the new recovery section into the stats screen (assuming stats_screen is a Gtk.Box or Gtk.Grid) + self.stats_screen.pack_start(recovery_frame, False, False, 10) + self.stats_screen.show_all() # Ensure the new content is displayed -def recover_btrfs(drive_path, log_textview, recovery_path, target_directory): - append_log(log_textview, f"Simulating Btrfs recovery from {recovery_path} to {target_directory} on {drive_path}...") + # Close the dialog + dialog.destroy() + + # Switch to the statistics screen to display the updated recovery information + self.stack.set_visible_child_name("statistics") + + def show_error_message(self, error_message): + """Displays a floating window with an error message.""" + dialog = Gtk.Dialog(title="Error", transient_for=self, modal=True) + dialog.set_default_size(300, 150) + + error_label = Gtk.Label(label=error_message) + error_label.set_name("error_label") + + # Add error message to the dialog content area + dialog.get_content_area().pack_start(error_label, True, True, 10) + + # Add a close button + close_button = dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE) + close_button.connect("clicked", lambda _: dialog.destroy()) + + dialog.show_all() + + def dry_run(self, command): + # Create a new top-level window for the dry run + confirm_dialog = Gtk.MessageDialog( + parent=None, + flags=Gtk.DialogFlags.MODAL, + type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.NONE, + message_format=" SaveMyNode\n\nA dry run simulates the recovery process without making actual changes to the filesystem.\n\n" + "Do you want to proceed?" + ) + confirm_dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK) + + # Wait for user response + response = confirm_dialog.run() + confirm_dialog.destroy() + + # If the user cancels, return early without executing the command + if response == Gtk.ResponseType.CANCEL: + return + + dialog = Gtk.Window(title="Dry Run Output") + dialog.set_default_size(600, 400) + dialog.set_position(Gtk.WindowPosition.CENTER) -def recover_xfs(drive_path, log_textview, recovery_path, target_directory): - append_log(log_textview, f"Simulating XFS recovery from {recovery_path} to {target_directory} on {drive_path}...") + # Create a vertical box to contain the widgets + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + dialog.add(vbox) + + # Create a label for the title + title_label = Gtk.Label(label="Command Output:\n") + vbox.pack_start(title_label, False, False, 6) + + # Create a scrolled window to contain the text view + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_vexpand(True) + scrolled_window.set_hexpand(True) + vbox.pack_start(scrolled_window, True, True, 0) + + # Create a text view to display the command output + text_view = Gtk.TextView() + text_view.set_editable(False) # Make the text view read-only + scrolled_window.add(text_view) + + # Prefix the command with pkexec to handle sudo permissions + current_dir = os.getcwd() + target_dir = "scripts/btrfs/" + + # Change to the target directory if not already in it + if current_dir != os.path.abspath(target_dir): + try: + os.chdir(target_dir) + except FileNotFoundError as e: + # Show an error dialog if the directory does not exist + error_dialog = Gtk.MessageDialog( + parent=dialog, + flags=Gtk.DialogFlags.MODAL, + type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + message_format=f"Directory error: {e}" + ) + error_dialog.run() + error_dialog.destroy() + return + full_command = f"pkexec {command}" + + # Run the command and capture the output + + try: + process = subprocess.Popen(full_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + stdout, stderr = process.communicate() + + if stdout: + # Create a success dialog for command output + output_dialog = Gtk.MessageDialog( + parent=dialog, + flags=Gtk.DialogFlags.MODAL, + type=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.OK, + message_format="Files found:\n" + ) + output_dialog.format_secondary_text(stdout) + output_dialog.run() + output_dialog.destroy() + + if stderr: + # Create an error dialog for errors + error_dialog = Gtk.MessageDialog( + parent=dialog, + flags=Gtk.DialogFlags.MODAL, + type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + message_format="Errors:" + ) + error_dialog.format_secondary_text(stderr) + error_dialog.run() + error_dialog.destroy() + + + except Exception as e: + # Create a message dialog for errors + error_dialog = Gtk.MessageDialog( + parent=dialog, + flags=Gtk.DialogFlags.MODAL, + type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + message_format=f"Error executing command: {e}" + ) + error_dialog.run() + error_dialog.destroy() + + # Create a close button + os.chdir("../..") # quite redundant but words + + + + def on_exit_clicked(self, button): + Gtk.main_quit() if __name__ == "__main__": - app = SaveMyNodeApp() - app.connect("destroy", Gtk.main_quit) - app.show_all() + win = SaveMyNodeApp() + win.connect("destroy", Gtk.main_quit) + win.show_all() Gtk.main() - \ No newline at end of file diff --git a/recover b/recover deleted file mode 100644 index e69de29..0000000 diff --git a/recover_xfs.py b/recover_xfs.py new file mode 100644 index 0000000..22b1dae --- /dev/null +++ b/recover_xfs.py @@ -0,0 +1,188 @@ +import struct +import sys +import os +import hashlib + +# Constants +XFS_SUPERBLOCK_OFFSET = 0 +XFS_SUPERBLOCK_SIZE = 512 +XFS_DINODE_MAGIC = 0x494E # 'IN' +XFS_EXTENT_FORMAT = 2 # Extent format + +class XFSSuperblock: + def __init__(self, data): + self.magicnum = struct.unpack_from(">I", data, 0)[0] + self.blocksize = struct.unpack_from(">I", data, 4)[0] + self.dblocks = struct.unpack_from(">Q", data, 8)[0] + self.icount = struct.unpack_from(">Q", data, 104)[0] + self.ifree = struct.unpack_from(">Q", data, 112)[0] + self.inodesize = struct.unpack_from(">H", data, 100)[0] + self.crc = struct.unpack_from(">I", data, 32)[0] # CRC for v5 + self.uuid = struct.unpack_from("16s", data, 100)[0] # UUID for v5 + + def is_valid(self): + return self.magicnum == 0x58465342 # "XFSB" + + def display_info(self): + print("XFS Superblock Information:") + print(f" Block Size: {self.blocksize} bytes") + print(f" Inode Size: {self.inodesize} bytes") + print(f" Total Data Blocks: {self.dblocks}") + print(f" Total Inodes: {self.icount}") + print(f" Free Inodes: {self.ifree}") + print(f" UUID: {self.uuid.hex()}") + +class XFSInode: + def __init__(self, data): + self.magic = struct.unpack_from(">H", data, 0)[0] + self.mode = struct.unpack_from(">H", data, 2)[0] + self.version = struct.unpack_from(">B", data, 4)[0] + self.format = struct.unpack_from(">B", data, 5)[0] + self.nlink = struct.unpack_from(">H", data, 16)[0] + self.uid = struct.unpack_from(">I", data, 18)[0] + self.gid = struct.unpack_from(">I", data, 22)[0] + self.size = struct.unpack_from(">Q", data, 56)[0] + + def is_deleted(self): + return self.nlink == 0 and self.magic == XFS_DINODE_MAGIC + +class XFSFileRecovery: + def __init__(self, image_path): + self.image_path = image_path + self.fd = None + self.superblock = None + self.image_size = 0 + + def open_image(self): + self.fd = open(self.image_path, 'rb') + self.image_size = os.path.getsize(self.image_path) # Get the image size + + def close_image(self): + if self.fd: + self.fd.close() + + def read_superblock(self): + self.fd.seek(XFS_SUPERBLOCK_OFFSET) + sb_data = self.fd.read(XFS_SUPERBLOCK_SIZE) + self.superblock = XFSSuperblock(sb_data) + + if not self.superblock.is_valid(): + print("Not a valid XFS filesystem.") + sys.exit(1) + + self.superblock.display_info() + + def read_inodes(self): + inode_start_offset = XFS_SUPERBLOCK_OFFSET + XFS_SUPERBLOCK_SIZE + inodes_read = 0 + self.fd.seek(inode_start_offset) + + while inodes_read < self.superblock.icount: + inode_data = self.fd.read(self.superblock.inodesize) + if not inode_data: + break + + inode = XFSInode(inode_data) + + # Only print non-zero inodes for debugging purposes + if inode.magic != 0 or inode.format != 0 or inode.size != 0: + print(f"Inode {inodes_read}: Magic = {hex(inode.magic)}, Format = {inode.format}, Size = {inode.size}") + + # Check for valid inode data format (e.g., extents) + if inode.format == XFS_EXTENT_FORMAT: # Example: extent format + print(f"Valid data inode found at index {inodes_read}, attempting recovery...") + self.recover_file(inode, inode_data) + + inodes_read += 1 + + def recover_file(self, inode, inode_data): + print("Recovering file from inode data...") + extents = self.extract_extents(inode, inode_data) + if not extents: + print("No extents found for this inode.") + return + + recovered_filename = f"recovered_file_{id(inode_data)}.dat" + with open(recovered_filename, 'wb') as out_file: + for extent in extents: + self.read_extent_data(extent, out_file) + + print(f"Recovered file written to {recovered_filename}") + self.verify_integrity(recovered_filename) + + def extract_extents(self, inode, inode_data): + """Extract extents from inode data based on inode format.""" + extents = [] + if inode.format == XFS_EXTENT_FORMAT: + # Example calculation; adjust as per actual format + num_extents = (len(inode_data) - 60) // 16 + for i in range(num_extents): + offset = 60 + i * 16 + try: + start_block = struct.unpack_from(">Q", inode_data, offset)[0] + block_count = struct.unpack_from(">I", inode_data, offset + 8)[0] + except struct.error as e: + print(f"Struct unpacking error: {e}") + continue + + # Validation checks for extent values + if start_block * self.superblock.blocksize >= self.image_size: + print(f"Invalid extent found: Start Block = {start_block}, exceeds image size.") + continue + + print(f" Found extent: Start Block = {start_block}, Block Count = {block_count}") + extents.append((start_block, block_count)) + else: + print(f"Unknown inode format {inode.format}; skipping...") + + return extents + + def read_extent_data(self, extent, out_file): + """Read data from an extent and write it to the output file.""" + start_block, block_count = extent + block_size = self.superblock.blocksize + + # Calculate the starting offset + offset = start_block * block_size + + # Validate offset before seeking + if offset >= self.image_size: + print(f"Error: Attempted to seek to offset {offset}, which is outside the image bounds.") + return + + print(f"Reading data from offset {offset} for {block_count} blocks of size {block_size}.") + + self.fd.seek(offset) + + for _ in range(block_count): + data = self.fd.read(block_size) + if not data: + break + out_file.write(data) + + def verify_integrity(self, filename): + """Verify the integrity of the recovered file by computing its hash.""" + hash_md5 = hashlib.md5() + with open(filename, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + print(f"MD5 hash of {filename}: {hash_md5.hexdigest()}") + + def run(self): + self.open_image() + self.read_superblock() + self.read_inodes() + self.close_image() + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python xfs_recovery.py ") + sys.exit(1) + + image_path = sys.argv[1] + if not os.path.exists(image_path): + print(f"Error: Disk image {image_path} does not exist.") + sys.exit(1) + + recovery_tool = XFSFileRecovery(image_path) + recovery_tool.run() diff --git a/scripts/NOTE.md b/scripts/NOTE.md new file mode 100644 index 0000000..0d4f5be --- /dev/null +++ b/scripts/NOTE.md @@ -0,0 +1,3 @@ +# NOTE + + diff --git a/scripts/btrfs/btrfs-recover.sh b/scripts/btrfs/btrfs-recover.sh new file mode 100755 index 0000000..12eaba1 --- /dev/null +++ b/scripts/btrfs/btrfs-recover.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +# Function to print usage information +function usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " -d, --device Specify the device path" + echo " -fp, --file-path Path of the file/dir to recover" + echo " -rp, --recovery-path Specify the recovery path" + echo " -D, --depth Specify the recovery depth" + echo " -R, --recover If 1, recover the files along with printing logs" + echo " -h, --help Display this help message" +} + +# Check if the partition is mounted before proceeding +# mount-check.sh expects: +# Argument 1: device path (e.g., /dev/sdb1) +function is_mounted() { + cmd="$(dirname $0)/mount-check.sh $dev" + res="$(bash ./$cmd)" + if [[ ! -z $res ]]; then + echo "ERROR: Device '$dev' is mounted at '$res'." + echo "Please unmount the device before proceeding." + exit 1 + fi +} + +# Initialize variables +dev="" +file_path="" +recovery_path="" + +# Function to validate the path +validate_path() { + if [ ! -e "$1" ]; then + echo "Error: Path '$1' does not exist." + exit 1 + fi +} + +# Parse options +while [[ $# -gt 0 ]]; do + case $1 in + -d|--device) + dev="$2" + validate_path "$dev" + shift 2 + ;; + -fp|--file-path) + file_path="$2" + shift 2 + ;; + -rp|--recovery-path) + recovery_path="$2" + validate_path "$recovery_path" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + -D|--depth) + depth="$2" + shift 2 + ;; + -R|--recover) + recover="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" + usage + exit 1 + ;; + esac +done + +# Check if required arguments are provided +if [ -z "$dev" ] || [ -z "$file_path" ] || [ -z "$recovery_path" ] || [ -z "$depth" ]; then + echo "Error: Missing required arguments." + usage + exit 1 +fi + +# Function to sanitize file path +function sanitize_filepath() { + # If the file path starts with '/', remove it + if [[ $file_path == /* ]]; then + file_path=$(echo "$file_path" | cut -c2-) + fi + + echo "" + # If the file path ends with '/', assume it's a directory + if [[ $file_path == */ ]]; then + rectype="dir" + recname="$dirname" + file_path+=".*" # Append .* for matching all files in the directory + else + rectype="file" + recname="$filename" + fi + + echo "Sanitized file path: '$file_path'" +} + +# Function to generate regex for file recovery +# generate-regex.sh expects: +# Argument 1: sanitized file path to create the regex pattern +function cook_regex() { + cmd="$(dirname $0)/generate-regex.sh $file_path" + regex="$(bash ./$cmd)" +} + +# Function for dry-run file recovery with depth levels +# dry-run.sh expects: +# Argument 1: depth (e.g., how deep the recovery should go) +# Argument 2: device path (e.g., /dev/sdb1) +# Argument 3: regex (to find files based on the pattern) +# Optional Argument 4: 1 if recovery should happen, 0 for just logging +# Optional Argument 5: recovery path (where recovered files will be stored) +function dryrun_with_depth_levels() { + cmd="$(dirname $0)/dry-run.sh $depth $dev $regex" + res="$(bash $cmd)" + echo "$cmd" +} + +# Function to recover files +# dry-run.sh with recovery option expects: +# Argument 1: depth (how deep to search) +# Argument 2: device path +# Argument 3: regex (generated earlier) +# Argument 4: recovery flag (1 for recovery) +# Argument 5: recovery path (where to save the recovered files) +function recover() { + cmd="$(dirname $0)/dry-run.sh $depth $dev $regex 1 $recovery_path" + regex="$(bash ./$cmd)" +} + +# Check if the device is mounted +is_mounted + +# Sanitize file path and generate recovery regex +sanitize_filepath +cook_regex + +# Perform dry-run recovery with specified depth +dryrun_with_depth_levels + +# If recover flag is set, perform actual file recovery +if [[ $recover -eq 1 ]]; then + recover +fi + +# Output the result of the operation +echo "$res" diff --git a/scripts/btrfs/dry-run.sh b/scripts/btrfs/dry-run.sh new file mode 100755 index 0000000..62db1ba --- /dev/null +++ b/scripts/btrfs/dry-run.sh @@ -0,0 +1,77 @@ +#!/bin/bash +roots="/tmp/btrfsroots.tmp" +tmp="/tmp/undeleter.tmp" +rectype="none" + +depth=$1 +dev=$2 +regex=$3 + +# If 1 then recover files to the destination directory +recover=$4 +dst=$5 + +function generateroots(){ + if [[ $depth -eq 1 || $depth -eq 0 ]]; then + sudo btrfs-find-root "$dev" &> "$tmp" + grep -a Well "$tmp" | sed -r -e 's/Well block ([0-9]+).*/\1/' | sort -rn > "$roots" + rootcount=$(wc -l "$roots" | awk '{print $1}') + > "$tmp" + elif [[ $depth -eq 2 ]]; then + sudo btrfs-find-root -a "$dev" &> "$tmp" + grep -a Well "$tmp" | sed -r -e 's/Well block ([0-9]+).*/\1/' | sort -rn > "$roots" + rootcount=$(wc -l $roots | awk '{print $1}') + > "$tmp" + fi +} + +function dryrun(){ + if [[ $depth -eq 0 ]]; then + sudo btrfs restore -Divv --path-regex '^/'${regex}'$' "$dev" / 2> /dev/null | grep -E "Restoring.*$recname" | cut -d" " -f 2- &> $tmp + # Level 1 finds roots and loops through them + elif [[ $depth -eq 1 ]]; then + while read -r i || [[ -n "$i" ]]; do + sudo btrfs restore -t "$i" -Divv --path-regex '^/'${regex}'$' "$dev" / 2> /dev/null | grep -E "Restoring.*$recname" | cut -d" " -f 2- &>> $tmp + done < "$roots" + # add the -a flag to the btrfs-find-roots, to find more roots + elif [[ $depth -eq 2 ]]; then + while read -r i || [[ -n "$i" ]]; do + sudo btrfs restore -t "$i" -Divv --path-regex '^/'${regex}'$' "$dev" / 2> /dev/null| grep -E "Restoring.*$recname" | cut -d" " -f 2- &>> $tmp + done < "$roots" + fi +} + +function checkresult(){ + if [[ ! -s $tmp ]]; then + echo "No results found" + else + echo -e "Successful dry run!\nDevice: $dev\nDepth: $depth\nRegex: $regex\n" + cat $tmp + fi +} + +function recover(){ + if [[ $depth = "0" ]]; then + sudo btrfs restore -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null & + recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) + elif [[ $depth == "1" ]]; then + while read -r i || [[ -n "$i" ]]; do + sudo btrfs restore -t "$i" -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null + done < "$roots" & + # Find and delete empty files in $dst + # so that we don't skip recovering a file on next iteration just because an empty version of the same file was recovered + recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) + elif [[ $depth == "2" ]]; then + while read -r i || [[ -n "$i" ]]; do + sudo btrfs restore -t "$i" -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null + done < "$roots" & + recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) + fi +} + +generateroots +dryrun +checkresult +if [[ $recover -eq 1 ]]; then + recover +fi diff --git a/scripts/btrfs/generate-regex.sh b/scripts/btrfs/generate-regex.sh new file mode 100755 index 0000000..29c5a29 --- /dev/null +++ b/scripts/btrfs/generate-regex.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +file_path="$1" +# Script to generate regex for the required filepath +IFS='/' read -ra file_patharray <<< "$file_path" + +if [[ ${#file_patharray[@]} -eq 1 ]]; then + # No '/' found, user is looking for a file in root of FS itself + regex="(|${file_patharray[0]})" +else + # Build the first part of the regex + regex="(|${file_patharray[0]}" + + # Build the regex one segment at a time + for ((i=1; i<${#file_patharray[@]}; i++)); do + regex+="(|/${file_patharray[i]}" + done + + # Close all the parentheses + for ((i=0; i<${#file_patharray[@]}; i++)); do + regex+=")" + done +fi +echo $regex \ No newline at end of file diff --git a/scripts/btrfs/mount-check.sh b/scripts/btrfs/mount-check.sh new file mode 100755 index 0000000..626a9b5 --- /dev/null +++ b/scripts/btrfs/mount-check.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Returns empty string if the device is not mounted, otherwise returns the mount point + +dev=$1 +mntfind="$(findmnt $dev)" + +if [[ -z "$mntfind" ]]; then + echo "" +else + echo "$mntfind" | awk '{print $2}' +fi \ No newline at end of file diff --git a/scripts/smn_btrfs.sh b/scripts/smn_btrfs.sh deleted file mode 100644 index a3f1c04..0000000 --- a/scripts/smn_btrfs.sh +++ /dev/null @@ -1,359 +0,0 @@ -#!/bin/bash - -dev=$1 -dst=$2 -roots="/tmp/btrfsroots.tmp" -depth=0 -tmp="/tmp/undeleter.tmp" -IFS=$'\n' -rectype="none" -# vars that can be used to change font color -white=$(tput setaf 7) -blue=$(tput setaf 6) -green=$(tput setaf 2) -yellow=$(tput setaf 3) -red=$(tput setaf 1) -normal=$(tput sgr0) # default color - -# Functions -function titler() { -# Function to surround whatever is inputted with some nice lines - input=$1 - (( count=${#input}+4 )) - eval printf '=%.0s' "{1..$count}" - printf "\n| ${yellow}%s${normal} |\n" "$input" - eval printf '=%.0s' "{1..$count}" - printf "\n" -} - -function spinner(){ - # This function takes care of the spinner used for long-lasting tasks - local pid=$! - local delay=0.75 - local spinstr='|/-'\\ - while [ -d /proc/"$pid" ]; do - local temp=${spinstr#?} - printf " [%c] " "$spinstr" - local spinstr=$temp${spinstr%"$temp"} - sleep $delay - printf "\b\b\b\b\b\b" - done - printf " \b\b\b\b" -} - -function syntaxcheck(){ - # Check syntax and provided parameters - if [[ -z $dev || -z $dst ]]; then - titler "Undelete-BTRFS | Syntax error" - printf "${red}Error: ${yellow}Invalid syntax or missing required parameters\n" - printf "${normal}Syntax: ./script.sh ${blue} ${normal}\n" - printf "${green}Example: ${normal}sudo ./undelete.sh ${blue}/dev/sda1 /mnt/${normal}\n\n" - exit 1 - elif [[ $EUID -ne 0 ]]; then - titler "Undelete-BTRFS | User privilege level error" - printf "${red}Error:${yellow} This script must be run with sudo (or as root) as btrfs restore requires it.\n" - printf "${normal}Syntax example: sudo ./undelete.sh ${blue}/dev/sda1 /mnt/${normal}\n" - printf "\n${yellow}Exiting...\n${normal}" - exit 1 - fi - # Check if the source dev provided exists - if [[ ! -a $dev ]]; then - titler "Undelete-BTRFS | Source check failed" - printf "${red}Error: ${blue}%s${yellow} doesn't seem to exist! \nCheck your syntax and try again\n\n" "$dev" - printf "Exiting...\n${normal}" - exit 1 - fi - # Check if the destination provided is a directory and that it's writable - if [[ ! -d $dst && ! -w $dst ]]; then - titler "Undelete-BTRFS | Destination check failed" - printf "${red}Error: ${blue}%s${yellow} doesn't exist or is not a writable directory! \nCheck your destination (create it if necessary) and try again\n\n" "$dst" - printf "Exiting...\n${normal}" - exit 1 - fi -} - -function mountcheck(){ - # Check if source device provided is mounted - mount=$(grep -cw "$dev" /etc/mtab) - if [[ ! $mount == "0" ]]; then - titler "Undelete-BTRFS | Mountcheck failed" - printf "${red}Error: ${blue}%s${yellow} is mounted! \nThis script can only be run against umounted devices. Please try again\n\n" "$dev" - printf "Exiting...\n${normal}" - exit 1 - fi -} - - -function regexbuild(){ - # The regex required by btrfs restore is utterly awkward... So we have a function for building it :-) - >$tmp - titler "Undelete-BTRFS | Regex builder" - printf "Welcome and good luck!\nMake sure you've read the README at ${blue}https://github.com/danthem/undelete-btrfs${normal} before continuing.\n" - printf "\nCheat sheet:\n•Remember to NOT include the mountpoint where FS is normally mounted. Pretend that you're in 'root' of the filesystem itself.\n" - printf "•Example of a ${blue}file${normal} path on a mounted filesystem: ${white}/data/documents/daniel.txt${normal}\n" - printf " -> How to write it: ${white}/documents/daniel.txt${normal}\n" - printf "•Example of a ${blue}directory${normal} path on a mounted filesystem: ${white}/data/pictures/important/${normal}\n" - printf " -> How to write it: ${white}/pictures/important/${normal}\n" - printf "•Maybe you want recover for instance all ${blue}files with extension${normal} .jpeg in a directory?\n" - printf " -> How to write it: ${white}/pictures/.*.jpeg${normal}\n\n" - read -er -p "Enter the path to a file or directory, following the rules above: " filepath - while [[ -z "$filepath" ]]; do - printf "\n${red}Err: No input given, try again.\n${normal}" - read -r -p "Enter the path to a file or directory, following the rules above: " filepath - done - # Pick out the dir and filename - dirname=$(echo "$filepath" | awk -F"/" '{ print $(NF-1) }') - filename=$(echo "$filepath" | awk -F"/" '{ print $NF }') - # Check is first character is a /, if so ignore it - if [[ $filepath == /* ]]; then - filepath=$(echo "$filepath"| cut -c2-) - fi - # Determine type of recovery.. are we doing full directory or single file? - # $rectype not used at the moment but will be eventually... probably. - if [[ $filepath == */ ]]; then - rectype="dir" - recname="$dirname" - filepath+=".*" - else - rectype="file" - recname="$filename" - fi - # Read provided path to array - - readarray -d/ -t filepatharray < <(echo "$filepath") - if [[ ${#filepatharray[@]} -eq 1 ]];then - #no / found, user is looking for a file in root of FS itself.. Easy to build the regex - regex="(|${recname})" - else - # Build the first set.. This is done to remove the / from the first seciotn - regex="(|${filepatharray[@]::1}" - # Build the array one by one - for i in "${filepatharray[@]:1}"; do - regex+=$(printf "(|/%s" "$i") - done - # Finally add enough ")" at the end - for i in "${filepatharray[@]}"; do - regex+=")" - #regex="$(echo $regex|tr -d "\n")" - done - fi - #printf "\nRegex:\n${blue}^/%s$ ${normal}\n\n" "$regex" - printf "\n${green}Great!${normal} First thing we will do is a dry-run, this will not actually recover any files, just check if we can find any files matching the regex.\n" - sleep 5 - dryrun - checkresult -} - -function dryrun(){ - # This is where we do the dryrun of BTRFS, this is used to quickly check if we can find the file using the provided regexbuild - # much faster than doing an actual restore. - clear - titler "Undelete-BTRFS | Dry-run | Depth-level: ${depth}" - printf "Performing a dry-run recovery with the provided path.\n${yellow}This is not recovering any files, just checking if files can be found${normal}\n" - sleep 2 - if [[ $depth -eq 0 ]]; then - btrfs restore -Divv --path-regex '^/'${regex}'$' "$dev" / 2> /dev/null | grep -E "Restoring.*$recname" | cut -d" " -f 2- &> $tmp - # We have 3 levels: 0, 1 and 2. 0 means a basic 'btrfs restore', 1 and 2 means that we first get the roots and then loop them - elif [[ $depth -eq 1 ]]; then - while read -r i || [[ -n "$i" ]]; do - btrfs restore -t "$i" -Divv --path-regex '^/'${regex}'$' "$dev" / 2> /dev/null | grep -E "Restoring.*$recname" | cut -d" " -f 2- &>> $tmp - done < "$roots" - # Level 2 is the 'deepest' level, here we add the -a flag to the btrfs-find-roots, this should give us way more roots to work with - elif [[ $depth -eq 2 ]]; then - while read -r i || [[ -n "$i" ]]; do - btrfs restore -t "$i" -Divv --path-regex '^/'${regex}'$' "$dev" / 2> /dev/null| grep -E "Restoring.*$recname" | cut -d" " -f 2- &>> $tmp - done < "$roots" - fi - } - -function checkresult(){ - clear - titler "Undelete-BTRFS | Dry-run results | Depth-level: ${depth}" - printf "Path entered: ${blue}%s${normal} \nRegex generated: ${blue}'^/%s\$'${normal} \nDepth-level: ${blue}%s${normal}\n" "$filepath" "$regex" "$depth" - if [[ $rootcount -gt 0 ]]; then printf "Root count: ${blue}%s${normal}\n\n" "$rootcount"; else printf "\n"; fi - - if [[ ! -s $tmp && $depth -eq 0 ]]; then - # we didn't find any data on first attempt (as $tmp is empty) - depth=1 - generateroots - dryrun - checkresult - elif [[ ! -s $tmp && $depth -eq 1 ]]; then - # didn't find any on the second attempt either - depth=2 - generateroots - dryrun - checkresult - elif [[ -s $tmp ]]; then - # if $tmp is not empty, it means we found some data! - printf "${green}Data found!${normal} here are the file(s) found: \n========\n" - sort -u $tmp - printf "========\n\nChoose one of the following: \n${blue}1${normal}) Recover the data \n${blue}2${normal}) Look one level deeper \n${blue}3${normal}) Try another path \n${blue}4${normal}) Exit\n" - while true; do - read -r -p "Enter choice: " input - case $input in - [1]) - recover - ;; - [2]) - if [[ $depth -eq 0 || $depth -eq 1 ]]; then - printf "\nTrying one level deeper...\n\n" - depth=$((depth + 1)) - generateroots - dryrun - checkresult - elif [[ $depth -eq 2 ]]; then - printf "You're already on the deepest level... Can't go deeper! \n\n" - fi - ;; - [3]) - clear - printf "${yellow}Returning to path selection...${normal}\n\n" - depth=0 - regexbuild - ;; - [4]) - exit 0 - ;; - *) - printf "\nInvalid input.\n" - esac - done - else - printf "${red}No data found :(${normal}\nUnable to find any data with the provided path at any depth level, please verify the entered path and try again\n" - printf "Keep in mind that directory paths must end with a '/' \nFor more rules/examples see ${blue}https://github.com/danthem/undelete-btrfs${normal}\n\n" - read -rsp "Press Enter to return to start..." - clear - depth=0 - printf "${yellow}Returning to path selection...${normal}\n\n" - regexbuild - fi -} - -function generateroots(){ - clear - titler "Undelete-BTRFS | Generating roots | Depth-level ${depth}" - if [[ $depth -eq 1 || $depth -eq 0 ]]; then - printf "Generating roots, please note that this may take a while to finish... " - btrfs-find-root "$dev" &> "$tmp" - grep -a Well "$tmp" | sed -r -e 's/Well block ([0-9]+).*/\1/' | sort -rn > "$roots" - printf "${green}Done${normal}!\n" - rootcount=$(wc -l "$roots" | awk '{print $1}') - > "$tmp" - if [[ ! -s "$roots" ]]; then - printf "\n${yellow}Note:${normal} No (additional) roots found with btrfs-find-roots \nAttempting with -a flag (depth level 2)...\n" - depth=2 - sleep 2 - generateroots - fi - elif [[ $depth -eq 2 ]]; then - printf "Looking even deeper for roots, this can take quite a while... " - btrfs-find-root -a "$dev" &> "$tmp" - grep -a Well "$tmp" | sed -r -e 's/Well block ([0-9]+).*/\1/' | sort -rn > "$roots" - printf "${green}Done${normal}!\n" - rootcount=$(wc -l $roots | awk '{print $1}') - > "$tmp" - fi -} - -function recover(){ - # Attempt recovery of files - clear - titler "Undelete-BTRFS | Recovering files | Depth-level: ${depth}" - if [[ $depth = "0" ]]; then - printf "Attempting recovery at depth level ${blue}%s${normal}, note that this may take a while..." "$depth" - btrfs restore -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null & - spinner - recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) - printf "${green}Done${normal}! \n" - # Find and delete empty recovered files, no point in keeping them around. - find "$dst" -empty -type f -delete - elif [[ $depth == "1" ]]; then - printf "Attempting recovery at depth level ${blue}%s${normal} with a root count of ${blue}%s${normal}, note that this may take a while..." "$depth" "$rootcount" - while read -r i || [[ -n "$i" ]]; do - btrfs restore -t "$i" -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null - done < "$roots" & - spinner - printf "${green}Done${normal}! \n" - # Find and delete empty files in $dst - # so that we don't skip recovering a file on next iteration just because an empty version of the same file was recovered - recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) - elif [[ $depth == "2" ]]; then - printf "\n${yellow}NOTE:${normal} You are about to start recovery at the deepest level. \nThis may take a long time and it's possible that console will get flooded with '(core dumped)'-messages.\nThis is normal and can be ignored.\n\n" - read -r -n1 -p "Press any key to continue..." - printf "Attempting recovery at depth level ${blue}%s${normal} with a root count of ${blue}%s${normal}, note that this may take a while..." "$depth" "$rootcount" - while read -r i || [[ -n "$i" ]]; do - btrfs restore -t "$i" -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null - find "$dst" -empty -type f -delete - done < "$roots" & - spinner - printf "${green}Done${normal}! \n" - recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) - fi - checkrecoverresults -} - -function checkrecoverresults(){ - clear - titler "Undelete-BTRFS | Recovery completed | Depth-level: ${depth}" - if [[ $depth = "0" || $depth = "1" ]]; then - printf "Recovery completed at depth level ${blue}%s${normal}! \n ==> ${blue}%s${normal} non-empty files found in %s.\n\n" "$depth" "$recoveredfiles" "$dst" - printf "Here's a small sample of '${white}find %s -type f${normal}' output:\n========\n" "$dst" - find "$dst" -type f | head -n20 - printf "========\\n(Showing max 20 files)\n\n" - printf "Are you happy with the results?\n${blue}1${normal}) Yes, exit script. \n${blue}2${normal}) No, try a deeper level restore. \n${blue}3${normal}) No, I want to try a different path.\n\n" - while true; do - read -r -p "Enter choice: " input - case $input in - [1]) - printf "\nExiting...\n\n" - exit 0 - ;; - [2]) - printf "Trying one level deeper..\n\n" - depth=$((depth + 1)) - generateroots - recover - ;; - [3]) - printf "\nReturning to path selection....\n\n" - depth=0 - regexbuild - ;; - *) - printf "\nInvalid input.\n" - esac - done - elif [[ $depth = "2" ]]; then - printf "Deepest level recovery completed! \n ==> ${blue}%s${normal} non-empty files found in %s.\n\n" "$recoveredfiles" "$dst" - printf "Here's a small sample of '${white}find %s -type f${normal}' output:\n========\n" "$dst" - find "$dst" -type f | head -n20 - printf "========\n\n" - printf "Are you happy with the results?\n${blue}1${normal}) Yes, exit script. \n${blue}2${normal}) No, I want to try a different path.\n\n" - while true; do - read -r -p "Enter choice: " input - case $input in - [1]) - printf "\nExiting...\n\n" - rm "$roots" "$tmp" - exit 0 - ;; - [2]) - printf "\nReturning to path selection....\n\n" - depth=0 - regexbuild - ;; - *) - printf "\nInvalid input.\n" - esac - done - fi -} - -#Exec start -syntaxcheck -mountcheck -clear ->$tmp ->$roots -regexbuild diff --git a/styles.css b/styles.css index f181196..7e7f16c 100644 --- a/styles.css +++ b/styles.css @@ -30,3 +30,19 @@ GtkTextView { opacity: 0; } +#title_label { + font-weight: bold; + font-size: 18px; + color: #689D6A; +} + +#error_label { + color: #e74c3c; + font-size: 14px; +} + +GtkFrame { + border-width: 2px; + border-color: #dcdcdc; +} + diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..f0cdc2a --- /dev/null +++ b/tui/README.md @@ -0,0 +1,37 @@ +# SaveMyNode TUI + +**SaveMyNode TUI** is a terminal-based user interface (TUI) built using Python's `rich` library. This tool is intended to facilitate file recovery from drives using Btrfs and XFS file systems. While the UI components and the structure of the tool are implemented, this is currently a dummy interface, and the full file recovery integration is pending. + +## Features +- **Dynamic Layout**: The UI is divided into different panels (header, drives, status, and footer) which dynamically display information about the drives, filesystem type, and recovery logs. +- **User Input**: Navigate the TUI with simple keyboard inputs for selecting drives, starting recovery, and managing the log. +- **Animated Progress**: When implemented, recovery will display a live progress bar animation using the `rich` library's progress feature. + +## Key Bindings +- **s**: Enter filesystem and drive selection mode +- **r**: Start the recovery process +- **c**: Clear the log +- **q**: Quit the application +- **b**: Go back to the previous screen during selection or recovery + +## Prerequisites +- Python 3.7 or higher +- Check [requirements](https://github.com/SaveMyNode/savemynode/blob/main/tui/requirements.txt) +- Linux environment (as the application uses `lsblk` to list drives) +- The `lsblk` command-line utility installed (standard on most Linux distributions) + +## Running + ```bash + git clone https://github.com/SaveMyNode/savemynode.git + cd tui/ + python tui.py + ``` + +> [!IMPORTANT] +> +> This TUI is currently a massive WIP [work-in-progress] +> +> We are still learning on the python rich library and have to +> make a lot of optimisations, UI/UX features and obviously the +> actual backend integration +> diff --git a/tui/requirements.txt b/tui/requirements.txt new file mode 100644 index 0000000..1a45987 --- /dev/null +++ b/tui/requirements.txt @@ -0,0 +1,2 @@ +rich==13.4.0 +readchar==4.0.3 diff --git a/tui/tui.py b/tui/tui.py new file mode 100644 index 0000000..8d8fea9 --- /dev/null +++ b/tui/tui.py @@ -0,0 +1,204 @@ +import os +import subprocess +from time import sleep +from rich.console import Console, Group +from rich.panel import Panel +from rich.layout import Layout +from rich.table import Table +from rich.live import Live +from rich import box +from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn +from rich.align import Align +import readchar +import time + +console = Console() + +class SaveMyNodeTUI: + def __init__(self): + self.filesystem_type = None + self.drive_path = None + self.recovery_path = None + self.target_directory = None + self.layout = Layout() + self.setup_layout() + self.log_messages = [] + self.current_mode = "main" + + def setup_layout(self): + self.layout.split( + Layout(name="header", size=3), + Layout(name="main", ratio=1), + Layout(name="footer", size=3) + ) + self.layout["main"].split_row( + Layout(name="drives", ratio=2), + Layout(name="status", ratio=1) + ) + + def run(self): + with Live(self.layout, refresh_per_second=4, screen=True) as live: + while True: + self.update_layout() + live.update(self.layout) + + key = readchar.readkey() + if self.current_mode == "main": + if key == 's': + self.current_mode = "select" + elif key == 'r': + self.current_mode = "recover" + elif key == 'c': + self.log_messages.clear() + elif key == 'q': + break + else: + self.log_messages.append("[red]Invalid command. Use s, r, c, or q.[/red]") + elif self.current_mode == "select": + self.select_filesystem_and_drive(key) + elif self.current_mode == "recover": + self.start_recovery(key) + + def update_layout(self): + self.layout["header"].update(Panel("SaveMyNode - Inode Recovery Tool", style="bold green")) + self.layout["main"]["drives"].update(self.get_drives_panel()) + self.layout["main"]["status"].update(self.get_status_panel()) + self.layout["footer"].update(self.get_footer_panel()) + + def get_drives(self): + """Get available drives using lsblk.""" + result = subprocess.run(["lsblk", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT"], capture_output=True, text=True) + drives = result.stdout.splitlines()[1:] # Skip the header + return drives + + def get_drives_panel(self): + table = Table(show_header=True, header_style="bold magenta", box=box.SIMPLE) + table.add_column("NAME", style="cyan") + table.add_column("FSTYPE", style="green") + table.add_column("SIZE", style="yellow") + table.add_column("MOUNTPOINT", style="blue") + + result = subprocess.run(["lsblk", "-o", "NAME,FSTYPE,SIZE,MOUNTPOINT"], capture_output=True, text=True) + for line in result.stdout.splitlines()[1:]: # Skip the header + parts = line.split() + if len(parts) == 4: + table.add_row(*parts) + elif len(parts) == 3: + table.add_row(parts[0], parts[1], parts[2], "") + + return Panel(table, title="Partition Details", border_style="green") + + def get_status_panel(self): + status = f""" +Filesystem: {self.filesystem_type or 'Not selected'} +Drive: {self.drive_path or 'Not selected'} +Recovery Path: {self.recovery_path or 'Not set'} +Target Directory: {self.target_directory or 'Not set'} + +Log: +{"".join(self.log_messages[-5:])} # Show last 5 log messages + """ + return Panel(status, title="Status", border_style="blue") + + def get_footer_panel(self): + if self.current_mode == "main": + return Panel("s: select filesystem/drive | r: start recovery | c: clear log | q: quit", style="italic") + elif self.current_mode == "select": + return Panel("1: Btrfs | 2: XFS | Enter drive number | b: back", style="italic") + elif self.current_mode == "recover": + return Panel("Enter recovery path and target directory | b: back", style="italic") + + def floating_prompt(self, message): + """Create a floating panel prompt""" + prompt_panel = Panel( + Align.center(message), + box=box.ROUNDED, + padding=(1, 2), + border_style="magenta", + ) + self.layout["main"]["drives"].update(Group(self.get_drives_panel(), Align.center(prompt_panel))) + + def select_filesystem_and_drive(self, key): + if key == 'b': + self.current_mode = "main" + return + + if not self.filesystem_type: + self.floating_prompt("Press 1 for Btrfs or 2 for XFS") + if key == '1': + self.filesystem_type = "Btrfs" + elif key == '2': + self.filesystem_type = "XFS" + else: + self.log_messages.append("[red]Invalid filesystem type.[/red]") + return + + if key.isdigit(): + drives = self.get_drives() + drive_index = int(key) + if 0 <= drive_index < len(drives): + self.drive_path = drives[drive_index].split()[0] + self.log_messages.append(f"[green]Selected {self.filesystem_type} filesystem on drive {self.drive_path}[/green]") + self.current_mode = "main" + else: + self.log_messages.append("[red]Invalid drive index.[/red]") + else: + self.log_messages.append("[red]Invalid input. Enter the drive number.[/red]") + + def start_recovery(self, key): + if key == 'b': + self.current_mode = "main" + return + + if not self.filesystem_type or not self.drive_path: + self.log_messages.append("[red]Error: Select filesystem and drive first[/red]") + self.current_mode = "main" + return + + if not self.recovery_path: + self.recovery_path = self.get_user_input("Enter recovery path: ") + return + + if not self.target_directory: + self.target_directory = self.get_user_input("Enter target directory: ") + if not os.path.exists(self.recovery_path) or not os.path.exists(self.target_directory): + self.log_messages.append("[red]Error: Invalid recovery path or target directory[/red]") + self.recovery_path = None + self.target_directory = None + self.current_mode = "main" + return + + self.log_messages.append(f"[green]Starting recovery...[/green]") + self.recovery_animation() + + def recovery_animation(self): + """Simulate a recovery process with a progress bar.""" + with Progress( + SpinnerColumn(), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), + console=console, # Pass the existing console object + transient=True # Optional: Makes the progress bar disappear when done + ) as progress: + task = progress.add_task("[green]Recovering files...", total=100) + + for i in range(100): + progress.update(task, advance=1) + time.sleep(0.05) # Simulate recovery progress + + + def get_user_input(self, prompt): + user_input = "" + while True: + self.floating_prompt(f"{prompt}{user_input}") + key = readchar.readkey() + if key == readchar.key.ENTER: + return user_input + elif key == readchar.key.BACKSPACE: + user_input = user_input[:-1] + else: + user_input += key + + +if __name__ == "__main__": + SaveMyNodeTUI().run()