Show a warning when copy to library is use don incompatible libs

Show a warning when attempting to copy books between libraries that do
no have the same set of custom columns. Fixes #1225484 [New feature: copy books between libraries with all custom metadata](https://bugs.launchpad.net/calibre/+bug/1225484)

Merge branch 'master' of https://github.com/cbhaley/calibre
This commit is contained in:
Kovid Goyal 2013-09-16 11:43:03 +05:30
commit 26f3d27fdb
5 changed files with 161 additions and 11 deletions

View File

@ -36,6 +36,13 @@ def cleanup_tags(tags):
ans.append(tag) ans.append(tag)
return ans return ans
def create_backend(
library_path, default_prefs=None, read_only=False,
progress_callback=lambda x, y:True, restore_all_prefs=False):
return DB(library_path, default_prefs=default_prefs,
read_only=read_only, restore_all_prefs=restore_all_prefs,
progress_callback=progress_callback)
class LibraryDatabase(object): class LibraryDatabase(object):
''' Emulate the old LibraryDatabase2 interface ''' ''' Emulate the old LibraryDatabase2 interface '''
@ -58,9 +65,9 @@ class LibraryDatabase(object):
self.is_second_db = is_second_db self.is_second_db = is_second_db
self.listeners = set() self.listeners = set()
backend = self.backend = DB(library_path, default_prefs=default_prefs, backend = self.backend = create_backend(library_path, default_prefs=default_prefs,
read_only=read_only, restore_all_prefs=restore_all_prefs, read_only=read_only, restore_all_prefs=restore_all_prefs,
progress_callback=progress_callback) progress_callback=progress_callback)
cache = self.new_api = Cache(backend) cache = self.new_api = Cache(backend)
cache.init() cache.init()
self.data = View(cache) self.data = View(cache)

View File

@ -43,6 +43,11 @@ class DevicePlugin(Plugin):
#: than THUMBNAIL_HEIGHT #: than THUMBNAIL_HEIGHT
# THUMBNAIL_WIDTH = 68 # THUMBNAIL_WIDTH = 68
#: Compression quality for thumbnails. Set this closer to 100 to have better
#: quality thumbnails with fewer compression artifacts. Of course, the
#: thumbnails get larger as well.
THUMBNAIL_COMPRESSION_QUALITY = 75
#: Set this to True if the device supports updating cover thumbnails during #: Set this to True if the device supports updating cover thumbnails during
#: sync_booklists. Setting it to true will ask device.py to refresh the #: sync_booklists. Setting it to true will ask device.py to refresh the
#: cover thumbnails during book matching #: cover thumbnails during book matching

View File

@ -202,8 +202,9 @@ class SMART_DEVICE_APP(DeviceConfig, DevicePlugin):
# making this number effectively around 10 to 15 larger. # making this number effectively around 10 to 15 larger.
PATH_FUDGE_FACTOR = 40 PATH_FUDGE_FACTOR = 40
THUMBNAIL_HEIGHT = 160 THUMBNAIL_HEIGHT = 160
DEFAULT_THUMBNAIL_HEIGHT = 160 DEFAULT_THUMBNAIL_HEIGHT = 160
THUMBNAIL_COMPRESSION_QUALITY = 70
PREFIX = '' PREFIX = ''
BACKLOADING_ERROR_MESSAGE = None BACKLOADING_ERROR_MESSAGE = None
@ -292,12 +293,22 @@ class SMART_DEVICE_APP(DeviceConfig, DevicePlugin):
'particular IP address. The driver will listen only on the ' 'particular IP address. The driver will listen only on the '
'entered address, and this address will be the one advertized ' 'entered address, and this address will be the one advertized '
'over mDNS (bonjour).') + '</p>', 'over mDNS (bonjour).') + '</p>',
_('Replace books with the same calibre identifier') + ':::<p>' + _('Replace books with same calibre ID') + ':::<p>' +
_('Use this option to overwrite a book on the device if that book ' _('Use this option to overwrite a book on the device if that book '
'has the same calibre identifier as the book being sent. The file name of the ' 'has the same calibre identifier as the book being sent. The file name of the '
'book will not change even if the save template produces a ' 'book will not change even if the save template produces a '
'different result. Using this option in most cases prevents ' 'different result. Using this option in most cases prevents '
'having multiple copies of a book on the device.') + '</p>', 'having multiple copies of a book on the device.') + '</p>',
_('Cover thumbnail compression quality') + ':::<p>' +
_('Use this option to control the size and quality of the cover '
'file sent to the device. It must be between 50 and 99. '
'The larger the number the higher quality the cover, but also '
'the larger the file. For example, changing this from 70 to 90 '
'results in a much better cover that is approximately 2.5 '
'times as big. To see the changes you must force calibre '
'to resend metadata to the device, either by changing '
'the metadata for the book (updating the last modification '
'time) or resending the book itself.') + '</p>',
] ]
EXTRA_CUSTOMIZATION_DEFAULT = [ EXTRA_CUSTOMIZATION_DEFAULT = [
False, '', False, '',
@ -306,7 +317,7 @@ class SMART_DEVICE_APP(DeviceConfig, DevicePlugin):
False, '', False, '',
'', '', '', '',
True, '', True, '',
True True, '70'
] ]
OPT_AUTOSTART = 0 OPT_AUTOSTART = 0
OPT_PASSWORD = 2 OPT_PASSWORD = 2
@ -317,6 +328,7 @@ class SMART_DEVICE_APP(DeviceConfig, DevicePlugin):
OPT_AUTODISCONNECT = 10 OPT_AUTODISCONNECT = 10
OPT_FORCE_IP_ADDRESS = 11 OPT_FORCE_IP_ADDRESS = 11
OPT_OVERWRITE_BOOKS_UUID = 12 OPT_OVERWRITE_BOOKS_UUID = 12
OPT_COMPRESSION_QUALITY = 13
def __init__(self, path): def __init__(self, path):
@ -1288,6 +1300,19 @@ class SMART_DEVICE_APP(DeviceConfig, DevicePlugin):
self.client_can_stream_metadata = False self.client_can_stream_metadata = False
self.client_wants_uuid_file_names = False self.client_wants_uuid_file_names = False
compression_quality_ok = True
try:
cq = int(self.settings().extra_customization[self.OPT_COMPRESSION_QUALITY])
if cq < 50 or cq > 99:
compression_quality_ok = False
except:
compression_quality_ok = False
if not compression_quality_ok:
self.THUMBNAIL_COMPRESSION_QUALITY = 70
message = 'Bad compression quality setting. It must be a number between 50 and 99'
self._debug(message)
return message
message = None message = None
try: try:
self.listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

