1#!/usr/local/bin/python3.8 2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 3 4 5__license__ = 'GPL v3' 6__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' 7__docformat__ = 'restructuredtext en' 8 9import errno 10import os 11from collections import Counter 12from functools import partial 13from qt.core import QDialog, QModelIndex, QObject, QTimer 14 15from calibre.constants import ismacos, trash_name 16from calibre.gui2 import Aborted, error_dialog, question_dialog 17from calibre.gui2.actions import InterfaceAction 18from calibre.gui2.dialogs.confirm_delete import confirm 19from calibre.gui2.dialogs.confirm_delete_location import confirm_location 20from calibre.gui2.dialogs.delete_matching_from_device import ( 21 DeleteMatchingFromDeviceDialog 22) 23from calibre.utils.recycle_bin import can_recycle 24 25single_shot = partial(QTimer.singleShot, 10) 26 27 28class MultiDeleter(QObject): # {{{ 29 30 def __init__(self, gui, ids, callback): 31 from calibre.gui2.dialogs.progress import ProgressDialog 32 QObject.__init__(self, gui) 33 self.model = gui.library_view.model() 34 self.ids = ids 35 self.permanent = False 36 if can_recycle and len(ids) > 100: 37 if question_dialog(gui, _('Are you sure?'), '<p>'+ 38 _('You are trying to delete {0} books. ' 39 'Sending so many files to the {1}' 40 ' <b>can be slow</b>. Should calibre skip the' 41 ' {1}? If you click Yes the files' 42 ' will be <b>permanently deleted</b>.').format(len(ids), trash_name()), 43 add_abort_button=True 44 ): 45 self.permanent = True 46 self.gui = gui 47 self.failures = [] 48 self.deleted_ids = [] 49 self.callback = callback 50 single_shot(self.delete_one) 51 self.pd = ProgressDialog(_('Deleting...'), parent=gui, 52 cancelable=False, min=0, max=len(self.ids), icon='trash.png') 53 self.pd.setModal(True) 54 self.pd.show() 55 56 def delete_one(self): 57 if not self.ids: 58 self.cleanup() 59 return 60 id_ = self.ids.pop() 61 title = 'id:%d'%id_ 62 try: 63 title_ = self.model.db.title(id_, index_is_id=True) 64 if title_: 65 title = title_ 66 self.model.db.delete_book(id_, notify=False, commit=False, 67 permanent=self.permanent) 68 self.deleted_ids.append(id_) 69 except: 70 import traceback 71 self.failures.append((id_, title, traceback.format_exc())) 72 single_shot(self.delete_one) 73 self.pd.value += 1 74 self.pd.set_msg(_('Deleted') + ' ' + title) 75 76 def cleanup(self): 77 self.pd.hide() 78 self.pd = None 79 self.model.db.commit() 80 self.model.db.clean() 81 self.model.books_deleted() # calls recount on the tag browser 82 self.callback(self.deleted_ids) 83 if self.failures: 84 msg = ['==> '+x[1]+'\n'+x[2] for x in self.failures] 85 error_dialog(self.gui, _('Failed to delete'), 86 _('Failed to delete some books, click the "Show details" button' 87 ' for details.'), det_msg='\n\n'.join(msg), show=True) 88# }}} 89 90 91class DeleteAction(InterfaceAction): 92 93 name = 'Remove Books' 94 action_spec = (_('Remove books'), 'remove_books.png', _('Delete books'), 'Backspace' if ismacos else 'Del') 95 action_type = 'current' 96 action_add_menu = True 97 action_menu_clone_qaction = _('Remove selected books') 98 99 accepts_drops = True 100 101 def accept_enter_event(self, event, mime_data): 102 if mime_data.hasFormat("application/calibre+from_library"): 103 return True 104 return False 105 106 def accept_drag_move_event(self, event, mime_data): 107 if mime_data.hasFormat("application/calibre+from_library"): 108 return True 109 return False 110 111 def drop_event(self, event, mime_data): 112 mime = 'application/calibre+from_library' 113 if mime_data.hasFormat(mime): 114 self.dropped_ids = tuple(map(int, mime_data.data(mime).data().split())) 115 QTimer.singleShot(1, self.do_drop) 116 return True 117 return False 118 119 def do_drop(self): 120 book_ids = self.dropped_ids 121 del self.dropped_ids 122 if book_ids: 123 self.do_library_delete(book_ids) 124 125 def genesis(self): 126 self.qaction.triggered.connect(self.delete_books) 127 self.delete_menu = self.qaction.menu() 128 m = partial(self.create_menu_action, self.delete_menu) 129 m('delete-specific', 130 _('Remove files of a specific format from selected books'), 131 triggered=self.delete_selected_formats) 132 m('delete-except', 133 _('Remove all formats from selected books, except...'), 134 triggered=self.delete_all_but_selected_formats) 135 self.delete_menu.addSeparator() 136 m('delete-all', 137 _('Remove all formats from selected books'), 138 triggered=self.delete_all_formats) 139 m('delete-covers', 140 _('Remove covers from selected books'), 141 triggered=self.delete_covers) 142 self.delete_menu.addSeparator() 143 m('delete-matching', 144 _('Remove matching books from device'), 145 triggered=self.remove_matching_books_from_device) 146 self.qaction.setMenu(self.delete_menu) 147 self.delete_memory = {} 148 149 def location_selected(self, loc): 150 enabled = loc == 'library' 151 for action in list(self.delete_menu.actions())[1:]: 152 action.setEnabled(enabled) 153 154 def _get_selected_formats(self, msg, ids, exclude=False, single=False): 155 from calibre.gui2.dialogs.select_formats import SelectFormats 156 c = Counter() 157 db = self.gui.library_view.model().db 158 for x in ids: 159 fmts_ = db.formats(x, index_is_id=True, verify_formats=False) 160 if fmts_: 161 for x in frozenset(x.lower() for x in fmts_.split(',')): 162 c[x] += 1 163 d = SelectFormats(c, msg, parent=self.gui, exclude=exclude, 164 single=single) 165 if d.exec() != QDialog.DialogCode.Accepted: 166 return None 167 return d.selected_formats 168 169 def _get_selected_ids(self, err_title=_('Cannot delete')): 170 rows = self.gui.library_view.selectionModel().selectedRows() 171 if not rows or len(rows) == 0: 172 d = error_dialog(self.gui, err_title, _('No book selected')) 173 d.exec() 174 return set() 175 return set(map(self.gui.library_view.model().id, rows)) 176 177 def remove_format_by_id(self, book_id, fmt): 178 title = self.gui.current_db.title(book_id, index_is_id=True) 179 if not confirm('<p>'+(_( 180 'The %(fmt)s format will be <b>permanently deleted</b> from ' 181 '%(title)s. Are you sure?')%dict(fmt=fmt, title=title)) + 182 '</p>', 'library_delete_specific_format', self.gui): 183 return 184 185 self.gui.library_view.model().db.remove_format(book_id, fmt, 186 index_is_id=True, notify=False) 187 self.gui.library_view.model().refresh_ids([book_id]) 188 self.gui.library_view.model().current_changed(self.gui.library_view.currentIndex(), 189 self.gui.library_view.currentIndex()) 190 self.gui.tags_view.recount_with_position_based_index() 191 192 def restore_format(self, book_id, original_fmt): 193 self.gui.current_db.restore_original_format(book_id, original_fmt) 194 self.gui.library_view.model().refresh_ids([book_id]) 195 self.gui.library_view.model().current_changed(self.gui.library_view.currentIndex(), 196 self.gui.library_view.currentIndex()) 197 self.gui.tags_view.recount_with_position_based_index() 198 199 def delete_selected_formats(self, *args): 200 ids = self._get_selected_ids() 201 if not ids: 202 return 203 fmts = self._get_selected_formats( 204 _('Choose formats to be deleted'), ids) 205 if not fmts: 206 return 207 m = self.gui.library_view.model() 208 m.db.new_api.remove_formats({book_id:fmts for book_id in ids}) 209 m.refresh_ids(ids) 210 m.current_changed(self.gui.library_view.currentIndex(), 211 self.gui.library_view.currentIndex()) 212 if ids: 213 self.gui.tags_view.recount_with_position_based_index() 214 215 def delete_all_but_selected_formats(self, *args): 216 ids = self._get_selected_ids() 217 if not ids: 218 return 219 fmts = self._get_selected_formats( 220 '<p>'+_('Choose formats <b>not</b> to be deleted.<p>Note that ' 221 'this will never remove all formats from a book.'), ids, 222 exclude=True) 223 if fmts is None: 224 return 225 m = self.gui.library_view.model() 226 removals = {} 227 for id in ids: 228 bfmts = m.db.formats(id, index_is_id=True) 229 if bfmts is None: 230 continue 231 bfmts = {x.lower() for x in bfmts.split(',')} 232 rfmts = bfmts - set(fmts) 233 if bfmts - rfmts: 234 # Do not delete if it will leave the book with no 235 # formats 236 removals[id] = rfmts 237 if removals: 238 m.db.new_api.remove_formats(removals) 239 m.refresh_ids(ids) 240 m.current_changed(self.gui.library_view.currentIndex(), 241 self.gui.library_view.currentIndex()) 242 if ids: 243 self.gui.tags_view.recount_with_position_based_index() 244 245 def delete_all_formats(self, *args): 246 ids = self._get_selected_ids() 247 if not ids: 248 return 249 if not confirm('<p>'+_('<b>All formats</b> for the selected books will ' 250 'be <b>deleted</b> from your library.<br>' 251 'The book metadata will be kept. Are you sure?') + 252 '</p>', 'delete_all_formats', self.gui): 253 return 254 db = self.gui.library_view.model().db 255 removals = {} 256 for id in ids: 257 fmts = db.formats(id, index_is_id=True, verify_formats=False) 258 if fmts: 259 removals[id] = fmts.split(',') 260 if removals: 261 db.new_api.remove_formats(removals) 262 self.gui.library_view.model().refresh_ids(ids) 263 self.gui.library_view.model().current_changed(self.gui.library_view.currentIndex(), 264 self.gui.library_view.currentIndex()) 265 if ids: 266 self.gui.tags_view.recount_with_position_based_index() 267 268 def remove_matching_books_from_device(self, *args): 269 if not self.gui.device_manager.is_device_present: 270 d = error_dialog(self.gui, _('Cannot delete books'), 271 _('No device is connected')) 272 d.exec() 273 return 274 ids = self._get_selected_ids() 275 if not ids: 276 # _get_selected_ids shows a dialog box if nothing is selected, so we 277 # do not need to show one here 278 return 279 to_delete = {} 280 some_to_delete = False 281 for model,name in ((self.gui.memory_view.model(), _('Main memory')), 282 (self.gui.card_a_view.model(), _('Storage card A')), 283 (self.gui.card_b_view.model(), _('Storage card B'))): 284 to_delete[name] = (model, model.paths_for_db_ids(ids)) 285 if len(to_delete[name][1]) > 0: 286 some_to_delete = True 287 if not some_to_delete: 288 d = error_dialog(self.gui, _('No books to delete'), 289 _('None of the selected books are on the device')) 290 d.exec() 291 return 292 d = DeleteMatchingFromDeviceDialog(self.gui, to_delete) 293 if d.exec(): 294 paths = {} 295 ids = {} 296 for (model, id, path) in d.result: 297 if model not in paths: 298 paths[model] = [] 299 ids[model] = [] 300 paths[model].append(path) 301 ids[model].append(id) 302 cv, row = self.gui.current_view(), -1 303 if cv is not self.gui.library_view: 304 row = cv.currentIndex().row() 305 for model in paths: 306 job = self.gui.remove_paths(paths[model]) 307 self.delete_memory[job] = (paths[model], model) 308 309 model.mark_for_deletion(job, ids[model], rows_are_ids=True) 310 self.gui.status_bar.show_message(_('Deleting books from device.'), 1000) 311 if row > -1: 312 nrow = row - 1 if row > 0 else row + 1 313 cv.set_current_row(min(cv.model().rowCount(None), max(0, nrow))) 314 315 def delete_covers(self, *args): 316 ids = self._get_selected_ids() 317 if not ids: 318 return 319 if not confirm('<p>'+ngettext( 320 'The cover from the selected book will be <b>permanently deleted</b>. Are you sure?', 321 'The covers from the {} selected books will be <b>permanently deleted</b>. ' 322 'Are you sure?', len(ids)).format(len(ids)), 323 'library_delete_covers', self.gui): 324 return 325 326 for id in ids: 327 self.gui.library_view.model().db.remove_cover(id) 328 self.gui.library_view.model().refresh_ids(ids) 329 self.gui.library_view.model().current_changed(self.gui.library_view.currentIndex(), 330 self.gui.library_view.currentIndex()) 331 332 def library_ids_deleted(self, ids_deleted, current_row=None): 333 view = self.gui.library_view 334 for v in (self.gui.memory_view, self.gui.card_a_view, self.gui.card_b_view): 335 if v is None: 336 continue 337 v.model().clear_ondevice(ids_deleted) 338 if current_row is not None: 339 ci = view.model().index(current_row, 0) 340 if not ci.isValid(): 341 # Current row is after the last row, set it to the last row 342 current_row = view.row_count() - 1 343 view.set_current_row(current_row) 344 if view.model().rowCount(QModelIndex()) < 1: 345 self.gui.book_details.reset_info() 346 347 def library_ids_deleted2(self, ids_deleted, next_id=None): 348 view = self.gui.library_view 349 current_row = None 350 if next_id is not None: 351 rmap = view.ids_to_rows([next_id]) 352 current_row = rmap.get(next_id, None) 353 self.library_ids_deleted(ids_deleted, current_row=current_row) 354 355 def do_library_delete(self, to_delete_ids): 356 view = self.gui.current_view() 357 next_id = view.next_id 358 # Ask the user if they want to delete the book from the library or device if it is in both. 359 if self.gui.device_manager.is_device_present: 360 on_device = False 361 on_device_ids = self._get_selected_ids() 362 for id in on_device_ids: 363 res = self.gui.book_on_device(id) 364 if res[0] or res[1] or res[2]: 365 on_device = True 366 if on_device: 367 break 368 if on_device: 369 loc = confirm_location('<p>' + _('Some of the selected books are on the attached device. ' 370 '<b>Where</b> do you want the selected files deleted from?'), 371 self.gui) 372 if not loc: 373 return 374 elif loc == 'dev': 375 self.remove_matching_books_from_device() 376 return 377 elif loc == 'both': 378 self.remove_matching_books_from_device() 379 # The following will run if the selected books are not on a connected device. 380 # The user has selected to delete from the library or the device and library. 381 if not confirm('<p>'+ngettext( 382 'The selected book will be <b>permanently deleted</b> and the files ' 383 'removed from your calibre library. Are you sure?', 384 'The {} selected books will be <b>permanently deleted</b> and the files ' 385 'removed from your calibre library. Are you sure?', len(to_delete_ids)).format(len(to_delete_ids)), 386 'library_delete_books', self.gui): 387 return 388 if len(to_delete_ids) < 5: 389 try: 390 view.model().delete_books_by_id(to_delete_ids) 391 except OSError as err: 392 if err.errno == errno.EACCES: 393 import traceback 394 fname = os.path.basename(getattr(err, 'filename', 'file') or 'file') 395 return error_dialog(self.gui, _('Permission denied'), 396 _('Could not access %s. Is it being used by another' 397 ' program? Click "Show details" for more information.')%fname, det_msg=traceback.format_exc(), 398 show=True) 399 raise 400 self.library_ids_deleted2(to_delete_ids, next_id=next_id) 401 else: 402 try: 403 self.__md = MultiDeleter(self.gui, to_delete_ids, 404 partial(self.library_ids_deleted2, next_id=next_id)) 405 except Aborted: 406 pass 407 408 def delete_books(self, *args): 409 ''' 410 Delete selected books from device or library. 411 ''' 412 view = self.gui.current_view() 413 rows = view.selectionModel().selectedRows() 414 if not rows or len(rows) == 0: 415 return 416 # Library view is visible. 417 if self.gui.stack.currentIndex() == 0: 418 to_delete_ids = [view.model().id(r) for r in rows] 419 self.do_library_delete(to_delete_ids) 420 # Device view is visible. 421 else: 422 cv, row = self.gui.current_view(), -1 423 if cv is not self.gui.library_view: 424 row = cv.currentIndex().row() 425 if self.gui.stack.currentIndex() == 1: 426 view = self.gui.memory_view 427 elif self.gui.stack.currentIndex() == 2: 428 view = self.gui.card_a_view 429 else: 430 view = self.gui.card_b_view 431 paths = view.model().paths(rows) 432 ids = view.model().indices(rows) 433 if not confirm('<p>'+ngettext( 434 'The selected book will be <b>permanently deleted</b> from your device. Are you sure?', 435 'The {} selected books will be <b>permanently deleted</b> from your device. Are you sure?', len(paths)).format(len(paths)), 436 'device_delete_books', self.gui): 437 return 438 job = self.gui.remove_paths(paths) 439 self.delete_memory[job] = (paths, view.model()) 440 view.model().mark_for_deletion(job, ids, rows_are_ids=True) 441 self.gui.status_bar.show_message(_('Deleting books from device.'), 1000) 442 if row > -1: 443 nrow = row - 1 if row > 0 else row + 1 444 cv.set_current_row(min(cv.model().rowCount(None), max(0, nrow))) 445