When merging books add an option to store discarded or replaced covers as an alternate cover in the data folder of the target book. Fixes #2071033 [[Enhancement] Merging Books: Copy cover of second book to data files](https://bugs.launchpad.net/calibre/+bug/2071033)

This commit is contained in:
Kovid Goyal 2024-06-27 13:28:30 +05:30
parent 0c75bde03f
commit d528bc6a35
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
3 changed files with 52 additions and 18 deletions

View File

@ -28,7 +28,7 @@ from calibre.customize.ui import run_plugins_on_import, run_plugins_on_postadd,
from calibre.db import SPOOL_SIZE, _get_next_series_num_for_list
from calibre.db.annotations import merge_annotations
from calibre.db.categories import get_categories
from calibre.db.constants import NOTES_DIR_NAME
from calibre.db.constants import COVER_FILE_NAME, DATA_DIR_NAME, NOTES_DIR_NAME
from calibre.db.errors import NoSuchBook, NoSuchFormat
from calibre.db.fields import IDENTITY, InvalidLinkTable, create_field
from calibre.db.lazy import FormatMetadata, FormatsList, ProxyMetadata
@ -3346,12 +3346,13 @@ class Cache:
self.backend.copy_extra_file_to(book_id, path, relpath, stream_or_path)
@write_api
def merge_book_metadata(self, dest_id, src_ids, replace_cover=False):
def merge_book_metadata(self, dest_id, src_ids, replace_cover=False, save_alternate_cover=False):
dest_mi = self.get_metadata(dest_id)
merged_identifiers = self._field_for('identifiers', dest_id) or {}
orig_dest_comments = dest_mi.comments
dest_cover = orig_dest_cover = self.cover(dest_id)
had_orig_cover = bool(dest_cover)
alternate_covers = []
from calibre.utils.date import is_date_undefined
def is_null_date(x):
@ -3379,8 +3380,14 @@ class Cache:
if not dest_cover or replace_cover:
src_cover = self.cover(src_id)
if src_cover:
if save_alternate_cover and dest_cover:
alternate_covers.append(dest_cover)
dest_cover = src_cover
replace_cover = False
elif save_alternate_cover:
src_cover = self.cover(src_id)
if src_cover:
alternate_covers.append(src_cover)
if not dest_mi.publisher:
dest_mi.publisher = src_mi.publisher
if not dest_mi.rating:
@ -3401,6 +3408,17 @@ class Cache:
if dest_cover and (not had_orig_cover or dest_cover is not orig_dest_cover):
self._set_cover({dest_id: dest_cover})
if alternate_covers:
existing = {x[0] for x in self._list_extra_files(dest_id)}
h, ext = os.path.splitext(COVER_FILE_NAME)
template = f'{DATA_DIR_NAME}/{h}-{{:03d}}{ext}'
for cdata in alternate_covers:
for i in range(1, 1000):
q = template.format(i)
if q not in existing:
existing.add(q)
self._add_extra_files(dest_id, {q: BytesIO(cdata)}, replace=False, auto_rename=True)
break
for key in self.field_metadata: # loop thru all defined fields
fm = self.field_metadata[key]

View File

