diff --git a/dist/renamer-0.6.8-py3-none-any.whl b/dist/renamer-0.6.8-py3-none-any.whl new file mode 100644 index 0000000..9155dd2 Binary files /dev/null and b/dist/renamer-0.6.8-py3-none-any.whl differ diff --git a/pyproject.toml b/pyproject.toml index 21c27ff..5376431 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "renamer" -version = "0.6.7" +version = "0.6.8" description = "Terminal-based media file renamer and metadata viewer" readme = "README.md" requires-python = ">=3.11" diff --git a/renamer/app.py b/renamer/app.py index faedb93..720878e 100644 --- a/renamer/app.py +++ b/renamer/app.py @@ -70,6 +70,7 @@ class AppCommandProvider(Provider): ("refresh", "Refresh File", "Refresh metadata for selected file (f)"), ("rename", "Rename File", "Rename the selected file (r)"), ("convert", "Convert AVI to MKV", "Convert AVI file to MKV container with metadata (c)"), + ("delete", "Delete File", "Delete the selected file (d)"), ("toggle_mode", "Toggle Display Mode", "Switch between technical and catalog view (m)"), ("expand", "Toggle Tree Expansion", "Expand or collapse all tree nodes (p)"), ("settings", "Settings", "Open settings screen (Ctrl+S)"), @@ -105,6 +106,7 @@ class RenamerApp(App): ("f", "refresh", "Refresh"), ("r", "rename", "Rename"), ("c", "convert", "Convert AVI→MKV"), + ("d", "delete", "Delete"), ("p", "expand", "Toggle Tree"), ("m", "toggle_mode", "Toggle Mode"), ("h", "help", "Help"), @@ -394,6 +396,22 @@ By Category:""" ConvertConfirmScreen(file_path, mkv_path, audio_languages, subtitle_files, extractor) ) + async def action_delete(self): + """Delete a file with confirmation.""" + from .screens import DeleteConfirmScreen + + tree = self.query_one("#file_tree", Tree) + node = tree.cursor_node + + if not (node and node.data and isinstance(node.data, Path) and node.data.is_file()): + self.notify("Please select a file first", severity="warning", timeout=3) + return + + file_path = node.data + + # Show confirmation screen + self.push_screen(DeleteConfirmScreen(file_path)) + async def action_expand(self): tree = self.query_one("#file_tree", Tree) if self.tree_expanded: @@ -527,9 +545,113 @@ By Category:""" else: logging.warning(f"No parent node found for {parent_dir}") logging.warning(f"Rescanning entire tree instead") - # If we can't find the parent node, just rescan the whole tree + # If we can't find the parent node, rescan the tree and try to select the new file + tree = self.query_one("#file_tree", Tree) + current_selection = tree.cursor_node.data if tree.cursor_node else None + self.scan_files() + # Try to restore selection to the new file, or the old selection, or parent dir + def find_and_select(node, target_path): + if node.data and isinstance(node.data, Path): + if node.data.resolve() == target_path.resolve(): + tree.select_node(node) + return True + for child in node.children: + if find_and_select(child, target_path): + return True + return False + + # Try to select the new file first + if not find_and_select(tree.root, file_path): + # If that fails, try to restore previous selection + if current_selection: + find_and_select(tree.root, current_selection) + + # Refresh details panel for selected node + if tree.cursor_node and tree.cursor_node.data: + self._start_loading_animation() + threading.Thread( + target=self._extract_and_show_details, args=(tree.cursor_node.data,) + ).start() + + def remove_file_from_tree(self, file_path: Path): + """Remove a file from the tree. + + Args: + file_path: Path to the file to remove + """ + logging.info(f"remove_file_from_tree called with file_path={file_path}") + + tree = self.query_one("#file_tree", Tree) + + # Find the node to remove + def find_node(node): + if node.data and isinstance(node.data, Path): + if node.data.resolve() == file_path.resolve(): + return node + for child in node.children: + found = find_node(child) + if found: + return found + return None + + node_to_remove = find_node(tree.root) + + if node_to_remove: + logging.info(f"Found node to remove: {node_to_remove.data}") + + # Find the parent node to select after deletion + parent_node = node_to_remove.parent + next_node = None + + # Try to select next sibling, or previous sibling, or parent + if parent_node: + siblings = list(parent_node.children) + try: + current_index = siblings.index(node_to_remove) + # Try next sibling first + if current_index + 1 < len(siblings): + next_node = siblings[current_index + 1] + # Try previous sibling + elif current_index > 0: + next_node = siblings[current_index - 1] + # Fall back to parent + else: + next_node = parent_node if parent_node != tree.root else None + except ValueError: + pass + + # Remove the node + node_to_remove.remove() + logging.info(f"Removed node from tree") + + # Select the next appropriate node + if next_node: + tree.select_node(next_node) + logging.info(f"Selected next node: {next_node.data}") + + # Refresh details if it's a file + if next_node.data and isinstance(next_node.data, Path) and next_node.data.is_file(): + self._start_loading_animation() + threading.Thread( + target=self._extract_and_show_details, args=(next_node.data,) + ).start() + else: + # Clear details panel + details = self.query_one("#details_technical", Static) + details.update("Select a file to view details") + proposed = self.query_one("#proposed", Static) + proposed.update("") + else: + # No node to select, clear details + details = self.query_one("#details_technical", Static) + details.update("No files in directory") + proposed = self.query_one("#proposed", Static) + proposed.update("") + else: + logging.warning(f"Node not found for {file_path}") + def on_key(self, event): if event.key == "right": tree = self.query_one("#file_tree", Tree) diff --git a/renamer/screens.py b/renamer/screens.py index e51a3f8..d1dc27b 100644 --- a/renamer/screens.py +++ b/renamer/screens.py @@ -57,6 +57,8 @@ ACTIONS: • s: Scan - Refresh the current directory • f: Refresh - Reload metadata for selected file • r: Rename - Rename selected file with proposed name +• c: Convert - Convert AVI file to MKV container with metadata +• d: Delete - Delete selected file (with confirmation) • p: Expand/Collapse - Toggle expansion of selected directory • m: Toggle Mode - Switch between technical and catalog display modes • ctrl+s: Settings - Open settings window @@ -495,5 +497,110 @@ Do you want to proceed with conversion? # Simulate convert button press convert_button = self.query_one("#convert") self.on_button_pressed(type('Event', (), {'button': convert_button})()) + elif event.key == "n" or event.key == "escape": + self.app.pop_screen() # type: ignore + + +class DeleteConfirmScreen(Screen): + """Confirmation screen for file deletion.""" + + CSS = """ + #delete_content { + text-align: center; + } + Button:focus { + background: $primary; + } + #buttons { + align: center middle; + } + #file_details { + text-align: left; + margin: 1 2; + padding: 1 2; + border: solid $error; + } + #warning_content { + text-align: center; + margin-bottom: 1; + margin-top: 1; + } + """ + + def __init__(self, file_path: Path): + super().__init__() + self.file_path = file_path + + def compose(self): + from .formatters.text_formatter import TextFormatter + + title_text = f"{TextFormatter.bold(TextFormatter.red('DELETE FILE'))}" + + # Build file details + file_size = self.file_path.stat().st_size if self.file_path.exists() else 0 + from .formatters.size_formatter import SizeFormatter + size_str = SizeFormatter.format_size_full(file_size) + + details_lines = [ + f"{TextFormatter.bold('File:')} {TextFormatter.cyan(escape(self.file_path.name))}", + f"{TextFormatter.bold('Path:')} {TextFormatter.grey(escape(str(self.file_path.parent)))}", + f"{TextFormatter.bold('Size:')} {TextFormatter.yellow(size_str)}", + ] + + details_text = "\n".join(details_lines) + + warning_text = f""" +{TextFormatter.bold(TextFormatter.red("WARNING: This action cannot be undone!"))} +{TextFormatter.yellow("The file will be permanently deleted from your system.")} + +Are you sure you want to delete this file? + """.strip() + + with Center(): + with Vertical(): + yield Static(title_text, id="delete_content", markup=True) + yield Static(details_text, id="file_details", markup=True) + yield Static(warning_text, id="warning_content", markup=True) + with Horizontal(id="buttons"): + yield Button("No (n)", id="cancel", variant="primary") + yield Button("Yes (y)", id="delete", variant="error") + + def on_mount(self): + # Set focus to "No" button by default (safer option) + self.set_focus(self.query_one("#cancel")) + + def on_button_pressed(self, event): + if event.button.id == "delete": + # Delete the file + app = self.app # type: ignore + + try: + if self.file_path.exists(): + self.file_path.unlink() + app.notify(f"✓ Deleted: {self.file_path.name}", severity="information", timeout=3) + logging.info(f"File deleted: {self.file_path}") + + # Remove from tree + app.remove_file_from_tree(self.file_path) + else: + app.notify(f"✗ File not found: {self.file_path.name}", severity="error", timeout=3) + + except PermissionError: + app.notify(f"✗ Permission denied: Cannot delete {self.file_path.name}", severity="error", timeout=5) + logging.error(f"Permission denied deleting file: {self.file_path}") + except Exception as e: + app.notify(f"✗ Error deleting file: {e}", severity="error", timeout=5) + logging.error(f"Error deleting file {self.file_path}: {e}", exc_info=True) + + self.app.pop_screen() # type: ignore + else: + # Cancel + self.app.pop_screen() # type: ignore + + def on_key(self, event): + if event.key == "y": + # Simulate delete button press + delete_button = self.query_one("#delete") + self.on_button_pressed(type('Event', (), {'button': delete_button})()) elif event.key == "n" or event.key == "escape": self.app.pop_screen() # type: ignore \ No newline at end of file diff --git a/uv.lock b/uv.lock index 6ce70af..04c2cee 100644 --- a/uv.lock +++ b/uv.lock @@ -462,7 +462,7 @@ wheels = [ [[package]] name = "renamer" -version = "0.6.7" +version = "0.6.8" source = { editable = "." } dependencies = [ { name = "langcodes" },