From 94a851996ed25ceec8e753899f2eb95b2ba38107 Mon Sep 17 00:00:00 2001 From: nots1dd Date: Wed, 11 Sep 2024 07:25:10 +0530 Subject: [PATCH 01/16] [FEAT] TUI setup --- tui/README.md | 35 ++++++++ tui/requirements.txt | 2 + tui/tui.py | 204 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 tui/README.md create mode 100644 tui/requirements.txt create mode 100644 tui/tui.py diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..7395ee1 --- /dev/null +++ b/tui/README.md @@ -0,0 +1,35 @@ +# 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 +- Linux environment (as the application uses `lsblk` to list drives) +- The `lsblk` command-line utility installed (standard on most Linux distributions) + +## Installation +1. Clone the repository: + ```bash + git clone https://github.com/SaveMyNode/savemynode.git + cd tui/ + +> [!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() From 4731f829e4ab4ed4cbcf64449e52227eeffb695a Mon Sep 17 00:00:00 2001 From: nots1dd Date: Wed, 11 Sep 2024 07:44:12 +0530 Subject: [PATCH 02/16] [CHORE] tui readme fix --- recover | 0 tui/README.md | 6 ++++-- 2 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 recover diff --git a/recover b/recover deleted file mode 100644 index e69de29..0000000 diff --git a/tui/README.md b/tui/README.md index 7395ee1..f0cdc2a 100644 --- a/tui/README.md +++ b/tui/README.md @@ -16,14 +16,16 @@ ## 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) -## Installation -1. Clone the repository: +## Running ```bash git clone https://github.com/SaveMyNode/savemynode.git cd tui/ + python tui.py + ``` > [!IMPORTANT] > From bd17bf28c58f77ec380b9049b2c6b5f7798ca9ef Mon Sep 17 00:00:00 2001 From: nots1dd Date: Wed, 11 Sep 2024 11:16:41 +0530 Subject: [PATCH 03/16] [FEAT] New screen+UI changes --- main.py | 270 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 137 insertions(+), 133 deletions(-) diff --git a/main.py b/main.py index c54ab65..064fe16 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ import gi import subprocess gi.require_version("Gtk", "3.0") -from gi.repository import Gtk +from gi.repository import Gtk, Gdk class SaveMyNodeApp(Gtk.Window): def __init__(self): @@ -9,28 +9,43 @@ def __init__(self): 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 apply_theme(self): css_provider = Gtk.CssProvider() css_provider.load_from_path("styles.css") @@ -38,6 +53,44 @@ 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) @@ -68,7 +121,7 @@ def populate_drive_combo(self): 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") @@ -92,56 +145,9 @@ def refresh_partition_details(self): 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}") - - 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) - - # Recovery Path - recovery_path_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - recovery_box.pack_start(recovery_path_box, False, False, 0) - - recovery_path_label = Gtk.Label(label="Recovery Path:") - recovery_path_box.pack_start(recovery_path_label, 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) - - # Target Directory - target_directory_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - recovery_box.pack_start(target_directory_box, False, False, 0) - - target_directory_label = Gtk.Label(label="Target Directory:") - target_directory_box.pack_start(target_directory_label, False, False, 0) - - self.target_directory_entry = Gtk.Entry() - target_directory_box.pack_start(self.target_directory_entry, True, True, 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) - - self.recovery_log_textview = Gtk.TextView() - self.recovery_log_textview.set_editable(False) - self.recovery_log_textview.set_cursor_visible(False) - - 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) + self.show_error_message(f"Error: {e}") def create_controls_section(self, parent_box): button_box = Gtk.Box(spacing=10) @@ -155,7 +161,40 @@ def create_controls_section(self, parent_box): exit_button.connect("clicked", self.on_exit_clicked) button_box.pack_start(exit_button, False, False, 0) - def on_help_button_clicked(self, button): + 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) + + 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) + + 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) + + 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) + + 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 on_title_clicked(self, widget, event): + self.show_manual() + + def show_manual(self): dialog = Gtk.Dialog(title="Manual - SaveMyNode", transient_for=self, modal=True) dialog.set_default_size(600, 400) @@ -173,7 +212,7 @@ def on_help_button_clicked(self, button): " - 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" @@ -183,83 +222,48 @@ def on_help_button_clicked(self, button): 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 - ) - 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() - - def on_target_directory_button_clicked(self, button): - dialog = Gtk.FileChooserDialog( - title="Select Target Directory", - parent=self, - action=Gtk.FileChooserAction.SELECT_FOLDER, - ) - dialog.add_buttons( - Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK - ) - dialog.set_default_size(800, 400) - - response = dialog.run() - if response == Gtk.ResponseType.OK: - self.target_directory_entry.set_text(dialog.get_filename()) - dialog.destroy() + 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 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.") - else: - append_log(self.recovery_log_textview, "Error: No drive selected.") - def on_exit_clicked(self, button): - Gtk.main_quit() + if not filesystem_text or not drive_text: + self.show_error_message("Please select both filesystem and drive.") + return + + # Switch to the statistics screen + self.set_title(f"Recovering from {filesystem_text} ({drive_text})") + self.stack.set_visible_child_name("statistics") + self.update_stats_screen(drive_text) -def append_log(textview, message): - buffer = textview.get_buffer() - buffer.insert(buffer.get_end_iter(), message + "\n") + def update_stats_screen(self, drive_text): + # Simulate gathering statistics + buffer = self.stats_textview.get_buffer() + # have to implement our own function to display statistics + buffer.set_text(f"Drive Statistics for {drive_text}:\n\n- Total Space: 500 GB\n- Used: 120 GB\n- Free: 380 GB") -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}...") + def on_inode_recovery_clicked(self, button): + print("Inode recovery started.") -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}...") + def on_partition_recovery_clicked(self, button): + print("Partition recovery started.") + + 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 From 4fe607063fbf29fae5e94d156bed9c813a37766c Mon Sep 17 00:00:00 2001 From: nots1dd Date: Wed, 11 Sep 2024 11:38:44 +0530 Subject: [PATCH 04/16] [FEAT] partition-details refresh + more UI --- main.py | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 064fe16..b635a20 100644 --- a/main.py +++ b/main.py @@ -91,18 +91,25 @@ def create_statistics_screen(self): 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) @@ -113,6 +120,25 @@ 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) @@ -138,7 +164,7 @@ def create_details_section(self, parent_box): self.refresh_partition_details() - def refresh_partition_details(self): + def refresh_partition_details(self, widget=None): try: result = subprocess.run(["lsblk", "-o", "NAME,FSTYPE,LABEL,SIZE,TYPE,MOUNTPOINT"], capture_output=True, text=True) if result.returncode == 0: @@ -157,9 +183,9 @@ def create_controls_section(self, parent_box): self.start_button.connect("clicked", self.on_start_recovery_clicked) button_box.pack_start(self.start_button, False, False, 0) - exit_button = Gtk.Button(label="Exit") - exit_button.connect("clicked", self.on_exit_clicked) - button_box.pack_start(exit_button, False, False, 0) + refresh_button = Gtk.Button(label="Refresh Partition Details") + refresh_button.connect("clicked", self.refresh_partition_details) + button_box.pack_start(refresh_button, False, False, 0) def create_recovery_options(self, parent_box): """Creates the buttons for recovery options on the statistics screen.""" @@ -206,7 +232,7 @@ def show_manual(self): 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" From 2af296289cdc85b6293daedd92bf73bf050cf3ee Mon Sep 17 00:00:00 2001 From: nots1dd Date: Wed, 11 Sep 2024 12:06:54 +0530 Subject: [PATCH 05/16] [FEAT] recovery proc UI setup --- README.md | 4 ++ main.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 4 deletions(-) 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 b635a20..707c4a8 100644 --- a/main.py +++ b/main.py @@ -242,8 +242,8 @@ def show_manual(self): " - 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() @@ -279,11 +279,130 @@ def update_stats_screen(self, drive_text): # have to implement our own function to display statistics buffer.set_text(f"Drive Statistics for {drive_text}:\n\n- Total Space: 500 GB\n- Used: 120 GB\n- Free: 380 GB") + def on_inode_recovery_clicked(self, button): - print("Inode recovery started.") + self.show_recovery_dialog("Inode Recovery", "Enter details for Inode Recovery") def on_partition_recovery_clicked(self, button): - print("Partition recovery started.") + self.show_recovery_dialog("Partition Recovery", "Enter details for Partition Recovery") + + def show_recovery_dialog(self, title, action_desc): + 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) + + # File Path Entry + file_path_label = Gtk.Label(label="Restoration Path: *") + vbox.pack_start(file_path_label, False, False, 0) + file_path_entry = Gtk.Entry() + file_path_entry.set_placeholder_text("e.g., /path/to/restore") + file_path_entry.set_margin_bottom(5) + vbox.pack_start(file_path_entry, False, False, 0) + + # Filename Entry + filename_label = Gtk.Label(label="Filename: *") + vbox.pack_start(filename_label, False, False, 0) + filename_entry = Gtk.Entry() + filename_entry.set_placeholder_text("e.g., recovered_file.txt") + filename_entry.set_margin_bottom(5) + vbox.pack_start(filename_entry, False, False, 0) + + # Metadata (Date, Time) + date_label = Gtk.Label(label="Date (YYYY-MM-DD):") + vbox.pack_start(date_label, False, False, 0) + date_entry = Gtk.Entry() + date_entry.set_placeholder_text("e.g., 2024-09-10") + date_entry.set_margin_bottom(5) + vbox.pack_start(date_entry, False, False, 0) + + time_label = Gtk.Label(label="Time (HH:MM:SS):") + vbox.pack_start(time_label, False, False, 0) + time_entry = Gtk.Entry() + time_entry.set_placeholder_text("e.g., 14:30:00") + time_entry.set_margin_bottom(5) + vbox.pack_start(time_entry, 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, file_path_entry, filename_entry, date_entry, time_entry)) + 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, file_path_entry, filename_entry, date_entry, time_entry): + file_path = file_path_entry.get_text().strip() + filename = filename_entry.get_text().strip() + date = date_entry.get_text().strip() + time = time_entry.get_text().strip() + + # Validate input values + if not file_path: + self.show_error_message("Restoration Path cannot be empty.") + return + if not filename: + self.show_error_message("Filename cannot be empty.") + return + + # Process the input values + print(f"Restoration Path: {file_path}") + print(f"Filename: {filename}") + print(f"Date: {date}") + print(f"Time: {time}") + + # Close the dialog + dialog.destroy() + + # Add actual recovery logic here based on the collected inputs + + 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 is_valid_date(self, date_str): + """Check if the date is in YYYY-MM-DD format.""" + try: + year, month, day = map(int, date_str.split('-')) + return 1 <= month <= 12 and 1 <= day <= 31 + except (ValueError, TypeError): + return False + + def is_valid_time(self, time_str): + """Check if the time is in HH:MM:SS format.""" + try: + hour, minute, second = map(int, time_str.split(':')) + return 0 <= hour < 24 and 0 <= minute < 60 and 0 <= second < 60 + except (ValueError, TypeError): + return False + def on_exit_clicked(self, button): Gtk.main_quit() From 0b4abd1e97341eef0d4e6fcd7f504c62d3321160 Mon Sep 17 00:00:00 2001 From: AltSumpreme Date: Wed, 11 Sep 2024 12:16:00 +0530 Subject: [PATCH 06/16] changed drive format on another window --- main.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 064fe16..358cbb5 100644 --- a/main.py +++ b/main.py @@ -73,6 +73,7 @@ def create_statistics_screen(self): 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) @@ -249,9 +250,33 @@ def on_start_recovery_clicked(self, button): def update_stats_screen(self, drive_text): # Simulate gathering statistics + clean_drive_text = [] # Initialize the list to store cleaned drive information + filesystem_text = self.filesystem_combo.get_active_text() + for line in drive_text.splitlines(): + # Strip the line and check if it's not empty + if line.strip(): + # Remove unwanted characters from the line (like '├─' and '└─') + clean_line = line.replace("├─", "").replace("└─", "").strip() + + # Split the line and take only the necessary parts (device name and size) + parts = clean_line.split() + if len(parts) > 1: # Ensure there are enough elements in the line + clean_drive_text.append(f"/dev/{parts[0]} ({parts[2]})") + + # Join the cleaned drive text into a single string with new lines + driver = "\n".join(clean_drive_text) + + # Simulated statistics (replace with real stats if available) + total_space = "500 GB" + used_space = "120 GB" + free_space = "380 GB" + + # Update the text buffer in the text view with the statistics buffer = self.stats_textview.get_buffer() - # have to implement our own function to display statistics - buffer.set_text(f"Drive Statistics for {drive_text}:\n\n- Total Space: 500 GB\n- Used: 120 GB\n- Free: 380 GB") + buffer.set_text(f"Drive Statistics for {driver}:\n\n" + f"- Total Space: {total_space}\n" + f"- Used: {used_space}\n" + f"- Free: {free_space}") def on_inode_recovery_clicked(self, button): print("Inode recovery started.") From c2e39fd0b93c45d6665bb538219a97872cbaad6a Mon Sep 17 00:00:00 2001 From: nots1dd Date: Wed, 11 Sep 2024 13:29:19 +0530 Subject: [PATCH 07/16] [CHORE] update_stats_screen conf --- main.py | 47 ++++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/main.py b/main.py index 3d55dec..4cafb2c 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import subprocess gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk +import re class SaveMyNodeApp(Gtk.Window): def __init__(self): @@ -91,8 +92,6 @@ def create_statistics_screen(self): self.create_recovery_options(box) return box - - def create_selection_section(self, parent_box): frame = Gtk.Frame(label="Select Filesystem and Drive") @@ -275,34 +274,44 @@ def on_start_recovery_clicked(self, button): self.update_stats_screen(drive_text) def update_stats_screen(self, drive_text): - # Simulate gathering statistics - clean_drive_text = [] # Initialize the list to store cleaned drive information + # Initialize a list to store cleaned drive information + clean_drive_text = [] filesystem_text = self.filesystem_combo.get_active_text() + for line in drive_text.splitlines(): - # Strip the line and check if it's not empty - if line.strip(): - # Remove unwanted characters from the line (like '├─' and '└─') - clean_line = line.replace("├─", "").replace("└─", "").strip() + # 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] - # Split the line and take only the necessary parts (device name and size) - parts = clean_line.split() - if len(parts) > 1: # Ensure there are enough elements in the line - clean_drive_text.append(f"/dev/{parts[0]} ({parts[2]})") + # Check if the size is empty or null + if not device_size: + device_size = "Size information unavailable" + + clean_drive_text.append(f"/dev/{device_name} ({device_size})") + else: + # Handle lines that do not have enough parts + clean_drive_text.append(f"/dev/{parts[0]} (null)") # Join the cleaned drive text into a single string with new lines driver = "\n".join(clean_drive_text) - + # Simulated statistics (replace with real stats if available) total_space = "500 GB" used_space = "120 GB" free_space = "380 GB" - - # Update the text buffer in the text view with the statistics + + # Update the statistics screen with the collected data buffer = self.stats_textview.get_buffer() - buffer.set_text(f"Drive Statistics for {driver}:\n\n" - f"- Total Space: {total_space}\n" - f"- Used: {used_space}\n" - f"- Free: {free_space}") + 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}") def on_inode_recovery_clicked(self, button): From 3eb69cba3c6cbedb3ac8e57568d330dc30cc0c73 Mon Sep 17 00:00:00 2001 From: Sasikuttan2163 Date: Wed, 11 Sep 2024 13:29:23 +0530 Subject: [PATCH 08/16] feat: add bash scripts --- __pycache__/log_helper.cpython-312.pyc | Bin 0 -> 381 bytes .../recovery_operations.cpython-312.pyc | Bin 0 -> 1437 bytes scripts/btrfs/btrfs-recover.sh | 133 ++++++++++++++++++ scripts/btrfs/dry-run.sh | 77 ++++++++++ scripts/btrfs/generate-regex.sh | 24 ++++ scripts/btrfs/mount-check.sh | 12 ++ scripts/smn_btrfs.sh | 12 +- 7 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 __pycache__/log_helper.cpython-312.pyc create mode 100644 __pycache__/recovery_operations.cpython-312.pyc create mode 100755 scripts/btrfs/btrfs-recover.sh create mode 100755 scripts/btrfs/dry-run.sh create mode 100755 scripts/btrfs/generate-regex.sh create mode 100755 scripts/btrfs/mount-check.sh diff --git a/__pycache__/log_helper.cpython-312.pyc b/__pycache__/log_helper.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f850381862745843edab52785f669900dc2e393 GIT binary patch literal 381 zcmX@j%ge<81ZoZs(o%u+V-N=h7@>^MASKfoQW#noq8KU}HJOr`U`l{AD-bgS@nWWwMeg^@)lQOK>^5UplA^%&?Jysi)DdC z1H%m#u6Ex>-wA5hg;XyJsa_URUm>!AWu^307Sqo_1q^CU}j`w{LIG4Xa-gb0Arp|w*UYD literal 0 HcmV?d00001 diff --git a/__pycache__/recovery_operations.cpython-312.pyc b/__pycache__/recovery_operations.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a749211dcf6100e72786ab92c593e608f998e68e GIT binary patch literal 1437 zcmeHHOHUI~6ux(+ofZ&Ec?1zjCQ9TXP#=-R0;L7f9cp+8B%~?5w^C?3cxR@xq!nXa z@DErJk}jEG(ydz(cW#CtflOGKuyRM}##9%c=>sA0@drG~J#)^z-~G;=GvC}#)zy^< z?3VXS$jc*iVkTwdNbzwWoO^r1-@`H!dUo#UJEmpf_p$H9uZ@uHY4v;AhYZ?zD}q*32s` zB#Vs7io~dxuN7U42BkPZm4#z3#mh*ys0l`;tj;ep$=qRF2`P7;Q2qn*o-~z896tSAcuo4!IQ^OXNqR~K%uo`HLfvIeM6cH@%aVXHL;cu7^1 zKNs5|(0e?{df`}+9ZJJ^i-tl<9({ZB%#O@w5>8Y4796)N_`MP>?Rf|<9$ zYV-8targ&*r{|VSdcKHri_H98nFHLp>R3HwMpaolP?R;njMK#Y5{?gq)0UacJkf9W z>vaQv7f#o9Uv^L%+!@ll`gO;EZXdWT93TU6JS(^&2 +# exit 1 +# fi + +# Perform actions based on the provided arguments +echo "Device: $dev" +echo "File Path: $file_path" +echo "Recovery Path: $recovery_path" + +# Sanitize filepath +function sanitize_filepath() { + # Check is first character is a /, if so ignore it + if [[ $file_path == /* ]]; then + file_path=$(echo "$file_path"| cut -c2-) + fi + echo "" + if [[ $file_path == */ ]]; then + rectype="dir" + recname="$dirname" + file_path+=".*" + else + rectype="file" + recname="$filename" + fi + echo "Sanitized $file_path" + +} + +# Makes regex to find files which match. +# Regex inspired from @danthem's script +function cook_regex() { + cmd="$(dirname $0)/generate-regex.sh $file_path" + regex="$(bash ./$cmd)" +} + +function dryrun_with_depth_levels() { + echo $depth + cmd="$(dirname $0)/dry-run.sh $depth $dev $regex" + res="$(bash $cmd)" + echo "$cmd" +} + +is_mounted +sanitize_filepath +cook_regex +dryrun_with_depth_levels + +echo $res + +# Get last directory in path and cut out filepath +# and dir name separately +# dir=$(echo "$file_path" | awk -F"/" '{ print $(NF-1) }') +# file=$(echo "$file_path" | awk -F"/" '{ print $NF }') + +# echo "Device: $dev Destination: $dest file: $dir / $file" \ No newline at end of file diff --git a/scripts/btrfs/dry-run.sh b/scripts/btrfs/dry-run.sh new file mode 100755 index 0000000..79e939a --- /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 +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 + cat $tmp + fi +} + +function recover(){ + if [[ $depth = "0" ]]; then + btrfs restore -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null & + recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) + # Find and delete empty recovered files, no point in keeping them around. + find "$dst" -empty -type f -delete + elif [[ $depth == "1" ]]; then + while read -r i || [[ -n "$i" ]]; do + 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 + btrfs restore -t "$i" -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null + find "$dst" -empty -type f -delete + 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 index a3f1c04..e7ed640 100644 --- a/scripts/smn_btrfs.sh +++ b/scripts/smn_btrfs.sh @@ -1,5 +1,13 @@ -#!/bin/bash - +#!/usr/bin/env bash +#Author: Daniel Elf +#Tested w/ btrfs-progs v5.19.1 +#Description: Somewhat interactive "undeleter" for BTRFS file systems. +# This will not work for every file in every scenario +# The best 'undeletion' you can do is to recover from backup :-) +#Syntax: ./undeletebtrfs.sh +#Example: ./undeletebtrfs.sh /dev/sda1 /mnt/undeleted +#NOTE: device must be unmounted +# var declarations dev=$1 dst=$2 roots="/tmp/btrfsroots.tmp" From 193bc520eb2071e02cea3de679c1de94e5b67f80 Mon Sep 17 00:00:00 2001 From: Sasikuttan2163 Date: Wed, 11 Sep 2024 13:48:43 +0530 Subject: [PATCH 09/16] added python xfs inode scan --- __pycache__/log_helper.cpython-312.pyc | Bin 381 -> 0 bytes __pycache__/recovery_operations.cpython-312.pyc | Bin 1437 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __pycache__/log_helper.cpython-312.pyc delete mode 100644 __pycache__/recovery_operations.cpython-312.pyc diff --git a/__pycache__/log_helper.cpython-312.pyc b/__pycache__/log_helper.cpython-312.pyc deleted file mode 100644 index 0f850381862745843edab52785f669900dc2e393..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 381 zcmX@j%ge<81ZoZs(o%u+V-N=h7@>^MASKfoQW#noq8KU}HJOr`U`l{AD-bgS@nWWwMeg^@)lQOK>^5UplA^%&?Jysi)DdC z1H%m#u6Ex>-wA5hg;XyJsa_URUm>!AWu^307Sqo_1q^CU}j`w{LIG4Xa-gb0Arp|w*UYD diff --git a/__pycache__/recovery_operations.cpython-312.pyc b/__pycache__/recovery_operations.cpython-312.pyc deleted file mode 100644 index a749211dcf6100e72786ab92c593e608f998e68e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1437 zcmeHHOHUI~6ux(+ofZ&Ec?1zjCQ9TXP#=-R0;L7f9cp+8B%~?5w^C?3cxR@xq!nXa z@DErJk}jEG(ydz(cW#CtflOGKuyRM}##9%c=>sA0@drG~J#)^z-~G;=GvC}#)zy^< z?3VXS$jc*iVkTwdNbzwWoO^r1-@`H!dUo#UJEmpf_p$H9uZ@uHY4v;AhYZ?zD}q*32s` zB#Vs7io~dxuN7U42BkPZm4#z3#mh*ys0l`;tj;ep$=qRF2`P7;Q2qn*o-~z896tSAcuo4!IQ^OXNqR~K%uo`HLfvIeM6cH@%aVXHL;cu7^1 zKNs5|(0e?{df`}+9ZJJ^i-tl<9({ZB%#O@w5>8Y4796)N_`MP>?Rf|<9$ zYV-8targ&*r{|VSdcKHri_H98nFHLp>R3HwMpaolP?R;njMK#Y5{?gq)0UacJkf9W z>vaQv7f#o9Uv^L%+!@ll`gO;EZXdWT93TU6JS(^ Date: Wed, 11 Sep 2024 13:49:01 +0530 Subject: [PATCH 10/16] add python inode xfs scnaner --- recover_xfs.py | 188 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 recover_xfs.py diff --git a/recover_xfs.py b/recover_xfs.py new file mode 100644 index 0000000..bd8ce6b --- /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() \ No newline at end of file From 24647c48024005c9274a69f07a13787d39c80d91 Mon Sep 17 00:00:00 2001 From: Sasikuttan2163 Date: Wed, 11 Sep 2024 14:08:26 +0530 Subject: [PATCH 11/16] feat: recovery implemented --- scripts/btrfs/btrfs-recover.sh | 15 +++++++++++++-- scripts/btrfs/dry-run.sh | 11 +++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/btrfs/btrfs-recover.sh b/scripts/btrfs/btrfs-recover.sh index b6e00a6..02b29c8 100755 --- a/scripts/btrfs/btrfs-recover.sh +++ b/scripts/btrfs/btrfs-recover.sh @@ -8,6 +8,7 @@ function usage() { 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" } @@ -59,6 +60,10 @@ while [[ $# -gt 0 ]]; do depth="$2" shift 2 ;; + -R|--recover) + recover="$2" + shift 2 + ;; *) echo "Unknown argument: $1" usage @@ -100,7 +105,7 @@ function sanitize_filepath() { rectype="file" recname="$filename" fi - echo "Sanitized $file_path" + echo "Sanitized filepath: $file_path" } @@ -110,9 +115,12 @@ function cook_regex() { cmd="$(dirname $0)/generate-regex.sh $file_path" regex="$(bash ./$cmd)" } +function recover() { + cmd="$(dirname $0)/dry-run.sh $depth $dev $regex 1 $recovery_path" + regex="$(bash ./$cmd)" +} function dryrun_with_depth_levels() { - echo $depth cmd="$(dirname $0)/dry-run.sh $depth $dev $regex" res="$(bash $cmd)" echo "$cmd" @@ -123,6 +131,9 @@ sanitize_filepath cook_regex dryrun_with_depth_levels +if [[ $recover -eq 1 ]]; then + recover +fi echo $res # Get last directory in path and cut out filepath diff --git a/scripts/btrfs/dry-run.sh b/scripts/btrfs/dry-run.sh index 79e939a..51cabf9 100755 --- a/scripts/btrfs/dry-run.sh +++ b/scripts/btrfs/dry-run.sh @@ -9,6 +9,8 @@ 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" @@ -49,21 +51,18 @@ function checkresult(){ function recover(){ if [[ $depth = "0" ]]; then - btrfs restore -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null & + sudo btrfs restore -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null & recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) - # Find and delete empty recovered files, no point in keeping them around. - find "$dst" -empty -type f -delete elif [[ $depth == "1" ]]; then while read -r i || [[ -n "$i" ]]; do - btrfs restore -t "$i" -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null + 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 - btrfs restore -t "$i" -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null - find "$dst" -empty -type f -delete + sudo btrfs restore -t "$i" -ivv --path-regex '^/'${regex}'$' "$dev" "$dst" &> /dev/null done < "$roots" & recoveredfiles=$(find "$dst" ! -empty -type f | wc -l) fi From a1ad325c69910921815a141d9e4b1c93800fb1c5 Mon Sep 17 00:00:00 2001 From: BufferFis Date: Wed, 11 Sep 2024 14:24:07 +0000 Subject: [PATCH 12/16] FIX: beta xfs data extraction script accepts sys argv --- recover_xfs.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/recover_xfs.py b/recover_xfs.py index bd8ce6b..22b1dae 100644 --- a/recover_xfs.py +++ b/recover_xfs.py @@ -10,7 +10,7 @@ XFS_EXTENT_FORMAT = 2 # Extent format class XFSSuperblock: - def _init_(self, data): + 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] @@ -33,7 +33,7 @@ def display_info(self): print(f" UUID: {self.uuid.hex()}") class XFSInode: - def _init_(self, data): + 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] @@ -47,7 +47,7 @@ def is_deleted(self): return self.nlink == 0 and self.magic == XFS_DINODE_MAGIC class XFSFileRecovery: - def _init_(self, image_path): + def __init__(self, image_path): self.image_path = image_path self.fd = None self.superblock = None @@ -174,7 +174,7 @@ def run(self): self.read_inodes() self.close_image() -if _name_ == "_main_": +if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: python xfs_recovery.py ") sys.exit(1) @@ -185,4 +185,4 @@ def run(self): sys.exit(1) recovery_tool = XFSFileRecovery(image_path) - recovery_tool.run() \ No newline at end of file + recovery_tool.run() From 79527bd8a60e8a515885ab8a5276df16efbf1668 Mon Sep 17 00:00:00 2001 From: nots1dd Date: Wed, 11 Sep 2024 15:05:59 +0530 Subject: [PATCH 13/16] [FEAT] backend integration conf --- main.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 149 insertions(+), 15 deletions(-) diff --git a/main.py b/main.py index 4cafb2c..a608fcc 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import gi +import os import subprocess gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk @@ -141,7 +142,7 @@ def on_clear_button_clicked(self, widget): 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:]: @@ -166,7 +167,7 @@ def create_details_section(self, parent_box): 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) @@ -200,6 +201,12 @@ def create_recovery_options(self, parent_box): partition_recovery_button.connect("clicked", self.on_partition_recovery_clicked) button_box.pack_start(partition_recovery_button, False, False, 0) + partition_recovery_button = Gtk.Button(label="Dry Run") + pattern = ".*" + command = f'./btrfs-recover.sh -d /dev/nvme0n1p7 -fp "{pattern}" -rp /tmp -D 2' + partition_recovery_button.connect("clicked", lambda w: self.dry_run(command)) + button_box.pack_start(partition_recovery_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) @@ -273,11 +280,14 @@ def on_start_recovery_clicked(self, button): self.stack.set_visible_child_name("statistics") self.update_stats_screen(drive_text) + 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() @@ -291,29 +301,50 @@ def update_stats_screen(self, drive_text): # 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})") - else: - # Handle lines that do not have enough parts - clean_drive_text.append(f"/dev/{parts[0]} (null)") + drive_names.append(f"/dev/{device_name}") # Join the cleaned drive text into a single string with new lines - driver = "\n".join(clean_drive_text) - - # Simulated statistics (replace with real stats if available) - total_space = "500 GB" - used_space = "120 GB" - free_space = "380 GB" + 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}") + # Update the statistics screen with the collected data 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}") + f"Drive Details:\n{driver_details}") - def on_inode_recovery_clicked(self, button): self.show_recovery_dialog("Inode Recovery", "Enter details for Inode Recovery") @@ -438,6 +469,109 @@ def is_valid_time(self, time_str): return False + def dry_run(self, command): + # Create a new top-level window for the dry run + dialog = Gtk.Window(title="Dry Run Output") + dialog.set_default_size(600, 400) + dialog.set_position(Gtk.WindowPosition.CENTER) + + # 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:") + 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="Command Output:" + ) + 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 + close_button = Gtk.Button(label="Close") + close_button.connect("clicked", lambda w: dialog.destroy()) + vbox.pack_start(close_button, False, False, 6) + + # Show all widgets in the window + dialog.show_all() + os.chdir("../..") # quite redundant but words + + + def on_exit_clicked(self, button): Gtk.main_quit() From 45557adde57e0b7b10a62785d7917851ddc03f0f Mon Sep 17 00:00:00 2001 From: nots1dd Date: Wed, 25 Sep 2024 23:01:24 +0530 Subject: [PATCH 14/16] [FEAT] UI changes --- .gitignore | 2 + main.py | 309 +++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 231 insertions(+), 80 deletions(-) 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/main.py b/main.py index a608fcc..3c08a48 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import gi import os +import cairo import subprocess gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk @@ -8,6 +9,7 @@ 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) @@ -48,6 +50,67 @@ def __init__(self): 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") @@ -150,6 +213,31 @@ def populate_drive_combo(self): except Exception as 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") parent_box.pack_start(frame, True, True, 10) @@ -165,6 +253,24 @@ def create_details_section(self, parent_box): self.refresh_partition_details() + 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,SIZE,MOUNTPOINT", "--noheadings"], capture_output=True, text=True) @@ -203,8 +309,10 @@ def create_recovery_options(self, parent_box): partition_recovery_button = Gtk.Button(label="Dry Run") pattern = ".*" - command = f'./btrfs-recover.sh -d /dev/nvme0n1p7 -fp "{pattern}" -rp /tmp -D 2' - partition_recovery_button.connect("clicked", lambda w: self.dry_run(command)) + 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) def show_error_message(self, error_message): @@ -280,7 +388,6 @@ def on_start_recovery_clicked(self, button): self.stack.set_visible_child_name("statistics") self.update_stats_screen(drive_text) - def update_stats_screen(self, drive_text): # Initialize a list to store cleaned drive information clean_drive_text = [] @@ -337,21 +444,85 @@ def update_stats_screen(self, drive_text): except Exception as e: self.show_error_message(f"Error retrieving drive statistics: {e}") - # Update the statistics screen with the collected data + # 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}") + 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): + 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 + + # Here you can add your recovery logic using the selected file types + print(f"Selected file types: {', '.join(selected_file_types)}") + self.show_success_message("Recovery started successfully!") 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): - self.show_recovery_dialog("Partition Recovery", "Enter details for Partition Recovery") + # 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) + ) + file_chooser.set_modal(True) + + response = file_chooser.run() - def show_recovery_dialog(self, title, action_desc): + if response == Gtk.ResponseType.OK: + restoration_path = file_chooser.get_filename() + file_chooser.destroy() + + # Step 2: Show recovery dialog for selecting file types + self.show_recovery_dialog("Partition Recovery", "Select file types and proceed", restoration_path) + else: + file_chooser.destroy() + + 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) @@ -363,36 +534,27 @@ def show_recovery_dialog(self, title, action_desc): description_label = Gtk.Label(label=action_desc) vbox.pack_start(description_label, False, False, 0) - # File Path Entry - file_path_label = Gtk.Label(label="Restoration Path: *") - vbox.pack_start(file_path_label, False, False, 0) - file_path_entry = Gtk.Entry() - file_path_entry.set_placeholder_text("e.g., /path/to/restore") - file_path_entry.set_margin_bottom(5) - vbox.pack_start(file_path_entry, False, False, 0) - - # Filename Entry - filename_label = Gtk.Label(label="Filename: *") - vbox.pack_start(filename_label, False, False, 0) - filename_entry = Gtk.Entry() - filename_entry.set_placeholder_text("e.g., recovered_file.txt") - filename_entry.set_margin_bottom(5) - vbox.pack_start(filename_entry, False, False, 0) - - # Metadata (Date, Time) - date_label = Gtk.Label(label="Date (YYYY-MM-DD):") - vbox.pack_start(date_label, False, False, 0) - date_entry = Gtk.Entry() - date_entry.set_placeholder_text("e.g., 2024-09-10") - date_entry.set_margin_bottom(5) - vbox.pack_start(date_entry, False, False, 0) - - time_label = Gtk.Label(label="Time (HH:MM:SS):") - vbox.pack_start(time_label, False, False, 0) - time_entry = Gtk.Entry() - time_entry.set_placeholder_text("e.g., 14:30:00") - time_entry.set_margin_bottom(5) - vbox.pack_start(time_entry, 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) @@ -401,7 +563,7 @@ def show_recovery_dialog(self, title, action_desc): # Add OK and Cancel buttons ok_button = Gtk.Button.new_with_label("OK") - ok_button.connect("clicked", lambda w: self.on_dialog_response(dialog, file_path_entry, filename_entry, date_entry, time_entry)) + 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") @@ -410,25 +572,17 @@ def show_recovery_dialog(self, title, action_desc): dialog.show_all() - def on_dialog_response(self, dialog, file_path_entry, filename_entry, date_entry, time_entry): - file_path = file_path_entry.get_text().strip() - filename = filename_entry.get_text().strip() - date = date_entry.get_text().strip() - time = time_entry.get_text().strip() - - # Validate input values - if not file_path: - self.show_error_message("Restoration Path cannot be empty.") + def on_dialog_response(self, dialog, restoration_path, file_type_checkboxes): + selected_file_types = [checkbox.get_label() 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 - if not filename: - self.show_error_message("Filename cannot be empty.") - return # Process the input values - print(f"Restoration Path: {file_path}") - print(f"Filename: {filename}") - print(f"Date: {date}") - print(f"Time: {time}") + print(f"Restoration Path: {restoration_path}") + print(f"Selected File Types: {', '.join(selected_file_types)}") # Close the dialog dialog.destroy() @@ -452,25 +606,26 @@ def show_error_message(self, error_message): dialog.show_all() - def is_valid_date(self, date_str): - """Check if the date is in YYYY-MM-DD format.""" - try: - year, month, day = map(int, date_str.split('-')) - return 1 <= month <= 12 and 1 <= day <= 31 - except (ValueError, TypeError): - return False + 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) - def is_valid_time(self, time_str): - """Check if the time is in HH:MM:SS format.""" - try: - hour, minute, second = map(int, time_str.split(':')) - return 0 <= hour < 24 and 0 <= minute < 60 and 0 <= second < 60 - except (ValueError, TypeError): - return False + # 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 - def dry_run(self, command): - # Create a new top-level window for the dry run dialog = Gtk.Window(title="Dry Run Output") dialog.set_default_size(600, 400) dialog.set_position(Gtk.WindowPosition.CENTER) @@ -561,13 +716,7 @@ def dry_run(self, command): error_dialog.run() error_dialog.destroy() - # Create a close button - close_button = Gtk.Button(label="Close") - close_button.connect("clicked", lambda w: dialog.destroy()) - vbox.pack_start(close_button, False, False, 6) - - # Show all widgets in the window - dialog.show_all() + # Create a close button os.chdir("../..") # quite redundant but words From 20fbd3b9e8146ab65a0fdf4cf1473f9915bd97a4 Mon Sep 17 00:00:00 2001 From: nots1dd Date: Fri, 27 Sep 2024 20:41:36 +0530 Subject: [PATCH 15/16] [FEAT] style changes --- main.py | 75 +++++++-- scripts/NOTE.md | 3 + scripts/smn_btrfs.sh | 367 ------------------------------------------- styles.css | 16 ++ 4 files changed, 81 insertions(+), 380 deletions(-) create mode 100644 scripts/NOTE.md delete mode 100644 scripts/smn_btrfs.sh diff --git a/main.py b/main.py index 3c08a48..2723514 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import gi +from gi.repository import GLib import os import cairo import subprocess @@ -282,18 +283,20 @@ def refresh_partition_details(self, widget=None): except Exception as e: self.show_error_message(f"Error: {e}") + def create_controls_section(self, parent_box): button_box = Gtk.Box(spacing=10) parent_box.pack_start(button_box, False, False, 10) - self.start_button = Gtk.Button(label="Start Recovery") + 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) - refresh_button = Gtk.Button(label="Refresh Partition Details") + 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) + 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) @@ -375,6 +378,7 @@ 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() @@ -383,11 +387,21 @@ def on_start_recovery_clicked(self, button): self.show_error_message("Please select both filesystem and drive.") return - # Switch to the statistics screen + # 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 = [] @@ -487,16 +501,29 @@ def update_stats_screen(self, drive_text): 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 - # Here you can add your recovery logic using the selected file types - print(f"Selected file types: {', '.join(selected_file_types)}") - self.show_success_message("Recovery started successfully!") + # 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)}" + ) + + # Retrieve the buffer from the stats textview (assuming stats_textview exists) + buffer = self.stats_textview.get_buffer() + buffer.set_text(recovery_message) + + # 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") @@ -573,21 +600,43 @@ def show_recovery_dialog(self, title, action_desc, restoration_path): dialog.show_all() def on_dialog_response(self, dialog, restoration_path, file_type_checkboxes): - selected_file_types = [checkbox.get_label() for checkbox in file_type_checkboxes if checkbox.get_active()] - + # 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 - # Process the input values - print(f"Restoration Path: {restoration_path}") - print(f"Selected File Types: {', '.join(selected_file_types)}") + # 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) + + # 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 # Close the dialog dialog.destroy() - # Add actual recovery logic here based on the collected inputs + # 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.""" 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/smn_btrfs.sh b/scripts/smn_btrfs.sh deleted file mode 100644 index e7ed640..0000000 --- a/scripts/smn_btrfs.sh +++ /dev/null @@ -1,367 +0,0 @@ -#!/usr/bin/env bash -#Author: Daniel Elf -#Tested w/ btrfs-progs v5.19.1 -#Description: Somewhat interactive "undeleter" for BTRFS file systems. -# This will not work for every file in every scenario -# The best 'undeletion' you can do is to recover from backup :-) -#Syntax: ./undeletebtrfs.sh -#Example: ./undeletebtrfs.sh /dev/sda1 /mnt/undeleted -#NOTE: device must be unmounted -# var declarations -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; +} + From cd716b700aa2a2ffc7eea496f0c3593b92909585 Mon Sep 17 00:00:00 2001 From: nots1dd Date: Sun, 29 Sep 2024 20:04:24 +0530 Subject: [PATCH 16/16] [FEAT] script change+UI --- main.py | 4 +- scripts/btrfs/btrfs-recover.sh | 85 +++++++++++++++++++--------------- scripts/btrfs/dry-run.sh | 1 + 3 files changed, 51 insertions(+), 39 deletions(-) diff --git a/main.py b/main.py index 2723514..e563ea4 100644 --- a/main.py +++ b/main.py @@ -684,7 +684,7 @@ def dry_run(self, command): dialog.add(vbox) # Create a label for the title - title_label = Gtk.Label(label="Command Output:") + 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 @@ -733,7 +733,7 @@ def dry_run(self, command): flags=Gtk.DialogFlags.MODAL, type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, - message_format="Command Output:" + message_format="Files found:\n" ) output_dialog.format_secondary_text(stdout) output_dialog.run() diff --git a/scripts/btrfs/btrfs-recover.sh b/scripts/btrfs/btrfs-recover.sh index 02b29c8..12eaba1 100755 --- a/scripts/btrfs/btrfs-recover.sh +++ b/scripts/btrfs/btrfs-recover.sh @@ -4,20 +4,23 @@ 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" + 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. Unmount the device before proceeding." + echo "ERROR: Device '$dev' is mounted at '$res'." + echo "Please unmount the device before proceeding." exit 1 fi } @@ -27,7 +30,7 @@ dev="" file_path="" recovery_path="" -# Function to validate path +# Function to validate the path validate_path() { if [ ! -e "$1" ]; then echo "Error: Path '$1' does not exist." @@ -79,66 +82,74 @@ if [ -z "$dev" ] || [ -z "$file_path" ] || [ -z "$recovery_path" ] || [ -z "$dep exit 1 fi -# Check if required arguments are provided -# if [[ -z "$dev" || -z "$file_path" || -z "$recovery_path" ]]; then -# echo "Error: Missing required arguments. Use -h for help." >&2 -# exit 1 -# fi - -# Perform actions based on the provided arguments -echo "Device: $dev" -echo "File Path: $file_path" -echo "Recovery Path: $recovery_path" - -# Sanitize filepath +# Function to sanitize file path function sanitize_filepath() { - # Check is first character is a /, if so ignore it + # If the file path starts with '/', remove it if [[ $file_path == /* ]]; then - file_path=$(echo "$file_path"| cut -c2-) + 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+=".*" + file_path+=".*" # Append .* for matching all files in the directory else rectype="file" recname="$filename" fi - echo "Sanitized filepath: $file_path" + echo "Sanitized file path: '$file_path'" } -# Makes regex to find files which match. -# Regex inspired from @danthem's script +# 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 recover() { - cmd="$(dirname $0)/dry-run.sh $depth $dev $regex 1 $recovery_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 -echo $res - -# Get last directory in path and cut out filepath -# and dir name separately -# dir=$(echo "$file_path" | awk -F"/" '{ print $(NF-1) }') -# file=$(echo "$file_path" | awk -F"/" '{ print $NF }') -# echo "Device: $dev Destination: $dest file: $dir / $file" \ No newline at end of file +# Output the result of the operation +echo "$res" diff --git a/scripts/btrfs/dry-run.sh b/scripts/btrfs/dry-run.sh index 51cabf9..62db1ba 100755 --- a/scripts/btrfs/dry-run.sh +++ b/scripts/btrfs/dry-run.sh @@ -45,6 +45,7 @@ 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 }