@ -579,7 +579,7 @@ class EditMetadataAction(InterfaceActionWithLibraryDrop):
if d.merge_formats:
self.add_formats(dest_id, self.formats_for_ids(list(src_ids)))
if d.merge_metadata:
self.merge_metadata(dest_id, src_ids, replace_cover=d.replace_cover)
self.merge_metadata(dest_id, src_ids, replace_cover=d.replace_cover, save_alternate_cover=d.save_alternate_cover)
if d.replace_cover:
covers_replaced = True
if d.delete_books:
@ -613,16 +613,17 @@ class EditMetadataAction(InterfaceActionWithLibraryDrop):
title = mi.title
hpos = self.gui.library_view.horizontalScrollBar().value()
if safe_merge:
if not confirm_merge('<p>'+_(
confirmed, save_alternate_cover = confirm_merge('<p>'+_(
'Book formats and metadata from the selected books '
'will be added to the <b>first selected book</b> (%s).<br> '
'The second and subsequently selected books will not '
'be deleted or changed.<br><br>'
'Please confirm you want to proceed.')%title + '</p>',
'merge_books_safe', self.gui, mi):
'merge_books_safe', self.gui, mi, ask_about_save_alternate_cover=True)
if not confirmed:
return
self.add_formats(dest_id, self.formats_for_books(rows))
self.merge_metadata(dest_id, src_ids)
self.merge_metadata(dest_id, src_ids, save_alternate_cover=save_alternate_cover)
elif merge_only_formats:
if not confirm_merge('<p>'+_(
'Book formats from the selected books will be merged '
@ -640,7 +641,7 @@ class EditMetadataAction(InterfaceActionWithLibraryDrop):
self.add_formats(dest_id, self.formats_for_books(rows))
self.delete_books_after_merge(src_ids)
else:
if not confirm_merge('<p>'+_(
confirmed, save_alternate_cover = confirm_merge('<p>'+_(
'Book formats and metadata from the selected books will be merged '
'into the <b>first selected book</b> (%s).<br><br>'
'After being merged, the second and '
@ -649,10 +650,11 @@ class EditMetadataAction(InterfaceActionWithLibraryDrop):
'and any duplicate formats in the second and subsequently selected books '
'will be permanently <b>deleted</b> from your calibre library.<br><br> '
'Are you <b>sure</b> you want to proceed?')%title + '</p>',
'merge_books', self.gui, mi):
'merge_books', self.gui, mi, ask_about_save_alternate_cover=True)
if not confirmed:
return
self.add_formats(dest_id, self.formats_for_books(rows))
self.merge_metadata(dest_id, src_ids)
self.merge_metadata(dest_id, src_ids, save_alternate_cover=save_alternate_cover)
self.merge_data_files(dest_id, src_ids)
self.delete_books_after_merge(src_ids)
# leave the selection highlight on first selected book
@ -709,8 +711,8 @@ class EditMetadataAction(InterfaceActionWithLibraryDrop):
def delete_books_after_merge(self, ids_to_delete):
self.gui.library_view.model().delete_books_by_id(ids_to_delete)
def merge_metadata(self, dest_id, src_ids, replace_cover=False):
self.gui.current_db.new_api.merge_book_metadata(dest_id, src_ids, replace_cover)
def merge_metadata(self, dest_id, src_ids, replace_cover=False, save_alternate_cover=False):
self.gui.current_db.new_api.merge_book_metadata(dest_id, src_ids, replace_cover, save_alternate_cover=save_alternate_cover)
# }}}
def edit_device_collections(self, view, oncard=None):

View File

@ -50,8 +50,9 @@ class Target(QTextBrowser):
class ConfirmMerge(Dialog):
def __init__(self, msg, name, parent, mi):
def __init__(self, msg, name, parent, mi, ask_about_save_alternate_cover=False):
self.msg, self.mi, self.conf_name = msg, mi, name
self.ask_about_save_alternate_cover = ask_about_save_alternate_cover
Dialog.__init__(self, _('Are you sure?'), 'confirm-merge-dialog', parent)
needed, sz = self.sizeHint(), self.size()
if needed.width() > sz.width() or needed.height() > sz.height():
@ -71,6 +72,13 @@ class ConfirmMerge(Dialog):
self.la = la = QLabel(self.msg)
la.setWordWrap(True)
l.addWidget(la)
self.save_alternate_cover_cb = c = QCheckBox(_('Save replaced or discarded &cover'), self)
c.setToolTip(_('Save the replaced or discarded cover in the data files associated with the target book as an alternate cover'))
c.setObjectName('choose-merge-cb-save_alternate_cover')
c.setChecked(bool(gprefs.get(c.objectName(), False)))
l.addWidget(c)
c.setVisible(self.ask_about_save_alternate_cover)
c.toggled.connect(self.alternate_covers_toggled)
self.confirm = c = QCheckBox(_('Show this confirmation again'), self)
c.setChecked(True)
c.stateChanged.connect(self.toggle)
@ -79,6 +87,9 @@ class ConfirmMerge(Dialog):
self.right = r = Target(self.mi, self)
s.addWidget(r)
def alternate_covers_toggled(self):
gprefs.set(self.save_alternate_cover_cb.objectName(), self.save_alternate_cover_cb.isChecked())
def toggle(self):
dynamic[confirm_config_name(self.conf_name)] = self.confirm.isChecked()
@ -88,12 +99,12 @@ class ConfirmMerge(Dialog):
return ans
def confirm_merge(msg, name, parent, mi):
def confirm_merge(msg, name, parent, mi, ask_about_save_alternate_cover=False):
config_set = dynamic
if not config_set.get(confirm_config_name(name), True):
return True
d = ConfirmMerge(msg, name, parent, mi)
return d.exec() == QDialog.DialogCode.Accepted
d = ConfirmMerge(msg, name, parent, mi, ask_about_save_alternate_cover)
return d.exec() == QDialog.DialogCode.Accepted, d.save_alternate_cover_cb.isChecked()
class ChooseMerge(Dialog):
@ -116,11 +127,11 @@ class ChooseMerge(Dialog):
w.fl = fl = FlowLayout()
l.addLayout(fl)
def cb(name, text, tt=''):
def cb(name, text, tt='', defval=True):
ans = QCheckBox(text)
fl.addWidget(ans)
prefs_key = ans.prefs_key = 'choose-merge-cb-' + name
ans.setChecked(gprefs.get(prefs_key, True))
ans.setChecked(gprefs.get(prefs_key, defval))
connect_lambda(ans.stateChanged, self, lambda self, state: self.state_changed(getattr(self, name), state), type=Qt.ConnectionType.QueuedConnection)
if tt:
ans.setToolTip(tt)
@ -135,6 +146,8 @@ class ChooseMerge(Dialog):
'Delete the selected books after merging'))
cb('replace_cover', _('Replace existing cover'), _(
'Replace the cover in the target book with the dragged cover'))
cb('save_alternate_cover', _('Save alternate cover'), _(
'Save the replaced or discarded cover in the data files associated with the target book as an alternate cover'), defval=False)
l.addStretch(10)
self.msg = la = QLabel(self)
la.setWordWrap(True)
@ -197,7 +210,7 @@ class ChooseMerge(Dialog):
def merge_type(self):
return MergeData(
self.merge_metadata.isChecked(), self.merge_formats.isChecked(), self.delete_books.isChecked(),
self.replace_cover.isChecked(),
self.replace_cover.isChecked(), self.save_alternate_cover.isChecked(),
)
@ -206,6 +219,7 @@ class MergeData(NamedTuple):
merge_formats: bool = False
delete_books: bool = False
replace_cover: bool = False
save_alternate_cover: bool = False
def merge_drop(dest_id, src_ids, gui):