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 copy
10import os
11import shutil
12from functools import partial
13from io import BytesIO
14from qt.core import (
15    QAction, QApplication, QDialog, QIcon, QMenu, QMimeData, QModelIndex, QTimer,
16    QUrl
17)
18
19from calibre.db.errors import NoSuchFormat
20from calibre.ebooks.metadata import authors_to_string
21from calibre.ebooks.metadata.book.base import Metadata
22from calibre.ebooks.metadata.opf2 import OPF, metadata_to_opf
23from calibre.ebooks.metadata.sources.prefs import msprefs
24from calibre.gui2 import Dispatcher, error_dialog, gprefs, question_dialog
25from calibre.gui2.actions import InterfaceAction
26from calibre.gui2.actions.show_quickview import get_quickview_action_plugin
27from calibre.gui2.dialogs.confirm_delete import confirm
28from calibre.gui2.dialogs.device_category_editor import DeviceCategoryEditor
29from calibre.gui2.dialogs.metadata_bulk import MetadataBulkDialog
30from calibre.library.comments import merge_comments
31from calibre.utils.config import tweaks
32from calibre.utils.date import is_date_undefined
33from calibre.utils.icu import sort_key
34from polyglot.builtins import iteritems
35
36
37class EditMetadataAction(InterfaceAction):
38
39    name = 'Edit Metadata'
40    action_spec = (_('Edit metadata'), 'edit_input.png', _('Change the title/author/cover etc. of books'), _('E'))
41    action_type = 'current'
42    action_add_menu = True
43
44    accepts_drops = True
45
46    def accept_enter_event(self, event, mime_data):
47        if mime_data.hasFormat("application/calibre+from_library"):
48            return True
49        return False
50
51    def accept_drag_move_event(self, event, mime_data):
52        if mime_data.hasFormat("application/calibre+from_library"):
53            return True
54        return False
55
56    def drop_event(self, event, mime_data):
57        mime = 'application/calibre+from_library'
58        if mime_data.hasFormat(mime):
59            self.dropped_ids = tuple(map(int, mime_data.data(mime).data().split()))
60            QTimer.singleShot(1, self.do_drop)
61            return True
62        return False
63
64    def do_drop(self):
65        book_ids = self.dropped_ids
66        del self.dropped_ids
67        if book_ids:
68            db = self.gui.library_view.model().db
69            rows = [db.row(i) for i in book_ids]
70            self.edit_metadata_for(rows, book_ids)
71
72    def genesis(self):
73        md = self.qaction.menu()
74        cm = partial(self.create_menu_action, md)
75        cm('individual', _('Edit metadata individually'), icon=self.qaction.icon(),
76                triggered=partial(self.edit_metadata, False, bulk=False))
77        cm('bulk', _('Edit metadata in bulk'),
78                triggered=partial(self.edit_metadata, False, bulk=True))
79        md.addSeparator()
80        cm('download', _('Download metadata and covers'),
81                triggered=partial(self.download_metadata, ids=None),
82                shortcut='Ctrl+D')
83        self.metadata_menu = md
84
85        self.metamerge_menu = mb = QMenu()
86        cm2 = partial(self.create_menu_action, mb)
87        cm2('merge delete', _('Merge into first selected book - delete others'),
88                triggered=self.merge_books)
89        mb.addSeparator()
90        cm2('merge keep', _('Merge into first selected book - keep others'),
91                triggered=partial(self.merge_books, safe_merge=True),
92                shortcut='Alt+M')
93        mb.addSeparator()
94        cm2('merge formats', _('Merge only formats into first selected book - delete others'),
95                triggered=partial(self.merge_books, merge_only_formats=True),
96                shortcut='Alt+Shift+M')
97        self.merge_menu = mb
98        md.addSeparator()
99        self.action_copy = cm('copy', _('Copy metadata'), icon='edit-copy.png', triggered=self.copy_metadata)
100        self.action_paste = cm('paste', _('Paste metadata'), icon='edit-paste.png', triggered=self.paste_metadata)
101        self.action_paste_ignore_excluded = ac = cm(
102            'paste_include_excluded_fields', _('Paste metadata including excluded fields'), icon='edit-paste.png',
103            triggered=self.paste_metadata_including_excluded_fields)
104        ac.setVisible(False)
105        self.action_merge = cm('merge', _('Merge book records'), icon='merge_books.png',
106            shortcut=_('M'), triggered=self.merge_books)
107        self.action_merge.setMenu(mb)
108
109        self.qaction.triggered.connect(self.edit_metadata)
110        ac = QAction(_('Copy URL to show book in calibre'), self.gui)
111        ac.setToolTip(_('Copy URLs to show the currently selected books in calibre, to the system clipboard'))
112        ac.triggered.connect(self.copy_show_link)
113        self.gui.addAction(ac)
114        self.gui.keyboard.register_shortcut(
115            self.unique_name + ' - ' + 'copy_show_book',
116            ac.text(), description=ac.toolTip(),
117            action=ac, group=self.action_spec[0])
118        ac = QAction(_('Copy URL to open book in calibre'), self.gui)
119        ac.triggered.connect(self.copy_view_link)
120        ac.setToolTip(_('Copy URLs to open the currently selected books in calibre, to the system clipboard'))
121        self.gui.addAction(ac)
122        self.gui.keyboard.register_shortcut(
123            self.unique_name + ' - ' + 'copy_view_book',
124            ac.text(), description=ac.toolTip(),
125            action=ac, group=self.action_spec[0])
126
127    def _copy_links(self, lines):
128        urls = QUrl.fromStringList(lines)
129        cb = QApplication.instance().clipboard()
130        md = QMimeData()
131        md.setText('\n'.join(lines))
132        md.setUrls(urls)
133        cb.setMimeData(md)
134
135    def copy_show_link(self):
136        db = self.gui.current_db
137        ids = [db.id(row.row()) for row in self.gui.library_view.selectionModel().selectedRows()]
138        db = db.new_api
139        library_id = getattr(db, 'server_library_id', None)
140        if not library_id or not ids:
141            return
142        lines = [f'calibre://show-book/{library_id}/{book_id}' for book_id in ids]
143        self._copy_links(lines)
144
145    def copy_view_link(self):
146        from calibre.gui2.actions.view import preferred_format
147        db = self.gui.current_db
148        ids = [db.id(row.row()) for row in self.gui.library_view.selectionModel().selectedRows()]
149        db = db.new_api
150        library_id = getattr(db, 'server_library_id', None)
151        if not library_id or not ids:
152            return
153        lines = []
154        for book_id in ids:
155            formats = db.new_api.formats(book_id, verify_formats=True)
156            if formats:
157                fmt = preferred_format(formats)
158                lines.append(f'calibre://view-book/{library_id}/{book_id}/{fmt}')
159        if lines:
160            self._copy_links(lines)
161
162    def location_selected(self, loc):
163        enabled = loc == 'library'
164        self.qaction.setEnabled(enabled)
165        self.menuless_qaction.setEnabled(enabled)
166        for action in self.metamerge_menu.actions() + self.metadata_menu.actions():
167            action.setEnabled(enabled)
168
169    def copy_metadata(self):
170        rows = self.gui.library_view.selectionModel().selectedRows()
171        if not rows or len(rows) == 0:
172            return error_dialog(self.gui, _('Cannot copy metadata'),
173                                _('No books selected'), show=True)
174        if len(rows) > 1:
175            return error_dialog(self.gui, _('Cannot copy metadata'),
176                                _('Multiple books selected, can only copy from one book at a time.'), show=True)
177        db = self.gui.current_db
178        book_id = db.id(rows[0].row())
179        mi = db.new_api.get_metadata(book_id)
180        md = QMimeData()
181        md.setText(str(mi))
182        md.setData('application/calibre-book-metadata', bytearray(metadata_to_opf(mi, default_lang='und')))
183        img = db.new_api.cover(book_id, as_image=True)
184        if img:
185            md.setImageData(img)
186        c = QApplication.clipboard()
187        c.setMimeData(md)
188
189    def paste_metadata(self):
190        self.do_paste()
191
192    def paste_metadata_including_excluded_fields(self):
193        self.do_paste(ignore_excluded_fields=True)
194
195    def do_paste(self, ignore_excluded_fields=False):
196        rows = self.gui.library_view.selectionModel().selectedRows()
197        if not rows or len(rows) == 0:
198            return error_dialog(self.gui, _('Cannot paste metadata'),
199                                _('No books selected'), show=True)
200        c = QApplication.clipboard()
201        md = c.mimeData()
202        if not md.hasFormat('application/calibre-book-metadata'):
203            return error_dialog(self.gui, _('Cannot paste metadata'),
204                                _('No copied metadata available'), show=True)
205        if len(rows) > 1:
206            if not confirm(_(
207                    'You are pasting metadata onto <b>multiple books</b> ({num_of_books}). Are you'
208                    ' sure you want to do that?').format(num_of_books=len(rows)), 'paste-onto-multiple', parent=self.gui):
209                return
210        data = bytes(md.data('application/calibre-book-metadata'))
211        mi = OPF(BytesIO(data), populate_spine=False, read_toc=False, try_to_guess_cover=False).to_book_metadata()
212        mi.application_id = mi.uuid_id = None
213        if ignore_excluded_fields:
214            exclude = set()
215        else:
216            exclude = set(tweaks['exclude_fields_on_paste'])
217        paste_cover = 'cover' not in exclude
218        cover = md.imageData() if paste_cover else None
219        exclude.discard('cover')
220        for field in exclude:
221            mi.set_null(field)
222        db = self.gui.current_db
223        book_ids = {db.id(r.row()) for r in rows}
224        title_excluded = 'title' in exclude
225        authors_excluded = 'authors' in exclude
226        for book_id in book_ids:
227            if title_excluded:
228                mi.title = db.new_api.field_for('title', book_id)
229            if authors_excluded:
230                mi.authors = db.new_api.field_for('authors', book_id)
231            db.new_api.set_metadata(book_id, mi, ignore_errors=True)
232        if cover:
233            db.new_api.set_cover({book_id: cover for book_id in book_ids})
234        self.refresh_books_after_metadata_edit(book_ids)
235
236    # Download metadata {{{
237    def download_metadata(self, ids=None, ensure_fields=None):
238        if ids is None:
239            rows = self.gui.library_view.selectionModel().selectedRows()
240            if not rows or len(rows) == 0:
241                return error_dialog(self.gui, _('Cannot download metadata'),
242                            _('No books selected'), show=True)
243            db = self.gui.library_view.model().db
244            ids = [db.id(row.row()) for row in rows]
245        from calibre.ebooks.metadata.sources.update import update_sources
246        from calibre.gui2.metadata.bulk_download import start_download
247        update_sources()
248        start_download(self.gui, ids,
249                Dispatcher(self.metadata_downloaded),
250                ensure_fields=ensure_fields)
251
252    def cleanup_bulk_download(self, tdir, *args):
253        try:
254            shutil.rmtree(tdir, ignore_errors=True)
255        except:
256            pass
257
258    def metadata_downloaded(self, job):
259        if job.failed:
260            self.gui.job_exception(job, dialog_title=_('Failed to download metadata'))
261            return
262        from calibre.gui2.metadata.bulk_download import get_job_details
263        (aborted, id_map, tdir, log_file, failed_ids, failed_covers, all_failed,
264                det_msg, lm_map) = get_job_details(job)
265        if aborted:
266            return self.cleanup_bulk_download(tdir)
267        if all_failed:
268            num = len(failed_ids | failed_covers)
269            self.cleanup_bulk_download(tdir)
270            return error_dialog(self.gui, _('Download failed'), ngettext(
271                'Failed to download metadata or cover for the selected book.',
272                'Failed to download metadata or covers for any of the {} books.', num
273            ).format(num), det_msg=det_msg, show=True)
274
275        self.gui.status_bar.show_message(_('Metadata download completed'), 3000)
276
277        msg = '<p>' + ngettext(
278            'Finished downloading metadata for the selected book.',
279            'Finished downloading metadata for <b>{} books</b>.', len(id_map)).format(len(id_map)) + ' ' + \
280            _('Proceed with updating the metadata in your library?')
281
282        show_copy_button = False
283        checkbox_msg = None
284        if failed_ids or failed_covers:
285            show_copy_button = True
286            num = len(failed_ids.union(failed_covers))
287            msg += '<p>'+_('Could not download metadata and/or covers for %d of the books. Click'
288                    ' "Show details" to see which books.')%num
289            checkbox_msg = _('Show the &failed books in the main book list '
290                    'after updating metadata')
291
292        if getattr(job, 'metadata_and_covers', None) == (False, True):
293            # Only covers, remove failed cover downloads from id_map
294            for book_id in failed_covers:
295                if hasattr(id_map, 'discard'):
296                    id_map.discard(book_id)
297        payload = (id_map, tdir, log_file, lm_map,
298                failed_ids.union(failed_covers))
299        review_apply = partial(self.apply_downloaded_metadata, True)
300        normal_apply = partial(self.apply_downloaded_metadata, False)
301        self.gui.proceed_question(
302            normal_apply, payload, log_file, _('Download log'),
303            _('Metadata download complete'), msg, icon='download-metadata.png',
304            det_msg=det_msg, show_copy_button=show_copy_button,
305            cancel_callback=partial(self.cleanup_bulk_download, tdir),
306            log_is_file=True, checkbox_msg=checkbox_msg,
307            checkbox_checked=False, action_callback=review_apply,
308            action_label=_('Revie&w downloaded metadata'),
309            action_icon=QIcon(I('auto_author_sort.png')))
310
311    def apply_downloaded_metadata(self, review, payload, *args):
312        good_ids, tdir, log_file, lm_map, failed_ids = payload
313        if not good_ids:
314            return
315        restrict_to_failed = False
316
317        modified = set()
318        db = self.gui.current_db
319
320        for i in good_ids:
321            lm = db.metadata_last_modified(i, index_is_id=True)
322            if lm is not None and lm_map[i] is not None and lm > lm_map[i]:
323                title = db.title(i, index_is_id=True)
324                authors = db.authors(i, index_is_id=True)
325                if authors:
326                    authors = [x.replace('|', ',') for x in authors.split(',')]
327                    title += ' - ' + authors_to_string(authors)
328                modified.add(title)
329
330        if modified:
331            from calibre.utils.icu import lower
332
333            modified = sorted(modified, key=lower)
334            if not question_dialog(self.gui, _('Some books changed'), '<p>' + _(
335                'The metadata for some books in your library has'
336                ' changed since you started the download. If you'
337                ' proceed, some of those changes may be overwritten. '
338                'Click "Show details" to see the list of changed books. '
339                'Do you want to proceed?'), det_msg='\n'.join(modified)):
340                return
341
342        id_map = {}
343        for bid in good_ids:
344            opf = os.path.join(tdir, '%d.mi'%bid)
345            if not os.path.exists(opf):
346                opf = None
347            cov = os.path.join(tdir, '%d.cover'%bid)
348            if not os.path.exists(cov):
349                cov = None
350            id_map[bid] = (opf, cov)
351
352        if review:
353            def get_metadata(book_id):
354                oldmi = db.get_metadata(book_id, index_is_id=True, get_cover=True, cover_as_data=True)
355                opf, cov = id_map[book_id]
356                if opf is None:
357                    newmi = Metadata(oldmi.title, authors=tuple(oldmi.authors))
358                else:
359                    with open(opf, 'rb') as f:
360                        newmi = OPF(f, basedir=os.path.dirname(opf), populate_spine=False).to_book_metadata()
361                        newmi.cover, newmi.cover_data = None, (None, None)
362                        for x in ('title', 'authors'):
363                            if newmi.is_null(x):
364                                # Title and author are set to null if they are
365                                # the same as the originals as an optimization,
366                                # we undo that, as it is confusing.
367                                newmi.set(x, copy.copy(oldmi.get(x)))
368                if cov:
369                    with open(cov, 'rb') as f:
370                        newmi.cover_data = ('jpg', f.read())
371                return oldmi, newmi
372            from calibre.gui2.metadata.diff import CompareMany
373            d = CompareMany(
374                set(id_map), get_metadata, db.field_metadata, parent=self.gui,
375                window_title=_('Review downloaded metadata'),
376                reject_button_tooltip=_('Discard downloaded metadata for this book'),
377                accept_all_tooltip=_('Use the downloaded metadata for all remaining books'),
378                reject_all_tooltip=_('Discard downloaded metadata for all remaining books'),
379                revert_tooltip=_('Discard the downloaded value for: %s'),
380                intro_msg=_('The downloaded metadata is on the left and the original metadata'
381                            ' is on the right. If a downloaded value is blank or unknown,'
382                            ' the original value is used.'),
383                action_button=(_('&View book'), I('view.png'), self.gui.iactions['View'].view_historical),
384                db=db
385            )
386            if d.exec() == QDialog.DialogCode.Accepted:
387                if d.mark_rejected:
388                    failed_ids |= d.rejected_ids
389                    restrict_to_failed = True
390                nid_map = {}
391                for book_id, (changed, mi) in iteritems(d.accepted):
392                    if mi is None:  # discarded
393                        continue
394                    if changed:
395                        opf, cov = id_map[book_id]
396                        cfile = mi.cover
397                        mi.cover, mi.cover_data = None, (None, None)
398                        if opf is not None:
399                            with open(opf, 'wb') as f:
400                                f.write(metadata_to_opf(mi))
401                        if cfile and cov:
402                            shutil.copyfile(cfile, cov)
403                            os.remove(cfile)
404                    nid_map[book_id] = id_map[book_id]
405                id_map = nid_map
406            else:
407                id_map = {}
408
409        restrict_to_failed = restrict_to_failed or bool(args and args[0])
410        restrict_to_failed = restrict_to_failed and bool(failed_ids)
411        if restrict_to_failed:
412            db.data.set_marked_ids(failed_ids)
413
414        self.apply_metadata_changes(
415            id_map, merge_comments=msprefs['append_comments'], icon='download-metadata.png',
416            callback=partial(self.downloaded_metadata_applied, tdir, restrict_to_failed))
417
418    def downloaded_metadata_applied(self, tdir, restrict_to_failed, *args):
419        if restrict_to_failed:
420            self.gui.search.set_search_string('marked:true')
421        self.cleanup_bulk_download(tdir)
422
423    # }}}
424
425    def edit_metadata(self, checked, bulk=None):
426        '''
427        Edit metadata of selected books in library.
428        '''
429        rows = self.gui.library_view.selectionModel().selectedRows()
430        if not rows or len(rows) == 0:
431            d = error_dialog(self.gui, _('Cannot edit metadata'),
432                             _('No books selected'))
433            d.exec()
434            return
435        row_list = [r.row() for r in rows]
436        m = self.gui.library_view.model()
437        ids = [m.id(r) for r in rows]
438        self.edit_metadata_for(row_list, ids, bulk=bulk)
439
440    def edit_metadata_for(self, rows, book_ids, bulk=None):
441        previous = self.gui.library_view.currentIndex()
442        if bulk or (bulk is None and len(rows) > 1):
443            return self.do_edit_bulk_metadata(rows, book_ids)
444
445        current_row = 0
446        row_list = rows
447        editing_multiple = len(row_list) > 1
448
449        if not editing_multiple:
450            cr = row_list[0]
451            row_list = \
452                list(range(self.gui.library_view.model().rowCount(QModelIndex())))
453            current_row = row_list.index(cr)
454
455        view = self.gui.library_view.alternate_views.current_view
456        try:
457            hpos = view.horizontalScrollBar().value()
458        except Exception:
459            hpos = 0
460
461        changed, rows_to_refresh = self.do_edit_metadata(row_list, current_row, editing_multiple)
462
463        m = self.gui.library_view.model()
464
465        if rows_to_refresh:
466            m.refresh_rows(rows_to_refresh)
467
468        if changed:
469            self.refresh_books_after_metadata_edit(changed, previous)
470        if self.gui.library_view.alternate_views.current_view is view:
471            if hasattr(view, 'restore_hpos'):
472                view.restore_hpos(hpos)
473            else:
474                view.horizontalScrollBar().setValue(hpos)
475
476    def refresh_books_after_metadata_edit(self, book_ids, previous=None):
477        m = self.gui.library_view.model()
478        m.refresh_ids(list(book_ids))
479        current = self.gui.library_view.currentIndex()
480        self.gui.refresh_cover_browser()
481        m.current_changed(current, previous or current)
482        self.gui.tags_view.recount_with_position_based_index()
483        qv = get_quickview_action_plugin()
484        if qv:
485            qv.refresh_quickview(current)
486
487    def do_edit_metadata(self, row_list, current_row, editing_multiple):
488        from calibre.gui2.metadata.single import edit_metadata
489        db = self.gui.library_view.model().db
490        changed, rows_to_refresh = edit_metadata(db, row_list, current_row,
491                parent=self.gui, view_slot=self.view_format_callback,
492                edit_slot=self.edit_format_callback,
493                set_current_callback=self.set_current_callback, editing_multiple=editing_multiple)
494        return changed, rows_to_refresh
495
496    def set_current_callback(self, id_):
497        db = self.gui.library_view.model().db
498        current_row = db.row(id_)
499        self.gui.library_view.set_current_row(current_row)
500        self.gui.library_view.scroll_to_row(current_row)
501
502    def view_format_callback(self, id_, fmt):
503        view = self.gui.iactions['View']
504        if id_ is None:
505            view._view_file(fmt)
506        else:
507            db = self.gui.library_view.model().db
508            view.view_format(db.row(id_), fmt)
509
510    def edit_format_callback(self, id_, fmt):
511        edit = self.gui.iactions['Tweak ePub']
512        edit.ebook_edit_format(id_, fmt)
513
514    def edit_bulk_metadata(self, checked):
515        '''
516        Edit metadata of selected books in library in bulk.
517        '''
518        rows = [r.row() for r in
519                self.gui.library_view.selectionModel().selectedRows()]
520        m = self.gui.library_view.model()
521        ids = [m.id(r) for r in rows]
522        if not rows or len(rows) == 0:
523            d = error_dialog(self.gui, _('Cannot edit metadata'),
524                    _('No books selected'))
525            d.exec()
526            return
527        self.do_edit_bulk_metadata(rows, ids)
528
529    def do_edit_bulk_metadata(self, rows, book_ids):
530        # Prevent the TagView from updating due to signals from the database
531        self.gui.tags_view.blockSignals(True)
532        changed = False
533        refresh_books = set(book_ids)
534        try:
535            current_tab = 0
536            while True:
537                dialog = MetadataBulkDialog(self.gui, rows,
538                                self.gui.library_view.model(), current_tab, refresh_books)
539                if dialog.changed:
540                    changed = True
541                if not dialog.do_again:
542                    break
543                current_tab = dialog.central_widget.currentIndex()
544        finally:
545            self.gui.tags_view.blockSignals(False)
546        if changed:
547            refresh_books |= dialog.refresh_books
548            m = self.gui.library_view.model()
549            if gprefs['refresh_book_list_on_bulk_edit']:
550                m.refresh(reset=False)
551                m.research()
552            else:
553                m.refresh_ids(refresh_books)
554            self.gui.tags_view.recount()
555            self.gui.refresh_cover_browser()
556            self.gui.library_view.select_rows(book_ids)
557
558    # Merge books {{{
559
560    def confirm_large_merge(self, num):
561        if num < 5:
562            return True
563        return confirm('<p>'+_(
564            'You are about to merge very many ({}) books. '
565            'Are you <b>sure</b> you want to proceed?').format(num) + '</p>',
566            'merge_too_many_books', self.gui)
567
568    def books_dropped(self, merge_map):
569        for dest_id, src_ids in iteritems(merge_map):
570            if not self.confirm_large_merge(len(src_ids) + 1):
571                continue
572            from calibre.gui2.dialogs.confirm_merge import merge_drop
573            merge_metadata, merge_formats, delete_books = merge_drop(dest_id, src_ids, self.gui)
574            if merge_metadata is None:
575                return
576            if merge_formats:
577                self.add_formats(dest_id, self.formats_for_ids(list(src_ids)))
578            if merge_metadata:
579                self.merge_metadata(dest_id, src_ids)
580            if delete_books:
581                self.delete_books_after_merge(src_ids)
582            # leave the selection highlight on the target book
583            row = self.gui.library_view.ids_to_rows([dest_id])[dest_id]
584            self.gui.library_view.set_current_row(row)
585
586    def merge_books(self, safe_merge=False, merge_only_formats=False):
587        '''
588        Merge selected books in library.
589        '''
590        from calibre.gui2.dialogs.confirm_merge import confirm_merge
591        if self.gui.current_view() is not self.gui.library_view:
592            return
593        rows = self.gui.library_view.indices_for_merge()
594        if not rows or len(rows) == 0:
595            return error_dialog(self.gui, _('Cannot merge books'),
596                                _('No books selected'), show=True)
597        if len(rows) < 2:
598            return error_dialog(self.gui, _('Cannot merge books'),
599                        _('At least two books must be selected for merging'),
600                        show=True)
601        if not self.confirm_large_merge(len(rows)):
602            return
603
604        dest_id, src_ids = self.books_to_merge(rows)
605        mi = self.gui.current_db.new_api.get_proxy_metadata(dest_id)
606        title = mi.title
607        hpos = self.gui.library_view.horizontalScrollBar().value()
608        if safe_merge:
609            if not confirm_merge('<p>'+_(
610                'Book formats and metadata from the selected books '
611                'will be added to the <b>first selected book</b> (%s).<br> '
612                'The second and subsequently selected books will not '
613                'be deleted or changed.<br><br>'
614                'Please confirm you want to proceed.')%title + '</p>',
615                'merge_books_safe', self.gui, mi):
616                return
617            self.add_formats(dest_id, self.formats_for_books(rows))
618            self.merge_metadata(dest_id, src_ids)
619        elif merge_only_formats:
620            if not confirm_merge('<p>'+_(
621                'Book formats from the selected books will be merged '
622                'into the <b>first selected book</b> (%s). '
623                'Metadata in the first selected book will not be changed. '
624                'Author, Title and all other metadata will <i>not</i> be merged.<br><br>'
625                'After being merged, the second and subsequently '
626                'selected books, with any metadata they have will be <b>deleted</b>. <br><br>'
627                'All book formats of the first selected book will be kept '
628                'and any duplicate formats in the second and subsequently selected books '
629                'will be permanently <b>deleted</b> from your calibre library.<br><br>  '
630                'Are you <b>sure</b> you want to proceed?')%title + '</p>',
631                'merge_only_formats', self.gui, mi):
632                return
633            self.add_formats(dest_id, self.formats_for_books(rows))
634            self.delete_books_after_merge(src_ids)
635        else:
636            if not confirm_merge('<p>'+_(
637                'Book formats and metadata from the selected books will be merged '
638                'into the <b>first selected book</b> (%s).<br><br>'
639                'After being merged, the second and '
640                'subsequently selected books will be <b>deleted</b>. <br><br>'
641                'All book formats of the first selected book will be kept '
642                'and any duplicate formats in the second and subsequently selected books '
643                'will be permanently <b>deleted</b> from your calibre library.<br><br>  '
644                'Are you <b>sure</b> you want to proceed?')%title + '</p>',
645                'merge_books', self.gui, mi):
646                return
647            self.add_formats(dest_id, self.formats_for_books(rows))
648            self.merge_metadata(dest_id, src_ids)
649            self.delete_books_after_merge(src_ids)
650            # leave the selection highlight on first selected book
651            dest_row = rows[0].row()
652            for row in rows:
653                if row.row() < rows[0].row():
654                    dest_row -= 1
655            self.gui.library_view.set_current_row(dest_row)
656        cr = self.gui.library_view.currentIndex().row()
657        self.gui.library_view.model().refresh_ids((dest_id,), cr)
658        self.gui.library_view.horizontalScrollBar().setValue(hpos)
659
660    def add_formats(self, dest_id, src_books, replace=False):
661        for src_book in src_books:
662            if src_book:
663                fmt = os.path.splitext(src_book)[-1].replace('.', '').upper()
664                with lopen(src_book, 'rb') as f:
665                    self.gui.library_view.model().db.add_format(dest_id, fmt, f, index_is_id=True,
666                            notify=False, replace=replace)
667
668    def formats_for_ids(self, ids):
669        m = self.gui.library_view.model()
670        ans = []
671        for id_ in ids:
672            dbfmts = m.db.formats(id_, index_is_id=True)
673            if dbfmts:
674                for fmt in dbfmts.split(','):
675                    try:
676                        path = m.db.format(id_, fmt, index_is_id=True,
677                                as_path=True)
678                        ans.append(path)
679                    except NoSuchFormat:
680                        continue
681        return ans
682
683    def formats_for_books(self, rows):
684        m = self.gui.library_view.model()
685        return self.formats_for_ids(list(map(m.id, rows)))
686
687    def books_to_merge(self, rows):
688        src_ids = []
689        m = self.gui.library_view.model()
690        for i, row in enumerate(rows):
691            id_ = m.id(row)
692            if i == 0:
693                dest_id = id_
694            else:
695                src_ids.append(id_)
696        return [dest_id, src_ids]
697
698    def delete_books_after_merge(self, ids_to_delete):
699        self.gui.library_view.model().delete_books_by_id(ids_to_delete)
700
701    def merge_metadata(self, dest_id, src_ids):
702        db = self.gui.library_view.model().db
703        dest_mi = db.get_metadata(dest_id, index_is_id=True)
704        merged_identifiers = db.get_identifiers(dest_id, index_is_id=True)
705        orig_dest_comments = dest_mi.comments
706        dest_cover = db.cover(dest_id, index_is_id=True)
707        had_orig_cover = bool(dest_cover)
708
709        def is_null_date(x):
710            return x is None or is_date_undefined(x)
711
712        for src_id in src_ids:
713            src_mi = db.get_metadata(src_id, index_is_id=True)
714
715            if src_mi.comments and orig_dest_comments != src_mi.comments:
716                if not dest_mi.comments:
717                    dest_mi.comments = src_mi.comments
718                else:
719                    dest_mi.comments = str(dest_mi.comments) + '\n\n' + str(src_mi.comments)
720            if src_mi.title and (not dest_mi.title or dest_mi.title == _('Unknown')):
721                dest_mi.title = src_mi.title
722            if (src_mi.authors and src_mi.authors[0] != _('Unknown')) and (not dest_mi.authors or dest_mi.authors[0] == _('Unknown')):
723                dest_mi.authors = src_mi.authors
724                dest_mi.author_sort = src_mi.author_sort
725            if src_mi.tags:
726                if not dest_mi.tags:
727                    dest_mi.tags = src_mi.tags
728                else:
729                    dest_mi.tags.extend(src_mi.tags)
730            if not dest_cover:
731                src_cover = db.cover(src_id, index_is_id=True)
732                if src_cover:
733                    dest_cover = src_cover
734            if not dest_mi.publisher:
735                dest_mi.publisher = src_mi.publisher
736            if not dest_mi.rating:
737                dest_mi.rating = src_mi.rating
738            if not dest_mi.series:
739                dest_mi.series = src_mi.series
740                dest_mi.series_index = src_mi.series_index
741            if is_null_date(dest_mi.pubdate) and not is_null_date(src_mi.pubdate):
742                dest_mi.pubdate = src_mi.pubdate
743
744            src_identifiers = db.get_identifiers(src_id, index_is_id=True)
745            src_identifiers.update(merged_identifiers)
746            merged_identifiers = src_identifiers.copy()
747
748        if merged_identifiers:
749            dest_mi.set_identifiers(merged_identifiers)
750        db.set_metadata(dest_id, dest_mi, ignore_errors=False)
751
752        if not had_orig_cover and dest_cover:
753            db.set_cover(dest_id, dest_cover)
754
755        for key in db.field_metadata:  # loop thru all defined fields
756            fm = db.field_metadata[key]
757            if not fm['is_custom']:
758                continue
759            dt = fm['datatype']
760            colnum = fm['colnum']
761            # Get orig_dest_comments before it gets changed
762            if dt == 'comments':
763                orig_dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True)
764
765            for src_id in src_ids:
766                dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True)
767                src_value = db.get_custom(src_id, num=colnum, index_is_id=True)
768                if (dt == 'comments' and src_value and src_value != orig_dest_value):
769                    if not dest_value:
770                        db.set_custom(dest_id, src_value, num=colnum)
771                    else:
772                        dest_value = str(dest_value) + '\n\n' + str(src_value)
773                        db.set_custom(dest_id, dest_value, num=colnum)
774                if (dt in {'bool', 'int', 'float', 'rating', 'datetime'} and dest_value is None):
775                    db.set_custom(dest_id, src_value, num=colnum)
776                if (dt == 'series' and not dest_value and src_value):
777                    src_index = db.get_custom_extra(src_id, num=colnum, index_is_id=True)
778                    db.set_custom(dest_id, src_value, num=colnum, extra=src_index)
779                if ((dt == 'enumeration' or (dt == 'text' and not fm['is_multiple'])) and not dest_value):
780                    db.set_custom(dest_id, src_value, num=colnum)
781                if (dt == 'text' and fm['is_multiple'] and src_value):
782                    if not dest_value:
783                        dest_value = src_value
784                    else:
785                        dest_value.extend(src_value)
786                    db.set_custom(dest_id, dest_value, num=colnum)
787    # }}}
788
789    def edit_device_collections(self, view, oncard=None):
790        model = view.model()
791        result = model.get_collections_with_ids()
792        d = DeviceCategoryEditor(self.gui, tag_to_match=None, data=result, key=sort_key)
793        d.exec()
794        if d.result() == QDialog.DialogCode.Accepted:
795            to_rename = d.to_rename  # dict of new text to old ids
796            to_delete = d.to_delete  # list of ids
797            for old_id, new_name in iteritems(to_rename):
798                model.rename_collection(old_id, new_name=str(new_name))
799            for item in to_delete:
800                model.delete_collection_using_id(item)
801            self.gui.upload_collections(model.db, view=view, oncard=oncard)
802            view.reset()
803
804    # Apply bulk metadata changes {{{
805    def apply_metadata_changes(self, id_map, title=None, msg='', callback=None,
806            merge_tags=True, merge_comments=False, icon=None):
807        '''
808        Apply the metadata changes in id_map to the database synchronously
809        id_map must be a mapping of ids to Metadata objects. Set any fields you
810        do not want updated in the Metadata object to null. An easy way to do
811        that is to create a metadata object as Metadata(_('Unknown')) and then
812        only set the fields you want changed on this object.
813
814        callback can be either None or a function accepting a single argument,
815        in which case it is called after applying is complete with the list of
816        changed ids.
817
818        id_map can also be a mapping of ids to 2-tuple's where each 2-tuple
819        contains the absolute paths to an OPF and cover file respectively. If
820        either of the paths is None, then the corresponding metadata is not
821        updated.
822        '''
823        if title is None:
824            title = _('Applying changed metadata')
825        self.apply_id_map = list(iteritems(id_map))
826        self.apply_current_idx = 0
827        self.apply_failures = []
828        self.applied_ids = set()
829        self.apply_pd = None
830        self.apply_callback = callback
831        if len(self.apply_id_map) > 1:
832            from calibre.gui2.dialogs.progress import ProgressDialog
833            self.apply_pd = ProgressDialog(title, msg, min=0,
834                    max=len(self.apply_id_map)-1, parent=self.gui,
835                    cancelable=False, icon=icon)
836            self.apply_pd.setModal(True)
837            self.apply_pd.show()
838        self._am_merge_tags = merge_tags
839        self._am_merge_comments = merge_comments
840        self.do_one_apply()
841
842    def do_one_apply(self):
843        if self.apply_current_idx >= len(self.apply_id_map):
844            return self.finalize_apply()
845
846        i, mi = self.apply_id_map[self.apply_current_idx]
847        if self.gui.current_db.has_id(i):
848            if isinstance(mi, tuple):
849                opf, cover = mi
850                if opf:
851                    mi = OPF(open(opf, 'rb'), basedir=os.path.dirname(opf),
852                            populate_spine=False).to_book_metadata()
853                    self.apply_mi(i, mi)
854                if cover:
855                    self.gui.current_db.set_cover(i, open(cover, 'rb'),
856                            notify=False, commit=False)
857                    self.applied_ids.add(i)
858            else:
859                self.apply_mi(i, mi)
860
861        self.apply_current_idx += 1
862        if self.apply_pd is not None:
863            self.apply_pd.value += 1
864        QTimer.singleShot(5, self.do_one_apply)
865
866    def apply_mi(self, book_id, mi):
867        db = self.gui.current_db
868
869        try:
870            set_title = not mi.is_null('title')
871            set_authors = not mi.is_null('authors')
872            idents = db.get_identifiers(book_id, index_is_id=True)
873            if mi.identifiers:
874                idents.update(mi.identifiers)
875            mi.identifiers = idents
876            if mi.is_null('series'):
877                mi.series_index = None
878            if self._am_merge_tags:
879                old_tags = db.tags(book_id, index_is_id=True)
880                if old_tags:
881                    tags = [x.strip() for x in old_tags.split(',')] + (
882                            mi.tags if mi.tags else [])
883                    mi.tags = list(set(tags))
884            if self._am_merge_comments:
885                old_comments = db.new_api.field_for('comments', book_id)
886                if old_comments and mi.comments and old_comments != mi.comments:
887                    mi.comments = merge_comments(old_comments, mi.comments)
888            db.set_metadata(book_id, mi, commit=False, set_title=set_title,
889                    set_authors=set_authors, notify=False)
890            self.applied_ids.add(book_id)
891        except:
892            import traceback
893            self.apply_failures.append((book_id, traceback.format_exc()))
894
895        try:
896            if mi.cover:
897                os.remove(mi.cover)
898        except:
899            pass
900
901    def finalize_apply(self):
902        db = self.gui.current_db
903        db.commit()
904
905        if self.apply_pd is not None:
906            self.apply_pd.hide()
907
908        if self.apply_failures:
909            msg = []
910            for i, tb in self.apply_failures:
911                title = db.title(i, index_is_id=True)
912                authors = db.authors(i, index_is_id=True)
913                if authors:
914                    authors = [x.replace('|', ',') for x in authors.split(',')]
915                    title += ' - ' + authors_to_string(authors)
916                msg.append(title+'\n\n'+tb+'\n'+('*'*80))
917
918            error_dialog(self.gui, _('Some failures'),
919                _('Failed to apply updated metadata for some books'
920                    ' in your library. Click "Show details" to see '
921                    'details.'), det_msg='\n\n'.join(msg), show=True)
922        changed_books = len(self.applied_ids or ())
923        self.refresh_gui(self.applied_ids)
924
925        self.apply_id_map = []
926        self.apply_pd = None
927        try:
928            if callable(self.apply_callback):
929                self.apply_callback(list(self.applied_ids))
930        finally:
931            self.apply_callback = None
932        if changed_books:
933            QApplication.alert(self.gui, 2000)
934
935    def refresh_gui(self, book_ids, covers_changed=True, tag_browser_changed=True):
936        if book_ids:
937            cr = self.gui.library_view.currentIndex().row()
938            self.gui.library_view.model().refresh_ids(
939                list(book_ids), cr)
940            if covers_changed:
941                self.gui.refresh_cover_browser()
942            if tag_browser_changed:
943                self.gui.tags_view.recount()
944
945    # }}}
946
947    def remove_metadata_item(self, book_id, field, value):
948        db = self.gui.current_db.new_api
949        fm = db.field_metadata[field]
950        affected_books = set()
951        if field == 'identifiers':
952            identifiers = db.field_for(field, book_id)
953            if identifiers.pop(value, False) is not False:
954                affected_books = db.set_field(field, {book_id:identifiers})
955        elif field == 'authors':
956            authors = db.field_for(field, book_id)
957            new_authors = [x for x in authors if x != value] or [_('Unknown')]
958            if new_authors != authors:
959                affected_books = db.set_field(field, {book_id:new_authors})
960        elif fm['is_multiple']:
961            item_id = db.get_item_id(field, value)
962            if item_id is not None:
963                affected_books = db.remove_items(field, (item_id,), {book_id})
964        else:
965            affected_books = db.set_field(field, {book_id:''})
966        if affected_books:
967            self.refresh_books_after_metadata_edit(affected_books)
968
969    def set_cover_from_format(self, book_id, fmt):
970        from calibre.ebooks.metadata.meta import get_metadata
971        from calibre.utils.config import prefs
972        fmt = fmt.lower()
973        cdata = None
974        db = self.gui.current_db.new_api
975        if fmt == 'pdf':
976            pdfpath = db.format_abspath(book_id, fmt)
977            if pdfpath is None:
978                return error_dialog(self.gui, _('Format file missing'), _(
979                    'Cannot read cover as the %s file is missing from this book') % 'PDF', show=True)
980            from calibre.gui2.metadata.pdf_covers import PDFCovers
981            d = PDFCovers(pdfpath, parent=self.gui)
982            ret = d.exec()
983            if ret == QDialog.DialogCode.Accepted:
984                cpath = d.cover_path
985                if cpath:
986                    with open(cpath, 'rb') as f:
987                        cdata = f.read()
988            d.cleanup()
989            if ret != QDialog.DialogCode.Accepted:
990                return
991        else:
992            stream = BytesIO()
993            try:
994                db.copy_format_to(book_id, fmt, stream)
995            except NoSuchFormat:
996                return error_dialog(self.gui, _('Format file missing'), _(
997                    'Cannot read cover as the %s file is missing from this book') % fmt.upper(), show=True)
998            old = prefs['read_file_metadata']
999            if not old:
1000                prefs['read_file_metadata'] = True
1001            try:
1002                stream.seek(0)
1003                mi = get_metadata(stream, fmt)
1004            except Exception:
1005                import traceback
1006                return error_dialog(self.gui, _('Could not read metadata'),
1007                            _('Could not read metadata from %s format')%fmt.upper(),
1008                             det_msg=traceback.format_exc(), show=True)
1009            finally:
1010                if old != prefs['read_file_metadata']:
1011                    prefs['read_file_metadata'] = old
1012            if mi.cover and os.access(mi.cover, os.R_OK):
1013                with open(mi.cover, 'rb') as f:
1014                    cdata = f.read()
1015            elif mi.cover_data[1] is not None:
1016                cdata = mi.cover_data[1]
1017            if cdata is None:
1018                return error_dialog(self.gui, _('Could not read cover'),
1019                            _('Could not read cover from %s format')%fmt.upper(), show=True)
1020        db.set_cover({book_id:cdata})
1021        current_idx = self.gui.library_view.currentIndex()
1022        self.gui.library_view.model().current_changed(current_idx, current_idx)
1023        self.gui.refresh_cover_browser()
1024