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