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)
|
self.gui.library_view.select_rows(book_ids)
|
||||||
|
|
||||||
# Merge books {{{
|
# 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):
|
def merge_books(self, safe_merge=False, merge_only_formats=False):
|
||||||
'''
|
'''
|
||||||
Merge selected books in library.
|
Merge selected books in library.
|
||||||
@ -495,12 +522,8 @@ class EditMetadataAction(InterfaceAction):
|
|||||||
return error_dialog(self.gui, _('Cannot merge books'),
|
return error_dialog(self.gui, _('Cannot merge books'),
|
||||||
_('At least two books must be selected for merging'),
|
_('At least two books must be selected for merging'),
|
||||||
show=True)
|
show=True)
|
||||||
if len(rows) > 5:
|
if not self.confirm_large_merge(len(rows)):
|
||||||
if not confirm('<p>'+_(
|
return
|
||||||
'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):
|
|
||||||
return
|
|
||||||
|
|
||||||
dest_id, src_ids = self.books_to_merge(rows)
|
dest_id, src_ids = self.books_to_merge(rows)
|
||||||
mi = self.gui.current_db.new_api.get_proxy_metadata(dest_id)
|
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,
|
self.gui.library_view.model().db.add_format(dest_id, fmt, f, index_is_id=True,
|
||||||
notify=False, replace=replace)
|
notify=False, replace=replace)
|
||||||
|
|
||||||
def formats_for_books(self, rows):
|
def formats_for_ids(self, ids):
|
||||||
m = self.gui.library_view.model()
|
m = self.gui.library_view.model()
|
||||||
ans = []
|
ans = []
|
||||||
for id_ in map(m.id, rows):
|
for id_ in ids:
|
||||||
dbfmts = m.db.formats(id_, index_is_id=True)
|
dbfmts = m.db.formats(id_, index_is_id=True)
|
||||||
if dbfmts:
|
if dbfmts:
|
||||||
for fmt in dbfmts.split(','):
|
for fmt in dbfmts.split(','):
|
||||||
@ -581,6 +604,10 @@ class EditMetadataAction(InterfaceAction):
|
|||||||
continue
|
continue
|
||||||
return ans
|
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):
|
def books_to_merge(self, rows):
|
||||||
src_ids = []
|
src_ids = []
|
||||||
m = self.gui.library_view.model()
|
m = self.gui.library_view.model()
|
||||||
|
@ -6,19 +6,48 @@ from __future__ import (unicode_literals, division, absolute_import,
|
|||||||
__license__ = 'GPL v3'
|
__license__ = 'GPL v3'
|
||||||
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
from PyQt5.Qt import (
|
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 import authors_to_string
|
||||||
from calibre.ebooks.metadata.book.base import field_metadata
|
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.widgets2 import Dialog
|
||||||
from calibre.gui2.dialogs.confirm_delete import confirm_config_name
|
from calibre.gui2.dialogs.confirm_delete import confirm_config_name
|
||||||
from calibre.utils.config import tweaks
|
from calibre.utils.config import tweaks
|
||||||
from calibre.utils.date import format_date
|
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):
|
class ConfirmMerge(Dialog):
|
||||||
|
|
||||||
def __init__(self, msg, name, parent, mi):
|
def __init__(self, msg, name, parent, mi):
|
||||||
@ -44,29 +73,7 @@ class ConfirmMerge(Dialog):
|
|||||||
c.stateChanged.connect(self.toggle)
|
c.stateChanged.connect(self.toggle)
|
||||||
l.addWidget(c)
|
l.addWidget(c)
|
||||||
|
|
||||||
self.right = r = QTextBrowser(self)
|
self.right = r = Target(self.mi, 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
|
|
||||||
))
|
|
||||||
s.addWidget(r)
|
s.addWidget(r)
|
||||||
|
|
||||||
def toggle(self):
|
def toggle(self):
|
||||||
@ -84,3 +91,103 @@ def confirm_merge(msg, name, parent, mi):
|
|||||||
return True
|
return True
|
||||||
d = ConfirmMerge(msg, name, parent, mi)
|
d = ConfirmMerge(msg, name, parent, mi)
|
||||||
return d.exec_() == d.Accepted
|
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):
|
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.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,
|
self.library_view.add_column_signal.connect(partial(self.iactions['Preferences'].do_config,
|
||||||
initial_plugin=('Interface', 'Custom Columns')),
|
initial_plugin=('Interface', 'Custom Columns')),
|
||||||
type=Qt.QueuedConnection)
|
type=Qt.QueuedConnection)
|
||||||
|
@ -206,12 +206,24 @@ def dragEnterEvent(self, event):
|
|||||||
int(event.possibleActions() & Qt.MoveAction) == 0:
|
int(event.possibleActions() & Qt.MoveAction) == 0:
|
||||||
return
|
return
|
||||||
paths = self.paths_from_event(event)
|
paths = self.paths_from_event(event)
|
||||||
|
md = event.mimeData()
|
||||||
|
|
||||||
if paths:
|
if paths or md.hasFormat('application/calibre+from_library'):
|
||||||
event.acceptProposedAction()
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
|
||||||
def dropEvent(self, event):
|
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)
|
paths = self.paths_from_event(event)
|
||||||
event.setDropAction(Qt.CopyAction)
|
event.setDropAction(Qt.CopyAction)
|
||||||
event.accept()
|
event.accept()
|
||||||
@ -291,6 +303,7 @@ class AlternateViews(object):
|
|||||||
view.selectionModel().currentChanged.connect(self.slave_current_changed)
|
view.selectionModel().currentChanged.connect(self.slave_current_changed)
|
||||||
view.selectionModel().selectionChanged.connect(self.slave_selection_changed)
|
view.selectionModel().selectionChanged.connect(self.slave_selection_changed)
|
||||||
view.files_dropped.connect(self.main_view.files_dropped)
|
view.files_dropped.connect(self.main_view.files_dropped)
|
||||||
|
view.books_dropped.connect(self.main_view.books_dropped)
|
||||||
|
|
||||||
def show_view(self, key=None):
|
def show_view(self, key=None):
|
||||||
view = self.views[key]
|
view = self.views[key]
|
||||||
@ -663,6 +676,7 @@ class GridView(QListView):
|
|||||||
|
|
||||||
update_item = pyqtSignal(object)
|
update_item = pyqtSignal(object)
|
||||||
files_dropped = pyqtSignal(object)
|
files_dropped = pyqtSignal(object)
|
||||||
|
books_dropped = pyqtSignal(object)
|
||||||
|
|
||||||
def __init__(self, parent):
|
def __init__(self, parent):
|
||||||
QListView.__init__(self, parent)
|
QListView.__init__(self, parent)
|
||||||
|
@ -204,6 +204,7 @@ class PreserveViewState(object): # {{{
|
|||||||
class BooksView(QTableView): # {{{
|
class BooksView(QTableView): # {{{
|
||||||
|
|
||||||
files_dropped = pyqtSignal(object)
|
files_dropped = pyqtSignal(object)
|
||||||
|
books_dropped = pyqtSignal(object)
|
||||||
add_column_signal = pyqtSignal()
|
add_column_signal = pyqtSignal()
|
||||||
is_library_view = True
|
is_library_view = True
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user