diff --git a/src/calibre/gui2/actions/copy_to_library.py b/src/calibre/gui2/actions/copy_to_library.py
index f326ed3f12..5a41e1556f 100644
--- a/src/calibre/gui2/actions/copy_to_library.py
+++ b/src/calibre/gui2/actions/copy_to_library.py
@@ -11,8 +11,9 @@ from threading import Thread
from contextlib import closing
from collections import defaultdict
-from PyQt4.Qt import (QToolButton, QDialog, QGridLayout, QIcon, QLabel, QDialogButtonBox,
- QFormLayout, QCheckBox, QWidget, QScrollArea, QVBoxLayout)
+from PyQt4.Qt import (
+ QToolButton, QDialog, QGridLayout, QIcon, QLabel, QDialogButtonBox,
+ QFormLayout, QCheckBox, QWidget, QScrollArea, QVBoxLayout, Qt, QListWidgetItem, QListWidget)
from calibre.gui2.actions import InterfaceAction
from calibre.gui2 import (error_dialog, Dispatcher, warning_dialog, gprefs,
@@ -90,10 +91,10 @@ def ask_about_cc_mismatch(gui, db, newdb, missing_cols, incompatible_cols): # {
class Worker(Thread): # {{{
- def __init__(self, ids, db, loc, progress, done, delete_after):
+ def __init__(self, ids, db, loc, progress, done, delete_after, add_duplicates):
Thread.__init__(self)
self.ids = ids
- self.processed = set([])
+ self.processed = set()
self.db = db
self.loc = loc
self.error = None
@@ -101,6 +102,8 @@ class Worker(Thread): # {{{
self.done = done
self.delete_after = delete_after
self.auto_merged_ids = {}
+ self.add_duplicates = add_duplicates
+ self.duplicate_ids = {}
def run(self):
try:
@@ -142,57 +145,68 @@ class Worker(Thread): # {{{
fmts = []
else:
fmts = fmts.split(',')
+ identical_book_list = set()
paths = []
for fmt in fmts:
p = self.db.format(x, fmt, index_is_id=True,
as_path=True)
if p:
paths.append(p)
- automerged = False
- if prefs['add_formats_to_existing']:
- identical_book_list = newdb.find_identical_books(mi)
- if identical_book_list: # books with same author and nearly same title exist in newdb
- self.auto_merged_ids[x] = _('%(title)s by %(author)s')%\
- dict(title=mi.title, author=mi.format_field('authors')[1])
- automerged = True
- seen_fmts = set()
- for identical_book in identical_book_list:
- ib_fmts = newdb.formats(identical_book, index_is_id=True)
- if ib_fmts:
- seen_fmts |= set(ib_fmts.split(','))
- replace = gprefs['automerge'] == 'overwrite'
- self.add_formats(identical_book, paths, newdb,
- replace=replace)
+ try:
+ if not self.add_duplicates:
+ if prefs['add_formats_to_existing'] or prefs['check_for_dupes_on_ctl']:
+ # Scanning for dupes can be slow on a large library so
+ # only do it if the option is set
+ identical_book_list = newdb.find_identical_books(mi)
+ if identical_book_list: # books with same author and nearly same title exist in newdb
+ if prefs['add_formats_to_existing']:
+ self.automerge_book(x, mi, identical_book_list, paths, newdb)
+ else: # Report duplicates for later processing
+ self.duplicate_ids[x] = (mi.title, mi.authors)
+ continue
- if gprefs['automerge'] == 'new record':
- incoming_fmts = \
- set([os.path.splitext(path)[-1].replace('.',
- '').upper() for path in paths])
-
- if incoming_fmts.intersection(seen_fmts):
- # There was at least one duplicate format
- # so create a new record and put the
- # incoming formats into it
- # We should arguably put only the duplicate
- # formats, but no real harm is done by having
- # all formats
- newdb.import_book(mi, paths, notify=False, import_hooks=False,
- apply_import_tags=tweaks['add_new_book_tags_when_importing_books'],
- preserve_uuid=False)
-
- if not automerged:
newdb.import_book(mi, paths, notify=False, import_hooks=False,
apply_import_tags=tweaks['add_new_book_tags_when_importing_books'],
preserve_uuid=self.delete_after)
co = self.db.conversion_options(x, 'PIPE')
if co is not None:
newdb.set_conversion_options(x, 'PIPE', co)
- self.processed.add(x)
- for path in paths:
- try:
- os.remove(path)
- except:
- pass
+ self.processed.add(x)
+ finally:
+ for path in paths:
+ try:
+ os.remove(path)
+ except:
+ pass
+
+ def automerge_book(self, book_id, mi, identical_book_list, paths, newdb):
+ self.auto_merged_ids[book_id] = _('%(title)s by %(author)s') % dict(title=mi.title, author=mi.format_field('authors')[1])
+ seen_fmts = set()
+ self.processed.add(book_id)
+ for identical_book in identical_book_list:
+ ib_fmts = newdb.formats(identical_book, index_is_id=True)
+ if ib_fmts:
+ seen_fmts |= set(ib_fmts.split(','))
+ replace = gprefs['automerge'] == 'overwrite'
+ self.add_formats(identical_book, paths, newdb,
+ replace=replace)
+
+ if gprefs['automerge'] == 'new record':
+ incoming_fmts = \
+ set([os.path.splitext(path)[-1].replace('.',
+ '').upper() for path in paths])
+
+ if incoming_fmts.intersection(seen_fmts):
+ # There was at least one duplicate format
+ # so create a new record and put the
+ # incoming formats into it
+ # We should arguably put only the duplicate
+ # formats, but no real harm is done by having
+ # all formats
+ newdb.import_book(mi, paths, notify=False, import_hooks=False,
+ apply_import_tags=tweaks['add_new_book_tags_when_importing_books'],
+ preserve_uuid=False)
+
# }}}
@@ -242,6 +256,51 @@ class ChooseLibrary(QDialog): # {{{
return (unicode(self.le.text()), self.delete_after_copy)
# }}}
+class DuplicatesQuestion(QDialog): # {{{
+
+ def __init__(self, parent, duplicates, loc):
+ QDialog.__init__(self, parent)
+ l = QVBoxLayout()
+ self.setLayout(l)
+ self.la = la = QLabel(_('Books with the same title and author as the following already exist in the library %s.'
+ ' Select which books you want copied anyway.') %
+ os.path.basename(loc))
+ la.setWordWrap(True)
+ l.addWidget(la)
+ self.setWindowTitle(_('Duplicate books'))
+ self.books = QListWidget(self)
+ self.items = []
+ for book_id, (title, authors) in duplicates.iteritems():
+ i = QListWidgetItem(_('%s by %s') % (title, ' & '.join(authors[:3])), self.books)
+ i.setData(Qt.UserRole, book_id)
+ i.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
+ i.setCheckState(Qt.Checked)
+ self.items.append(i)
+ l.addWidget(self.books)
+ self.bb = bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ bb.accepted.connect(self.accept)
+ bb.rejected.connect(self.reject)
+ self.a = b = bb.addButton(_('Select &all'), bb.ActionRole)
+ b.clicked.connect(self.select_all)
+ self.n = b = bb.addButton(_('Select &none'), bb.ActionRole)
+ b.clicked.connect(self.select_none)
+ l.addWidget(bb)
+ self.resize(600, 400)
+
+ def select_all(self):
+ for i in self.items:
+ i.setCheckState(Qt.Checked)
+
+ def select_none(self):
+ for i in self.items:
+ i.setCheckState(Qt.Unchecked)
+
+ @property
+ def ids(self):
+ return {i.data(Qt.UserRole).toInt()[0] for i in self.items if i.checkState() == Qt.Checked}
+
+# }}}
+
# Static session-long set of pairs of libraries that have had their custom columns
# checked for compatibility
libraries_with_checked_columns = defaultdict(set)
@@ -323,20 +382,9 @@ class CopyToLibraryAction(InterfaceAction):
return error_dialog(self.gui, _('No library'),
_('No library found at %s')%loc, show=True)
- aname = _('Moving to') if delete_after else _('Copying to')
- dtitle = '%s %s'%(aname, os.path.basename(loc))
-
- self.pd = ProgressDialog(dtitle, min=0, max=len(ids)-1,
- parent=self.gui, cancelable=False)
-
- def progress(idx, title):
- self.pd.set_msg(title)
- 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
@@ -367,9 +415,26 @@ class CopyToLibraryAction(InterfaceAction):
del newdb
if not continue_processing:
return
+ duplicate_ids = self.do_copy(ids, db, loc, delete_after, False)
+ if duplicate_ids:
+ d = DuplicatesQuestion(self.gui, duplicate_ids, loc)
+ if d.exec_() == d.Accepted:
+ ids = d.ids
+ if ids:
+ self.do_copy(list(ids), db, loc, delete_after, add_duplicates=True)
+
+ def do_copy(self, ids, db, loc, delete_after, add_duplicates=False):
+ aname = _('Moving to') if delete_after else _('Copying to')
+ dtitle = '%s %s'%(aname, os.path.basename(loc))
+ self.pd = ProgressDialog(dtitle, min=0, max=len(ids)-1,
+ parent=self.gui, cancelable=False)
+
+ def progress(idx, title):
+ self.pd.set_msg(title)
+ self.pd.set_value(idx)
self.worker = Worker(ids, db, loc, Dispatcher(progress),
- Dispatcher(self.pd.accept), delete_after)
+ Dispatcher(self.pd.accept), delete_after, add_duplicates)
self.worker.start()
self.pd.exec_()
@@ -382,29 +447,31 @@ class CopyToLibraryAction(InterfaceAction):
e, tb = self.worker.error
error_dialog(self.gui, _('Failed'), _('Could not copy books: ') + e,
det_msg=tb, show=True)
- else:
- self.gui.status_bar.show_message(donemsg %
- dict(num=len(ids), loc=loc), 2000)
- if self.worker.auto_merged_ids:
- books = '\n'.join(self.worker.auto_merged_ids.itervalues())
- info_dialog(self.gui, _('Auto merged'),
- _('Some books were automatically merged into existing '
- 'records in the target library. Click Show '
- 'details to see which ones. This behavior is '
- 'controlled by the Auto merge option in '
- 'Preferences->Adding books.'), det_msg=books,
- show=True)
- if delete_after and self.worker.processed:
- v = self.gui.library_view
- ci = v.currentIndex()
- row = None
- if ci.isValid():
- row = ci.row()
+ return
- v.model().delete_books_by_id(self.worker.processed,
- permanent=True)
- self.gui.iactions['Remove Books'].library_ids_deleted(
- self.worker.processed, row)
+ self.gui.status_bar.show_message(donemsg %
+ dict(num=len(ids), loc=loc), 2000)
+ if self.worker.auto_merged_ids:
+ books = '\n'.join(self.worker.auto_merged_ids.itervalues())
+ info_dialog(self.gui, _('Auto merged'),
+ _('Some books were automatically merged into existing '
+ 'records in the target library. Click Show '
+ 'details to see which ones. This behavior is '
+ 'controlled by the Auto merge option in '
+ 'Preferences->Adding books.'), det_msg=books,
+ show=True)
+ if delete_after and self.worker.processed:
+ v = self.gui.library_view
+ ci = v.currentIndex()
+ row = None
+ if ci.isValid():
+ row = ci.row()
+
+ v.model().delete_books_by_id(self.worker.processed,
+ permanent=True)
+ self.gui.iactions['Remove Books'].library_ids_deleted(
+ self.worker.processed, row)
+ return self.worker.duplicate_ids
def cannot_do_dialog(self):
warning_dialog(self.gui, _('Not allowed'),
diff --git a/src/calibre/gui2/preferences/adding.py b/src/calibre/gui2/preferences/adding.py
index f41e830083..eb7c34442f 100644
--- a/src/calibre/gui2/preferences/adding.py
+++ b/src/calibre/gui2/preferences/adding.py
@@ -27,6 +27,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
r('read_file_metadata', prefs)
r('swap_author_names', prefs)
r('add_formats_to_existing', prefs)
+ r('check_for_dupes_on_ctl', prefs)
r('preserve_date_on_ctl', gprefs)
r('manual_add_auto_convert', gprefs)
choices = [
diff --git a/src/calibre/gui2/preferences/adding.ui b/src/calibre/gui2/preferences/adding.ui
index 383f1b9b04..be4073d14c 100644
--- a/src/calibre/gui2/preferences/adding.ui
+++ b/src/calibre/gui2/preferences/adding.ui
@@ -24,21 +24,87 @@
The Add &Process
- -
-
-
- Automerge: If books with similar titles and authors found, merge the incoming formats automatically into
-existing book records. The box to the right controls what happens when an existing record already has
-the incoming format. Note that this option also affects the Copy to library action.
-
-Title match ignores leading indefinite articles ("the", "a", "an"), punctuation, case, etc. Author match is exact.
-
+
-
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Swap the firstname and lastname of the author. This affects only metadata read from file names.
+
+
+ &Swap author firstname and lastname
+
+
+
+
+
+ -
+
- &Automerge added books if they already exist in the calibre library:
+ &Tags to apply when adding a book:
+
+
+ opt_new_book_tags
- -
+
-
+
+
+ A comma-separated list of tags that will be applied to books added to the library
+
+
+
+ -
+
+
+ &Configure metadata from file name
+
+
+
-
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 363
+
+
+
+
+
+
+
+ -
+
+
+ When using the "&Copy to library" action to copy books between libraries, preserve the date
+
+
+
+ -
+
+
+ Automatically &convert added books to the current output format
+
+
+
+ -
Automerge: If books with similar titles and authors found, merge the incoming formats automatically into
@@ -71,93 +137,34 @@ Author matching is exact.
- -
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Swap the firstname and lastname of the author. This affects only metadata read from file names.
-
-
- &Swap author firstname and lastname
-
-
-
-
-
- -
-
-
- &Tags to apply when adding a book:
-
-
- opt_new_book_tags
-
-
-
- -
-
-
- A comma-separated list of tags that will be applied to books added to the library
-
-
-
- -
-
-
- &Configure metadata from file name
-
-
-
-
-
-
- Qt::Vertical
-
-
-
- 20
- 363
-
-
-
-
-
-
-
- -
-
-
- When using the "&Copy to library" action to copy books between libraries, preserve the date
-
-
-
- -
-
-
- Automatically &convert added books to the current output format
-
-
-
- -
+
-
&Mark newly added books
+ -
+
+
+ Automerge: If books with similar titles and authors found, merge the incoming formats automatically into
+existing book records. The box to the right controls what happens when an existing record already has
+the incoming format. Note that this option also affects the Copy to library action.
+
+Title match ignores leading indefinite articles ("the", "a", "an"), punctuation, case, etc. Author match is exact.
+
+
+ &Automerge added books if they already exist in the calibre library:
+
+
+
+ -
+
+
+ When using the "Copy to Library" action check for &duplicates with the same title and author
+
+
+
diff --git a/src/calibre/utils/config_base.py b/src/calibre/utils/config_base.py
index 3f5e4048b6..2c4f3e38f8 100644
--- a/src/calibre/utils/config_base.py
+++ b/src/calibre/utils/config_base.py
@@ -398,6 +398,8 @@ def _prefs():
help=_('Swap author first and last names when reading metadata'))
c.add_opt('add_formats_to_existing', default=False,
help=_('Add new formats to existing book records'))
+ c.add_opt('check_for_dupes_on_ctl', default=False,
+ help=_('Check for duplicates when copying to another library'))
c.add_opt('installation_uuid', default=None, help='Installation UUID')
c.add_opt('new_book_tags', default=[], help=_('Tags to apply to books added to the library'))
c.add_opt('mark_new_books', default=False, help=_(