From 96b58acc8fdb6cf9913d2047e4c99de54c7269e9 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Apr 2023 20:03:55 +0530 Subject: [PATCH] A GUI to manage the new trash can --- manual/gui.rst | 4 +- src/calibre/db/backend.py | 9 +- src/calibre/db/cache.py | 10 ++ src/calibre/gui2/actions/delete.py | 23 ++- src/calibre/gui2/trash.py | 258 +++++++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 src/calibre/gui2/trash.py diff --git a/manual/gui.rst b/manual/gui.rst index fdf9057bd0..d5c217bd55 100644 --- a/manual/gui.rst +++ b/manual/gui.rst @@ -270,8 +270,10 @@ Remove books 6. **Remove matching books from device**: Allows you to remove e-book files from a connected device that match the books that are selected in the book list. + 7. **Restore recently deleted**: Allows you to undo the removal of books or formats. + .. note:: - Note that when you use :guilabel:`Remove books` to delete books from your calibre library, the book record is permanently deleted, but the files are placed into the :guilabel:`Recycle Bin/Trash`. This allows you to recover the files if you change your mind. + Note that when you use :guilabel:`Remove books` to delete books from your calibre library, the book record is deleted, but the books is temporarily stored, for a few days, in a trash folder. You can undo the delete by right clicking the :guilabel:`Remove books` button and choosing to :guilabel:`Restore recently deleted` books. .. _configuration: diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py index ce9c6a4a7c..44f7adec00 100644 --- a/src/calibre/db/backend.py +++ b/src/calibre/db/backend.py @@ -57,6 +57,7 @@ from polyglot.builtins import ( # }}} COVER_FILE_NAME = 'cover.jpg' +DEFAULT_TRASH_EXPIRY_TIME_SECONDS = 14 * 86400 TRASH_DIR_NAME = '.caltrash' BOOK_ID_PATH_TEMPLATE = ' ({})' CUSTOM_DATA_TYPES = frozenset(('rating', 'text', 'comments', 'datetime', @@ -573,7 +574,7 @@ class DB: defs['similar_series_search_key'] = 'series' defs['similar_series_match_kind'] = 'match_any' defs['last_expired_trash_at'] = 0.0 - defs['expire_old_trash_after'] = 14 * 86400 + defs['expire_old_trash_after'] = DEFAULT_TRASH_EXPIRY_TIME_SECONDS defs['book_display_fields'] = [ ('title', False), ('authors', True), ('series', True), ('identifiers', True), ('tags', True), ('formats', True), @@ -1907,6 +1908,12 @@ class DB: if time.time() - self.last_expired_trash_at >= 3600: self.expire_old_trash() + def delete_trash_entry(self, book_id, category): + self.ensure_trash_dir() + path = os.path.join(self.trash_dir, category, str(book_id)) + if os.path.exists(path): + self.rmtree(path) + def expire_old_trash(self, expire_age_in_seconds=-1): if expire_age_in_seconds < 0: expire_age_in_seconds = max(1 * 24 * 3600, float(self.prefs['expire_old_trash_after'])) diff --git a/src/calibre/db/cache.py b/src/calibre/db/cache.py index 545241be72..1b67a028b9 100644 --- a/src/calibre/db/cache.py +++ b/src/calibre/db/cache.py @@ -2719,6 +2719,16 @@ class Cache: if annotations: self._restore_annotations(book_id, annotations) + @write_api + def delete_trash_entry(self, book_id, category): + " Delete an entry from the trash. Here category is 'b' for books and 'f' for formats. " + self.backend.delete_trash_entry(book_id, category) + + @write_api + def expire_old_trash(self): + ' Expire entries from the trash that are too old ' + self.backend.expire_old_trash() + @write_api def restore_book(self, book_id, mi, last_modified, path, formats, annotations=()): ''' Restore the book entry in the database for a book that already exists on the filesystem ''' diff --git a/src/calibre/gui2/actions/delete.py b/src/calibre/gui2/actions/delete.py index e60758584d..724e6bf585 100644 --- a/src/calibre/gui2/actions/delete.py +++ b/src/calibre/gui2/actions/delete.py @@ -134,6 +134,8 @@ class DeleteAction(InterfaceAction): m('delete-matching', _('Remove matching books from device'), triggered=self.remove_matching_books_from_device) + self.delete_menu.addSeparator() + m('delete-undelete', _('Restore recently deleted'), triggered=self.undelete_recent, icon='edit-undo.png') self.qaction.setMenu(self.delete_menu) self.delete_memory = {} @@ -401,10 +403,23 @@ class DeleteAction(InterfaceAction): with BusyCursor(): for book_id in book_ids: db.move_book_from_trash(book_id) - self.gui.current_db.data.books_added(book_ids) - self.gui.iactions['Add Books'].refresh_gui(len(book_ids)) - self.gui.library_view.resort() - self.gui.library_view.select_rows(set(book_ids), using_ids=True) + self.refresh_after_undelete(book_ids) + + def refresh_after_undelete(self, book_ids): + self.gui.current_db.data.books_added(book_ids) + self.gui.iactions['Add Books'].refresh_gui(len(book_ids)) + self.gui.library_view.resort() + self.gui.library_view.select_rows(set(book_ids), using_ids=True) + + def undelete_recent(self): + from calibre.gui2.trash import TrashView + current_idx = self.gui.library_view.currentIndex() + d = TrashView(self.gui.current_db, self.gui) + d.books_restored.connect(self.refresh_after_undelete) + d.exec() + if d.formats_restored: + if current_idx.isValid(): + self.gui.library_view.model().current_changed(current_idx, current_idx) def do_library_delete(self, to_delete_ids): view = self.gui.current_view() diff --git a/src/calibre/gui2/trash.py b/src/calibre/gui2/trash.py new file mode 100644 index 0000000000..2fd7f900a7 --- /dev/null +++ b/src/calibre/gui2/trash.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2023, Kovid Goyal + + +import time +import traceback +from qt.core import ( + QAbstractItemView, QDialogButtonBox, QHBoxLayout, QIcon, QLabel, QListWidget, + QListWidgetItem, 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.backend import DEFAULT_TRASH_EXPIRY_TIME_SECONDS, TrashEntry +from calibre.gui2 import error_dialog +from calibre.gui2.widgets import BusyCursor +from calibre.gui2.widgets2 import Dialog + +THUMBNAIL_SIZE = 60, 80 +MARGIN_SIZE = 8 + + +def time_spec(mtime: float) -> str: + delta = time.time() - mtime + if delta <= 86400: + if delta <= 3600: + return _('less than an hour ago') + return _('{} hours ago').format(int(delta) // 3600) + else: + return _('{} days ago').format(int(delta) // 86400) + + +class TrashItemDelegate(QStyledItemDelegate): + + def __init__(self, parent): + super().__init__(parent) + self.pixmap_cache = {} + + def sizeHint(self, option, index): + return QSize(THUMBNAIL_SIZE[0] + MARGIN_SIZE + 256, THUMBNAIL_SIZE[1] + MARGIN_SIZE) + + def paint(self, painter: QPainter, option, index): + super().paint(painter, option, index) + painter.save() + entry: TrashEntry = index.data(Qt.ItemDataRole.UserRole) + if option is not None and option.state & QStyle.StateFlag.State_Selected: + p = option.palette + group = (QPalette.ColorGroup.Active if option.state & QStyle.StateFlag.State_Active else + QPalette.ColorGroup.Inactive) + c = p.color(group, QPalette.ColorRole.HighlightedText) + painter.setPen(c) + + text = entry.title + '\n' + entry.author + '\n' + _('Deleted: {}').format(time_spec(entry.mtime)) + if entry.formats: + text += '\n' + ', '.join(sorted(entry.formats)) + r = QRectF(option.rect) + if entry.cover_path: + dp = self.parent().devicePixelRatioF() + p = self.pixmap_cache.get(entry.cover_path) + if p is None: + p = QPixmap() + p.load(entry.cover_path) + scaled, w, h = fit_image(p.width(), p.height(), int(THUMBNAIL_SIZE[0] * dp), int(THUMBNAIL_SIZE[1] * dp)) + if scaled: + p = p.scaled(w, h, transformMode=Qt.TransformationMode.SmoothTransformation) + p.setDevicePixelRatio(self.parent().devicePixelRatioF()) + self.pixmap_cache[entry.cover_path] = p + w, h = p.width() / dp, p.height() / dp + width, height = THUMBNAIL_SIZE[0] + MARGIN_SIZE, THUMBNAIL_SIZE[1] + MARGIN_SIZE + pos = r.topLeft() + if width > w: + pos.setX(pos.x() + (width - w) / 2) + if height > h: + pos.setY(pos.y() + (height - h) / 2) + painter.drawPixmap(pos, p) + r.adjust(THUMBNAIL_SIZE[0] + MARGIN_SIZE, 0, 0, 0) + painter.drawText(r, Qt.TextFlag.TextWordWrap | Qt.AlignmentFlag.AlignTop, text) + painter.restore() + + +class TrashList(QListWidget): + + restore_item = pyqtSignal(object, object) + + def __init__(self, entries: List[TrashEntry], parent: 'TrashView'): + super().__init__(parent) + self.db = parent.db + self.delegate = TrashItemDelegate(self) + self.setItemDelegate(self.delegate) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + for entry in entries: + i = QListWidgetItem(self) + i.setData(Qt.ItemDataRole.UserRole, entry) + self.addItem(i) + self.itemDoubleClicked.connect(self.double_clicked) + + @property + def selected_entries(self) -> Iterator[TrashEntry]: + for i in self.selectedItems(): + yield i.data(Qt.ItemDataRole.UserRole) + + def double_clicked(self, item): + self.restore_item.emit(self, item) + + +class TrashView(Dialog): + + books_restored = pyqtSignal(object) + + def __init__(self, db, parent=None): + self.db = db.new_api + self.expire_on_close = False + self.formats_restored = set() + super().__init__(_('Recently deleted books'), 'trash-view-for-library', parent=parent, default_buttons=QDialogButtonBox.StandardButton.Close) + self.finished.connect(self.expire_old_trash) + + def setup_ui(self): + self.l = l = QVBoxLayout(self) + self.setWindowIcon(QIcon.ic('trash.png')) + + with BusyCursor(): + books, formats = self.db.list_trash_entries() + self.books = TrashList(books, self) + self.books.restore_item.connect(self.restore_item) + self.formats = TrashList(formats, self) + self.formats.restore_item.connect(self.restore_item) + + self.tabs = t = QTabWidget(self) + l.addWidget(t) + t.addTab(self.books, QIcon.ic('book.png'), 'books') + t.addTab(self.formats, QIcon.ic('mimetypes/zero.png'), 'formats') + + la = QLabel(_('&Permanently delete after:')) + self.auto_delete = ad = QSpinBox(self) + ad.setMinimum(1) + ad.setMaximum(365) + ad.setValue(int(self.db.pref('expire_old_trash_after', DEFAULT_TRASH_EXPIRY_TIME_SECONDS) / 86400)) + ad.setSuffix(_(' days')) + ad.setToolTip(_('Deleted items are permanently deleted automatically after the specified number of days')) + ad.valueChanged.connect(self.trash_expiry_time_changed) + h = QHBoxLayout() + h.addWidget(la), h.addWidget(ad), h.addStretch(10) + la.setBuddy(ad) + l.addLayout(h) + + l.addWidget(self.bb) + self.restore_button = b = self.bb.addButton(_('&Restore selected'), QDialogButtonBox.ButtonRole.ActionRole) + b.clicked.connect(self.restore_selected) + b.setIcon(QIcon.ic('edit-undo.png')) + self.delete_button = b = self.bb.addButton(_('Permanently &delete selected'), QDialogButtonBox.ButtonRole.ActionRole) + b.setToolTip(_('Remove the selected entries from the trash bin, thereby deleting them permanently')) + b.setIcon(QIcon.ic('edit-clear.png')) + b.clicked.connect(self.delete_selected) + self.update_titles() + + def update_titles(self): + self.tabs.setTabText(0, _('&Books ({})').format(self.books.count())) + self.tabs.setTabText(1, _('&Formats ({})').format(self.formats.count())) + + def trash_expiry_time_changed(self, val): + self.db.set_pref('expire_old_trash_after', 86400 * self.auto_delete.value()) + self.expire_on_close = True + + def expire_old_trash(self): + if self.expire_on_close: + self.db.expire_old_trash() + + def sizeHint(self): + return QSize(500, 650) + + def do_operation_on_selected(self, func): + ok_items, failed_items = [], [] + for i in self.tabs.currentWidget().selectedItems(): + entry = i.data(Qt.ItemDataRole.UserRole) + try: + func(entry) + except Exception as e: + failed_items.append((entry, e, traceback.format_exc())) + else: + ok_items.append(i) + return ok_items, failed_items + + @property + def books_tab_is_selected(self): + return self.tabs.currentWidget() is self.books + + def restore_item(self, which, item): + is_books = which is self.books + entry = item.data(Qt.ItemDataRole.UserRole) + if is_books: + self.db.move_book_from_trash(entry.book_id) + self.books_restored.emit({entry.book_id}) + else: + self.formats_restored.add(entry.book_id) + for fmt in entry.formats: + self.db.move_format_from_trash(entry.book_id, fmt) + self.remove_entries([item]) + + def restore_selected(self): + is_books = self.books_tab_is_selected + done = set() + + def f(entry): + if is_books: + self.db.move_book_from_trash(entry.book_id) + done.add(entry.book_id) + else: + self.formats_restored.add(entry.book_id) + for fmt in entry.formats: + self.db.move_format_from_trash(entry.book_id, fmt) + + ok, failed = self.do_operation_on_selected(f) + if done: + self.books_restored.emit(done) + self.remove_entries(ok) + self.show_failures(failed, _('restore')) + + def remove_entries(self, remove): + w = self.tabs.currentWidget() + for i in remove: + w.takeItem(w.row(i)) + self.update_titles() + + def delete_selected(self): + category = 'b' if self.books_tab_is_selected else 'f' + + def f(entry): + self.db.delete_trash_entry(entry.book_id, category) + ok, failed = self.do_operation_on_selected(f) + self.remove_entries(ok) + self.show_failures(failed, _('delete')) + + def show_failures(self, failures, operation): + if not failures: + return + det_msg = [] + for (entry, exc, tb) in failures: + det_msg.append(_('Failed for {} with error:').format(entry.title)) + det_msg.append(tb) + det_msg.append('-' * 40) + det_msg.append('') + det_msg = det_msg[:-2] + entry_type = _('Books') if self.books_tab_is_selected else _('Formats') + error_dialog( + self, _('Failed to process some {}').format(entry_type), + _('Could not {0} some {1}. Click "Show details" for details.').format(operation, entry_type), + det_msg='\n'.join(det_msg), show=True) + + + + +if __name__ == '__main__': + from calibre.gui2 import Application + from calibre.library import db + app = Application([]) + TrashView(db()).exec() + del app