mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
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:
parent
ce7da098ca
commit
312dae429e
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user