A GUI to manage the new trash can

This commit is contained in:
Kovid Goyal 2023-04-13 20:03:55 +05:30
parent 6725340a9f
commit 96b58acc8f
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 298 additions and 6 deletions

View File

@ -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:

View File

@ -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']))

View File

@ -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 '''

View File

@ -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()

258
src/calibre/gui2/trash.py Normal file
View File

@ -0,0 +1,258 @@
#!/usr/bin/env python
# License: GPLv3 Copyright: 2023, Kovid Goyal <kovid at kovidgoyal.net>
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