From 05464cc4f92e67115a1f30c1936d5e42a7027f1a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 16 Aug 2023 18:18:45 +0530 Subject: [PATCH] Trash dialog: Allow right clicking on an entry to save it to disk. Fixes #2030342 [Trash Bin: Option to copy file out](https://bugs.launchpad.net/calibre/+bug/2030342) --- src/calibre/db/backend.py | 6 ++++++ src/calibre/db/cache.py | 12 +++++++++++ src/calibre/gui2/trash.py | 42 +++++++++++++++++++++++++++++++++------ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py index 43cf1feb6e..86648ed6c3 100644 --- a/src/calibre/db/backend.py +++ b/src/calibre/db/backend.py @@ -2098,6 +2098,12 @@ class DB: dest = os.path.abspath(os.path.join(self.library_path, path)) copy_tree(bdir, dest, delete_source=True) + def copy_book_from_trash(self, book_id, dest): + bdir = os.path.join(self.trash_dir, 'b', str(book_id)) + if not os.path.isdir(bdir): + raise ValueError(f'The book {book_id} not present in the trash folder') + copy_tree(bdir, dest, delete_source=False) + def path_for_trash_format(self, book_id, fmt): bdir = os.path.join(self.trash_dir, 'f', str(book_id)) if not os.path.isdir(bdir): diff --git a/src/calibre/db/cache.py b/src/calibre/db/cache.py index 878a5d8dca..3d16672a42 100644 --- a/src/calibre/db/cache.py +++ b/src/calibre/db/cache.py @@ -2702,6 +2702,14 @@ class Cache: e.cover_path = self.format_abspath(e.book_id, '__COVER_INTERNAL__') return books, formats + @read_api + def copy_format_from_trash(self, book_id, fmt, dest): + fmt = fmt.upper() + fpath = self.backend.path_for_trash_format(book_id, fmt) + if not fpath: + raise ValueError(f'No format {fmt} found in book {book_id}') + shutil.copyfile(fpath, dest) + @write_api def move_format_from_trash(self, book_id, fmt): ''' Undelete a format from the trash directory ''' @@ -2722,6 +2730,10 @@ class Cache: self.event_dispatcher(EventType.format_added, book_id, fmt) self.backend.remove_trash_formats_dir_if_empty(book_id) + @read_api + def copy_book_from_trash(self, book_id, dest: str): + self.backend.copy_book_from_trash(book_id, dest) + @write_api def move_book_from_trash(self, book_id): ''' Undelete a book from the trash directory ''' diff --git a/src/calibre/gui2/trash.py b/src/calibre/gui2/trash.py index 80a74a7bf6..d10101c9e2 100644 --- a/src/calibre/gui2/trash.py +++ b/src/calibre/gui2/trash.py @@ -6,14 +6,14 @@ import traceback from operator import attrgetter from qt.core import ( QAbstractItemView, QDialogButtonBox, QHBoxLayout, QIcon, QLabel, QListWidget, - QListWidgetItem, QPainter, QPalette, QPixmap, QRectF, QSize, QSpinBox, QStyle, - QStyledItemDelegate, Qt, QTabWidget, QVBoxLayout, pyqtSignal, + QListWidgetItem, QMenu, QPainter, QPalette, QPixmap, QRectF, QSize, QSpinBox, + QStyle, QStyledItemDelegate, Qt, QTabWidget, QVBoxLayout, pyqtSignal, ) from typing import Iterator, List from calibre import fit_image from calibre.db.constants import DEFAULT_TRASH_EXPIRY_TIME_SECONDS, TrashEntry -from calibre.gui2 import error_dialog +from calibre.gui2 import error_dialog, choose_dir, choose_save_file from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.widgets import BusyCursor from calibre.gui2.widgets2 import Dialog @@ -84,8 +84,9 @@ class TrashList(QListWidget): restore_item = pyqtSignal(object, object) - def __init__(self, entries: List[TrashEntry], parent: 'TrashView'): + def __init__(self, entries: List[TrashEntry], parent: 'TrashView', is_books: bool): super().__init__(parent) + self.is_books = is_books self.db = parent.db self.delegate = TrashItemDelegate(self) self.setItemDelegate(self.delegate) @@ -95,6 +96,8 @@ class TrashList(QListWidget): i.setData(Qt.ItemDataRole.UserRole, entry) self.addItem(i) self.itemDoubleClicked.connect(self.double_clicked) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.show_context_menu) @property def selected_entries(self) -> Iterator[TrashEntry]: @@ -104,6 +107,33 @@ class TrashList(QListWidget): def double_clicked(self, item): self.restore_item.emit(self, item) + def show_context_menu(self, pos): + item = self.itemAt(pos) + if item is None: + return + m = QMenu(self) + entry = item.data(Qt.ItemDataRole.UserRole) + m.addAction(QIcon.ic('save.png'), _('Save "{}" to disk').format(entry.title)).triggered.connect(self.save_current_item) + m.exec(self.mapToGlobal(pos)) + + def save_current_item(self): + item = self.currentItem() + if item is not None: + self.save_entry(item.data(Qt.ItemDataRole.UserRole)) + + def save_entry(self, entry: TrashEntry): + if self.is_books: + dest = choose_dir(self, 'save-trash-book', _('Choose a location to save: {}').format(entry.title)) + if not dest: + return + self.db.copy_book_from_trash(entry.book_id, dest) + else: + for fmt in entry.formats: + dest = choose_save_file(self, 'save-trash-format', _('Choose a location to save: {}').format( + entry.title +'.' + fmt.lower()), initial_filename=entry.title + '.' + fmt.lower()) + if dest: + self.db.copy_format_from_trash(entry.book_id, fmt, dest) + class TrashView(Dialog): @@ -122,9 +152,9 @@ class TrashView(Dialog): with BusyCursor(): books, formats = self.db.list_trash_entries() - self.books = TrashList(books, self) + self.books = TrashList(books, self, True) self.books.restore_item.connect(self.restore_item) - self.formats = TrashList(formats, self) + self.formats = TrashList(formats, self, False) self.formats.restore_item.connect(self.restore_item) self.tabs = t = QTabWidget(self)