From 312dae429ee30da613fda58b46363b16bd374c63 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 13 Jun 2018 17:31:26 +0530 Subject: [PATCH] Book list: Allow drag and drop of books onto other books to merge the book records. Fixes #1775123 [[Enhancement] Merge books with Drag/Drop](https://bugs.launchpad.net/calibre/+bug/1775123) --- src/calibre/gui2/actions/edit_metadata.py | 43 +++++- src/calibre/gui2/dialogs/confirm_merge.py | 157 ++++++++++++++++---- src/calibre/gui2/init.py | 1 + src/calibre/gui2/library/alternate_views.py | 16 +- src/calibre/gui2/library/views.py | 1 + 5 files changed, 184 insertions(+), 34 deletions(-) diff --git a/src/calibre/gui2/actions/edit_metadata.py b/src/calibre/gui2/actions/edit_metadata.py index 41645f11f2..79c4fbceff 100644 --- a/src/calibre/gui2/actions/edit_metadata.py +++ b/src/calibre/gui2/actions/edit_metadata.py @@ -480,6 +480,33 @@ class EditMetadataAction(InterfaceAction): self.gui.library_view.select_rows(book_ids) # Merge books {{{ + + def confirm_large_merge(self, num): + if num < 5: + return True + return confirm('

'+_( + 'You are about to merge very many ({}) books. ' + 'Are you sure you want to proceed?').format(num) + '

