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)

This commit is contained in:
Kovid Goyal 2018-06-13 17:31:26 +05:30
parent ce7da098ca
commit 312dae429e
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 184 additions and 34 deletions

View File

@ -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('<p>'+_(
'You are about to merge very many ({}) books. '
'Are you <b>sure</b> you want to proceed?').format(num) + '</p>',
'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,11 +522,7 @@ 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('<p>'+_(
'You are about to merge more than 5 books. '
'Are you <b>sure</b> you want to proceed?') + '</p>',
'merge_too_many_books', self.gui):
if not self.confirm_large_merge(len(rows)):
return
dest_id, src_ids = self.books_to_merge(rows)
@ -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()

View File

@ -6,19 +6,48 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
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='<i>%s</i>' % mi.series)
self.setHtml('''
<h3 style="text-align:center">{mb}</h3>
<p><b>{title}</b> - <i>{authors}</i><br></p>
<table>
<tr><td>{fm[timestamp][name]}:</td><td>{date}</td></tr>
<tr><td>{fm[pubdate][name]}:</td><td>{published}</td></tr>
<tr><td>{fm[formats][name]}:</td><td>{formats}</td></tr>
<tr><td>{fm[series][name]}:</td><td>{series}</td></tr>
</table>
'''.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='<i>%s</i>' % mi.series)
r.setHtml('''
<h3 style="text-align:center">{mb}</h3>
<p><b>{title}</b> - <i>{authors}</i><br></p>
<table>
<tr><td>{fm[timestamp][name]}:</td><td>{date}</td></tr>
<tr><td>{fm[pubdate][name]}:</td><td>{published}</td></tr>
<tr><td>{fm[formats][name]}:</td><td>{formats}</td></tr>
<tr><td>{fm[series][name]}:</td><td>{series}</td></tr>
</table>
'''.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 = '<p>'
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 += '<br>'
msg += _('All book formats of the first selected book will be kept.') + '<br><br>'
if rm:
msg += _('After being merged, the selected books will be <b>deleted</b>.')
if mf:
msg += '<br><br>' + _(
'Any duplicate formats in the selected books '
'will be permanently <b>deleted</b> 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('<br>'):
msg += '<br><br>'
msg += _('Are you <b>sure</b> you want to proceed?') + '</p>'
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

View File

@ -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)

View File

@ -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)

View File

@ -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