mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
A GUI to manage the new trash can
This commit is contained in:
parent
6725340a9f
commit
96b58acc8f
@ -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:
|
||||
|
||||
|
@ -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']))
|
||||
|
@ -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 '''
|
||||
|
@ -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
258
src/calibre/gui2/trash.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user