', + 'merge_too_many_books', self.gui) + + def books_dropped(self, merge_map): + for dest_id, src_ids in merge_map.iteritems(): + if not self.confirm_large_merge(len(src_ids) + 1): + continue + from calibre.gui2.dialogs.confirm_merge import merge_drop + merge_metadata, merge_formats, delete_books = merge_drop(dest_id, src_ids, self.gui) + if merge_metadata is None: + return + if merge_formats: + self.add_formats(dest_id, self.formats_for_ids(list(src_ids))) + if merge_metadata: + self.merge_metadata(dest_id, src_ids) + if delete_books: + self.delete_books_after_merge(src_ids) + # leave the selection highlight on the target book + row = self.gui.library_view.ids_to_rows([dest_id])[dest_id] + self.gui.library_view.set_current_row(row) + def merge_books(self, safe_merge=False, merge_only_formats=False): ''' Merge selected books in library. @@ -495,12 +522,8 @@ class EditMetadataAction(InterfaceAction): return error_dialog(self.gui, _('Cannot merge books'), _('At least two books must be selected for merging'), show=True) - if len(rows) > 5: - if not confirm('

'+_( - 'You are about to merge more than 5 books. ' - 'Are you sure you want to proceed?') + '

', - 'merge_too_many_books', self.gui): - return + if not self.confirm_large_merge(len(rows)): + return dest_id, src_ids = self.books_to_merge(rows) mi = self.gui.current_db.new_api.get_proxy_metadata(dest_id) @@ -566,10 +589,10 @@ class EditMetadataAction(InterfaceAction): self.gui.library_view.model().db.add_format(dest_id, fmt, f, index_is_id=True, notify=False, replace=replace) - def formats_for_books(self, rows): + def formats_for_ids(self, ids): m = self.gui.library_view.model() ans = [] - for id_ in map(m.id, rows): + for id_ in ids: dbfmts = m.db.formats(id_, index_is_id=True) if dbfmts: for fmt in dbfmts.split(','): @@ -581,6 +604,10 @@ class EditMetadataAction(InterfaceAction): continue return ans + def formats_for_books(self, rows): + m = self.gui.library_view.model() + return self.formats_for_ids(map(m.id, rows)) + def books_to_merge(self, rows): src_ids = [] m = self.gui.library_view.model() diff --git a/src/calibre/gui2/dialogs/confirm_merge.py b/src/calibre/gui2/dialogs/confirm_merge.py index 6984c57ba1..cd1a77d82a 100644 --- a/src/calibre/gui2/dialogs/confirm_merge.py +++ b/src/calibre/gui2/dialogs/confirm_merge.py @@ -6,19 +6,48 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' +from functools import partial from PyQt5.Qt import ( - QVBoxLayout, QSplitter, QWidget, QLabel, QCheckBox, QTextBrowser + QVBoxLayout, QSplitter, QWidget, QLabel, QCheckBox, QTextBrowser, Qt, ) from calibre.ebooks.metadata import authors_to_string from calibre.ebooks.metadata.book.base import field_metadata -from calibre.gui2 import dynamic +from calibre.gui2 import dynamic, gprefs from calibre.gui2.widgets2 import Dialog from calibre.gui2.dialogs.confirm_delete import confirm_config_name from calibre.utils.config import tweaks from calibre.utils.date import format_date +class Target(QTextBrowser): + + def __init__(self, mi, parent=None): + QTextBrowser.__init__(self, parent) + series = '' + fm = field_metadata + if mi.series: + series = _('{num} of {series}').format(num=mi.format_series_index(), series='%s' % mi.series) + self.setHtml(''' +

{mb}

+

{title} - {authors}

+ + + + + +
{fm[timestamp][name]}:{date}
{fm[pubdate][name]}:{published}
{fm[formats][name]}:{formats}
{fm[series][name]}:{series}
+ '''.format( + mb=_('Target book'), + title=mi.title, + authors=authors_to_string(mi.authors), + date=format_date(mi.timestamp, tweaks['gui_timestamp_display_format']), fm=fm, + published=(format_date(mi.pubdate, tweaks['gui_pubdate_display_format']) if mi.pubdate else ''), + formats=', '.join(mi.formats or ()), + series=series + )) + + class ConfirmMerge(Dialog): def __init__(self, msg, name, parent, mi): @@ -44,29 +73,7 @@ class ConfirmMerge(Dialog): c.stateChanged.connect(self.toggle) l.addWidget(c) - self.right = r = QTextBrowser(self) - series = '' - mi, fm = self.mi, field_metadata - if mi.series: - series = _('{num} of {series}').format(num=mi.format_series_index(), series='%s' % mi.series) - r.setHtml(''' -

{mb}

-

{title} - {authors}

- - - - - -
{fm[timestamp][name]}:{date}
{fm[pubdate][name]}:{published}
{fm[formats][name]}:{formats}
{fm[series][name]}:{series}
- '''.format( - mb=_('Target book'), - title=mi.title, - authors=authors_to_string(mi.authors), - date=format_date(mi.timestamp, tweaks['gui_timestamp_display_format']), fm=fm, - published=(format_date(mi.pubdate, tweaks['gui_pubdate_display_format']) if mi.pubdate else ''), - formats=', '.join(mi.formats or ()), - series=series - )) + self.right = r = Target(self.mi, self) s.addWidget(r) def toggle(self): @@ -84,3 +91,103 @@ def confirm_merge(msg, name, parent, mi): return True d = ConfirmMerge(msg, name, parent, mi) return d.exec_() == d.Accepted + + +class ChooseMerge(Dialog): + + def __init__(self, dest_id, src_ids, gui): + self.dest_id, self.src_ids = dest_id, src_ids + self.mi = gui.current_db.new_api.get_metadata(dest_id) + Dialog.__init__(self, _('Merge books'), 'choose-merge-dialog', parent=gui) + + def setup_ui(self): + self.l = l = QVBoxLayout(self) + self.splitter = s = QSplitter(self) + s.setChildrenCollapsible(False) + l.addWidget(s), l.addWidget(self.bb) + self.bb.setStandardButtons(self.bb.Yes | self.bb.No) + self.left = w = QWidget(self) + s.addWidget(w) + w.l = l = QVBoxLayout(w) + l.setContentsMargins(0, 0, 0, 0) + + def cb(name, text, tt=''): + ans = QCheckBox(text) + l.addWidget(ans) + prefs_key = ans.prefs_key = 'choose-merge-cb-' + name + ans.setChecked(gprefs.get(prefs_key, True)) + ans.stateChanged.connect(partial(self.state_changed, ans), type=Qt.QueuedConnection) + if tt: + ans.setToolTip(tt) + setattr(self, name, ans) + return ans + + cb('merge_metadata', _('Merge metadata'), _( + 'Merge the metadata of the selected books into the target book')) + cb('merge_formats', _('Merge formats'), _( + 'Merge the book files of the selected books into the target book')) + cb('delete_books', _('Delete merged books'), _( + 'Delete the selected books after merging')) + l.addStretch(10) + self.msg = la = QLabel(self) + la.setWordWrap(True) + l.addWidget(la) + self.update_msg() + + self.right = r = Target(self.mi, self) + s.addWidget(r) + + def state_changed(self, cb, state): + mm = self.merge_metadata.isChecked() + mf = self.merge_formats.isChecked() + if not mm and not mf: + (self.merge_metadata if cb is self.merge_formats else self.merge_formats).setChecked(True) + gprefs[cb.prefs_key] = cb.isChecked() + self.update_msg() + + def update_msg(self): + mm = self.merge_metadata.isChecked() + mf = self.merge_formats.isChecked() + rm = self.delete_books.isChecked() + msg = '

' + if mm and mf: + msg += _( + 'Book formats and metadata from the selected books' + ' will be merged into the target book ({title}).') + elif mf: + msg += _('Book formats from the selected books ' + 'will be merged into to the target book ({title}).' + ' Metadata in the target book will not be changed.') + elif mm: + msg += _('Metadata from the selected books ' + 'will be merged into to the target book ({title}).' + ' Formats will not be merged.') + msg += '
' + msg += _('All book formats of the first selected book will be kept.') + '

' + if rm: + msg += _('After being merged, the selected books will be deleted.') + if mf: + msg += '

' + _( + 'Any duplicate formats in the selected books ' + 'will be permanently deleted from your calibre library.') + else: + if mf: + msg += _( + 'Any formats not in the target book will be added to it from the selected books.') + if not msg.endswith('
'): + msg += '

' + + msg += _('Are you sure you want to proceed?') + '

' + msg = msg.format(title=self.mi.title) + self.msg.setText(msg) + + @property + def merge_type(self): + return self.merge_metadata.isChecked(), self.merge_formats.isChecked(), self.delete_books.isChecked() + + +def merge_drop(dest_id, src_ids, gui): + d = ChooseMerge(dest_id, src_ids, gui) + if d.exec_() != d.Accepted: + return None, None, None + return d.merge_type diff --git a/src/calibre/gui2/init.py b/src/calibre/gui2/init.py index ad2f5bd397..6b0694abf8 100644 --- a/src/calibre/gui2/init.py +++ b/src/calibre/gui2/init.py @@ -41,6 +41,7 @@ class LibraryViewMixin(object): # {{{ def init_library_view_mixin(self, db): self.library_view.files_dropped.connect(self.iactions['Add Books'].files_dropped, type=Qt.QueuedConnection) + self.library_view.books_dropped.connect(self.iactions['Edit Metadata'].books_dropped, type=Qt.QueuedConnection) self.library_view.add_column_signal.connect(partial(self.iactions['Preferences'].do_config, initial_plugin=('Interface', 'Custom Columns')), type=Qt.QueuedConnection) diff --git a/src/calibre/gui2/library/alternate_views.py b/src/calibre/gui2/library/alternate_views.py index 6427026459..83f45a0136 100644 --- a/src/calibre/gui2/library/alternate_views.py +++ b/src/calibre/gui2/library/alternate_views.py @@ -206,12 +206,24 @@ def dragEnterEvent(self, event): int(event.possibleActions() & Qt.MoveAction) == 0: return paths = self.paths_from_event(event) + md = event.mimeData() - if paths: + if paths or md.hasFormat('application/calibre+from_library'): event.acceptProposedAction() def dropEvent(self, event): + md = event.mimeData() + if md.hasFormat('application/calibre+from_library'): + ids = set(map(int, bytes(md.data('application/calibre+from_library')).decode('utf-8').split(' '))) + row = self.indexAt(event.pos()).row() + if row > -1 and ids: + book_id = self.model().id(row) + if book_id: + self.books_dropped.emit({book_id: ids}) + event.setDropAction(Qt.CopyAction) + event.accept() + return paths = self.paths_from_event(event) event.setDropAction(Qt.CopyAction) event.accept() @@ -291,6 +303,7 @@ class AlternateViews(object): view.selectionModel().currentChanged.connect(self.slave_current_changed) view.selectionModel().selectionChanged.connect(self.slave_selection_changed) view.files_dropped.connect(self.main_view.files_dropped) + view.books_dropped.connect(self.main_view.books_dropped) def show_view(self, key=None): view = self.views[key] @@ -663,6 +676,7 @@ class GridView(QListView): update_item = pyqtSignal(object) files_dropped = pyqtSignal(object) + books_dropped = pyqtSignal(object) def __init__(self, parent): QListView.__init__(self, parent) diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index 2a02e4a108..464be2081b 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -204,6 +204,7 @@ class PreserveViewState(object): # {{{ class BooksView(QTableView): # {{{ files_dropped = pyqtSignal(object) + books_dropped = pyqtSignal(object) add_column_signal = pyqtSignal() is_library_view = True