View File

@ -9,8 +9,10 @@ import os
from functools import partial from functools import partial
from threading import Thread from threading import Thread
from contextlib import closing from contextlib import closing
from collections import defaultdict
from PyQt4.Qt import (QToolButton, QDialog, QGridLayout, QIcon, QLabel, QDialogButtonBox) from PyQt4.Qt import (QToolButton, QDialog, QGridLayout, QIcon, QLabel, QDialogButtonBox,
QFormLayout, QCheckBox, QWidget, QScrollArea, QVBoxLayout)
from calibre.gui2.actions import InterfaceAction from calibre.gui2.actions import InterfaceAction
from calibre.gui2 import (error_dialog, Dispatcher, warning_dialog, gprefs, from calibre.gui2 import (error_dialog, Dispatcher, warning_dialog, gprefs,
@ -19,6 +21,72 @@ from calibre.gui2.dialogs.progress import ProgressDialog
from calibre.gui2.widgets import HistoryLineEdit from calibre.gui2.widgets import HistoryLineEdit
from calibre.utils.config import prefs, tweaks from calibre.utils.config import prefs, tweaks
from calibre.utils.date import now from calibre.utils.date import now
from calibre.utils.icu import sort_key
def ask_about_cc_mismatch(gui, db, newdb, missing_cols, incompatible_cols): # {{{
source_metadata = db.field_metadata.custom_field_metadata(include_composites=True)
ndbname = os.path.basename(newdb.library_path)
d = QDialog(gui)
d.setWindowTitle(_('Different custom columns'))
l = QFormLayout()
tl = QVBoxLayout()
d.setLayout(tl)
d.s = QScrollArea(d)
tl.addWidget(d.s)
d.w = QWidget(d)
d.s.setWidget(d.w)
d.s.setWidgetResizable(True)
d.w.setLayout(l)
d.setMinimumWidth(600)
d.setMinimumHeight(500)
d.bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel)
msg = _('The custom columns in the <i>{0}</i> library are different from the '
'custom columns in the <i>{1}</i> library. As a result, some metadata might not be copied.').format(
os.path.basename(db.library_path), ndbname)
d.la = la = QLabel(msg)
la.setWordWrap(True)
la.setStyleSheet('QLabel { margin-bottom: 1.5ex }')
l.addRow(la)
if incompatible_cols:
la = d.la2 = QLabel(_('The following columns are incompatible - they have the same name'
' but different data types. They will be ignored: ') +
', '.join(sorted(incompatible_cols, key=sort_key)))
la.setWordWrap(True)
la.setStyleSheet('QLabel { margin-bottom: 1.5ex }')
l.addRow(la)
missing_widgets = []
if missing_cols:
la = d.la3 = QLabel(_('The following columns are missing in the <i>{0}</i> library.'
' You can choose to add them automatically below.').format(
ndbname))
la.setWordWrap(True)
l.addRow(la)
for k in missing_cols:
widgets = (k, QCheckBox(_('Add to the %s library') % ndbname))
l.addRow(QLabel(k), widgets[1])
missing_widgets.append(widgets)
d.la4 = la = QLabel(_('This warning is only shown once per library, per session'))
la.setWordWrap(True)
tl.addWidget(la)
tl.addWidget(d.bb)
d.bb.accepted.connect(d.accept)
d.bb.rejected.connect(d.reject)
d.resize(d.sizeHint())
if d.exec_() == d.Accepted:
for k, cb in missing_widgets:
if cb.isChecked():
col_meta = source_metadata[k]
newdb.create_custom_column(
col_meta['label'], col_meta['name'], col_meta['datatype'],
len(col_meta['is_multiple']) > 0,
col_meta['is_editable'], col_meta['display'])
return True
return False
# }}}
class Worker(Thread): # {{{ class Worker(Thread): # {{{
@ -47,11 +115,11 @@ class Worker(Thread): # {{{
self.done() self.done()
def add_formats(self, id, paths, newdb, replace=True): def add_formats(self, id_, paths, newdb, replace=True):
for path in paths: for path in paths:
fmt = os.path.splitext(path)[-1].replace('.', '').upper() fmt = os.path.splitext(path)[-1].replace('.', '').upper()
with open(path, 'rb') as f: with open(path, 'rb') as f:
newdb.add_format(id, fmt, f, index_is_id=True, newdb.add_format(id_, fmt, f, index_is_id=True,
notify=False, replace=replace) notify=False, replace=replace)
def doit(self): def doit(self):
@ -174,6 +242,10 @@ class ChooseLibrary(QDialog): # {{{
return (unicode(self.le.text()), self.delete_after_copy) return (unicode(self.le.text()), self.delete_after_copy)
# }}} # }}}
# Static session-long set of pairs of libraries that have had their custom columns
# checked for compatibility
libraries_with_checked_columns = defaultdict(set)
class CopyToLibraryAction(InterfaceAction): class CopyToLibraryAction(InterfaceAction):
name = 'Copy To Library' name = 'Copy To Library'
@ -231,6 +303,11 @@ class CopyToLibraryAction(InterfaceAction):
_('Cannot copy to current library.'), show=True) _('Cannot copy to current library.'), show=True)
self.copy_to_library(path, delete_after) self.copy_to_library(path, delete_after)
def _column_is_compatible(self, source_metadata, dest_metadata):
return (source_metadata['datatype'] == dest_metadata['datatype'] and
(source_metadata['datatype'] != 'text' or
source_metadata['is_multiple'] == dest_metadata['is_multiple']))
def copy_to_library(self, loc, delete_after=False): def copy_to_library(self, loc, delete_after=False):
rows = self.gui.library_view.selectionModel().selectedRows() rows = self.gui.library_view.selectionModel().selectedRows()
if not rows or len(rows) == 0: if not rows or len(rows) == 0:
@ -252,6 +329,41 @@ class CopyToLibraryAction(InterfaceAction):
self.pd.set_msg(title) self.pd.set_msg(title)
self.pd.set_value(idx) self.pd.set_value(idx)
# Open the new db so we can check the custom columns. We use only the
# backend since we only need the custom column definitions, not the
# rest of the data in the db.
global libraries_with_checked_columns
from calibre.db.legacy import create_backend
newdb = create_backend(loc)
continue_processing = True
with closing(newdb):
if newdb.library_id not in libraries_with_checked_columns[db.library_id]:
newdb_meta = newdb.field_metadata.custom_field_metadata()
incompatible_columns = []
missing_columns = []
for k, m in db.field_metadata.custom_iteritems():
if m['datatype'] == 'composite':
continue
if k not in newdb_meta:
missing_columns.append(k)
elif not self._column_is_compatible(m, newdb_meta[k]):
incompatible_columns.append(k)
if missing_columns or incompatible_columns:
continue_processing = ask_about_cc_mismatch(self.gui, db, newdb,
missing_columns, incompatible_columns)
if continue_processing:
libraries_with_checked_columns[db.library_id].add(newdb.library_id)
newdb.close()
del newdb
if not continue_processing:
return
self.worker = Worker(ids, db, loc, Dispatcher(progress), self.worker = Worker(ids, db, loc, Dispatcher(progress),
Dispatcher(self.pd.accept), delete_after) Dispatcher(self.pd.accept), delete_after)
self.worker.start() self.worker.start()

View File

@ -1233,7 +1233,8 @@ class DeviceMixin(object): # {{{
ht = self.device_manager.device.THUMBNAIL_HEIGHT \ ht = self.device_manager.device.THUMBNAIL_HEIGHT \
if self.device_manager else DevicePlugin.THUMBNAIL_HEIGHT if self.device_manager else DevicePlugin.THUMBNAIL_HEIGHT
try: try:
return thumbnail(data, ht, ht) return thumbnail(data, ht, ht,
compression_quality=self.device_manager.device.THUMBNAIL_COMPRESSION_QUALITY)
except: except:
pass pass