Example #1
0
class MultiDeleter(QObject):  # {{{

    def __init__(self, gui, ids, callback):
        from calibre.gui2.dialogs.progress import ProgressDialog
        QObject.__init__(self, gui)
        self.model = gui.library_view.model()
        self.ids = ids
        self.permanent = False
        if can_recycle and len(ids) > 100:
            if question_dialog(gui, _('Are you sure?'), '<p>'+
                _('You are trying to delete %d books. '
                    'Sending so many files to the Recycle'
                    ' Bin <b>can be slow</b>. Should calibre skip the'
                    ' Recycle Bin? If you click Yes the files'
                    ' will be <b>permanently deleted</b>.')%len(ids)):
                self.permanent = True
        self.gui = gui
        self.failures = []
        self.deleted_ids = []
        self.callback = callback
        single_shot(self.delete_one)
        self.pd = ProgressDialog(_('Deleting...'), parent=gui,
                cancelable=False, min=0, max=len(self.ids), icon='trash.png')
        self.pd.setModal(True)
        self.pd.show()

    def delete_one(self):
        if not self.ids:
            self.cleanup()
            return
        id_ = self.ids.pop()
        title = 'id:%d'%id_
        try:
            title_ = self.model.db.title(id_, index_is_id=True)
            if title_:
                title = title_
            self.model.db.delete_book(id_, notify=False, commit=False,
                    permanent=self.permanent)
            self.deleted_ids.append(id_)
        except:
            import traceback
            self.failures.append((id_, title, traceback.format_exc()))
        single_shot(self.delete_one)
        self.pd.value += 1
        self.pd.set_msg(_('Deleted') + ' ' + title)

    def cleanup(self):
        self.pd.hide()
        self.pd = None
        self.model.db.commit()
        self.model.db.clean()
        self.model.books_deleted()
        self.gui.tags_view.recount()
        self.callback(self.deleted_ids)
        if self.failures:
            msg = ['==> '+x[1]+'\n'+x[2] for x in self.failures]
            error_dialog(self.gui, _('Failed to delete'),
                    _('Failed to delete some books, click the Show Details button'
                    ' for details.'), det_msg='\n\n'.join(msg), show=True)
Example #2
0
    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, add_duplicates)
        self.worker.start()

        self.pd.exec_()

        donemsg = _('Copied %(num)d books to %(loc)s')
        if delete_after:
            donemsg = _('Moved %(num)d books to %(loc)s')

        if self.worker.error is not None:
            e, tb = self.worker.error
            error_dialog(self.gui, _('Failed'), _('Could not copy books: ') + e,
                    det_msg=tb, show=True)
            return

        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
Example #3
0
 def queue_files(self):
     self.tdir = PersistentTemporaryDirectory('_queue_polish')
     self.jobs = []
     if len(self.book_id_map) <= 5:
         for i, (book_id,
                 formats) in enumerate(self.book_id_map.iteritems()):
             self.do_book(i + 1, book_id, formats)
     else:
         self.queue = [(i + 1, id_)
                       for i, id_ in enumerate(self.book_id_map)]
         self.pd = ProgressDialog(_('Queueing books for polishing'),
                                  max=len(self.queue),
                                  parent=self)
         QTimer.singleShot(0, self.do_one)
         self.pd.exec_()
Example #4
0
class MultiDeleter(QObject):  # {{{

    def __init__(self, gui, ids, callback):
        from calibre.gui2.dialogs.progress import ProgressDialog
        QObject.__init__(self, gui)
        self.model = gui.library_view.model()
        self.ids = ids
        self.permanent = False
        if can_recycle and len(ids) > 100:
            if question_dialog(gui, _('Are you sure?'), '<p>'+
                _('You are trying to delete %d books. '
                    'Sending so many files to the Recycle'
                    ' Bin <b>can be slow</b>. Should calibre skip the'
                    ' Recycle Bin? If you click Yes the files'
                    ' will be <b>permanently deleted</b>.')%len(ids)):
                self.permanent = True
        self.gui = gui
        self.failures = []
        self.deleted_ids = []
        self.callback = callback
        single_shot(self.delete_one)
        self.pd = ProgressDialog(_('Deleting...'), parent=gui,
                cancelable=False, min=0, max=len(self.ids), icon='trash.png')
        self.pd.setModal(True)
        self.pd.show()

    def delete_one(self):
        if not self.ids:
            self.cleanup()
            return
        id_ = self.ids.pop()
        title = 'id:%d'%id_
        try:
            title_ = self.model.db.title(id_, index_is_id=True)
            if title_:
                title = title_
            self.model.db.delete_book(id_, notify=False, commit=False,
                    permanent=self.permanent)
            self.deleted_ids.append(id_)
        except:
            import traceback
            self.failures.append((id_, title, traceback.format_exc()))
        single_shot(self.delete_one)
        self.pd.value += 1
        self.pd.set_msg(_('Deleted') + ' ' + title)

    def cleanup(self):
        self.pd.hide()
        self.pd = None
        self.model.db.commit()
        self.model.db.clean()
        self.model.books_deleted()  # calls recount on the tag browser
        self.callback(self.deleted_ids)
        if self.failures:
            msg = ['==> '+x[1]+'\n'+x[2] for x in self.failures]
            error_dialog(self.gui, _('Failed to delete'),
                    _('Failed to delete some books, click the "Show details" button'
                    ' for details.'), det_msg='\n\n'.join(msg), show=True)
Example #5
0
    def __init__(self, worker, rq, callback, parent):
        QObject.__init__(self, parent)
        self.worker = worker
        self.rq = rq
        self.callback = callback
        self.parent = parent

        self.worker.start()
        self.dialog = ProgressDialog(_('Moving library...'), '',
                max=self.worker.total, parent=parent)
        self.dialog.button_box.setDisabled(True)
        self.dialog.setModal(True)
        self.dialog.show()
        self.timer = QTimer(self)
        self.connect(self.timer, SIGNAL('timeout()'), self.check)
        self.timer.start(200)
Example #6
0
    def __init__(self, book_ids, db, opts, root, parent=None, spare_server=None):
        QObject.__init__(self, parent)
        if parent is not None:
            setattr(parent, 'no_gc_%s' % id(self), self)
        self.db = db.new_api
        self.plugboards = self.db.pref('plugboards', {})
        self.template_functions = self.db.pref('user_template_functions', [])
        load_user_template_functions('', self.template_functions)
        self.collected_data = {}
        self.metadata_data = {}
        self.errors = defaultdict(list)
        self._book_id_data = {}
        self.all_book_ids = frozenset(book_ids)
        self.pd = ProgressDialog(_('Saving %d books...') % len(self.all_book_ids), _('Collecting metadata...'), min=0, max=0, parent=parent, icon='save.png')
        self.do_one_signal.connect(self.tick, type=Qt.QueuedConnection)
        self.do_one = self.do_one_collect
        self.ids_to_collect = iter(self.all_book_ids)
        self.plugboard_cache = {}
        self.tdir = PersistentTemporaryDirectory('_save_to_disk')
        self.server = spare_server

        self.pd.show()
        self.root, self.opts, self.path_length = sanitize_args(root, opts)
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()
Example #7
0
    def __init__(self, parent, db, callback, rows, path, opts, spare_server=None):
        QObject.__init__(self, parent)
        self.pd = ProgressDialog(_("Saving..."), parent=parent)
        self.spare_server = spare_server
        self.db = db
        self.opts = opts
        self.pd.setModal(True)
        self.pd.show()
        self.pd.set_min(0)
        self.pd.set_msg(_("Collecting data, please wait..."))
        self._parent = parent
        self.callback = callback
        self.callback_called = False
        self.rq = Queue()
        self.ids = [x for x in map(db.id, [r.row() for r in rows]) if x is not None]
        self.pd_max = len(self.ids)
        self.pd.set_max(0)
        self.pd.value = 0
        self.failures = set([])

        from calibre.ebooks.metadata.worker import SaveWorker

        self.worker = SaveWorker(self.rq, db, self.ids, path, self.opts, spare_server=self.spare_server)
        self.pd.canceled_signal.connect(self.canceled)
        self.continue_updating = True
        single_shot(self.update)
Example #8
0
    def __init__(self, db, auth_token, dl_loc):
        super(importer, self).__init__()

        self.db = db
        self.auth_token = auth_token
        self.dl_loc = dl_loc

        self.download_names = []

        self.signals = importerSignals()

        self.pd = ProgressDialog(_('Fetching books...'),
                                 _('Connecting to Humble Bundle...'),
                                 min=0,
                                 max=0,
                                 icon='images/icon.png')
Example #9
0
    def __init__(self, source, single_book_per_directory=True, db=None, parent=None, callback=None, pool=None, list_of_archives=False):
        if not validate_source(source, parent):
            return
        QObject.__init__(self, parent)
        self.single_book_per_directory = single_book_per_directory
        self.ignore_opf = False
        self.list_of_archives = list_of_archives
        self.callback = callback
        self.add_formats_to_existing = prefs['add_formats_to_existing']
        self.do_one_signal.connect(self.tick, type=Qt.QueuedConnection)
        self.pool = pool
        self.pd = ProgressDialog(_('Adding books...'), _('Scanning for files...'), min=0, max=0, parent=parent, icon='add_book.png')
        self.db = getattr(db, 'new_api', None)
        if self.db is not None:
            self.dbref = weakref.ref(db)
        self.source = source
        self.tdir = PersistentTemporaryDirectory('_add_books')
        self.scan_error = None
        self.file_groups = OrderedDict()
        self.abort_scan = False
        self.duplicates = []
        self.report = []
        self.items = []
        self.added_book_ids = set()
        self.merged_books = set()
        self.added_duplicate_info = set()
        self.pd.show()

        self.scan_thread = Thread(target=self.scan, name='ScanBooks')
        self.scan_thread.daemon = True
        self.scan_thread.start()
        self.do_one = self.monitor_scan
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()
Example #10
0
    def apply_metadata_changes(self, id_map, title=None, msg='', callback=None,
            merge_tags=True):
        '''
        Apply the metadata changes in id_map to the database synchronously
        id_map must be a mapping of ids to Metadata objects. Set any fields you
        do not want updated in the Metadata object to null. An easy way to do
        that is to create a metadata object as Metadata(_('Unknown')) and then
        only set the fields you want changed on this object.

        callback can be either None or a function accepting a single argument,
        in which case it is called after applying is complete with the list of
        changed ids.

        id_map can also be a mapping of ids to 2-tuple's where each 2-tuple
        contains the absolute paths to an OPF and cover file respectively. If
        either of the paths is None, then the corresponding metadata is not
        updated.
        '''
        if title is None:
            title = _('Applying changed metadata')
        self.apply_id_map = list(id_map.iteritems())
        self.apply_current_idx = 0
        self.apply_failures = []
        self.applied_ids = set()
        self.apply_pd = None
        self.apply_callback = callback
        if len(self.apply_id_map) > 1:
            from calibre.gui2.dialogs.progress import ProgressDialog
            self.apply_pd = ProgressDialog(title, msg, min=0,
                    max=len(self.apply_id_map)-1, parent=self.gui,
                    cancelable=False)
            self.apply_pd.setModal(True)
            self.apply_pd.show()
        self._am_merge_tags = True
        self.do_one_apply()
Example #11
0
    def __init__(self,
                 source,
                 single_book_per_directory=True,
                 db=None,
                 parent=None,
                 callback=None,
                 pool=None,
                 list_of_archives=False):
        if not validate_source(source, parent):
            return
        QObject.__init__(self, parent)
        self.single_book_per_directory = single_book_per_directory
        self.ignore_opf = False
        self.list_of_archives = list_of_archives
        self.callback = callback
        self.add_formats_to_existing = prefs['add_formats_to_existing']
        self.do_one_signal.connect(self.tick, type=Qt.QueuedConnection)
        self.pool = pool
        self.pd = ProgressDialog(_('Adding books...'),
                                 _('Scanning for files...'),
                                 min=0,
                                 max=0,
                                 parent=parent,
                                 icon='add_book.png')
        self.db = getattr(db, 'new_api', None)
        if self.db is not None:
            self.dbref = weakref.ref(db)
        self.source = source
        self.tdir = PersistentTemporaryDirectory('_add_books')
        self.scan_error = None
        self.file_groups = OrderedDict()
        self.abort_scan = False
        self.duplicates = []
        self.report = []
        self.items = []
        self.added_book_ids = set()
        self.merged_books = set()
        self.added_duplicate_info = set()
        self.pd.show()

        self.scan_thread = Thread(target=self.scan, name='ScanBooks')
        self.scan_thread.daemon = True
        self.scan_thread.start()
        self.do_one = self.monitor_scan
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()
Example #12
0
    def __init__(self, parent, db, device, annotation_map, done_callback):
        QThread.__init__(self, parent)
        self.errors = {}
        self.db = db
        self.keep_going = True
        self.pd = ProgressDialog(_('Merging user annotations into database'), '',
                0, len(annotation_map), parent=parent)

        self.device = device
        self.annotation_map = annotation_map
        self.done_callback = done_callback
        self.pd.canceled_signal.connect(self.canceled)
        self.pd.setModal(True)
        self.pd.show()
        self.update_progress.connect(self.pd.set_value,
                type=Qt.QueuedConnection)
        self.update_done.connect(self.pd.hide, type=Qt.QueuedConnection)
Example #13
0
 def add_isbns(self, books, add_tags=[], check_for_existing=False):
     books = list(books)
     if check_for_existing:
         books = self.check_for_existing_isbns(books)
         if not books:
             return
     self.isbn_books = books
     self.add_by_isbn_ids = set()
     self.isbn_add_tags = add_tags
     QTimer.singleShot(10, self.do_one_isbn_add)
     self.isbn_add_dialog = ProgressDialog(
         _('Adding'),
         _('Creating book records from ISBNs'),
         max=len(books),
         cancelable=False,
         parent=self.gui)
     self.isbn_add_dialog.exec()
Example #14
0
    def apply_metadata_changes(self,
                               id_map,
                               title=None,
                               msg='',
                               callback=None,
                               merge_tags=True,
                               merge_comments=False,
                               icon=None):
        '''
        Apply the metadata changes in id_map to the database synchronously
        id_map must be a mapping of ids to Metadata objects. Set any fields you
        do not want updated in the Metadata object to null. An easy way to do
        that is to create a metadata object as Metadata(_('Unknown')) and then
        only set the fields you want changed on this object.

        callback can be either None or a function accepting a single argument,
        in which case it is called after applying is complete with the list of
        changed ids.

        id_map can also be a mapping of ids to 2-tuple's where each 2-tuple
        contains the absolute paths to an OPF and cover file respectively. If
        either of the paths is None, then the corresponding metadata is not
        updated.
        '''
        if title is None:
            title = _('Applying changed metadata')
        self.apply_id_map = list(iteritems(id_map))
        self.apply_current_idx = 0
        self.apply_failures = []
        self.applied_ids = set()
        self.apply_pd = None
        self.apply_callback = callback
        if len(self.apply_id_map) > 1:
            from calibre.gui2.dialogs.progress import ProgressDialog
            self.apply_pd = ProgressDialog(title,
                                           msg,
                                           min=0,
                                           max=len(self.apply_id_map) - 1,
                                           parent=self.gui,
                                           cancelable=False,
                                           icon=icon)
            self.apply_pd.setModal(True)
            self.apply_pd.show()
        self._am_merge_tags = merge_tags
        self._am_merge_comments = merge_comments
        self.do_one_apply()
Example #15
0
    def add_books_from_device(self, view, paths=None):
        backloading_err = self.gui.device_manager.device.BACKLOADING_ERROR_MESSAGE
        if backloading_err is not None:
            return error_dialog(self.gui,
                                _('Add to library'),
                                backloading_err,
                                show=True)
        if paths is None:
            rows = view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                d = error_dialog(self.gui, _('Add to library'),
                                 _('No book selected'))
                d.exec_()
                return
            paths = [p for p in view.model().paths(rows) if p is not None]
        ve = self.gui.device_manager.device.VIRTUAL_BOOK_EXTENSIONS

        def ext(x):
            ans = os.path.splitext(x)[1]
            ans = ans[1:] if len(ans) > 1 else ans
            return ans.lower()

        remove = set([p for p in paths if ext(p) in ve])
        if remove:
            paths = [p for p in paths if p not in remove]
            info_dialog(self.gui,
                        _('Not Implemented'),
                        _('The following books are virtual and cannot be added'
                          ' to the calibre library:'),
                        '\n'.join(remove),
                        show=True)
            if not paths:
                return
        if not paths or len(paths) == 0:
            d = error_dialog(self.gui, _('Add to library'),
                             _('No book files found'))
            d.exec_()
            return

        self.gui.device_manager.prepare_addable_books(
            self.Dispatcher(partial(self.books_prepared, view)), paths)
        self.bpd = ProgressDialog(_('Downloading books'),
                                  msg=_('Downloading books from device'),
                                  parent=self.gui,
                                  cancelable=False)
        QTimer.singleShot(1000, self.show_bpd)
Example #16
0
    def copy_to_library(self, loc, delete_after=False):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _('Cannot copy'),
                    _('No books selected'), show=True)
        ids = list(map(self.gui.library_view.model().id, rows))
        db = self.gui.library_view.model().db
        if not db.exists_at(loc):
            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)

        self.worker = Worker(ids, db, loc, Dispatcher(progress),
                             Dispatcher(self.pd.accept), delete_after)
        self.worker.start()

        self.pd.exec_()

        donemsg = _('Copied %(num)d books to %(loc)s')
        if delete_after:
            donemsg = _('Moved %(num)d books to %(loc)s')

        if self.worker.error is not None:
            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()

                v.model().delete_books_by_id(self.worker.processed,
                        permanent=True)
                self.gui.iactions['Remove Books'].library_ids_deleted(
                        self.worker.processed, row)
Example #17
0
 def add_isbns(self, books, add_tags=[]):
     self.isbn_books = list(books)
     self.add_by_isbn_ids = set()
     self.isbn_add_tags = add_tags
     QTimer.singleShot(10, self.do_one_isbn_add)
     self.isbn_add_dialog = ProgressDialog(_('Adding'),
             _('Creating book records from ISBNs'), max=len(books),
             cancelable=False, parent=self.gui)
     self.isbn_add_dialog.exec_()
Example #18
0
 def queue_files(self):
     self.tdir = PersistentTemporaryDirectory('_queue_polish')
     self.jobs = []
     if len(self.book_id_map) <= 5:
         for i, (book_id, formats) in enumerate(self.book_id_map.iteritems()):
             self.do_book(i+1, book_id, formats)
     else:
         self.queue = [(i+1, id_) for i, id_ in enumerate(self.book_id_map)]
         self.pd = ProgressDialog(_('Queueing books for polishing'),
                                  max=len(self.queue), parent=self)
         QTimer.singleShot(0, self.do_one)
         self.pd.exec_()
Example #19
0
 def __init__(self, parent, db, callback, spare_server=None):
     QObject.__init__(self, parent)
     self.pd = ProgressDialog(_("Adding..."), parent=parent)
     self.pd.setMaximumWidth(min(600, int(available_width() * 0.75)))
     self.spare_server = spare_server
     self.db = db
     self.pd.setModal(True)
     self.pd.show()
     self._parent = parent
     self.rfind = self.worker = None
     self.callback = callback
     self.callback_called = False
     self.pd.canceled_signal.connect(self.canceled)
Example #20
0
    def __init__(self, book_ids, db, opts, root, parent=None, pool=None):
        QObject.__init__(self, parent)
        self.db = db.new_api
        self.plugboards = self.db.pref('plugboards', {})
        self.template_functions = self.db.pref('user_template_functions', [])
        load_user_template_functions('', self.template_functions)
        self.collected_data = {}
        self.errors = defaultdict(list)
        self._book_id_data = {}
        self.all_book_ids = frozenset(book_ids)
        self.pd = ProgressDialog(_('Saving %d books...') % len(self.all_book_ids), _('Collecting metadata...'), min=0, max=0, parent=parent, icon='save.png')
        self.do_one_signal.connect(self.tick, type=Qt.QueuedConnection)
        self.do_one = self.do_one_collect
        self.ids_to_collect = iter(self.all_book_ids)
        self.tdir = PersistentTemporaryDirectory('_save_to_disk')
        self.pool = pool

        self.pd.show()
        self.root, self.opts, self.path_length = sanitize_args(root, opts)
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()
Example #21
0
    def __init__(self,
                 parent,
                 db,
                 callback,
                 rows,
                 path,
                 opts,
                 spare_server=None):
        QObject.__init__(self, parent)
        self.pd = ProgressDialog(_('Saving...'), parent=parent)
        self.spare_server = spare_server
        self.db = db
        self.opts = opts
        self.pd.setModal(True)
        self.pd.show()
        self.pd.set_min(0)
        self.pd.set_msg(_('Collecting data, please wait...'))
        self._parent = parent
        self.callback = callback
        self.callback_called = False
        self.rq = Queue()
        self.ids = [
            x for x in map(db.id, [r.row() for r in rows]) if x is not None
        ]
        self.pd_max = len(self.ids)
        self.pd.set_max(0)
        self.pd.value = 0
        self.failures = set([])

        from calibre.ebooks.metadata.worker import SaveWorker
        self.worker = SaveWorker(self.rq,
                                 db,
                                 self.ids,
                                 path,
                                 self.opts,
                                 spare_server=self.spare_server)
        self.pd.canceled_signal.connect(self.canceled)
        self.continue_updating = True
        single_shot(self.update)
Example #22
0
 def __init__(self, gui, ids, callback):
     from calibre.gui2.dialogs.progress import ProgressDialog
     QObject.__init__(self, gui)
     self.model = gui.library_view.model()
     self.ids = ids
     self.permanent = False
     if can_recycle and len(ids) > 100:
         if question_dialog(gui, _('Are you sure?'), '<p>'+
             _('You are trying to delete %d books. '
                 'Sending so many files to the Recycle'
                 ' Bin <b>can be slow</b>. Should calibre skip the'
                 ' Recycle Bin? If you click Yes the files'
                 ' will be <b>permanently deleted</b>.')%len(ids)):
             self.permanent = True
     self.gui = gui
     self.failures = []
     self.deleted_ids = []
     self.callback = callback
     single_shot(self.delete_one)
     self.pd = ProgressDialog(_('Deleting...'), parent=gui,
             cancelable=False, min=0, max=len(self.ids), icon='trash.png')
     self.pd.setModal(True)
     self.pd.show()
Example #23
0
    def copy_to_library(self, loc, delete_after=False):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _("Cannot copy"), _("No books selected"), show=True)
        ids = list(map(self.gui.library_view.model().id, rows))
        db = self.gui.library_view.model().db
        if not db.exists_at(loc):
            return error_dialog(self.gui, _("No library"), _("No library found at %s") % loc, show=True)

        self.pd = ProgressDialog(_("Copying"), min=0, max=len(ids) - 1, parent=self.gui, cancelable=False)

        def progress(idx, title):
            self.pd.set_msg(_("Copying") + " " + title)
            self.pd.set_value(idx)

        self.worker = Worker(ids, db, loc, Dispatcher(progress), Dispatcher(self.pd.accept), delete_after)
        self.worker.start()

        self.pd.exec_()

        if self.worker.error is not None:
            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(_("Copied %(num)d books to %(loc)s") % 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)
Example #24
0
    def __init__(self, worker, rq, callback, parent):
        QObject.__init__(self, parent)
        self.worker = worker
        self.rq = rq
        self.callback = callback
        self.parent = parent

        self.worker.start()
        self.dialog = ProgressDialog(_('Moving library...'), '',
                max=self.worker.total, parent=parent)
        self.dialog.button_box.setDisabled(True)
        self.dialog.setModal(True)
        self.dialog.show()
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.check)
        self.timer.start(200)
Example #25
0
class MoveMonitor(QObject):
    def __init__(self, worker, rq, callback, parent):
        QObject.__init__(self, parent)
        self.worker = worker
        self.rq = rq
        self.callback = callback
        self.parent = parent

        self.worker.start()
        self.dialog = ProgressDialog(_('Moving library...'),
                                     '',
                                     max=self.worker.total,
                                     parent=parent)
        self.dialog.button_box.setDisabled(True)
        self.dialog.setModal(True)
        self.dialog.show()
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.check)
        self.timer.start(200)

    def check(self):
        if self.worker.is_alive():
            self.update()
        else:
            self.timer.stop()
            self.dialog.hide()
            if self.worker.failed:
                error_dialog(self.parent,
                             _('Failed to move library'),
                             _('Failed to move library'),
                             self.worker.details,
                             show=True)
                return self.callback(None)
            else:
                return self.callback(self.worker.to)

    def update(self):
        try:
            title = self.rq.get_nowait()[-1]
            self.dialog.value += 1
            self.dialog.set_msg(_('Copied') + ' ' + title)
        except Empty:
            pass
Example #26
0
class MoveMonitor(QObject):

    def __init__(self, worker, rq, callback, parent):
        QObject.__init__(self, parent)
        self.worker = worker
        self.rq = rq
        self.callback = callback
        self.parent = parent

        self.worker.start()
        self.dialog = ProgressDialog(_('Moving library...'), '',
                max=self.worker.total, parent=parent)
        self.dialog.button_box.setDisabled(True)
        self.dialog.setModal(True)
        self.dialog.show()
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.check)
        self.timer.start(200)

    def check(self):
        if self.worker.is_alive():
            self.update()
        else:
            self.timer.stop()
            self.dialog.hide()
            if self.worker.failed:
                error_dialog(self.parent, _('Failed to move library'),
                    _('Failed to move library'), self.worker.details, show=True)
                return self.callback(None)
            else:
                return self.callback(self.worker.to)

    def update(self):
        try:
            title = self.rq.get_nowait()[-1]
            self.dialog.value += 1
            self.dialog.set_msg(_('Copied') + ' '+title)
        except Empty:
            pass
Example #27
0
    def add_books_from_device(self, view, paths=None):
        backloading_err = self.gui.device_manager.device.BACKLOADING_ERROR_MESSAGE
        if backloading_err is not None:
            return error_dialog(self.gui, _("Add to library"), backloading_err, show=True)
        if paths is None:
            rows = view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                d = error_dialog(self.gui, _("Add to library"), _("No book selected"))
                d.exec_()
                return
            paths = [p for p in view.model().paths(rows) if p is not None]
        ve = self.gui.device_manager.device.VIRTUAL_BOOK_EXTENSIONS

        def ext(x):
            ans = os.path.splitext(x)[1]
            ans = ans[1:] if len(ans) > 1 else ans
            return ans.lower()

        remove = set([p for p in paths if ext(p) in ve])
        if remove:
            paths = [p for p in paths if p not in remove]
            info_dialog(
                self.gui,
                _("Not Implemented"),
                _("The following books are virtual and cannot be added" " to the calibre library:"),
                "\n".join(remove),
                show=True,
            )
            if not paths:
                return
        if not paths or len(paths) == 0:
            d = error_dialog(self.gui, _("Add to library"), _("No book files found"))
            d.exec_()
            return

        self.gui.device_manager.prepare_addable_books(self.Dispatcher(partial(self.books_prepared, view)), paths)
        self.bpd = ProgressDialog(
            _("Downloading books"), msg=_("Downloading books from device"), parent=self.gui, cancelable=False
        )
        QTimer.singleShot(1000, self.show_bpd)
Example #28
0
class Updater(QThread):  # {{{

    update_progress = pyqtSignal(int)
    update_done = pyqtSignal()

    def __init__(self, parent, db, device, annotation_map, done_callback):
        QThread.__init__(self, parent)
        self.errors = {}
        self.db = db
        self.keep_going = True
        self.pd = ProgressDialog(_('Merging user annotations into database'),
                                 '',
                                 0,
                                 len(annotation_map),
                                 parent=parent)

        self.device = device
        self.annotation_map = annotation_map
        self.done_callback = done_callback
        self.pd.canceled_signal.connect(self.canceled)
        self.pd.setModal(True)
        self.pd.show()
        self.update_progress.connect(self.pd.set_value,
                                     type=Qt.ConnectionType.QueuedConnection)
        self.update_done.connect(self.pd.hide,
                                 type=Qt.ConnectionType.QueuedConnection)

    def canceled(self):
        self.keep_going = False
        self.pd.hide()

    def run(self):
        for i, id_ in enumerate(self.annotation_map):
            if not self.keep_going:
                break
            bm = Device.UserAnnotation(self.annotation_map[id_][0],
                                       self.annotation_map[id_][1])
            try:
                self.device.add_annotation_to_library(self.db, id_, bm)
            except:
                import traceback
                self.errors[id_] = traceback.format_exc()
            self.update_progress.emit(i)
        self.update_done.emit()
        self.done_callback(list(self.annotation_map.keys()), self.errors)
Example #29
0
 def __init__(self, gui, ids, callback):
     from calibre.gui2.dialogs.progress import ProgressDialog
     QObject.__init__(self, gui)
     self.model = gui.library_view.model()
     self.ids = ids
     self.permanent = False
     if can_recycle and len(ids) > 100:
         if question_dialog(gui, _('Are you sure?'), '<p>'+
             _('You are trying to delete %d books. '
                 'Sending so many files to the Recycle'
                 ' Bin <b>can be slow</b>. Should calibre skip the'
                 ' Recycle Bin? If you click Yes the files'
                 ' will be <b>permanently deleted</b>.')%len(ids)):
             self.permanent = True
     self.gui = gui
     self.failures = []
     self.deleted_ids = []
     self.callback = callback
     single_shot(self.delete_one)
     self.pd = ProgressDialog(_('Deleting...'), parent=gui,
             cancelable=False, min=0, max=len(self.ids), icon='trash.png')
     self.pd.setModal(True)
     self.pd.show()
Example #30
0
class Updater(QThread): # {{{

    update_progress = pyqtSignal(int)
    update_done     = pyqtSignal()

    def __init__(self, parent, db, device, annotation_map, done_callback):
        QThread.__init__(self, parent)
        self.errors = {}
        self.db = db
        self.keep_going = True
        self.pd = ProgressDialog(_('Merging user annotations into database'), '',
                0, len(annotation_map), parent=parent)

        self.device = device
        self.annotation_map = annotation_map
        self.done_callback = done_callback
        self.pd.canceled_signal.connect(self.canceled)
        self.pd.setModal(True)
        self.pd.show()
        self.update_progress.connect(self.pd.set_value,
                type=Qt.QueuedConnection)
        self.update_done.connect(self.pd.hide, type=Qt.QueuedConnection)

    def canceled(self):
        self.keep_going = False
        self.pd.hide()

    def run(self):
        for i, id_ in enumerate(self.annotation_map):
            if not self.keep_going:
                break
            bm = Device.UserAnnotation(self.annotation_map[id_][0],
                    self.annotation_map[id_][1])
            try:
                self.device.add_annotation_to_library(self.db, id_, bm)
            except:
                import traceback
                self.errors[id_] = traceback.format_exc()
            self.update_progress.emit(i)
        self.update_done.emit()
        self.done_callback(self.annotation_map.keys(), self.errors)
Example #31
0
class EditMetadataAction(InterfaceAction):

    name = 'Edit Metadata'
    action_spec = (_('Edit metadata'), 'edit_input.png',
                   _('Change the title/author/cover etc. of books'), _('E'))
    action_type = 'current'
    action_add_menu = True

    accepts_drops = True

    def accept_enter_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def accept_drag_move_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def drop_event(self, event, mime_data):
        mime = 'application/calibre+from_library'
        if mime_data.hasFormat(mime):
            self.dropped_ids = tuple(
                map(int,
                    str(mime_data.data(mime)).split()))
            QTimer.singleShot(1, self.do_drop)
            return True
        return False

    def do_drop(self):
        book_ids = self.dropped_ids
        del self.dropped_ids
        if book_ids:
            db = self.gui.library_view.model().db
            rows = [db.row(i) for i in book_ids]
            self.edit_metadata_for(rows, book_ids)

    def genesis(self):
        md = self.qaction.menu()
        cm = partial(self.create_menu_action, md)
        cm('individual',
           _('Edit metadata individually'),
           icon=self.qaction.icon(),
           triggered=partial(self.edit_metadata, False, bulk=False))
        md.addSeparator()
        cm('bulk',
           _('Edit metadata in bulk'),
           triggered=partial(self.edit_metadata, False, bulk=True))
        md.addSeparator()
        cm('download',
           _('Download metadata and covers'),
           triggered=partial(self.download_metadata, ids=None),
           shortcut='Ctrl+D')
        self.metadata_menu = md

        mb = QMenu()
        cm2 = partial(self.create_menu_action, mb)
        cm2('merge delete',
            _('Merge into first selected book - delete others'),
            triggered=self.merge_books)
        mb.addSeparator()
        cm2('merge keep',
            _('Merge into first selected book - keep others'),
            triggered=partial(self.merge_books, safe_merge=True),
            shortcut='Alt+M')
        mb.addSeparator()
        cm2('merge formats',
            _('Merge only formats into first selected book - delete others'),
            triggered=partial(self.merge_books, merge_only_formats=True),
            shortcut='Alt+Shift+M')
        self.merge_menu = mb
        md.addSeparator()
        self.action_merge = cm('merge',
                               _('Merge book records'),
                               icon='merge_books.png',
                               shortcut=_('M'),
                               triggered=self.merge_books)
        self.action_merge.setMenu(mb)

        self.qaction.triggered.connect(self.edit_metadata)

    def location_selected(self, loc):
        enabled = loc == 'library'
        self.qaction.setEnabled(enabled)
        self.action_merge.setEnabled(enabled)

    # Download metadata {{{
    def download_metadata(self, ids=None, ensure_fields=None):
        if ids is None:
            rows = self.gui.library_view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                return error_dialog(self.gui,
                                    _('Cannot download metadata'),
                                    _('No books selected'),
                                    show=True)
            db = self.gui.library_view.model().db
            ids = [db.id(row.row()) for row in rows]
        from calibre.gui2.metadata.bulk_download import start_download
        start_download(self.gui,
                       ids,
                       Dispatcher(self.metadata_downloaded),
                       ensure_fields=ensure_fields)

    def cleanup_bulk_download(self, tdir, *args):
        try:
            shutil.rmtree(tdir, ignore_errors=True)
        except:
            pass

    def metadata_downloaded(self, job):
        if job.failed:
            self.gui.job_exception(
                job, dialog_title=_('Failed to download metadata'))
            return
        from calibre.gui2.metadata.bulk_download import get_job_details
        (aborted, id_map, tdir, log_file, failed_ids, failed_covers,
         all_failed, det_msg, lm_map) = get_job_details(job)
        if aborted:
            return self.cleanup_bulk_download(tdir)
        if all_failed:
            num = len(failed_ids | failed_covers)
            self.cleanup_bulk_download(tdir)
            return error_dialog(
                self.gui,
                _('Download failed'),
                _('Failed to download metadata or covers for any of the %d'
                  ' book(s).') % num,
                det_msg=det_msg,
                show=True)

        self.gui.status_bar.show_message(_('Metadata download completed'),
                                         3000)

        msg = '<p>' + _('Finished downloading metadata for <b>%d book(s)</b>. '
                        'Proceed with updating the metadata in your library?'
                        ) % len(id_map)

        show_copy_button = False
        checkbox_msg = None
        if failed_ids or failed_covers:
            show_copy_button = True
            num = len(failed_ids.union(failed_covers))
            msg += '<p>' + _(
                'Could not download metadata and/or covers for %d of the books. Click'
                ' "Show details" to see which books.') % num
            checkbox_msg = _('Show the &failed books in the main book list '
                             'after updating metadata')

        if getattr(job, 'metadata_and_covers', None) == (False, True):
            # Only covers, remove failed cover downloads from id_map
            for book_id in failed_covers:
                if hasattr(id_map, 'discard'):
                    id_map.discard(book_id)
        payload = (id_map, tdir, log_file, lm_map,
                   failed_ids.union(failed_covers))
        review_apply = partial(self.apply_downloaded_metadata, True)
        normal_apply = partial(self.apply_downloaded_metadata, False)
        self.gui.proceed_question(normal_apply,
                                  payload,
                                  log_file,
                                  _('Download log'),
                                  _('Download complete'),
                                  msg,
                                  det_msg=det_msg,
                                  show_copy_button=show_copy_button,
                                  cancel_callback=partial(
                                      self.cleanup_bulk_download, tdir),
                                  log_is_file=True,
                                  checkbox_msg=checkbox_msg,
                                  checkbox_checked=False,
                                  action_callback=review_apply,
                                  action_label=_('Review downloaded metadata'),
                                  action_icon=QIcon(I('auto_author_sort.png')))

    def apply_downloaded_metadata(self, review, payload, *args):
        good_ids, tdir, log_file, lm_map, failed_ids = payload
        if not good_ids:
            return

        modified = set()
        db = self.gui.current_db

        for i in good_ids:
            lm = db.metadata_last_modified(i, index_is_id=True)
            if lm is not None and lm_map[i] is not None and lm > lm_map[i]:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace('|', ',') for x in authors.split(',')]
                    title += ' - ' + authors_to_string(authors)
                modified.add(title)

        if modified:
            from calibre.utils.icu import lower

            modified = sorted(modified, key=lower)
            if not question_dialog(
                    self.gui,
                    _('Some books changed'),
                    '<p>' +
                    _('The metadata for some books in your library has'
                      ' changed since you started the download. If you'
                      ' proceed, some of those changes may be overwritten. '
                      'Click "Show details" to see the list of changed books. '
                      'Do you want to proceed?'),
                    det_msg='\n'.join(modified)):
                return

        id_map = {}
        for bid in good_ids:
            opf = os.path.join(tdir, '%d.mi' % bid)
            if not os.path.exists(opf):
                opf = None
            cov = os.path.join(tdir, '%d.cover' % bid)
            if not os.path.exists(cov):
                cov = None
            id_map[bid] = (opf, cov)

        if review:

            def get_metadata(book_id):
                oldmi = db.get_metadata(book_id,
                                        index_is_id=True,
                                        get_cover=True,
                                        cover_as_data=True)
                opf, cov = id_map[book_id]
                if opf is None:
                    newmi = Metadata(oldmi.title, authors=tuple(oldmi.authors))
                else:
                    with open(opf, 'rb') as f:
                        newmi = OPF(f,
                                    basedir=os.path.dirname(opf),
                                    populate_spine=False).to_book_metadata()
                        newmi.cover, newmi.cover_data = None, (None, None)
                        for x in ('title', 'authors'):
                            if newmi.is_null(x):
                                # Title and author are set to null if they are
                                # the same as the originals as an optimization,
                                # we undo that, as it is confusing.
                                newmi.set(x, copy.copy(oldmi.get(x)))
                if cov:
                    with open(cov, 'rb') as f:
                        newmi.cover_data = ('jpg', f.read())
                return oldmi, newmi

            from calibre.gui2.metadata.diff import CompareMany
            d = CompareMany(
                set(id_map),
                get_metadata,
                db.field_metadata,
                parent=self.gui,
                window_title=_('Review downloaded metadata'),
                reject_button_tooltip=_(
                    'Discard downloaded metadata for this book'),
                accept_all_tooltip=_(
                    'Use the downloaded metadata for all remaining books'),
                reject_all_tooltip=_(
                    'Discard downloaded metadata for all remaining books'),
                revert_tooltip=_('Discard the downloaded value for: %s'),
                intro_msg=
                _('The downloaded metadata is on the left and the original metadata'
                  ' is on the right. If a downloaded value is blank or unknown,'
                  ' the original value is used.'),
                action_button=(_('&View Book'), I('view.png'),
                               self.gui.iactions['View'].view_historical),
            )
            if d.exec_() == d.Accepted:
                nid_map = {}
                for book_id, (changed, mi) in d.accepted.iteritems():
                    if mi is None:  # discarded
                        continue
                    if changed:
                        opf, cov = id_map[book_id]
                        cfile = mi.cover
                        mi.cover, mi.cover_data = None, (None, None)
                        if opf is not None:
                            with open(opf, 'wb') as f:
                                f.write(metadata_to_opf(mi))
                        if cfile and cov:
                            shutil.copyfile(cfile, cov)
                            os.remove(cfile)
                    nid_map[book_id] = id_map[book_id]
                id_map = nid_map
            else:
                id_map = {}

        restrict_to_failed = bool(args and args[0])
        if restrict_to_failed:
            db.data.set_marked_ids(failed_ids)

        self.apply_metadata_changes(id_map,
                                    merge_comments=msprefs['append_comments'],
                                    callback=partial(
                                        self.downloaded_metadata_applied, tdir,
                                        restrict_to_failed))

    def downloaded_metadata_applied(self, tdir, restrict_to_failed, *args):
        if restrict_to_failed:
            self.gui.search.set_search_string('marked:true')
        self.cleanup_bulk_download(tdir)

    # }}}

    def edit_metadata(self, checked, bulk=None):
        '''
        Edit metadata of selected books in library.
        '''
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _('Cannot edit metadata'),
                             _('No books selected'))
            d.exec_()
            return
        row_list = [r.row() for r in rows]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        self.edit_metadata_for(row_list, ids, bulk=bulk)

    def edit_metadata_for(self, rows, book_ids, bulk=None):
        previous = self.gui.library_view.currentIndex()
        if bulk or (bulk is None and len(rows) > 1):
            return self.do_edit_bulk_metadata(rows, book_ids)

        current_row = 0
        row_list = rows

        if len(row_list) == 1:
            cr = row_list[0]
            row_list = \
                list(range(self.gui.library_view.model().rowCount(QModelIndex())))
            current_row = row_list.index(cr)

        view = self.gui.library_view.alternate_views.current_view
        try:
            hpos = view.horizontalScrollBar().value()
        except Exception:
            hpos = 0

        changed, rows_to_refresh = self.do_edit_metadata(row_list, current_row)

        m = self.gui.library_view.model()

        if rows_to_refresh:
            m.refresh_rows(rows_to_refresh)

        if changed:
            m.refresh_ids(list(changed))
            current = self.gui.library_view.currentIndex()
            if self.gui.cover_flow:
                self.gui.cover_flow.dataChanged()
            m.current_changed(current, previous)
            self.gui.tags_view.recount()
        if self.gui.library_view.alternate_views.current_view is view:
            if hasattr(view, 'restore_hpos'):
                view.restore_hpos(hpos)
            else:
                view.horizontalScrollBar().setValue(hpos)

    def do_edit_metadata(self, row_list, current_row):
        from calibre.gui2.metadata.single import edit_metadata
        db = self.gui.library_view.model().db
        changed, rows_to_refresh = edit_metadata(
            db,
            row_list,
            current_row,
            parent=self.gui,
            view_slot=self.view_format_callback,
            set_current_callback=self.set_current_callback)
        return changed, rows_to_refresh

    def set_current_callback(self, id_):
        db = self.gui.library_view.model().db
        current_row = db.row(id_)
        self.gui.library_view.set_current_row(current_row)
        self.gui.library_view.scroll_to_row(current_row)

    def view_format_callback(self, id_, fmt):
        view = self.gui.iactions['View']
        if id_ is None:
            view._view_file(fmt)
        else:
            db = self.gui.library_view.model().db
            view.view_format(db.row(id_), fmt)

    def edit_bulk_metadata(self, checked):
        '''
        Edit metadata of selected books in library in bulk.
        '''
        rows = [
            r.row()
            for r in self.gui.library_view.selectionModel().selectedRows()
        ]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _('Cannot edit metadata'),
                             _('No books selected'))
            d.exec_()
            return
        self.do_edit_bulk_metadata(rows, ids)

    def do_edit_bulk_metadata(self, rows, book_ids):
        # Prevent the TagView from updating due to signals from the database
        self.gui.tags_view.blockSignals(True)
        changed = False
        refresh_books = set(book_ids)
        try:
            current_tab = 0
            while True:
                dialog = MetadataBulkDialog(self.gui, rows,
                                            self.gui.library_view.model(),
                                            current_tab, refresh_books)
                if dialog.changed:
                    changed = True
                if not dialog.do_again:
                    break
                current_tab = dialog.central_widget.currentIndex()
        finally:
            self.gui.tags_view.blockSignals(False)
        if changed:
            refresh_books |= dialog.refresh_books
            m = self.gui.library_view.model()
            if gprefs['refresh_book_list_on_bulk_edit']:
                m.refresh(reset=False)
                m.research()
            else:
                m.refresh_ids(refresh_books)
            self.gui.tags_view.recount()
            if self.gui.cover_flow:
                self.gui.cover_flow.dataChanged()
            self.gui.library_view.select_rows(book_ids)

    # Merge books {{{
    def merge_books(self, safe_merge=False, merge_only_formats=False):
        '''
        Merge selected books in library.
        '''
        if self.gui.stack.currentIndex() != 0:
            return
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui,
                                _('Cannot merge books'),
                                _('No books selected'),
                                show=True)
        if len(rows) < 2:
            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):
                return

        dest_id, src_ids = self.books_to_merge(rows)
        title = self.gui.library_view.model().db.title(dest_id,
                                                       index_is_id=True)
        if safe_merge:
            if not confirm(
                    '<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):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
        elif merge_only_formats:
            if not confirm(
                    '<p>' +
                    _('Book formats from the selected books will be merged '
                      'into the <b>first selected book</b> (%s). '
                      'Metadata in the first selected book will not be changed. '
                      'Author, Title and all other metadata will <i>not</i> be merged.<br><br>'
                      'After merger the second and subsequently '
                      'selected books, with any metadata they have will be <b>deleted</b>. <br><br>'
                      'All book formats of the first selected book will be kept '
                      '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_only_formats', self.gui):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.delete_books_after_merge(src_ids)
        else:
            if not confirm(
                    '<p>' +
                    _('Book formats and metadata from the selected books will be merged '
                      'into the <b>first selected book</b> (%s).<br><br>'
                      'After merger the second and '
                      'subsequently selected books will be <b>deleted</b>. <br><br>'
                      'All book formats of the first selected book will be kept '
                      '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):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
            self.delete_books_after_merge(src_ids)
            # leave the selection highlight on first selected book
            dest_row = rows[0].row()
            for row in rows:
                if row.row() < rows[0].row():
                    dest_row -= 1
            ci = self.gui.library_view.model().index(dest_row, 0)
            if ci.isValid():
                self.gui.library_view.setCurrentIndex(ci)
                self.gui.library_view.model().current_changed(ci, ci)

    def add_formats(self, dest_id, src_books, replace=False):
        for src_book in src_books:
            if src_book:
                fmt = os.path.splitext(src_book)[-1].replace('.', '').upper()
                with open(src_book, 'rb') as f:
                    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):
        m = self.gui.library_view.model()
        ans = []
        for id_ in map(m.id, rows):
            dbfmts = m.db.formats(id_, index_is_id=True)
            if dbfmts:
                for fmt in dbfmts.split(','):
                    try:
                        path = m.db.format(id_,
                                           fmt,
                                           index_is_id=True,
                                           as_path=True)
                        ans.append(path)
                    except NoSuchFormat:
                        continue
        return ans

    def books_to_merge(self, rows):
        src_ids = []
        m = self.gui.library_view.model()
        for i, row in enumerate(rows):
            id_ = m.id(row)
            if i == 0:
                dest_id = id_
            else:
                src_ids.append(id_)
        return [dest_id, src_ids]

    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):
        db = self.gui.library_view.model().db
        dest_mi = db.get_metadata(dest_id, index_is_id=True)
        merged_identifiers = db.get_identifiers(dest_id, index_is_id=True)
        orig_dest_comments = dest_mi.comments
        dest_cover = db.cover(dest_id, index_is_id=True)
        had_orig_cover = bool(dest_cover)
        for src_id in src_ids:
            src_mi = db.get_metadata(src_id, index_is_id=True)

            if src_mi.comments and orig_dest_comments != src_mi.comments:
                if not dest_mi.comments:
                    dest_mi.comments = src_mi.comments
                else:
                    dest_mi.comments = unicode(
                        dest_mi.comments) + u'\n\n' + unicode(src_mi.comments)
            if src_mi.title and (not dest_mi.title
                                 or dest_mi.title == _('Unknown')):
                dest_mi.title = src_mi.title
            if src_mi.title and (not dest_mi.authors
                                 or dest_mi.authors[0] == _('Unknown')):
                dest_mi.authors = src_mi.authors
                dest_mi.author_sort = src_mi.author_sort
            if src_mi.tags:
                if not dest_mi.tags:
                    dest_mi.tags = src_mi.tags
                else:
                    dest_mi.tags.extend(src_mi.tags)
            if not dest_cover:
                src_cover = db.cover(src_id, index_is_id=True)
                if src_cover:
                    dest_cover = src_cover
            if not dest_mi.publisher:
                dest_mi.publisher = src_mi.publisher
            if not dest_mi.rating:
                dest_mi.rating = src_mi.rating
            if not dest_mi.series:
                dest_mi.series = src_mi.series
                dest_mi.series_index = src_mi.series_index

            src_identifiers = db.get_identifiers(src_id, index_is_id=True)
            src_identifiers.update(merged_identifiers)
            merged_identifiers = src_identifiers.copy()

        if merged_identifiers:
            dest_mi.set_identifiers(merged_identifiers)
        db.set_metadata(dest_id, dest_mi, ignore_errors=False)

        if not had_orig_cover and dest_cover:
            db.set_cover(dest_id, dest_cover)

        for key in db.field_metadata:  # loop thru all defined fields
            fm = db.field_metadata[key]
            if not fm['is_custom']:
                continue
            dt = fm['datatype']
            colnum = fm['colnum']
            # Get orig_dest_comments before it gets changed
            if dt == 'comments':
                orig_dest_value = db.get_custom(dest_id,
                                                num=colnum,
                                                index_is_id=True)

            for src_id in src_ids:
                dest_value = db.get_custom(dest_id,
                                           num=colnum,
                                           index_is_id=True)
                src_value = db.get_custom(src_id, num=colnum, index_is_id=True)
                if (dt == 'comments' and src_value
                        and src_value != orig_dest_value):
                    if not dest_value:
                        db.set_custom(dest_id, src_value, num=colnum)
                    else:
                        dest_value = unicode(dest_value) + u'\n\n' + unicode(
                            src_value)
                        db.set_custom(dest_id, dest_value, num=colnum)
                if (dt in {'bool', 'int', 'float', 'rating', 'datetime'}
                        and dest_value is None):
                    db.set_custom(dest_id, src_value, num=colnum)
                if (dt == 'series' and not dest_value and src_value):
                    src_index = db.get_custom_extra(src_id,
                                                    num=colnum,
                                                    index_is_id=True)
                    db.set_custom(dest_id,
                                  src_value,
                                  num=colnum,
                                  extra=src_index)
                if (dt == 'enumeration'
                        or (dt == 'text' and not fm['is_multiple'])
                        and not dest_value):
                    db.set_custom(dest_id, src_value, num=colnum)
                if (dt == 'text' and fm['is_multiple'] and src_value):
                    if not dest_value:
                        dest_value = src_value
                    else:
                        dest_value.extend(src_value)
                    db.set_custom(dest_id, dest_value, num=colnum)

    # }}}

    def edit_device_collections(self, view, oncard=None):
        model = view.model()
        result = model.get_collections_with_ids()
        d = DeviceCategoryEditor(self.gui,
                                 tag_to_match=None,
                                 data=result,
                                 key=sort_key)
        d.exec_()
        if d.result() == d.Accepted:
            to_rename = d.to_rename  # dict of new text to old ids
            to_delete = d.to_delete  # list of ids
            for old_id, new_name in to_rename.iteritems():
                model.rename_collection(old_id, new_name=unicode(new_name))
            for item in to_delete:
                model.delete_collection_using_id(item)
            self.gui.upload_collections(model.db, view=view, oncard=oncard)
            view.reset()

    # Apply bulk metadata changes {{{
    def apply_metadata_changes(self,
                               id_map,
                               title=None,
                               msg='',
                               callback=None,
                               merge_tags=True,
                               merge_comments=False):
        '''
        Apply the metadata changes in id_map to the database synchronously
        id_map must be a mapping of ids to Metadata objects. Set any fields you
        do not want updated in the Metadata object to null. An easy way to do
        that is to create a metadata object as Metadata(_('Unknown')) and then
        only set the fields you want changed on this object.

        callback can be either None or a function accepting a single argument,
        in which case it is called after applying is complete with the list of
        changed ids.

        id_map can also be a mapping of ids to 2-tuple's where each 2-tuple
        contains the absolute paths to an OPF and cover file respectively. If
        either of the paths is None, then the corresponding metadata is not
        updated.
        '''
        if title is None:
            title = _('Applying changed metadata')
        self.apply_id_map = list(id_map.iteritems())
        self.apply_current_idx = 0
        self.apply_failures = []
        self.applied_ids = set()
        self.apply_pd = None
        self.apply_callback = callback
        if len(self.apply_id_map) > 1:
            from calibre.gui2.dialogs.progress import ProgressDialog
            self.apply_pd = ProgressDialog(title,
                                           msg,
                                           min=0,
                                           max=len(self.apply_id_map) - 1,
                                           parent=self.gui,
                                           cancelable=False)
            self.apply_pd.setModal(True)
            self.apply_pd.show()
        self._am_merge_tags = merge_tags
        self._am_merge_comments = merge_comments
        self.do_one_apply()

    def do_one_apply(self):
        if self.apply_current_idx >= len(self.apply_id_map):
            return self.finalize_apply()

        i, mi = self.apply_id_map[self.apply_current_idx]
        if self.gui.current_db.has_id(i):
            if isinstance(mi, tuple):
                opf, cover = mi
                if opf:
                    mi = OPF(open(opf, 'rb'),
                             basedir=os.path.dirname(opf),
                             populate_spine=False).to_book_metadata()
                    self.apply_mi(i, mi)
                if cover:
                    self.gui.current_db.set_cover(i,
                                                  open(cover, 'rb'),
                                                  notify=False,
                                                  commit=False)
                    self.applied_ids.add(i)
            else:
                self.apply_mi(i, mi)

        self.apply_current_idx += 1
        if self.apply_pd is not None:
            self.apply_pd.value += 1
        QTimer.singleShot(5, self.do_one_apply)

    def apply_mi(self, book_id, mi):
        db = self.gui.current_db

        try:
            set_title = not mi.is_null('title')
            set_authors = not mi.is_null('authors')
            idents = db.get_identifiers(book_id, index_is_id=True)
            if mi.identifiers:
                idents.update(mi.identifiers)
            mi.identifiers = idents
            if mi.is_null('series'):
                mi.series_index = None
            if self._am_merge_tags:
                old_tags = db.tags(book_id, index_is_id=True)
                if old_tags:
                    tags = [x.strip() for x in old_tags.split(',')
                            ] + (mi.tags if mi.tags else [])
                    mi.tags = list(set(tags))
            if self._am_merge_comments:
                old_comments = db.new_api.field_for('comments', book_id)
                if old_comments and mi.comments and old_comments != mi.comments:
                    mi.comments = merge_comments(old_comments, mi.comments)
            db.set_metadata(book_id,
                            mi,
                            commit=False,
                            set_title=set_title,
                            set_authors=set_authors,
                            notify=False)
            self.applied_ids.add(book_id)
        except:
            import traceback
            self.apply_failures.append((book_id, traceback.format_exc()))

        try:
            if mi.cover:
                os.remove(mi.cover)
        except:
            pass

    def finalize_apply(self):
        db = self.gui.current_db
        db.commit()

        if self.apply_pd is not None:
            self.apply_pd.hide()

        if self.apply_failures:
            msg = []
            for i, tb in self.apply_failures:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace('|', ',') for x in authors.split(',')]
                    title += ' - ' + authors_to_string(authors)
                msg.append(title + '\n\n' + tb + '\n' + ('*' * 80))

            error_dialog(self.gui,
                         _('Some failures'),
                         _('Failed to apply updated metadata for some books'
                           ' in your library. Click "Show Details" to see '
                           'details.'),
                         det_msg='\n\n'.join(msg),
                         show=True)
        self.refresh_gui(self.applied_ids)

        self.apply_id_map = []
        self.apply_pd = None
        try:
            if callable(self.apply_callback):
                self.apply_callback(list(self.applied_ids))
        finally:
            self.apply_callback = None

    def refresh_gui(self,
                    book_ids,
                    covers_changed=True,
                    tag_browser_changed=True):
        if book_ids:
            cr = self.gui.library_view.currentIndex().row()
            self.gui.library_view.model().refresh_ids(list(book_ids), cr)
            if covers_changed and self.gui.cover_flow:
                self.gui.cover_flow.dataChanged()
            if tag_browser_changed:
                self.gui.tags_view.recount()
Example #32
0
class Adder(QObject):

    do_one_signal = pyqtSignal()

    def __init__(self, source, single_book_per_directory=True, db=None, parent=None, callback=None, pool=None, list_of_archives=False):
        if not validate_source(source, parent):
            return
        QObject.__init__(self, parent)
        self.author_map_rules = None
        if gprefs.get('author_map_on_add_rules'):
            from calibre.ebooks.metadata.author_mapper import compile_rules as acr
            self.author_map_rules = acr(gprefs['author_map_on_add_rules'])
        self.single_book_per_directory = single_book_per_directory
        self.ignore_opf = False
        self.list_of_archives = list_of_archives
        self.callback = callback
        self.add_formats_to_existing = prefs['add_formats_to_existing']
        self.do_one_signal.connect(self.tick, type=Qt.QueuedConnection)
        self.pool = pool
        self.pd = ProgressDialog(_('Adding books...'), _('Scanning for files...'), min=0, max=0, parent=parent, icon='add_book.png')
        self.db = getattr(db, 'new_api', None)
        if self.db is not None:
            self.dbref = weakref.ref(db)
        self.source = source
        self.tdir = PersistentTemporaryDirectory('_add_books')
        self.scan_error = None
        self.file_groups = OrderedDict()
        self.abort_scan = False
        self.duplicates = []
        self.report = []
        self.items = []
        self.added_book_ids = set()
        self.merged_books = set()
        self.added_duplicate_info = set()
        self.pd.show()

        self.scan_thread = Thread(target=self.scan, name='ScanBooks')
        self.scan_thread.daemon = True
        self.scan_thread.start()
        self.do_one = self.monitor_scan
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()

    def break_cycles(self):
        self.abort_scan = True
        self.pd.close()
        self.pd.deleteLater()
        if self.pool is not None:
            self.pool.shutdown()
        if not self.items:
            shutil.rmtree(self.tdir, ignore_errors=True)
        self.setParent(None)
        self.find_identical_books_data = self.merged_books = self.added_duplicate_info = self.pool = self.items = self.duplicates = self.pd = self.db = self.dbref = self.tdir = self.file_groups = self.scan_thread = None  # noqa
        self.deleteLater()

    def tick(self):
        if self.pd.canceled:
            try:
                if callable(self.callback):
                    self.callback(self)
            finally:
                self.break_cycles()
            return
        self.do_one()

    # Filesystem scan {{{

    def scan(self):

        try:
            compiled_rules = tuple(map(compile_rule, gprefs.get('add_filter_rules', ())))
        except Exception:
            compiled_rules = ()
            import traceback
            traceback.print_exc()

        if iswindows or isosx:
            def find_files(root):
                for dirpath, dirnames, filenames in os.walk(root):
                    for files in find_books_in_directory(dirpath, self.single_book_per_directory, compiled_rules=compiled_rules):
                        if self.abort_scan:
                            return
                        self.file_groups[len(self.file_groups)] = files
        else:
            def find_files(root):
                if isinstance(root, type(u'')):
                    root = root.encode(filesystem_encoding)
                for dirpath, dirnames, filenames in os.walk(root):
                    try:
                        dirpath = dirpath.decode(filesystem_encoding)
                    except UnicodeDecodeError:
                        prints('Ignoring non-decodable directory:', dirpath)
                        continue
                    for files in find_books_in_directory(dirpath, self.single_book_per_directory, compiled_rules=compiled_rules):
                        if self.abort_scan:
                            return
                        self.file_groups[len(self.file_groups)] = files

        def extract(source):
            tdir = tempfile.mkdtemp(suffix='_archive', dir=self.tdir)
            if source.lower().endswith('.zip'):
                from calibre.utils.zipfile import ZipFile
                try:
                    with ZipFile(source) as zf:
                        zf.extractall(tdir)
                except Exception:
                    prints('Corrupt ZIP file, trying to use local headers')
                    from calibre.utils.localunzip import extractall
                    extractall(source, tdir)
            elif source.lower().endswith('.rar'):
                from calibre.utils.unrar import extract
                extract(source, tdir)
            return tdir

        try:
            if isinstance(self.source, basestring):
                find_files(self.source)
                self.ignore_opf = True
            else:
                unreadable_files = []
                for path in self.source:
                    if self.abort_scan:
                        return
                    if os.access(path, os.R_OK):
                        if self.list_of_archives:
                            find_files(extract(path))
                            self.ignore_opf = True
                        else:
                            self.file_groups[len(self.file_groups)] = [path]
                    else:
                        unreadable_files.append(path)
                if unreadable_files:
                    if not self.file_groups:
                        m = ngettext('You do not have permission to read the selected file.',
                                     'You do not have permission to read the selected files.', len(unreadable_files))
                        self.scan_error = m + '\n' + '\n'.join(unreadable_files)
                    else:
                        a = self.report.append
                        for f in unreadable_files:
                            a(_('Could not add %s as you do not have permission to read the file' % f))
                            a('')
        except Exception:
            self.scan_error = traceback.format_exc()

    def monitor_scan(self):
        self.scan_thread.join(0.05)
        if self.scan_thread.is_alive():
            self.do_one_signal.emit()
            return
        if self.scan_error is not None:
            error_dialog(self.pd, _('Cannot add books'), _(
                'Failed to add any books, click "Show details" for more information.'),
                         det_msg=self.scan_error, show=True)
            self.break_cycles()
            return
        if not self.file_groups:
            error_dialog(self.pd, _('Could not add'), _(
                'No e-book files were found in %s') % self.source, show=True)
            self.break_cycles()
            return
        self.pd.max = len(self.file_groups)
        self.pd.title = ngettext(
            'Reading metadata and adding to library (one book)...',
            'Reading metadata and adding to library ({} books)...',
            self.pd.max).format(self.pd.max)
        self.pd.msg = ''
        self.pd.value = 0
        self.pool = Pool(name='AddBooks') if self.pool is None else self.pool
        if self.db is not None:
            if self.add_formats_to_existing:
                self.find_identical_books_data = self.db.data_for_find_identical_books()
            else:
                try:
                    self.pool.set_common_data(self.db.data_for_has_book())
                except Failure as err:
                    error_dialog(self.pd, _('Cannot add books'), _(
                    'Failed to add any books, click "Show details" for more information.'),
                    det_msg=as_unicode(err.failure_message) + '\n' + as_unicode(err.details), show=True)
                    self.pd.canceled = True
        self.groups_to_add = iter(self.file_groups)
        self.do_one = self.do_one_group
        self.do_one_signal.emit()
    # }}}

    def do_one_group(self):
        try:
            group_id = next(self.groups_to_add)
        except StopIteration:
            self.do_one = self.monitor_pool
            self.do_one_signal.emit()
            return
        try:
            self.pool(group_id, 'calibre.ebooks.metadata.worker', 'read_metadata',
                      self.file_groups[group_id], group_id, self.tdir)
        except Failure as err:
            error_dialog(self.pd, _('Cannot add books'), _(
            'Failed to add any books, click "Show details" for more information.'),
            det_msg=as_unicode(err.failure_message) + '\n' + as_unicode(err.details), show=True)
            self.pd.canceled = True
        self.do_one_signal.emit()

    def monitor_pool(self):
        try:
            worker_result = self.pool.results.get(True, 0.05)
            self.pool.results.task_done()
        except Empty:
            try:
                self.pool.wait_for_tasks(timeout=0.01)
            except RuntimeError:
                pass  # Tasks still remaining
            except Failure as err:
                error_dialog(self.pd, _('Cannot add books'), _(
                'Failed to add some books, click "Show details" for more information.'),
                det_msg=unicode(err.failure_message) + '\n' + unicode(err.details), show=True)
                self.pd.canceled = True
            else:
                # All tasks completed
                try:
                    join_with_timeout(self.pool.results, 0.01)
                except RuntimeError:
                    pass  # There are results remaining
                else:
                    # No results left
                    self.process_duplicates()
                    return
        else:
            group_id = worker_result.id
            if worker_result.is_terminal_failure:
                error_dialog(self.pd, _('Critical failure'), _(
                    'The read metadata worker process crashed while processing'
                    ' some files. Adding of books is aborted. Click "Show details"'
                    ' to see which files caused the problem.'), show=True,
                    det_msg='\n'.join(self.file_groups[group_id]))
                self.pd.canceled = True
            else:
                try:
                    self.process_result(group_id, worker_result.result)
                except Exception:
                    self.report_metadata_failure(group_id, traceback.format_exc())
                self.pd.value += 1

        self.do_one_signal.emit()

    def report_metadata_failure(self, group_id, details):
        a = self.report.append
        paths = self.file_groups[group_id]
        a(''), a('-' * 70)
        m = ngettext('Failed to read metadata from the file:', 'Failed to read metadata from the files:', len(paths))
        a(m)
        [a('\t' + f) for f in paths]
        a(_('With error:')), a(details)
        mi = Metadata(_('Unknown'))
        mi.read_metadata_failed = False
        return mi

    def process_result(self, group_id, result):
        if result.err:
            mi = self.report_metadata_failure(group_id, result.traceback)
            paths = self.file_groups[group_id]
            has_cover = False
            duplicate_info = set() if self.add_formats_to_existing else False
        else:
            paths, opf, has_cover, duplicate_info = result.value
            try:
                mi = OPF(BytesIO(opf), basedir=self.tdir, populate_spine=False, try_to_guess_cover=False).to_book_metadata()
                mi.read_metadata_failed = False
            except Exception:
                mi = self.report_metadata_failure(group_id, traceback.format_exc())

        if mi.is_null('title'):
            for path in paths:
                mi.title = os.path.splitext(os.path.basename(path))[0]
                break
        if mi.application_id == '__calibre_dummy__':
            mi.application_id = None
        if gprefs.get('tag_map_on_add_rules'):
            from calibre.ebooks.metadata.tag_mapper import map_tags
            mi.tags = map_tags(mi.tags, gprefs['tag_map_on_add_rules'])
        if self.author_map_rules:
            from calibre.ebooks.metadata.author_mapper import map_authors
            new_authors = map_authors(mi.authors, self.author_map_rules)
            if new_authors != mi.authors:
                mi.authors = new_authors
                if self.db is None:
                    mi.author_sort = authors_to_sort_string(mi.authors)
                else:
                    mi.author_sort = self.db.author_sort_from_authors(mi.authors)

        self.pd.msg = mi.title

        cover_path = os.path.join(self.tdir, '%s.cdata' % group_id) if has_cover else None

        if self.db is None:
            if paths:
                self.items.append((mi, cover_path, paths))
            return

        if self.add_formats_to_existing:
            identical_book_ids = find_identical_books(mi, self.find_identical_books_data)
            if identical_book_ids:
                try:
                    self.merge_books(mi, cover_path, paths, identical_book_ids)
                except Exception:
                    a = self.report.append
                    a(''), a('-' * 70)
                    a(_('Failed to merge the book: ') + mi.title)
                    [a('\t' + f) for f in paths]
                    a(_('With error:')), a(traceback.format_exc())
            else:
                self.add_book(mi, cover_path, paths)
        else:
            if duplicate_info or icu_lower(mi.title or _('Unknown')) in self.added_duplicate_info:
                self.duplicates.append((mi, cover_path, paths))
            else:
                self.add_book(mi, cover_path, paths)

    def merge_books(self, mi, cover_path, paths, identical_book_ids):
        self.merged_books.add((mi.title, ' & '.join(mi.authors)))
        seen_fmts = set()
        replace = gprefs['automerge'] == 'overwrite'
        cover_removed = False
        for identical_book_id in identical_book_ids:
            ib_fmts = {fmt.upper() for fmt in self.db.formats(identical_book_id)}
            seen_fmts |= ib_fmts
            self.add_formats(identical_book_id, paths, mi, replace=replace)
        if gprefs['automerge'] == 'new record':
            incoming_fmts = {path.rpartition(os.extsep)[-1].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
                self.add_book(mi, cover_path, paths)
                cover_removed = True
        if not cover_removed and cover_path:
            try:
                os.remove(cover_path)
            except Exception:
                pass

    def add_book(self, mi, cover_path, paths):
        if DEBUG:
            st = time.time()
        try:
            cdata = None
            if cover_path:
                with open(cover_path, 'rb') as f:
                    cdata = f.read()
                try:
                    os.remove(cover_path)
                except Exception:
                    pass
            book_id = self.dbref().create_book_entry(mi, cover=cdata)
            self.added_book_ids.add(book_id)
        except Exception:
            a = self.report.append
            a(''), a('-' * 70)
            a(_('Failed to add the book: ') + mi.title)
            [a('\t' + f) for f in paths]
            a(_('With error:')), a(traceback.format_exc())
            return
        self.add_formats(book_id, paths, mi, is_an_add=True)
        try:
            if self.add_formats_to_existing:
                self.db.update_data_for_find_identical_books(book_id, self.find_identical_books_data)
            else:
                self.added_duplicate_info.add(icu_lower(mi.title or _('Unknown')))
        except Exception:
            # Ignore this exception since all it means is that duplicate
            # detection/automerge will fail for this book.
            traceback.print_exc()
        if DEBUG:
            prints('Added', mi.title, 'to db in: %.1f' % (time.time() - st))

    def add_formats(self, book_id, paths, mi, replace=True, is_an_add=False):
        fmap = {p.rpartition(os.path.extsep)[-1].lower():p for p in paths}
        fmt_map = {}
        for fmt, path in fmap.iteritems():
            # The onimport plugins have already been run by the read metadata
            # worker
            if self.ignore_opf and fmt.lower() == 'opf':
                continue
            try:
                if self.db.add_format(book_id, fmt, path, run_hooks=False, replace=replace):
                    run_plugins_on_postimport(self.dbref(), book_id, fmt)
                    fmt_map[fmt.lower()] = path
            except Exception:
                a = self.report.append
                a(''), a('-' * 70)
                a(_('Failed to add the file {0} to the book: {1}').format(path, mi.title))
                a(_('With error:')), a(traceback.format_exc())
        if is_an_add:
            run_plugins_on_postadd(self.dbref(), book_id, fmt_map)

    def process_duplicates(self):
        if self.duplicates:
            d = DuplicatesQuestion(self.dbref(), self.duplicates, self.pd)
            duplicates = tuple(d.duplicates)
            d.deleteLater()
            if duplicates:
                self.do_one = self.process_duplicate
                self.duplicates_to_process = iter(duplicates)
                self.pd.title = _('Adding duplicates')
                self.pd.msg = ''
                self.pd.max, self.pd.value = len(duplicates), 0
                self.do_one_signal.emit()
                return
        self.finish()

    def process_duplicate(self):
        try:
            mi, cover_path, paths = next(self.duplicates_to_process)
        except StopIteration:
            self.finish()
            return
        self.pd.value += 1
        self.pd.msg = mi.title
        self.add_book(mi, cover_path, paths)
        self.do_one_signal.emit()

    def finish(self):
        if DEBUG:
            prints('Added %s books in %.1f seconds' % (len(self.added_book_ids or self.items), time.time() - self.start_time))
        if self.report:
            added_some = self.items or self.added_book_ids
            d = warning_dialog if added_some else error_dialog
            msg = _('There were problems adding some files, click "Show details" for more information') if added_some else _(
                'Failed to add any books, click "Show details" for more information')
            d(self.pd, _('Errors while adding'), msg, det_msg='\n'.join(self.report), show=True)

        if gprefs['manual_add_auto_convert'] and self.added_book_ids and self.parent() is not None:
            self.parent().iactions['Convert Books'].auto_convert_auto_add(
                self.added_book_ids)

        try:
            if callable(self.callback):
                self.callback(self)
        finally:
            self.break_cycles()

    @property
    def number_of_books_added(self):
        return len(self.added_book_ids)
Example #33
0
 def __init__(self, *args, **kwargs):
     PD.__init__(self, *args, **kwargs)
     self.on_progress_update.connect(self.progressed, type=Qt.QueuedConnection)
     self.finished_moving.connect(self.accept, type=Qt.QueuedConnection)
Example #34
0
class Adder(QObject):

    do_one_signal = pyqtSignal()

    def __init__(self, source, single_book_per_directory=True, db=None, parent=None, callback=None, pool=None, list_of_archives=False):
        if not validate_source(source, parent):
            return
        QObject.__init__(self, parent)
        self.single_book_per_directory = single_book_per_directory
        self.ignore_opf = False
        self.list_of_archives = list_of_archives
        self.callback = callback
        self.add_formats_to_existing = prefs['add_formats_to_existing']
        self.do_one_signal.connect(self.tick, type=Qt.QueuedConnection)
        self.pool = pool
        self.pd = ProgressDialog(_('Adding books...'), _('Scanning for files...'), min=0, max=0, parent=parent, icon='add_book.png')
        self.db = getattr(db, 'new_api', None)
        if self.db is not None:
            self.dbref = weakref.ref(db)
        self.source = source
        self.tdir = PersistentTemporaryDirectory('_add_books')
        self.scan_error = None
        self.file_groups = OrderedDict()
        self.abort_scan = False
        self.duplicates = []
        self.report = []
        self.items = []
        self.added_book_ids = set()
        self.merged_books = set()
        self.added_duplicate_info = set()
        self.pd.show()

        self.scan_thread = Thread(target=self.scan, name='ScanBooks')
        self.scan_thread.daemon = True
        self.scan_thread.start()
        self.do_one = self.monitor_scan
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()

    def break_cycles(self):
        self.abort_scan = True
        self.pd.close()
        self.pd.deleteLater()
        if self.pool is not None:
            self.pool.shutdown()
        if not self.items:
            shutil.rmtree(self.tdir, ignore_errors=True)
        self.setParent(None)
        self.find_identical_books_data = self.merged_books = self.added_duplicate_info = self.pool = self.items = self.duplicates = self.pd = self.db = self.dbref = self.tdir = self.file_groups = self.scan_thread = None  # noqa
        self.deleteLater()

    def tick(self):
        if self.pd.canceled:
            try:
                if callable(self.callback):
                    self.callback(self)
            finally:
                self.break_cycles()
            return
        self.do_one()

    # Filesystem scan {{{

    def scan(self):

        def find_files(root):
            for dirpath, dirnames, filenames in os.walk(root):
                for files in find_books_in_directory(dirpath, self.single_book_per_directory):
                    if self.abort_scan:
                        return
                    self.file_groups[len(self.file_groups)] = files

        def extract(source):
            tdir = tempfile.mkdtemp(suffix='_archive', dir=self.tdir)
            if source.lower().endswith('.zip'):
                from calibre.utils.zipfile import ZipFile
                try:
                    with ZipFile(source) as zf:
                        zf.extractall(tdir)
                except Exception:
                    prints('Corrupt ZIP file, trying to use local headers')
                    from calibre.utils.localunzip import extractall
                    extractall(source, tdir)
            elif source.lower().endswith('.rar'):
                from calibre.utils.unrar import extract
                extract(source, tdir)
            return tdir

        try:
            if isinstance(self.source, basestring):
                find_files(self.source)
                self.ignore_opf = True
            else:
                unreadable_files = []
                for path in self.source:
                    if self.abort_scan:
                        return
                    if os.access(path, os.R_OK):
                        if self.list_of_archives:
                            find_files(extract(path))
                            self.ignore_opf = True
                        else:
                            self.file_groups[len(self.file_groups)] = [path]
                    else:
                        unreadable_files.append(path)
                if unreadable_files:
                    if not self.file_groups:
                        self.scan_error = _('You do not have permission to read the selected file(s).') + '\n'
                        self.scan_error += '\n'.join(unreadable_files)
                    else:
                        a = self.report.append
                        for f in unreadable_files:
                            a(_('Could not add %s as you do not have permission to read the file' % f))
                            a('')
        except Exception:
            self.scan_error = traceback.format_exc()

    def monitor_scan(self):
        self.scan_thread.join(0.05)
        if self.scan_thread.is_alive():
            self.do_one_signal.emit()
            return
        if self.scan_error is not None:
            error_dialog(self.pd, _('Cannot add books'), _(
                'Failed to add any books, click "Show details" for more information.'),
                         det_msg=self.scan_error, show=True)
            self.break_cycles()
            return
        if not self.file_groups:
            error_dialog(self.pd, _('Could not add'), _(
                'No ebook files were found in %s') % self.source, show=True)
            self.break_cycles()
            return
        self.pd.max = len(self.file_groups)
        self.pd.title = _('Reading metadata and adding to library (%d books)...') % self.pd.max
        self.pd.msg = ''
        self.pd.value = 0
        self.pool = Pool(name='AddBooks') if self.pool is None else self.pool
        if self.db is not None:
            if self.add_formats_to_existing:
                self.find_identical_books_data = self.db.data_for_find_identical_books()
            else:
                try:
                    self.pool.set_common_data(self.db.data_for_has_book())
                except Failure as err:
                    error_dialog(self.pd, _('Cannot add books'), _(
                    'Failed to add any books, click "Show details" for more information.'),
                    det_msg=unicode(err.failure_message) + '\n' + unicode(err.details), show=True)
                    self.pd.canceled = True
        self.groups_to_add = iter(self.file_groups)
        self.do_one = self.do_one_group
        self.do_one_signal.emit()
    # }}}

    def do_one_group(self):
        try:
            group_id = next(self.groups_to_add)
        except StopIteration:
            self.do_one = self.monitor_pool
            self.do_one_signal.emit()
            return
        try:
            self.pool(group_id, 'calibre.ebooks.metadata.worker', 'read_metadata',
                      self.file_groups[group_id], group_id, self.tdir)
        except Failure as err:
            error_dialog(self.pd, _('Cannot add books'), _(
            'Failed to add any books, click "Show details" for more information.'),
            det_msg=unicode(err.failure_message) + '\n' + unicode(err.details), show=True)
            self.pd.canceled = True
        self.do_one_signal.emit()

    def monitor_pool(self):
        try:
            worker_result = self.pool.results.get(True, 0.05)
            self.pool.results.task_done()
        except Empty:
            try:
                self.pool.wait_for_tasks(timeout=0.01)
            except RuntimeError:
                pass  # Tasks still remaining
            except Failure as err:
                error_dialog(self.pd, _('Cannot add books'), _(
                'Failed to add some books, click "Show details" for more information.'),
                det_msg=unicode(err.failure_message) + '\n' + unicode(err.details), show=True)
                self.pd.canceled = True
            else:
                # All tasks completed
                try:
                    join_with_timeout(self.pool.results, 0.01)
                except RuntimeError:
                    pass  # There are results remaining
                else:
                    # No results left
                    self.process_duplicates()
                    return
        else:
            group_id = worker_result.id
            if worker_result.is_terminal_failure:
                error_dialog(self.pd, _('Critical failure'), _(
                    'The read metadata worker process crashed while processing'
                    ' some files. Adding of books is aborted. Click "Show details"'
                    ' to see which files caused the problem.'), show=True,
                    det_msg='\n'.join(self.file_groups[group_id]))
                self.pd.canceled = True
            else:
                try:
                    self.process_result(group_id, worker_result.result)
                except Exception:
                    self.report_metadata_failure(group_id, traceback.format_exc())
                self.pd.value += 1

        self.do_one_signal.emit()

    def report_metadata_failure(self, group_id, details):
        a = self.report.append
        paths = self.file_groups[group_id]
        a(''), a('-' * 70)
        a(_('Failed to read metadata from the file(s):'))
        [a('\t' + f) for f in paths]
        a(_('With error:')), a(details)
        mi = Metadata(_('Unknown'))
        mi.read_metadata_failed = False
        return mi

    def process_result(self, group_id, result):
        if result.err:
            mi = self.report_metadata_failure(group_id, result.traceback)
            paths = self.file_groups[group_id]
            has_cover = False
            duplicate_info = set() if self.add_formats_to_existing else False
        else:
            paths, opf, has_cover, duplicate_info = result.value
            try:
                mi = OPF(BytesIO(opf), basedir=self.tdir, populate_spine=False, try_to_guess_cover=False).to_book_metadata()
                mi.read_metadata_failed = False
            except Exception:
                mi = self.report_metadata_failure(group_id, traceback.format_exc())

        if mi.is_null('title'):
            for path in paths:
                mi.title = os.path.splitext(os.path.basename(path))[0]
                break
        if mi.application_id == '__calibre_dummy__':
            mi.application_id = None

        self.pd.msg = mi.title

        cover_path = os.path.join(self.tdir, '%s.cdata' % group_id) if has_cover else None

        if self.db is None:
            if paths:
                self.items.append((mi, cover_path, paths))
            return

        if self.add_formats_to_existing:
            identical_book_ids = find_identical_books(mi, self.find_identical_books_data)
            if identical_book_ids:
                try:
                    self.merge_books(mi, cover_path, paths, identical_book_ids)
                except Exception:
                    a = self.report.append
                    a(''), a('-' * 70)
                    a(_('Failed to merge the book: ') + mi.title)
                    [a('\t' + f) for f in paths]
                    a(_('With error:')), a(traceback.format_exc())
            else:
                self.add_book(mi, cover_path, paths)
        else:
            if duplicate_info or icu_lower(mi.title or _('Unknown')) in self.added_duplicate_info:
                self.duplicates.append((mi, cover_path, paths))
            else:
                self.add_book(mi, cover_path, paths)

    def merge_books(self, mi, cover_path, paths, identical_book_ids):
        self.merged_books.add((mi.title, ' & '.join(mi.authors)))
        seen_fmts = set()
        replace = gprefs['automerge'] == 'overwrite'
        cover_removed = False
        for identical_book_id in identical_book_ids:
            ib_fmts = {fmt.upper() for fmt in self.db.formats(identical_book_id)}
            seen_fmts |= ib_fmts
            self.add_formats(identical_book_id, paths, mi, replace=replace)
        if gprefs['automerge'] == 'new record':
            incoming_fmts = {path.rpartition(os.extsep)[-1].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
                self.add_book(mi, cover_path, paths)
                cover_removed = True
        if not cover_removed and cover_path:
            try:
                os.remove(cover_path)
            except Exception:
                pass

    def add_book(self, mi, cover_path, paths):
        if DEBUG:
            st = time.time()
        try:
            cdata = None
            if cover_path:
                with open(cover_path, 'rb') as f:
                    cdata = f.read()
                try:
                    os.remove(cover_path)
                except Exception:
                    pass
            book_id = self.dbref().create_book_entry(mi, cover=cdata)
            self.added_book_ids.add(book_id)
        except Exception:
            a = self.report.append
            a(''), a('-' * 70)
            a(_('Failed to add the book: ') + mi.title)
            [a('\t' + f) for f in paths]
            a(_('With error:')), a(traceback.format_exc())
            return
        self.add_formats(book_id, paths, mi)
        try:
            if self.add_formats_to_existing:
                self.db.update_data_for_find_identical_books(book_id, self.find_identical_books_data)
            else:
                self.added_duplicate_info.add(icu_lower(mi.title or _('Unknown')))
        except Exception:
            # Ignore this exception since all it means is that duplicate
            # detection/automerge will fail for this book.
            traceback.print_exc()
        if DEBUG:
            prints('Added', mi.title, 'to db in: %.1f' % (time.time() - st))

    def add_formats(self, book_id, paths, mi, replace=True):
        fmap = {p.rpartition(os.path.extsep)[-1].lower():p for p in paths}
        for fmt, path in fmap.iteritems():
            # The onimport plugins have already been run by the read metadata
            # worker
            if self.ignore_opf and fmt.lower() == 'opf':
                continue
            try:
                if self.db.add_format(book_id, fmt, path, run_hooks=False, replace=replace):
                    run_plugins_on_postimport(self.dbref(), book_id, fmt)
            except Exception:
                a = self.report.append
                a(''), a('-' * 70)
                a(_('Failed to add the file {0} to the book: {1}').format(path, mi.title))
                a(_('With error:')), a(traceback.format_exc())

    def process_duplicates(self):
        if self.duplicates:
            d = DuplicatesQuestion(self.dbref(), self.duplicates, self.pd)
            duplicates = tuple(d.duplicates)
            d.deleteLater()
            if duplicates:
                self.do_one = self.process_duplicate
                self.duplicates_to_process = iter(duplicates)
                self.pd.title = _('Adding duplicates')
                self.pd.msg = ''
                self.pd.max, self.pd.value = len(duplicates), 0
                self.do_one_signal.emit()
                return
        self.finish()

    def process_duplicate(self):
        try:
            mi, cover_path, paths = next(self.duplicates_to_process)
        except StopIteration:
            self.finish()
            return
        self.pd.value += 1
        self.pd.msg = mi.title
        self.add_book(mi, cover_path, paths)
        self.do_one_signal.emit()

    def finish(self):
        if DEBUG:
            prints('Added %s books in %.1f seconds' % (len(self.added_book_ids or self.items), time.time() - self.start_time))
        if self.report:
            added_some = self.items or self.added_book_ids
            d = warning_dialog if added_some else error_dialog
            msg = _('There were problems adding some files, click "Show details" for more information') if added_some else _(
                'Failed to add any books, click "Show details" for more information')
            d(self.pd, _('Errors while adding'), msg, det_msg='\n'.join(self.report), show=True)

        if gprefs['manual_add_auto_convert'] and self.added_book_ids and self.parent() is not None:
            self.parent().iactions['Convert Books'].auto_convert_auto_add(
                self.added_book_ids)

        try:
            if callable(self.callback):
                self.callback(self)
        finally:
            self.break_cycles()

    @property
    def number_of_books_added(self):
        return len(self.added_book_ids)
Example #35
0
class EditMetadataAction(InterfaceAction):

    name = 'Edit Metadata'
    action_spec = (_('Edit metadata'), 'edit_input.png', _('Change the title/author/cover etc. of books'), _('E'))
    action_type = 'current'
    action_add_menu = True

    accepts_drops = True

    def accept_enter_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def accept_drag_move_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def drop_event(self, event, mime_data):
        mime = 'application/calibre+from_library'
        if mime_data.hasFormat(mime):
            self.dropped_ids = tuple(map(int, str(mime_data.data(mime)).split()))
            QTimer.singleShot(1, self.do_drop)
            return True
        return False

    def do_drop(self):
        book_ids = self.dropped_ids
        del self.dropped_ids
        if book_ids:
            db = self.gui.library_view.model().db
            rows = [db.row(i) for i in book_ids]
            self.edit_metadata_for(rows, book_ids)

    def genesis(self):
        md = self.qaction.menu()
        cm = partial(self.create_menu_action, md)
        cm('individual', _('Edit metadata individually'), icon=self.qaction.icon(),
                triggered=partial(self.edit_metadata, False, bulk=False))
        md.addSeparator()
        cm('bulk', _('Edit metadata in bulk'),
                triggered=partial(self.edit_metadata, False, bulk=True))
        md.addSeparator()
        cm('download', _('Download metadata and covers'),
                triggered=partial(self.download_metadata, ids=None),
                shortcut='Ctrl+D')
        self.metadata_menu = md

        self.metamerge_menu = mb = QMenu()
        cm2 = partial(self.create_menu_action, mb)
        cm2('merge delete', _('Merge into first selected book - delete others'),
                triggered=self.merge_books)
        mb.addSeparator()
        cm2('merge keep', _('Merge into first selected book - keep others'),
                triggered=partial(self.merge_books, safe_merge=True),
                shortcut='Alt+M')
        mb.addSeparator()
        cm2('merge formats', _('Merge only formats into first selected book - delete others'),
                triggered=partial(self.merge_books, merge_only_formats=True),
                shortcut='Alt+Shift+M')
        self.merge_menu = mb
        md.addSeparator()
        self.action_copy = cm('copy', _('Copy metadata'), icon='edit-copy.png', triggered=self.copy_metadata)
        self.action_paset = cm('paste', _('Paste metadata'), icon='edit-paste.png', triggered=self.paste_metadata)
        self.action_merge = cm('merge', _('Merge book records'), icon='merge_books.png',
            shortcut=_('M'), triggered=self.merge_books)
        self.action_merge.setMenu(mb)

        self.qaction.triggered.connect(self.edit_metadata)

    def location_selected(self, loc):
        enabled = loc == 'library'
        self.qaction.setEnabled(enabled)
        self.menuless_qaction.setEnabled(enabled)
        for action in self.metamerge_menu.actions() + self.metadata_menu.actions():
            action.setEnabled(enabled)

    def copy_metadata(self):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _('Cannot copy metadata'),
                                _('No books selected'), show=True)
        if len(rows) > 1:
            return error_dialog(self.gui, _('Cannot copy metadata'),
                                _('Multiple books selected, can only copy from one book at a time.'), show=True)
        db = self.gui.current_db
        book_id = db.id(rows[0].row())
        mi = db.new_api.get_metadata(book_id)
        md = QMimeData()
        md.setText(unicode(mi))
        md.setData('application/calibre-book-metadata', bytearray(metadata_to_opf(mi, default_lang='und')))
        img = db.new_api.cover(book_id, as_image=True)
        if img:
            md.setImageData(img)
        c = QApplication.clipboard()
        c.setMimeData(md)

    def paste_metadata(self):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _('Cannot paste metadata'),
                                _('No books selected'), show=True)
        c = QApplication.clipboard()
        md = c.mimeData()
        if not md.hasFormat('application/calibre-book-metadata'):
            return error_dialog(self.gui, _('Cannot paste metadata'),
                                _('No copied metadata available'), show=True)
        if len(rows) > 1:
            if not confirm(_(
                    'You are pasting metadata onto <b>multiple books</b> ({num_of_books}). Are you'
                    ' sure you want to do that?').format(num_of_books=len(rows)), 'paste-onto-multiple', parent=self.gui):
                return
        data = bytes(md.data('application/calibre-book-metadata'))
        mi = OPF(BytesIO(data), populate_spine=False, read_toc=False, try_to_guess_cover=False).to_book_metadata()
        mi.application_id = mi.uuid_id = None
        exclude = set(tweaks['exclude_fields_on_paste'])
        paste_cover = 'cover' not in exclude
        cover = md.imageData() if paste_cover else None
        exclude.discard('cover')
        for field in exclude:
            mi.set_null(field)
        db = self.gui.current_db
        book_ids = {db.id(r.row()) for r in rows}
        title_excluded = 'title' in exclude
        authors_excluded = 'authors' in exclude
        for book_id in book_ids:
            if title_excluded:
                mi.title = db.new_api.field_for('title', book_id)
            if authors_excluded:
                mi.authors = db.new_api.field_for('authors', book_id)
            db.new_api.set_metadata(book_id, mi, ignore_errors=True)
        if cover:
            db.new_api.set_cover({book_id: cover for book_id in book_ids})
        self.refresh_books_after_metadata_edit(book_ids)

    # Download metadata {{{
    def download_metadata(self, ids=None, ensure_fields=None):
        if ids is None:
            rows = self.gui.library_view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                return error_dialog(self.gui, _('Cannot download metadata'),
                            _('No books selected'), show=True)
            db = self.gui.library_view.model().db
            ids = [db.id(row.row()) for row in rows]
        from calibre.gui2.metadata.bulk_download import start_download
        from calibre.ebooks.metadata.sources.update import update_sources
        update_sources()
        start_download(self.gui, ids,
                Dispatcher(self.metadata_downloaded),
                ensure_fields=ensure_fields)

    def cleanup_bulk_download(self, tdir, *args):
        try:
            shutil.rmtree(tdir, ignore_errors=True)
        except:
            pass

    def metadata_downloaded(self, job):
        if job.failed:
            self.gui.job_exception(job, dialog_title=_('Failed to download metadata'))
            return
        from calibre.gui2.metadata.bulk_download import get_job_details
        (aborted, id_map, tdir, log_file, failed_ids, failed_covers, all_failed,
                det_msg, lm_map) = get_job_details(job)
        if aborted:
            return self.cleanup_bulk_download(tdir)
        if all_failed:
            num = len(failed_ids | failed_covers)
            self.cleanup_bulk_download(tdir)
            return error_dialog(self.gui, _('Download failed'), ngettext(
                'Failed to download metadata or cover for the selected book.',
                'Failed to download metadata or covers for any of the {} books.', num
            ).format(num), det_msg=det_msg, show=True)

        self.gui.status_bar.show_message(_('Metadata download completed'), 3000)

        msg = '<p>' + ngettext(
            'Finished downloading metadata for the selected book.',
            'Finished downloading metadata for <b>{} books</b>.', len(id_map)).format(len(id_map)) + ' ' + \
            _('Proceed with updating the metadata in your library?')

        show_copy_button = False
        checkbox_msg = None
        if failed_ids or failed_covers:
            show_copy_button = True
            num = len(failed_ids.union(failed_covers))
            msg += '<p>'+_('Could not download metadata and/or covers for %d of the books. Click'
                    ' "Show details" to see which books.')%num
            checkbox_msg = _('Show the &failed books in the main book list '
                    'after updating metadata')

        if getattr(job, 'metadata_and_covers', None) == (False, True):
            # Only covers, remove failed cover downloads from id_map
            for book_id in failed_covers:
                if hasattr(id_map, 'discard'):
                    id_map.discard(book_id)
        payload = (id_map, tdir, log_file, lm_map,
                failed_ids.union(failed_covers))
        review_apply = partial(self.apply_downloaded_metadata, True)
        normal_apply = partial(self.apply_downloaded_metadata, False)
        self.gui.proceed_question(
            normal_apply, payload, log_file, _('Download log'),
            _('Metadata download complete'), msg, icon='download-metadata.png',
            det_msg=det_msg, show_copy_button=show_copy_button,
            cancel_callback=partial(self.cleanup_bulk_download, tdir),
            log_is_file=True, checkbox_msg=checkbox_msg,
            checkbox_checked=False, action_callback=review_apply,
            action_label=_('Revie&w downloaded metadata'),
            action_icon=QIcon(I('auto_author_sort.png')))

    def apply_downloaded_metadata(self, review, payload, *args):
        good_ids, tdir, log_file, lm_map, failed_ids = payload
        if not good_ids:
            return
        restrict_to_failed = False

        modified = set()
        db = self.gui.current_db

        for i in good_ids:
            lm = db.metadata_last_modified(i, index_is_id=True)
            if lm is not None and lm_map[i] is not None and lm > lm_map[i]:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace('|', ',') for x in authors.split(',')]
                    title += ' - ' + authors_to_string(authors)
                modified.add(title)

        if modified:
            from calibre.utils.icu import lower

            modified = sorted(modified, key=lower)
            if not question_dialog(self.gui, _('Some books changed'), '<p>' + _(
                'The metadata for some books in your library has'
                ' changed since you started the download. If you'
                ' proceed, some of those changes may be overwritten. '
                'Click "Show details" to see the list of changed books. '
                'Do you want to proceed?'), det_msg='\n'.join(modified)):
                return

        id_map = {}
        for bid in good_ids:
            opf = os.path.join(tdir, '%d.mi'%bid)
            if not os.path.exists(opf):
                opf = None
            cov = os.path.join(tdir, '%d.cover'%bid)
            if not os.path.exists(cov):
                cov = None
            id_map[bid] = (opf, cov)

        if review:
            def get_metadata(book_id):
                oldmi = db.get_metadata(book_id, index_is_id=True, get_cover=True, cover_as_data=True)
                opf, cov = id_map[book_id]
                if opf is None:
                    newmi = Metadata(oldmi.title, authors=tuple(oldmi.authors))
                else:
                    with open(opf, 'rb') as f:
                        newmi = OPF(f, basedir=os.path.dirname(opf), populate_spine=False).to_book_metadata()
                        newmi.cover, newmi.cover_data = None, (None, None)
                        for x in ('title', 'authors'):
                            if newmi.is_null(x):
                                # Title and author are set to null if they are
                                # the same as the originals as an optimization,
                                # we undo that, as it is confusing.
                                newmi.set(x, copy.copy(oldmi.get(x)))
                if cov:
                    with open(cov, 'rb') as f:
                        newmi.cover_data = ('jpg', f.read())
                return oldmi, newmi
            from calibre.gui2.metadata.diff import CompareMany
            d = CompareMany(
                set(id_map), get_metadata, db.field_metadata, parent=self.gui,
                window_title=_('Review downloaded metadata'),
                reject_button_tooltip=_('Discard downloaded metadata for this book'),
                accept_all_tooltip=_('Use the downloaded metadata for all remaining books'),
                reject_all_tooltip=_('Discard downloaded metadata for all remaining books'),
                revert_tooltip=_('Discard the downloaded value for: %s'),
                intro_msg=_('The downloaded metadata is on the left and the original metadata'
                            ' is on the right. If a downloaded value is blank or unknown,'
                            ' the original value is used.'),
                action_button=(_('&View Book'), I('view.png'), self.gui.iactions['View'].view_historical),
                db=db
            )
            if d.exec_() == d.Accepted:
                if d.mark_rejected:
                    failed_ids |= d.rejected_ids
                    restrict_to_failed = True
                nid_map = {}
                for book_id, (changed, mi) in d.accepted.iteritems():
                    if mi is None:  # discarded
                        continue
                    if changed:
                        opf, cov = id_map[book_id]
                        cfile = mi.cover
                        mi.cover, mi.cover_data = None, (None, None)
                        if opf is not None:
                            with open(opf, 'wb') as f:
                                f.write(metadata_to_opf(mi))
                        if cfile and cov:
                            shutil.copyfile(cfile, cov)
                            os.remove(cfile)
                    nid_map[book_id] = id_map[book_id]
                id_map = nid_map
            else:
                id_map = {}

        restrict_to_failed = restrict_to_failed or bool(args and args[0])
        restrict_to_failed = restrict_to_failed and bool(failed_ids)
        if restrict_to_failed:
            db.data.set_marked_ids(failed_ids)

        self.apply_metadata_changes(
            id_map, merge_comments=msprefs['append_comments'], icon='download-metadata.png',
            callback=partial(self.downloaded_metadata_applied, tdir, restrict_to_failed))

    def downloaded_metadata_applied(self, tdir, restrict_to_failed, *args):
        if restrict_to_failed:
            self.gui.search.set_search_string('marked:true')
        self.cleanup_bulk_download(tdir)

    # }}}

    def edit_metadata(self, checked, bulk=None):
        '''
        Edit metadata of selected books in library.
        '''
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _('Cannot edit metadata'),
                             _('No books selected'))
            d.exec_()
            return
        row_list = [r.row() for r in rows]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        self.edit_metadata_for(row_list, ids, bulk=bulk)

    def edit_metadata_for(self, rows, book_ids, bulk=None):
        previous = self.gui.library_view.currentIndex()
        if bulk or (bulk is None and len(rows) > 1):
            return self.do_edit_bulk_metadata(rows, book_ids)

        current_row = 0
        row_list = rows
        editing_multiple = len(row_list) > 1

        if not editing_multiple:
            cr = row_list[0]
            row_list = \
                list(range(self.gui.library_view.model().rowCount(QModelIndex())))
            current_row = row_list.index(cr)

        view = self.gui.library_view.alternate_views.current_view
        try:
            hpos = view.horizontalScrollBar().value()
        except Exception:
            hpos = 0

        changed, rows_to_refresh = self.do_edit_metadata(row_list, current_row, editing_multiple)

        m = self.gui.library_view.model()

        if rows_to_refresh:
            m.refresh_rows(rows_to_refresh)

        if changed:
            self.refresh_books_after_metadata_edit(changed, previous)
        if self.gui.library_view.alternate_views.current_view is view:
            if hasattr(view, 'restore_hpos'):
                view.restore_hpos(hpos)
            else:
                view.horizontalScrollBar().setValue(hpos)

    def refresh_books_after_metadata_edit(self, book_ids, previous=None):
        m = self.gui.library_view.model()
        m.refresh_ids(list(book_ids))
        current = self.gui.library_view.currentIndex()
        self.gui.refresh_cover_browser()
        m.current_changed(current, previous or current)
        self.gui.tags_view.recount_with_position_based_index()
        qv = get_quickview_action_plugin()
        if qv:
            qv.refresh_quickview(current)

    def do_edit_metadata(self, row_list, current_row, editing_multiple):
        from calibre.gui2.metadata.single import edit_metadata
        db = self.gui.library_view.model().db
        changed, rows_to_refresh = edit_metadata(db, row_list, current_row,
                parent=self.gui, view_slot=self.view_format_callback,
                edit_slot=self.edit_format_callback,
                set_current_callback=self.set_current_callback, editing_multiple=editing_multiple)
        return changed, rows_to_refresh

    def set_current_callback(self, id_):
        db = self.gui.library_view.model().db
        current_row = db.row(id_)
        self.gui.library_view.set_current_row(current_row)
        self.gui.library_view.scroll_to_row(current_row)

    def view_format_callback(self, id_, fmt):
        view = self.gui.iactions['View']
        if id_ is None:
            view._view_file(fmt)
        else:
            db = self.gui.library_view.model().db
            view.view_format(db.row(id_), fmt)

    def edit_format_callback(self, id_, fmt):
        edit = self.gui.iactions['Tweak ePub']
        edit.ebook_edit_format(id_, fmt)

    def edit_bulk_metadata(self, checked):
        '''
        Edit metadata of selected books in library in bulk.
        '''
        rows = [r.row() for r in
                self.gui.library_view.selectionModel().selectedRows()]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _('Cannot edit metadata'),
                    _('No books selected'))
            d.exec_()
            return
        self.do_edit_bulk_metadata(rows, ids)

    def do_edit_bulk_metadata(self, rows, book_ids):
        # Prevent the TagView from updating due to signals from the database
        self.gui.tags_view.blockSignals(True)
        changed = False
        refresh_books = set(book_ids)
        try:
            current_tab = 0
            while True:
                dialog = MetadataBulkDialog(self.gui, rows,
                                self.gui.library_view.model(), current_tab, refresh_books)
                if dialog.changed:
                    changed = True
                if not dialog.do_again:
                    break
                current_tab = dialog.central_widget.currentIndex()
        finally:
            self.gui.tags_view.blockSignals(False)
        if changed:
            refresh_books |= dialog.refresh_books
            m = self.gui.library_view.model()
            if gprefs['refresh_book_list_on_bulk_edit']:
                m.refresh(reset=False)
                m.research()
            else:
                m.refresh_ids(refresh_books)
            self.gui.tags_view.recount()
            self.gui.refresh_cover_browser()
            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.
        '''
        from calibre.gui2.dialogs.confirm_merge import confirm_merge
        if self.gui.current_view() is not self.gui.library_view:
            return
        rows = self.gui.library_view.indices_for_merge()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _('Cannot merge books'),
                                _('No books selected'), show=True)
        if len(rows) < 2:
            return error_dialog(self.gui, _('Cannot merge books'),
                        _('At least two books must be selected for merging'),
                        show=True)
        if not self.confirm_large_merge(len(rows)):
            return

        dest_id, src_ids = self.books_to_merge(rows)
        mi = self.gui.current_db.new_api.get_proxy_metadata(dest_id)
        title = mi.title
        hpos = self.gui.library_view.horizontalScrollBar().value()
        if safe_merge:
            if not 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):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
        elif merge_only_formats:
            if not confirm_merge('<p>'+_(
                'Book formats from the selected books will be merged '
                'into the <b>first selected book</b> (%s). '
                'Metadata in the first selected book will not be changed. '
                'Author, Title and all other metadata will <i>not</i> be merged.<br><br>'
                'After being merged, the second and subsequently '
                'selected books, with any metadata they have will be <b>deleted</b>. <br><br>'
                'All book formats of the first selected book will be kept '
                '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_only_formats', self.gui, mi):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.delete_books_after_merge(src_ids)
        else:
            if not 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 '
                'subsequently selected books will be <b>deleted</b>. <br><br>'
                'All book formats of the first selected book will be kept '
                '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):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
            self.delete_books_after_merge(src_ids)
            # leave the selection highlight on first selected book
            dest_row = rows[0].row()
            for row in rows:
                if row.row() < rows[0].row():
                    dest_row -= 1
            self.gui.library_view.set_current_row(dest_row)
        cr = self.gui.library_view.currentIndex().row()
        self.gui.library_view.model().refresh_ids((dest_id,), cr)
        self.gui.library_view.horizontalScrollBar().setValue(hpos)

    def add_formats(self, dest_id, src_books, replace=False):
        for src_book in src_books:
            if src_book:
                fmt = os.path.splitext(src_book)[-1].replace('.', '').upper()
                with lopen(src_book, 'rb') as f:
                    self.gui.library_view.model().db.add_format(dest_id, fmt, f, index_is_id=True,
                            notify=False, replace=replace)

    def formats_for_ids(self, ids):
        m = self.gui.library_view.model()
        ans = []
        for id_ in ids:
            dbfmts = m.db.formats(id_, index_is_id=True)
            if dbfmts:
                for fmt in dbfmts.split(','):
                    try:
                        path = m.db.format(id_, fmt, index_is_id=True,
                                as_path=True)
                        ans.append(path)
                    except NoSuchFormat:
                        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()
        for i, row in enumerate(rows):
            id_ = m.id(row)
            if i == 0:
                dest_id = id_
            else:
                src_ids.append(id_)
        return [dest_id, src_ids]

    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):
        db = self.gui.library_view.model().db
        dest_mi = db.get_metadata(dest_id, index_is_id=True)
        merged_identifiers = db.get_identifiers(dest_id, index_is_id=True)
        orig_dest_comments = dest_mi.comments
        dest_cover = db.cover(dest_id, index_is_id=True)
        had_orig_cover = bool(dest_cover)

        def is_null_date(x):
            return x is None or is_date_undefined(x)

        for src_id in src_ids:
            src_mi = db.get_metadata(src_id, index_is_id=True)

            if src_mi.comments and orig_dest_comments != src_mi.comments:
                if not dest_mi.comments:
                    dest_mi.comments = src_mi.comments
                else:
                    dest_mi.comments = unicode(dest_mi.comments) + u'\n\n' + unicode(src_mi.comments)
            if src_mi.title and (not dest_mi.title or dest_mi.title == _('Unknown')):
                dest_mi.title = src_mi.title
            if src_mi.title and (not dest_mi.authors or dest_mi.authors[0] == _('Unknown')):
                dest_mi.authors = src_mi.authors
                dest_mi.author_sort = src_mi.author_sort
            if src_mi.tags:
                if not dest_mi.tags:
                    dest_mi.tags = src_mi.tags
                else:
                    dest_mi.tags.extend(src_mi.tags)
            if not dest_cover:
                src_cover = db.cover(src_id, index_is_id=True)
                if src_cover:
                    dest_cover = src_cover
            if not dest_mi.publisher:
                dest_mi.publisher = src_mi.publisher
            if not dest_mi.rating:
                dest_mi.rating = src_mi.rating
            if not dest_mi.series:
                dest_mi.series = src_mi.series
                dest_mi.series_index = src_mi.series_index
            if is_null_date(dest_mi.pubdate) and not is_null_date(src_mi.pubdate):
                dest_mi.pubdate = src_mi.pubdate

            src_identifiers = db.get_identifiers(src_id, index_is_id=True)
            src_identifiers.update(merged_identifiers)
            merged_identifiers = src_identifiers.copy()

        if merged_identifiers:
            dest_mi.set_identifiers(merged_identifiers)
        db.set_metadata(dest_id, dest_mi, ignore_errors=False)

        if not had_orig_cover and dest_cover:
            db.set_cover(dest_id, dest_cover)

        for key in db.field_metadata:  # loop thru all defined fields
            fm = db.field_metadata[key]
            if not fm['is_custom']:
                continue
            dt = fm['datatype']
            colnum = fm['colnum']
            # Get orig_dest_comments before it gets changed
            if dt == 'comments':
                orig_dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True)

            for src_id in src_ids:
                dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True)
                src_value = db.get_custom(src_id, num=colnum, index_is_id=True)
                if (dt == 'comments' and src_value and src_value != orig_dest_value):
                    if not dest_value:
                        db.set_custom(dest_id, src_value, num=colnum)
                    else:
                        dest_value = unicode(dest_value) + u'\n\n' + unicode(src_value)
                        db.set_custom(dest_id, dest_value, num=colnum)
                if (dt in {'bool', 'int', 'float', 'rating', 'datetime'} and dest_value is None):
                    db.set_custom(dest_id, src_value, num=colnum)
                if (dt == 'series' and not dest_value and src_value):
                    src_index = db.get_custom_extra(src_id, num=colnum, index_is_id=True)
                    db.set_custom(dest_id, src_value, num=colnum, extra=src_index)
                if ((dt == 'enumeration' or (dt == 'text' and not fm['is_multiple'])) and not dest_value):
                    db.set_custom(dest_id, src_value, num=colnum)
                if (dt == 'text' and fm['is_multiple'] and src_value):
                    if not dest_value:
                        dest_value = src_value
                    else:
                        dest_value.extend(src_value)
                    db.set_custom(dest_id, dest_value, num=colnum)
    # }}}

    def edit_device_collections(self, view, oncard=None):
        model = view.model()
        result = model.get_collections_with_ids()
        d = DeviceCategoryEditor(self.gui, tag_to_match=None, data=result, key=sort_key)
        d.exec_()
        if d.result() == d.Accepted:
            to_rename = d.to_rename  # dict of new text to old ids
            to_delete = d.to_delete  # list of ids
            for old_id, new_name in to_rename.iteritems():
                model.rename_collection(old_id, new_name=unicode(new_name))
            for item in to_delete:
                model.delete_collection_using_id(item)
            self.gui.upload_collections(model.db, view=view, oncard=oncard)
            view.reset()

    # Apply bulk metadata changes {{{
    def apply_metadata_changes(self, id_map, title=None, msg='', callback=None,
            merge_tags=True, merge_comments=False, icon=None):
        '''
        Apply the metadata changes in id_map to the database synchronously
        id_map must be a mapping of ids to Metadata objects. Set any fields you
        do not want updated in the Metadata object to null. An easy way to do
        that is to create a metadata object as Metadata(_('Unknown')) and then
        only set the fields you want changed on this object.

        callback can be either None or a function accepting a single argument,
        in which case it is called after applying is complete with the list of
        changed ids.

        id_map can also be a mapping of ids to 2-tuple's where each 2-tuple
        contains the absolute paths to an OPF and cover file respectively. If
        either of the paths is None, then the corresponding metadata is not
        updated.
        '''
        if title is None:
            title = _('Applying changed metadata')
        self.apply_id_map = list(id_map.iteritems())
        self.apply_current_idx = 0
        self.apply_failures = []
        self.applied_ids = set()
        self.apply_pd = None
        self.apply_callback = callback
        if len(self.apply_id_map) > 1:
            from calibre.gui2.dialogs.progress import ProgressDialog
            self.apply_pd = ProgressDialog(title, msg, min=0,
                    max=len(self.apply_id_map)-1, parent=self.gui,
                    cancelable=False, icon=icon)
            self.apply_pd.setModal(True)
            self.apply_pd.show()
        self._am_merge_tags = merge_tags
        self._am_merge_comments = merge_comments
        self.do_one_apply()

    def do_one_apply(self):
        if self.apply_current_idx >= len(self.apply_id_map):
            return self.finalize_apply()

        i, mi = self.apply_id_map[self.apply_current_idx]
        if self.gui.current_db.has_id(i):
            if isinstance(mi, tuple):
                opf, cover = mi
                if opf:
                    mi = OPF(open(opf, 'rb'), basedir=os.path.dirname(opf),
                            populate_spine=False).to_book_metadata()
                    self.apply_mi(i, mi)
                if cover:
                    self.gui.current_db.set_cover(i, open(cover, 'rb'),
                            notify=False, commit=False)
                    self.applied_ids.add(i)
            else:
                self.apply_mi(i, mi)

        self.apply_current_idx += 1
        if self.apply_pd is not None:
            self.apply_pd.value += 1
        QTimer.singleShot(5, self.do_one_apply)

    def apply_mi(self, book_id, mi):
        db = self.gui.current_db

        try:
            set_title = not mi.is_null('title')
            set_authors = not mi.is_null('authors')
            idents = db.get_identifiers(book_id, index_is_id=True)
            if mi.identifiers:
                idents.update(mi.identifiers)
            mi.identifiers = idents
            if mi.is_null('series'):
                mi.series_index = None
            if self._am_merge_tags:
                old_tags = db.tags(book_id, index_is_id=True)
                if old_tags:
                    tags = [x.strip() for x in old_tags.split(',')] + (
                            mi.tags if mi.tags else [])
                    mi.tags = list(set(tags))
            if self._am_merge_comments:
                old_comments = db.new_api.field_for('comments', book_id)
                if old_comments and mi.comments and old_comments != mi.comments:
                    mi.comments = merge_comments(old_comments, mi.comments)
            db.set_metadata(book_id, mi, commit=False, set_title=set_title,
                    set_authors=set_authors, notify=False)
            self.applied_ids.add(book_id)
        except:
            import traceback
            self.apply_failures.append((book_id, traceback.format_exc()))

        try:
            if mi.cover:
                os.remove(mi.cover)
        except:
            pass

    def finalize_apply(self):
        db = self.gui.current_db
        db.commit()

        if self.apply_pd is not None:
            self.apply_pd.hide()

        if self.apply_failures:
            msg = []
            for i, tb in self.apply_failures:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace('|', ',') for x in authors.split(',')]
                    title += ' - ' + authors_to_string(authors)
                msg.append(title+'\n\n'+tb+'\n'+('*'*80))

            error_dialog(self.gui, _('Some failures'),
                _('Failed to apply updated metadata for some books'
                    ' in your library. Click "Show Details" to see '
                    'details.'), det_msg='\n\n'.join(msg), show=True)
        changed_books = len(self.applied_ids or ())
        self.refresh_gui(self.applied_ids)

        self.apply_id_map = []
        self.apply_pd = None
        try:
            if callable(self.apply_callback):
                self.apply_callback(list(self.applied_ids))
        finally:
            self.apply_callback = None
        if changed_books:
            QApplication.alert(self.gui, 2000)

    def refresh_gui(self, book_ids, covers_changed=True, tag_browser_changed=True):
        if book_ids:
            cr = self.gui.library_view.currentIndex().row()
            self.gui.library_view.model().refresh_ids(
                list(book_ids), cr)
            if covers_changed:
                self.gui.refresh_cover_browser()
            if tag_browser_changed:
                self.gui.tags_view.recount()

    # }}}

    def remove_metadata_item(self, book_id, field, value):
        db = self.gui.current_db.new_api
        fm = db.field_metadata[field]
        affected_books = set()
        if field == 'identifiers':
            identifiers = db.field_for(field, book_id)
            if identifiers.pop(value, False) is not False:
                affected_books = db.set_field(field, {book_id:identifiers})
        elif fm['is_multiple']:
            item_id = db.get_item_id(field, value)
            if item_id is not None:
                affected_books = db.remove_items(field, (item_id,), {book_id})
        else:
            affected_books = db.set_field(field, {book_id:''})
        if affected_books:
            self.refresh_books_after_metadata_edit(affected_books)

    def set_cover_from_format(self, book_id, fmt):
        from calibre.utils.config import prefs
        from calibre.ebooks.metadata.meta import get_metadata
        fmt = fmt.lower()
        cdata = None
        db = self.gui.current_db.new_api
        if fmt == 'pdf':
            pdfpath = db.format_abspath(book_id, fmt)
            if pdfpath is None:
                return error_dialog(self.gui, _('Format file missing'), _(
                    'Cannot read cover as the %s file is missing from this book') % 'PDF', show=True)
            from calibre.gui2.metadata.pdf_covers import PDFCovers
            d = PDFCovers(pdfpath, parent=self.gui)
            ret = d.exec_()
            if ret == d.Accepted:
                cpath = d.cover_path
                if cpath:
                    with open(cpath, 'rb') as f:
                        cdata = f.read()
            d.cleanup()
            if ret != d.Accepted:
                return
        else:
            stream = BytesIO()
            try:
                db.copy_format_to(book_id, fmt, stream)
            except NoSuchFormat:
                return error_dialog(self.gui, _('Format file missing'), _(
                    'Cannot read cover as the %s file is missing from this book') % fmt.upper(), show=True)
            old = prefs['read_file_metadata']
            if not old:
                prefs['read_file_metadata'] = True
            try:
                stream.seek(0)
                mi = get_metadata(stream, fmt)
            except Exception:
                import traceback
                return error_dialog(self.gui, _('Could not read metadata'),
                            _('Could not read metadata from %s format')%fmt.upper(),
                             det_msg=traceback.format_exc(), show=True)
            finally:
                if old != prefs['read_file_metadata']:
                    prefs['read_file_metadata'] = old
            if mi.cover and os.access(mi.cover, os.R_OK):
                cdata = open(mi.cover).read()
            elif mi.cover_data[1] is not None:
                cdata = mi.cover_data[1]
            if cdata is None:
                return error_dialog(self.gui, _('Could not read cover'),
                            _('Could not read cover from %s format')%fmt.upper(), show=True)
        db.set_cover({book_id:cdata})
        current_idx = self.gui.library_view.currentIndex()
        self.gui.library_view.model().current_changed(current_idx, current_idx)
        self.gui.refresh_cover_browser()
Example #36
0
class importer(QRunnable):
    def __init__(self, db, auth_token, dl_loc):
        super(importer, self).__init__()

        self.db = db
        self.auth_token = auth_token
        self.dl_loc = dl_loc

        self.download_names = []

        self.signals = importerSignals()

        self.pd = ProgressDialog(_('Fetching books...'),
                                 _('Connecting to Humble Bundle...'),
                                 min=0,
                                 max=0,
                                 icon='images/icon.png')

    @pyqtSlot()
    def run(self):
        self.pd.show()

        # Identify any existing books with humblebundle tag
        existing_hb_filenames = self.db.all_field_names('#humble_filenames')
        self.signals.log.emit(
            str(len(existing_hb_filenames)) +
            ' existing books from Humble Bundle identified.')

        # Attempt to authenticate
        hapi = HumbleApi(self.auth_token)
        ConfigData.download_location = self.dl_loc

        if hapi.check_login():
            self.signals.log.emit('Authentication successful...')
        else:
            self.signals.log.emit(
                'Unable to login - check authentication token.')
            self.done()

        # Get orders
        game_keys = hapi.get_gamekeys()
        self.signals.log.emit('%s orders/keys found...' % (len(game_keys)))

        key_downloads = dict()

        # Get relevant downloads
        num_books_found = 0
        num_new_books = 0
        for key in game_keys:
            humble_downloads = []
            order = hapi.get_order(key)

            for subproduct in order.subproducts or []:
                for download in subproduct.downloads or []:
                    # Check platform
                    if download.platform != 'ebook':
                        continue

                    for dl_struct in download.download_structs:
                        num_books_found += 1

                        # Check filename
                        if dl_struct.filename in existing_hb_filenames:
                            continue

                        self.download_names.append(dl_struct.filename)
                        humble_downloads.append(
                            HumbleDownload(download, dl_struct, order,
                                           subproduct, key))
                        num_new_books += 1

            key_downloads[key] = humble_downloads

        self.signals.log.emit(
            '(%s/%s) books found do not already exist in Calibre...' %
            (num_new_books, num_books_found))

        ticker = 0
        for key in key_downloads:
            # Update URL in case of expiry
            HumbleDownload.update_download_list_url(hapi,
                                                    key_downloads.get(key))

            for hd in key_downloads.get(key):
                ticker += 1
                if self.pd.canceled:
                    self.signals.log.emit('Downloads aborted.')
                    self.pd.close()
                    self.pd.deleteLater()
                    return

                self.pd.msg = '(%s/%s) Downloading %s ...' % (
                    ticker, num_new_books, hd.filename)
                hd.download_file()

        self.done()

    def done(self):
        self.signals.done_downloads.emit(self.download_names)
        self.signals.log.emit('Downloads complete.')

        self.pd.close()
        self.pd.deleteLater()
Example #37
0
 def __init__(self, parent, size):
     ProgressDialog.__init__(self, _('Downloading icons...'), _(
         'Downloading icons, please wait...'), max=size, parent=parent, icon='download_metadata.png')
     self.ds.connect(self.bar.setValue, type=Qt.QueuedConnection)
     self.acc.connect(self.accept, type=Qt.QueuedConnection)
     self.rej.connect(self.reject, type=Qt.QueuedConnection)
Example #38
0
 def __init__(self, *args, **kwargs):
     PD.__init__(self, *args, **kwargs)
     self.on_progress_update.connect(self.progressed,
                                     type=Qt.QueuedConnection)
     self.finished_moving.connect(self.accept, type=Qt.QueuedConnection)
Example #39
0
class CopyToLibraryAction(InterfaceAction):

    name = 'Copy To Library'
    action_spec = (_('Copy to library'), 'copy-to-library.png',
            _('Copy selected books to the specified library'), None)
    popup_type = QToolButton.InstantPopup
    dont_add_to = frozenset(['context-menu-device'])
    action_type = 'current'
    action_add_menu = True

    def genesis(self):
        self.menu = self.qaction.menu()

    @property
    def stats(self):
        return self.gui.iactions['Choose Library'].stats

    def library_changed(self, db):
        self.build_menus()

    def initialization_complete(self):
        self.library_changed(self.gui.library_view.model().db)

    def location_selected(self, loc):
        enabled = loc == 'library'
        self.qaction.setEnabled(enabled)

    def build_menus(self):
        self.menu.clear()
        if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
            self.menu.addAction('disabled', self.cannot_do_dialog)
            return
        db = self.gui.library_view.model().db
        locations = list(self.stats.locations(db))
        if len(locations) > 50:
            self.menu.addAction(_('Choose library by path...'), self.choose_library)
            self.menu.addSeparator()
        for name, loc in locations:
            name = name.replace('&', '&&')
            self.menu.addAction(name, partial(self.copy_to_library,
                loc))
            self.menu.addAction(name + ' ' + _('(delete after copy)'),
                    partial(self.copy_to_library, loc, delete_after=True))
            self.menu.addSeparator()

        if len(locations) <= 50:
            self.menu.addAction(_('Choose library by path...'), self.choose_library)
        self.qaction.setVisible(bool(locations))
        if isosx:
            # The cloned action has to have its menu updated
            self.qaction.changed.emit()

    def choose_library(self):
        d = ChooseLibrary(self.gui)
        if d.exec_() == d.Accepted:
            path, delete_after = d.args
            if not path:
                return
            db = self.gui.library_view.model().db
            current = os.path.normcase(os.path.abspath(db.library_path))
            if current == os.path.normcase(os.path.abspath(path)):
                return error_dialog(self.gui, _('Cannot copy'),
                    _('Cannot copy to current library.'), show=True)
            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):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _('Cannot copy'),
                    _('No books selected'), show=True)
        ids = list(map(self.gui.library_view.model().id, rows))
        db = self.gui.library_view.model().db
        if not db.exists_at(loc):
            return error_dialog(self.gui, _('No library'),
                    _('No library found at %s')%loc, show=True)

        # 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. We also do not want the user defined
        # formatter functions because loading them can poison the template cache
        global libraries_with_checked_columns

        from calibre.db.legacy import create_backend
        newdb = create_backend(loc, load_user_formatter_functions=False)

        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
        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, icon='lt.png')

        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, add_duplicates)
        self.worker.start()

        self.pd.exec_()

        donemsg = _('Copied %(num)d books to %(loc)s')
        if delete_after:
            donemsg = _('Moved %(num)d books to %(loc)s')

        if self.worker.error is not None:
            e, tb = self.worker.error
            error_dialog(self.gui, _('Failed'), _('Could not copy books: ') + e,
                    det_msg=tb, show=True)
            return

        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'),
                    _('You cannot use other libraries while using the environment'
                      ' variable CALIBRE_OVERRIDE_DATABASE_PATH.'), show=True)
Example #40
0
    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=True,
                                 icon='lt.png')

        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,
                             add_duplicates)
        self.worker.start()
        self.pd.canceled_signal.connect(self.worker.cancel_processing)

        self.pd.exec_()
        self.pd.canceled_signal.disconnect()

        if self.worker.left_after_cancel:
            msg = _(
                'The copying process was interrupted. {} books were copied.'
            ).format(len(self.worker.processed))
            if delete_after:
                msg += ' ' + _('No books were deleted from this library.')
            msg += ' ' + _(
                'The best way to resume this operation is to re-copy all the books with the option to'
                ' "Check for duplicates when copying to library" in Preferences->Import/export->Adding books turned on.'
            )
            warning_dialog(self.gui, _('Canceled'), msg, show=True)
            return

        if self.worker.error is not None:
            e, tb = self.worker.error
            error_dialog(self.gui,
                         _('Failed'),
                         _('Could not copy books: ') + e,
                         det_msg=tb,
                         show=True)
            return

        if delete_after:
            donemsg = ngettext('Moved the book to {loc}',
                               'Moved {num} books to {loc}',
                               len(self.worker.processed))
        else:
            donemsg = ngettext('Copied the book to {loc}',
                               'Copied {num} books to {loc}',
                               len(self.worker.processed))

        self.gui.status_bar.show_message(
            donemsg.format(num=len(self.worker.processed), 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->Import/export->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)

        if self.worker.failed_books:

            def fmt_err(book_id):
                err, tb = self.worker.failed_books[book_id]
                title = db.title(book_id, index_is_id=True)
                return _('Copying: {0} failed, with error:\n{1}').format(
                    title, tb)

            title, msg = _('Failed to copy some books'), _(
                'Could not copy some books, click "Show Details" for more information.'
            )
            tb = '\n\n'.join(map(fmt_err, self.worker.failed_books))
            tb = ngettext('Failed to copy a book, see below for details',
                          'Failed to copy {} books, see below for details',
                          len(self.worker.failed_books)).format(
                              len(self.worker.failed_books)) + '\n\n' + tb
            if len(ids) == len(self.worker.failed_books):
                title, msg = _('Failed to copy books'), _(
                    'Could not copy any books, click "Show Details" for more information.'
                )
            error_dialog(self.gui, title, msg, det_msg=tb, show=True)
        return self.worker.duplicate_ids
Example #41
0
class Polish(QDialog):  # {{{

    def __init__(self, db, book_id_map, parent=None):
        from calibre.ebooks.oeb.polish.main import HELP
        QDialog.__init__(self, parent)
        self.db, self.book_id_map = weakref.ref(db), book_id_map
        self.setWindowIcon(QIcon(I('polish.png')))
        title = _('Polish book')
        if len(book_id_map) > 1:
            title = _('Polish %d books')%len(book_id_map)
        self.setWindowTitle(title)

        self.help_text = {
            'polish': _('<h3>About Polishing books</h3>%s')%HELP['about'].format(
                _('''<p>If you have both EPUB and ORIGINAL_EPUB in your book,
                  then polishing will run on ORIGINAL_EPUB (the same for other
                  ORIGINAL_* formats).  So if you
                  want Polishing to not run on the ORIGINAL_* format, delete the
                  ORIGINAL_* format before running it.</p>''')
            ),

            'embed':_('<h3>Embed referenced fonts</h3>%s')%HELP['embed'],
            'subset':_('<h3>Subsetting fonts</h3>%s')%HELP['subset'],

            'smarten_punctuation':
            _('<h3>Smarten punctuation</h3>%s')%HELP['smarten_punctuation'],

            'metadata':_('<h3>Updating metadata</h3>'
                         '<p>This will update all metadata <i>except</i> the cover in the'
                         ' ebook files to match the current metadata in the'
                         ' calibre library.</p>'
                         ' <p>Note that most ebook'
                         ' formats are not capable of supporting all the'
                         ' metadata in calibre.</p><p>There is a separate option to'
                         ' update the cover.</p>'),
            'do_cover': _('<p>Update the covers in the ebook files to match the'
                        ' current cover in the calibre library.</p>'
                        '<p>If the ebook file does not have'
                        ' an identifiable cover, a new cover is inserted.</p>'
                        ),
            'jacket':_('<h3>Book Jacket</h3>%s')%HELP['jacket'],
            'remove_jacket':_('<h3>Remove Book Jacket</h3>%s')%HELP['remove_jacket'],
        }

        self.l = l = QGridLayout()
        self.setLayout(l)

        self.la = la = QLabel('<b>'+_('Select actions to perform:'))
        l.addWidget(la, 0, 0, 1, 2)

        count = 0
        self.all_actions = OrderedDict([
            ('embed', _('&Embed all referenced fonts')),
            ('subset', _('&Subset all embedded fonts')),
            ('smarten_punctuation', _('Smarten &punctuation')),
            ('metadata', _('Update &metadata in the book files')),
            ('do_cover', _('Update the &cover in the book files')),
            ('jacket', _('Add metadata as a "book &jacket" page')),
            ('remove_jacket', _('&Remove a previously inserted book jacket')),
        ])
        prefs = gprefs.get('polishing_settings', {})
        for name, text in self.all_actions.iteritems():
            count += 1
            x = QCheckBox(text, self)
            x.setChecked(prefs.get(name, False))
            x.stateChanged.connect(partial(self.option_toggled, name))
            l.addWidget(x, count, 0, 1, 1)
            setattr(self, 'opt_'+name, x)
            la = QLabel(' <a href="#%s">%s</a>'%(name, _('About')))
            setattr(self, 'label_'+name, x)
            la.linkActivated.connect(self.help_link_activated)
            l.addWidget(la, count, 1, 1, 1)

        count += 1
        l.addItem(QSpacerItem(10, 10, vPolicy=QSizePolicy.Expanding), count, 1, 1, 2)

        la = self.help_label = QLabel('')
        self.help_link_activated('#polish')
        la.setWordWrap(True)
        la.setTextFormat(Qt.RichText)
        la.setFrameShape(QFrame.StyledPanel)
        la.setAlignment(Qt.AlignLeft|Qt.AlignTop)
        la.setLineWidth(2)
        la.setStyleSheet('QLabel { margin-left: 75px }')
        l.addWidget(la, 0, 2, count+1, 1)
        l.setColumnStretch(2, 1)

        self.show_reports = sr = QCheckBox(_('Show &report'), self)
        sr.setChecked(gprefs.get('polish_show_reports', True))
        sr.setToolTip(textwrap.fill(_('Show a report of all the actions performed'
                        ' after polishing is completed')))
        l.addWidget(sr, count+1, 0, 1, 1)
        self.bb = bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel)
        bb.accepted.connect(self.accept)
        bb.rejected.connect(self.reject)
        self.save_button = sb = bb.addButton(_('&Save Settings'), bb.ActionRole)
        sb.clicked.connect(self.save_settings)
        self.load_button = lb = bb.addButton(_('&Load Settings'), bb.ActionRole)
        self.load_menu = QMenu(lb)
        lb.setMenu(self.load_menu)
        self.all_button = b = bb.addButton(_('Select &all'), bb.ActionRole)
        b.clicked.connect(partial(self.select_all, True))
        self.none_button = b = bb.addButton(_('Select &none'), bb.ActionRole)
        b.clicked.connect(partial(self.select_all, False))
        l.addWidget(bb, count+1, 1, 1, -1)
        self.setup_load_button()

        self.resize(QSize(950, 600))

    def select_all(self, enable):
        for action in self.all_actions:
            x = getattr(self, 'opt_'+action)
            x.blockSignals(True)
            x.setChecked(enable)
            x.blockSignals(False)

    def save_settings(self):
        if not self.something_selected:
            return error_dialog(self, _('No actions selected'),
                _('You must select at least one action before saving'),
                                show=True)
        name, ok = QInputDialog.getText(self, _('Choose name'),
                _('Choose a name for these settings'))
        if ok:
            name = unicode(name).strip()
            if name:
                settings = {ac:getattr(self, 'opt_'+ac).isChecked() for ac in
                            self.all_actions}
                saved = gprefs.get('polish_settings', {})
                saved[name] = settings
                gprefs.set('polish_settings', saved)
                self.setup_load_button()

    def setup_load_button(self):
        saved = gprefs.get('polish_settings', {})
        m = self.load_menu
        m.clear()
        self.__actions = []
        a = self.__actions.append
        for name in sorted(saved):
            a(m.addAction(name, partial(self.load_settings, name)))
        m.addSeparator()
        a(m.addAction(_('Remove saved settings'), self.clear_settings))
        self.load_button.setEnabled(bool(saved))

    def clear_settings(self):
        gprefs.set('polish_settings', {})
        self.setup_load_button()

    def load_settings(self, name):
        saved = gprefs.get('polish_settings', {}).get(name, {})
        for action in self.all_actions:
            checked = saved.get(action, False)
            x = getattr(self, 'opt_'+action)
            x.blockSignals(True)
            x.setChecked(checked)
            x.blockSignals(False)

    def option_toggled(self, name, state):
        if state == Qt.Checked:
            self.help_label.setText(self.help_text[name])

    def help_link_activated(self, link):
        link = unicode(link)[1:]
        self.help_label.setText(self.help_text[link])

    @property
    def something_selected(self):
        for action in self.all_actions:
            if getattr(self, 'opt_'+action).isChecked():
                return True
        return False

    def accept(self):
        self.actions = ac = {}
        saved_prefs = {}
        gprefs['polish_show_reports'] = bool(self.show_reports.isChecked())
        something = False
        for action in self.all_actions:
            ac[action] = saved_prefs[action] = bool(getattr(self, 'opt_'+action).isChecked())
            if ac[action]:
                something = True
        if ac['jacket'] and not ac['metadata']:
            if not question_dialog(self, _('Must update metadata'),
                _('You have selected the option to add metadata as '
                  'a "book jacket". For this option to work, you '
                  'must also select the option to update metadata in'
                  ' the book files. Do you want to select it?')):
                return
            ac['metadata'] = saved_prefs['metadata'] = True
            self.opt_metadata.setChecked(True)
        if not something:
            return error_dialog(self, _('No actions selected'),
                _('You must select at least one action, or click Cancel.'),
                                show=True)
        gprefs['polishing_settings'] = saved_prefs
        self.queue_files()
        return super(Polish, self).accept()

    def queue_files(self):
        self.tdir = PersistentTemporaryDirectory('_queue_polish')
        self.jobs = []
        if len(self.book_id_map) <= 5:
            for i, (book_id, formats) in enumerate(self.book_id_map.iteritems()):
                self.do_book(i+1, book_id, formats)
        else:
            self.queue = [(i+1, id_) for i, id_ in enumerate(self.book_id_map)]
            self.pd = ProgressDialog(_('Queueing books for polishing'),
                                     max=len(self.queue), parent=self)
            QTimer.singleShot(0, self.do_one)
            self.pd.exec_()

    def do_one(self):
        if not self.queue:
            self.pd.accept()
            return
        if self.pd.canceled:
            self.jobs = []
            self.pd.reject()
            return
        num, book_id = self.queue.pop()
        try:
            self.do_book(num, book_id, self.book_id_map[book_id])
        except:
            self.pd.reject()
            raise
        else:
            self.pd.set_value(num)
            QTimer.singleShot(0, self.do_one)

    def do_book(self, num, book_id, formats):
        base = os.path.join(self.tdir, unicode(book_id))
        os.mkdir(base)
        db = self.db()
        opf = os.path.join(base, 'metadata.opf')
        with open(opf, 'wb') as opf_file:
            mi = create_opf_file(db, book_id, opf_file=opf_file)[0]
        data = {'opf':opf, 'files':[]}
        for action in self.actions:
            data[action] = bool(getattr(self, 'opt_'+action).isChecked())
        cover = os.path.join(base, 'cover.jpg')
        if db.copy_cover_to(book_id, cover, index_is_id=True):
            data['cover'] = cover
        is_orig = {}
        for fmt in formats:
            ext = fmt.replace('ORIGINAL_', '').lower()
            is_orig[ext.upper()] = 'ORIGINAL_' in fmt
            with open(os.path.join(base, '%s.%s'%(book_id, ext)), 'wb') as f:
                db.copy_format_to(book_id, fmt, f, index_is_id=True)
                data['files'].append(f.name)

        desc = ngettext(_('Polish %s')%mi.title,
                        _('Polish book %(nums)s of %(tot)s (%(title)s)')%dict(
                            nums=num, tot=len(self.book_id_map),
                            title=mi.title), len(self.book_id_map))
        if hasattr(self, 'pd'):
            self.pd.set_msg(_('Queueing book %(nums)s of %(tot)s (%(title)s)')%dict(
                            nums=num, tot=len(self.book_id_map), title=mi.title))

        self.jobs.append((desc, data, book_id, base, is_orig))
Example #42
0
class Saver(QObject):  # {{{
    def __init__(self, parent, db, callback, rows, path, opts, spare_server=None):
        QObject.__init__(self, parent)
        self.pd = ProgressDialog(_("Saving..."), parent=parent)
        self.spare_server = spare_server
        self.db = db
        self.opts = opts
        self.pd.setModal(True)
        self.pd.show()
        self.pd.set_min(0)
        self.pd.set_msg(_("Collecting data, please wait..."))
        self._parent = parent
        self.callback = callback
        self.callback_called = False
        self.rq = Queue()
        self.ids = [x for x in map(db.id, [r.row() for r in rows]) if x is not None]
        self.pd_max = len(self.ids)
        self.pd.set_max(0)
        self.pd.value = 0
        self.failures = set([])

        from calibre.ebooks.metadata.worker import SaveWorker

        self.worker = SaveWorker(self.rq, db, self.ids, path, self.opts, spare_server=self.spare_server)
        self.pd.canceled_signal.connect(self.canceled)
        self.continue_updating = True
        single_shot(self.update)

    def canceled(self):
        self.continue_updating = False
        if self.worker is not None:
            self.worker.canceled = True
        self.pd.hide()
        if not self.callback_called:
            self.callback(self.worker.path, self.failures, self.worker.error)
            self.callback_called = True

    def update(self):
        if not self.continue_updating:
            return
        if not self.worker.is_alive():
            # Check that all ids were processed
            while self.ids:
                # Get all queued results since worker is dead
                before = len(self.ids)
                self.get_result()
                if before == len(self.ids):
                    # No results available => worker died unexpectedly
                    for i in list(self.ids):
                        self.failures.add(("id:%d" % i, "Unknown error"))
                        self.ids.remove(i)

        if not self.ids:
            self.continue_updating = False
            self.pd.hide()
            if not self.callback_called:
                try:
                    # Give the worker time to clean up and set worker.error
                    self.worker.join(2)
                except:
                    pass  # The worker was not yet started
                self.callback_called = True
                self.callback(self.worker.path, self.failures, self.worker.error)

        if self.continue_updating:
            self.get_result()
            single_shot(self.update)

    def get_result(self):
        try:
            id, title, ok, tb = self.rq.get_nowait()
        except Empty:
            return
        if self.pd.max != self.pd_max:
            self.pd.max = self.pd_max
        self.pd.value += 1
        self.ids.remove(id)
        if not isinstance(title, unicode):
            title = str(title).decode(preferred_encoding, "replace")
        self.pd.set_msg(_("Saved") + " " + title)

        if not ok:
            self.failures.add((title, tb))
Example #43
0
class Saver(QObject):  # {{{
    def __init__(self,
                 parent,
                 db,
                 callback,
                 rows,
                 path,
                 opts,
                 spare_server=None):
        QObject.__init__(self, parent)
        self.pd = ProgressDialog(_('Saving...'), parent=parent)
        self.spare_server = spare_server
        self.db = db
        self.opts = opts
        self.pd.setModal(True)
        self.pd.show()
        self.pd.set_min(0)
        self.pd.set_msg(_('Collecting data, please wait...'))
        self._parent = parent
        self.callback = callback
        self.callback_called = False
        self.rq = Queue()
        self.ids = [
            x for x in map(db.id, [r.row() for r in rows]) if x is not None
        ]
        self.pd_max = len(self.ids)
        self.pd.set_max(0)
        self.pd.value = 0
        self.failures = set([])

        from calibre.ebooks.metadata.worker import SaveWorker
        self.worker = SaveWorker(self.rq,
                                 db,
                                 self.ids,
                                 path,
                                 self.opts,
                                 spare_server=self.spare_server)
        self.pd.canceled_signal.connect(self.canceled)
        self.continue_updating = True
        single_shot(self.update)

    def canceled(self):
        self.continue_updating = False
        if self.worker is not None:
            self.worker.canceled = True
        self.pd.hide()
        if not self.callback_called:
            self.callback(self.worker.path, self.failures, self.worker.error)
            self.callback_called = True

    def update(self):
        if not self.continue_updating:
            return
        if not self.worker.is_alive():
            # Check that all ids were processed
            while self.ids:
                # Get all queued results since worker is dead
                before = len(self.ids)
                self.get_result()
                if before == len(self.ids):
                    # No results available => worker died unexpectedly
                    for i in list(self.ids):
                        self.failures.add(('id:%d' % i, 'Unknown error'))
                        self.ids.remove(i)

        if not self.ids:
            self.continue_updating = False
            self.pd.hide()
            if not self.callback_called:
                try:
                    # Give the worker time to clean up and set worker.error
                    self.worker.join(2)
                except:
                    pass  # The worker was not yet started
                self.callback_called = True
                self.callback(self.worker.path, self.failures,
                              self.worker.error)

        if self.continue_updating:
            self.get_result()
            single_shot(self.update)

    def get_result(self):
        try:
            id, title, ok, tb = self.rq.get_nowait()
        except Empty:
            return
        if self.pd.max != self.pd_max:
            self.pd.max = self.pd_max
        self.pd.value += 1
        self.ids.remove(id)
        if not isinstance(title, unicode):
            title = str(title).decode(preferred_encoding, 'replace')
        self.pd.set_msg(_('Saved') + ' ' + title)

        if not ok:
            self.failures.add((title, tb))
Example #44
0
    def __init__(self,
                 source,
                 single_book_per_directory=True,
                 db=None,
                 parent=None,
                 callback=None,
                 pool=None,
                 list_of_archives=False):
        if isinstance(source, str):
            source = make_long_path_useable(source)
        else:
            source = list(map(make_long_path_useable, source))
        if not validate_source(source, parent):
            return
        QObject.__init__(self, parent)
        self.author_map_rules = None
        if gprefs.get('author_map_on_add_rules'):
            from calibre.ebooks.metadata.author_mapper import compile_rules as acr
            self.author_map_rules = acr(gprefs['author_map_on_add_rules'])
        self.single_book_per_directory = single_book_per_directory
        self.ignore_opf = False
        self.list_of_archives = list_of_archives
        self.callback = callback
        self.add_formats_to_existing = prefs['add_formats_to_existing']
        self.do_one_signal.connect(self.tick,
                                   type=Qt.ConnectionType.QueuedConnection)
        self.pool = pool
        self.pd = ProgressDialog(_('Adding books...'),
                                 _('Scanning for files...'),
                                 min=0,
                                 max=0,
                                 parent=parent,
                                 icon='add_book.png')
        self.win_id = None
        if parent is not None and hasattr(parent, 'effectiveWinId'):
            self.win_id = parent.effectiveWinId()
            if self.win_id is not None:
                self.win_id = int(self.win_id)
        self.db = getattr(db, 'new_api', None)
        if self.db is not None:
            self.dbref = weakref.ref(db)
        self.source = source
        self.tdir = PersistentTemporaryDirectory('_add_books')
        self.scan_error = None
        self.file_groups = OrderedDict()
        self.abort_scan = False
        self.duplicates = []
        self.report = []
        self.items = []
        self.added_book_ids = set()
        self.merged_formats_added_to = set()
        self.merged_books = set()
        self.added_duplicate_info = set()
        self.pd.show()

        self.scan_thread = Thread(target=self.scan, name='ScanBooks')
        self.scan_thread.daemon = True
        self.scan_thread.start()
        self.do_one = self.monitor_scan
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()
Example #45
0
class AddAction(InterfaceAction):

    name = 'Add Books'
    action_spec = (_('Add books'), 'add_book.png',
            _('Add books to the calibre library/device from files on your computer')
            , _('A'))
    action_type = 'current'
    action_add_menu = True
    action_menu_clone_qaction = _('Add books from a single directory')

    def genesis(self):
        self._add_filesystem_book = self.Dispatcher(self.__add_filesystem_book)
        self.add_menu = self.qaction.menu()
        ma = partial(self.create_menu_action, self.add_menu)
        ma('recursive-single', _('Add books from directories, including '
            'sub-directories (One book per directory, assumes every ebook '
            'file is the same book in a different format)')).triggered.connect(
            self.add_recursive_single)
        ma('recursive-multiple', _('Add books from directories, including '
            'sub directories (Multiple books per directory, assumes every '
            'ebook file is a different book)')).triggered.connect(
                    self.add_recursive_multiple)
        arm = self.add_archive_menu = self.add_menu.addMenu(_('Add multiple books from archive (ZIP/RAR)'))
        self.create_menu_action(arm, 'recursive-single-archive', _(
            'One book per directory in the archive')).triggered.connect(partial(self.add_archive, True))
        self.create_menu_action(arm, 'recursive-multiple-archive', _(
            'Multiple books per directory in the archive')).triggered.connect(partial(self.add_archive, False))
        self.add_menu.addSeparator()
        ma('add-empty', _('Add Empty book. (Book entry with no formats)'),
                shortcut='Shift+Ctrl+E').triggered.connect(self.add_empty)
        ma('add-isbn', _('Add from ISBN')).triggered.connect(self.add_from_isbn)
        self.add_menu.addSeparator()
        ma('add-formats', _('Add files to selected book records'),
                triggered=self.add_formats, shortcut='Shift+A')
        arm = self.add_archive_menu = self.add_menu.addMenu(_('Add an empty file to selected book records'))
        from calibre.ebooks.oeb.polish.create import valid_empty_formats
        for fmt in sorted(valid_empty_formats):
            self.create_menu_action(arm, 'add-empty-' + fmt,
                                    _('Add empty {}').format(fmt.upper())).triggered.connect(
                                         partial(self.add_empty_format, fmt))
        self.add_menu.addSeparator()
        ma('add-config', _('Control the adding of books'),
                triggered=self.add_config)

        self.qaction.triggered.connect(self.add_books)

    def location_selected(self, loc):
        enabled = loc == 'library'
        for action in list(self.add_menu.actions())[1:]:
            action.setEnabled(enabled)

    def add_config(self):
        self.gui.iactions['Preferences'].do_config(
            initial_plugin=('Import/Export', 'Adding'),
            close_after_initial=True)

    def add_formats(self, *args):
        if self.gui.stack.currentIndex() != 0:
            return
        view = self.gui.library_view
        rows = view.selectionModel().selectedRows()
        if not rows:
            return error_dialog(self.gui, _('No books selected'),
                    _('Cannot add files as no books are selected'), show=True)
        ids = [view.model().id(r) for r in rows]

        if len(ids) > 1 and not question_dialog(
                self.gui,
                _('Are you sure?'),
                _('Are you sure you want to add the same'
                  ' files to all %d books? If the format'
                  ' already exists for a book, it will be replaced.')%len(ids)):
            return

        books = choose_files(self.gui, 'add formats dialog dir',
                _('Select book files'), filters=get_filters())
        if not books:
            return

        db = view.model().db
        if len(ids) == 1:
            formats = db.formats(ids[0], index_is_id=True)
            if formats:
                formats = {x.upper() for x in formats.split(',')}
                nformats = {f.rpartition('.')[-1].upper() for f in books}
                override = formats.intersection(nformats)
                if override:
                    title = db.title(ids[0], index_is_id=True)
                    msg = _('The {0} format(s) will be replaced in the book {1}. Are you sure?').format(
                        ', '.join(override), title)
                    if not confirm(msg, 'confirm_format_override_on_add', title=_('Are you sure?'), parent=self.gui):
                        return

        fmt_map = {os.path.splitext(fpath)[1][1:].upper():fpath for fpath in books}

        for id_ in ids:
            for fmt, fpath in fmt_map.iteritems():
                if fmt:
                    db.add_format_with_hooks(id_, fmt, fpath, index_is_id=True,
                        notify=True)
        current_idx = self.gui.library_view.currentIndex()
        if current_idx.isValid():
            view.model().current_changed(current_idx, current_idx)

    def add_empty_format(self, format_):
        if self.gui.stack.currentIndex() != 0:
            return
        view = self.gui.library_view
        rows = view.selectionModel().selectedRows()
        if not rows:
            return error_dialog(self.gui, _('No books selected'),
                    _('Cannot add files as no books are selected'), show=True)

        ids = [view.model().id(r) for r in rows]

        if len(ids) > 1 and not question_dialog(
                self.gui,
                _('Are you sure?'),
                _('Are you sure you want to add the same'
                  ' empty file to all %d books? If the format'
                  ' already exists for a book, it will be replaced.')%len(ids)):
            return

        db = self.gui.library_view.model().db
        if len(ids) == 1:
            formats = db.formats(ids[0], index_is_id=True)
            if formats:
                formats = {x.lower() for x in formats.split(',')}
                if format_ in formats:
                    title = db.title(ids[0], index_is_id=True)
                    msg = _('The {0} format will be replaced in the book {1}. Are you sure?').format(
                        format_, title)
                    if not confirm(msg, 'confirm_format_override_on_add', title=_('Are you sure?'),
                                   parent=self.gui):
                        return

        for id_ in ids:
            from calibre.ebooks.oeb.polish.create import create_book
            pt = PersistentTemporaryFile(suffix='.' + format_)
            pt.close()
            try:
                mi = db.new_api.get_metadata(id_, get_cover=False,
                                    get_user_categories=False, cover_as_data=False)
                create_book(mi, pt.name, fmt=format_)
                db.add_format_with_hooks(id_, format_, pt.name, index_is_id=True, notify=True)
            finally:
                os.remove(pt.name)

        current_idx = self.gui.library_view.currentIndex()
        if current_idx.isValid():
            view.model().current_changed(current_idx, current_idx)

    def add_archive(self, single):
        paths = choose_files(
            self.gui, 'recursive-archive-add', _('Choose archive file'),
            filters=[(_('Archives'), ('zip', 'rar'))], all_files=False, select_only_single_file=False)
        if paths:
            self.do_add_recursive(paths, single, list_of_archives=True)

    def add_recursive(self, single):
        root = choose_dir(self.gui, 'recursive book import root dir dialog',
                          _('Select root folder'))
        if not root:
            return
        lp = os.path.normcase(os.path.abspath(self.gui.current_db.library_path))
        if lp.startswith(os.path.normcase(os.path.abspath(root)) + os.pathsep):
            return error_dialog(self.gui, _('Cannot add'), _(
                'Cannot add books from the folder: %s as it contains the currently opened calibre library') % root, show=True)
        self.do_add_recursive(root, single)

    def do_add_recursive(self, root, single, list_of_archives=False):
        from calibre.gui2.add import Adder
        Adder(root, single_book_per_directory=single, db=self.gui.current_db, list_of_archives=list_of_archives,
              callback=self._files_added, parent=self.gui, pool=self.gui.spare_pool())

    def add_recursive_single(self, *args):
        '''
        Add books from the local filesystem to either the library or the device
        recursively assuming one book per folder.
        '''
        self.add_recursive(True)

    def add_recursive_multiple(self, *args):
        '''
        Add books from the local filesystem to either the library or the device
        recursively assuming multiple books per folder.
        '''
        self.add_recursive(False)

    def add_empty(self, *args):
        '''
        Add an empty book item to the library. This does not import any formats
        from a book file.
        '''
        author = series = title = None
        index = self.gui.library_view.currentIndex()
        if index.isValid():
            raw = index.model().db.authors(index.row())
            if raw:
                authors = [a.strip().replace('|', ',') for a in raw.split(',')]
                if authors:
                    author = authors[0]
            series = index.model().db.series(index.row())
            title = index.model().db.title(index.row())
        dlg = AddEmptyBookDialog(self.gui, self.gui.library_view.model().db,
                                 author, series, dup_title=title)
        if dlg.exec_() == dlg.Accepted:
            temp_files = []
            num = dlg.qty_to_add
            series = dlg.selected_series
            title = dlg.selected_title or _('Unknown')
            db = self.gui.library_view.model().db
            ids = []
            if dlg.duplicate_current_book:
                origmi = db.get_metadata(index.row(), get_cover=True, cover_as_data=True)
            for x in xrange(num):
                if dlg.duplicate_current_book:
                    mi = origmi
                else:
                    mi = MetaInformation(title, dlg.selected_authors)
                    if series:
                        mi.series = series
                        mi.series_index = db.get_next_series_num_for(series)
                fmts = []
                empty_format = gprefs.get('create_empty_format_file', '')
                if empty_format:
                    from calibre.ebooks.oeb.polish.create import create_book
                    pt = PersistentTemporaryFile(suffix='.' + empty_format)
                    pt.close()
                    temp_files.append(pt.name)
                    create_book(mi, pt.name, fmt=empty_format)
                    fmts = [pt.name]
                ids.append(db.import_book(mi, fmts))
            self.gui.library_view.model().books_added(num)
            self.gui.refresh_cover_browser()
            self.gui.tags_view.recount()
            if ids:
                ids.reverse()
                self.gui.library_view.select_rows(ids)
            for path in temp_files:
                os.remove(path)

    def add_isbns(self, books, add_tags=[]):
        self.isbn_books = list(books)
        self.add_by_isbn_ids = set()
        self.isbn_add_tags = add_tags
        QTimer.singleShot(10, self.do_one_isbn_add)
        self.isbn_add_dialog = ProgressDialog(_('Adding'),
                _('Creating book records from ISBNs'), max=len(books),
                cancelable=False, parent=self.gui)
        self.isbn_add_dialog.exec_()

    def do_one_isbn_add(self):
        try:
            db = self.gui.library_view.model().db

            try:
                x = self.isbn_books.pop(0)
            except IndexError:
                self.gui.library_view.model().books_added(self.isbn_add_dialog.value)
                self.isbn_add_dialog.accept()
                self.gui.iactions['Edit Metadata'].download_metadata(
                    ids=self.add_by_isbn_ids, ensure_fields=frozenset(['title',
                        'authors']))
                return

            mi = MetaInformation(None)
            mi.isbn = x['isbn']
            if self.isbn_add_tags:
                mi.tags = list(self.isbn_add_tags)
            fmts = [] if x['path'] is None else [x['path']]
            self.add_by_isbn_ids.add(db.import_book(mi, fmts))
            self.isbn_add_dialog.value += 1
            QTimer.singleShot(10, self.do_one_isbn_add)
        except:
            self.isbn_add_dialog.accept()
            raise

    def files_dropped(self, paths):
        to_device = self.gui.stack.currentIndex() != 0
        self._add_books(paths, to_device)

    def remote_file_dropped_on_book(self, url, fname):
        if self.gui.current_view() is not self.gui.library_view:
            return
        db = self.gui.library_view.model().db
        current_idx = self.gui.library_view.currentIndex()
        if not current_idx.isValid():
            return
        cid = db.id(current_idx.row())
        from calibre.gui2.dnd import DownloadDialog
        d = DownloadDialog(url, fname, self.gui)
        d.start_download()
        if d.err is None:
            self.files_dropped_on_book(None, [d.fpath], cid=cid)

    def files_dropped_on_book(self, event, paths, cid=None, do_confirm=True):
        accept = False
        if self.gui.current_view() is not self.gui.library_view:
            return
        db = self.gui.library_view.model().db
        cover_changed = False
        current_idx = self.gui.library_view.currentIndex()
        if cid is None:
            if not current_idx.isValid():
                return
            cid = db.id(current_idx.row()) if cid is None else cid
        formats = []
        for path in paths:
            ext = os.path.splitext(path)[1].lower()
            if ext:
                ext = ext[1:]
            if ext in IMAGE_EXTENSIONS:
                pmap = QPixmap()
                pmap.load(path)
                if not pmap.isNull():
                    accept = True
                    db.set_cover(cid, pmap)
                    cover_changed = True
            elif ext in BOOK_EXTENSIONS:
                formats.append((ext, path))
                accept = True
        if accept and event is not None:
            event.accept()
        if do_confirm and formats:
            if not confirm(
                _('You have dropped some files onto the book <b>%s</b>. This will'
                  ' add or replace the files for this book. Do you want to proceed?') % db.title(cid, index_is_id=True),
                'confirm_drop_on_book', parent=self.gui):
                formats = []
        for ext, path in formats:
            db.add_format_with_hooks(cid, ext, path, index_is_id=True)
        if current_idx.isValid():
            self.gui.library_view.model().current_changed(current_idx, current_idx)
        if cover_changed:
            self.gui.refresh_cover_browser()

    def __add_filesystem_book(self, paths, allow_device=True):
        if isinstance(paths, basestring):
            paths = [paths]
        books = [path for path in map(os.path.abspath, paths) if os.access(path,
            os.R_OK)]

        if books:
            to_device = allow_device and self.gui.stack.currentIndex() != 0
            self._add_books(books, to_device)
            if to_device:
                self.gui.status_bar.show_message(
                        _('Uploading books to device.'), 2000)

    def add_filesystem_book(self, paths, allow_device=True):
        self._add_filesystem_book(paths, allow_device=allow_device)

    def add_from_isbn(self, *args):
        from calibre.gui2.dialogs.add_from_isbn import AddFromISBN
        d = AddFromISBN(self.gui)
        if d.exec_() == d.Accepted:
            self.add_isbns(d.books, add_tags=d.set_tags)

    def add_books(self, *args):
        '''
        Add books from the local filesystem to either the library or the device.
        '''
        filters = get_filters()
        to_device = self.gui.stack.currentIndex() != 0
        if to_device:
            fmts = self.gui.device_manager.device.settings().format_map
            filters = [(_('Supported books'), fmts)]

        books = choose_files(self.gui, 'add books dialog dir',
                _('Select books'), filters=filters)
        if not books:
            return
        self._add_books(books, to_device)

    def _add_books(self, paths, to_device, on_card=None):
        if on_card is None:
            on_card = 'carda' if self.gui.stack.currentIndex() == 2 else \
                      'cardb' if self.gui.stack.currentIndex() == 3 else None
        if not paths:
            return
        from calibre.gui2.add import Adder
        Adder(paths, db=None if to_device else self.gui.current_db,
              parent=self.gui, callback=partial(self._files_added, on_card=on_card), pool=self.gui.spare_pool())

    def _files_added(self, adder, on_card=None):
        if adder.items:
            paths, infos, names = [], [], []
            for mi, cover_path, format_paths in adder.items:
                mi.cover = cover_path
                paths.append(format_paths[0]), infos.append(mi)
                names.append(ascii_filename(os.path.basename(paths[-1])))
            self.gui.upload_books(paths, names, infos, on_card=on_card)
            self.gui.status_bar.show_message(
                    _('Uploading books to device.'), 2000)
            return

        if adder.number_of_books_added > 0:
            self.gui.library_view.model().books_added(adder.number_of_books_added)
            self.gui.library_view.set_current_row(0)
            self.gui.refresh_cover_browser()
            self.gui.tags_view.recount()

        if adder.merged_books:
            merged = defaultdict(list)
            for title, author in adder.merged_books:
                merged[author].append(title)
            lines = []
            for author in sorted(merged, key=sort_key):
                lines.append(author)
                for title in sorted(merged[author], key=sort_key):
                    lines.append('\t' + title)
                lines.append('')
            info_dialog(self.gui, _('Merged some books'),
                _('The following %d duplicate books were found and incoming '
                    'book formats were processed and merged into your '
                    'Calibre database according to your automerge '
                    'settings:')%len(adder.merged_books),
                    det_msg='\n'.join(lines), show=True)

        if adder.number_of_books_added > 0 or adder.merged_books:
            # The formats of the current book could have changed if
            # automerge is enabled
            current_idx = self.gui.library_view.currentIndex()
            if current_idx.isValid():
                self.gui.library_view.model().current_changed(current_idx,
                        current_idx)

    def _add_from_device_adder(self, adder, on_card=None, model=None):
        self._files_added(adder, on_card=on_card)
        # set the in-library flags, and as a consequence send the library's
        # metadata for this book to the device. This sets the uuid to the
        # correct value. Note that set_books_in_library might sync_booklists
        self.gui.set_books_in_library(booklists=[model.db], reset=True)
        self.gui.refresh_ondevice()

    def add_books_from_device(self, view, paths=None):
        backloading_err = self.gui.device_manager.device.BACKLOADING_ERROR_MESSAGE
        if backloading_err is not None:
            return error_dialog(self.gui, _('Add to library'), backloading_err,
                    show=True)
        if paths is None:
            rows = view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                d = error_dialog(self.gui, _('Add to library'), _('No book selected'))
                d.exec_()
                return
            paths = [p for p in view.model().paths(rows) if p is not None]
        ve = self.gui.device_manager.device.VIRTUAL_BOOK_EXTENSIONS
        def ext(x):
            ans = os.path.splitext(x)[1]
            ans = ans[1:] if len(ans) > 1 else ans
            return ans.lower()
        remove = set([p for p in paths if ext(p) in ve])
        if remove:
            paths = [p for p in paths if p not in remove]
            info_dialog(self.gui,  _('Not Implemented'),
                        _('The following books are virtual and cannot be added'
                          ' to the calibre library:'), '\n'.join(remove),
                        show=True)
            if not paths:
                return
        if not paths or len(paths) == 0:
            d = error_dialog(self.gui, _('Add to library'), _('No book files found'))
            d.exec_()
            return

        self.gui.device_manager.prepare_addable_books(self.Dispatcher(partial(
            self.books_prepared, view)), paths)
        self.bpd = ProgressDialog(_('Downloading books'),
                msg=_('Downloading books from device'), parent=self.gui,
                cancelable=False)
        QTimer.singleShot(1000, self.show_bpd)

    def show_bpd(self):
        if self.bpd is not None:
            self.bpd.show()

    def books_prepared(self, view, job):
        self.bpd.hide()
        self.bpd = None
        if job.exception is not None:
            self.gui.device_job_exception(job)
            return
        paths = job.result
        ok_paths = [x for x in paths if isinstance(x, basestring)]
        failed_paths = [x for x in paths if isinstance(x, tuple)]
        if failed_paths:
            if not ok_paths:
                msg = _('Could not download files from the device')
                typ = error_dialog
            else:
                msg = _('Could not download some files from the device')
                typ = warning_dialog
            det_msg = [x[0]+ '\n    ' + as_unicode(x[1]) for x in failed_paths]
            det_msg = '\n\n'.join(det_msg)
            typ(self.gui, _('Could not download files'), msg, det_msg=det_msg,
                    show=True)

        if ok_paths:
            from calibre.gui2.add import Adder
            callback = partial(self._add_from_device_adder, on_card=None, model=view.model())
            Adder(ok_paths, db=self.gui.current_db, parent=self.gui, callback=callback, pool=self.gui.spare_pool())
Example #46
0
class EditMetadataAction(InterfaceAction):

    name = 'Edit Metadata'
    action_spec = (_('Edit metadata'), 'edit_input.png',
                   _('Change the title/author/cover etc. of books'), _('E'))
    action_type = 'current'
    action_add_menu = True

    accepts_drops = True

    def accept_enter_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def accept_drag_move_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def drop_event(self, event, mime_data):
        mime = 'application/calibre+from_library'
        if mime_data.hasFormat(mime):
            self.dropped_ids = tuple(
                map(int,
                    mime_data.data(mime).data().split()))
            QTimer.singleShot(1, self.do_drop)
            return True
        return False

    def do_drop(self):
        book_ids = self.dropped_ids
        del self.dropped_ids
        if book_ids:
            db = self.gui.library_view.model().db
            rows = [db.row(i) for i in book_ids]
            self.edit_metadata_for(rows, book_ids)

    def genesis(self):
        md = self.qaction.menu()
        cm = partial(self.create_menu_action, md)
        cm('individual',
           _('Edit metadata individually'),
           icon=self.qaction.icon(),
           triggered=partial(self.edit_metadata, False, bulk=False))
        cm('bulk',
           _('Edit metadata in bulk'),
           triggered=partial(self.edit_metadata, False, bulk=True))
        md.addSeparator()
        cm('download',
           _('Download metadata and covers'),
           icon='download-metadata.png',
           triggered=partial(self.download_metadata, ids=None),
           shortcut='Ctrl+D')
        self.metadata_menu = md

        self.metamerge_menu = mb = QMenu()
        cm2 = partial(self.create_menu_action, mb)
        cm2('merge delete',
            _('Merge into first selected book - delete others'),
            triggered=self.merge_books)
        mb.addSeparator()
        cm2('merge keep',
            _('Merge into first selected book - keep others'),
            triggered=partial(self.merge_books, safe_merge=True),
            shortcut='Alt+M')
        mb.addSeparator()
        cm2('merge formats',
            _('Merge only formats into first selected book - delete others'),
            triggered=partial(self.merge_books, merge_only_formats=True),
            shortcut='Alt+Shift+M')
        self.merge_menu = mb
        md.addSeparator()
        self.action_copy = cm('copy',
                              _('Copy metadata'),
                              icon='edit-copy.png',
                              triggered=self.copy_metadata)
        self.action_paste = cm('paste',
                               _('Paste metadata'),
                               icon='edit-paste.png',
                               triggered=self.paste_metadata)
        self.action_paste_ignore_excluded = ac = cm(
            'paste_include_excluded_fields',
            _('Paste metadata including excluded fields'),
            icon='edit-paste.png',
            triggered=self.paste_metadata_including_excluded_fields)
        ac.setVisible(False)
        self.action_merge = cm('merge',
                               _('Merge book records'),
                               icon='merge_books.png',
                               shortcut=_('M'),
                               triggered=self.merge_books)
        self.action_merge.setMenu(mb)

        self.qaction.triggered.connect(self.edit_metadata)
        ac = QAction(_('Copy URL to show book in calibre'), self.gui)
        ac.setToolTip(
            _('Copy URLs to show the currently selected books in calibre, to the system clipboard'
              ))
        ac.triggered.connect(self.copy_show_link)
        self.gui.addAction(ac)
        self.gui.keyboard.register_shortcut(self.unique_name + ' - ' +
                                            'copy_show_book',
                                            ac.text(),
                                            description=ac.toolTip(),
                                            action=ac,
                                            group=self.action_spec[0])
        ac = QAction(_('Copy URL to open book in calibre'), self.gui)
        ac.triggered.connect(self.copy_view_link)
        ac.setToolTip(
            _('Copy URLs to open the currently selected books in calibre, to the system clipboard'
              ))
        self.gui.addAction(ac)
        self.gui.keyboard.register_shortcut(self.unique_name + ' - ' +
                                            'copy_view_book',
                                            ac.text(),
                                            description=ac.toolTip(),
                                            action=ac,
                                            group=self.action_spec[0])

    def _copy_links(self, lines):
        urls = QUrl.fromStringList(lines)
        cb = QApplication.instance().clipboard()
        md = QMimeData()
        md.setText('\n'.join(lines))
        md.setUrls(urls)
        cb.setMimeData(md)

    def copy_show_link(self):
        db = self.gui.current_db
        ids = [
            db.id(row.row())
            for row in self.gui.library_view.selectionModel().selectedRows()
        ]
        db = db.new_api
        library_id = getattr(db, 'server_library_id', None)
        if not library_id or not ids:
            return
        lines = [
            f'calibre://show-book/{library_id}/{book_id}' for book_id in ids
        ]
        self._copy_links(lines)

    def copy_view_link(self):
        from calibre.gui2.actions.view import preferred_format
        db = self.gui.current_db
        ids = [
            db.id(row.row())
            for row in self.gui.library_view.selectionModel().selectedRows()
        ]
        db = db.new_api
        library_id = getattr(db, 'server_library_id', None)
        if not library_id or not ids:
            return
        lines = []
        for book_id in ids:
            formats = db.new_api.formats(book_id, verify_formats=True)
            if formats:
                fmt = preferred_format(formats)
                lines.append(
                    f'calibre://view-book/{library_id}/{book_id}/{fmt}')
        if lines:
            self._copy_links(lines)

    def location_selected(self, loc):
        enabled = loc == 'library'
        self.qaction.setEnabled(enabled)
        self.menuless_qaction.setEnabled(enabled)
        for action in self.metamerge_menu.actions(
        ) + self.metadata_menu.actions():
            action.setEnabled(enabled)

    def copy_metadata(self):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui,
                                _('Cannot copy metadata'),
                                _('No books selected'),
                                show=True)
        if len(rows) > 1:
            return error_dialog(
                self.gui,
                _('Cannot copy metadata'),
                _('Multiple books selected, can only copy from one book at a time.'
                  ),
                show=True)
        db = self.gui.current_db
        book_id = db.id(rows[0].row())
        mi = db.new_api.get_metadata(book_id)
        md = QMimeData()
        md.setText(str(mi))
        md.setData('application/calibre-book-metadata',
                   bytearray(metadata_to_opf(mi, default_lang='und')))
        img = db.new_api.cover(book_id, as_image=True)
        if img:
            md.setImageData(img)
        c = QApplication.clipboard()
        c.setMimeData(md)

    def paste_metadata(self):
        self.do_paste()

    def paste_metadata_including_excluded_fields(self):
        self.do_paste(ignore_excluded_fields=True)

    def do_paste(self, ignore_excluded_fields=False):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui,
                                _('Cannot paste metadata'),
                                _('No books selected'),
                                show=True)
        c = QApplication.clipboard()
        md = c.mimeData()
        if not md.hasFormat('application/calibre-book-metadata'):
            return error_dialog(self.gui,
                                _('Cannot paste metadata'),
                                _('No copied metadata available'),
                                show=True)
        if len(rows) > 1:
            if not confirm(_(
                    'You are pasting metadata onto <b>multiple books</b> ({num_of_books}). Are you'
                    ' sure you want to do that?').format(
                        num_of_books=len(rows)),
                           'paste-onto-multiple',
                           parent=self.gui):
                return
        data = bytes(md.data('application/calibre-book-metadata'))
        mi = OPF(BytesIO(data),
                 populate_spine=False,
                 read_toc=False,
                 try_to_guess_cover=False).to_book_metadata()
        mi.application_id = mi.uuid_id = None
        if ignore_excluded_fields:
            exclude = set()
        else:
            exclude = set(tweaks['exclude_fields_on_paste'])
        paste_cover = 'cover' not in exclude
        cover = md.imageData() if paste_cover else None
        exclude.discard('cover')
        for field in exclude:
            mi.set_null(field)
        db = self.gui.current_db
        book_ids = {db.id(r.row()) for r in rows}
        title_excluded = 'title' in exclude
        authors_excluded = 'authors' in exclude
        for book_id in book_ids:
            if title_excluded:
                mi.title = db.new_api.field_for('title', book_id)
            if authors_excluded:
                mi.authors = db.new_api.field_for('authors', book_id)
            db.new_api.set_metadata(book_id, mi, ignore_errors=True)
        if cover:
            db.new_api.set_cover({book_id: cover for book_id in book_ids})
        self.refresh_books_after_metadata_edit(book_ids)

    # Download metadata {{{
    def download_metadata(self, ids=None, ensure_fields=None):
        if ids is None:
            rows = self.gui.library_view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                return error_dialog(self.gui,
                                    _('Cannot download metadata'),
                                    _('No books selected'),
                                    show=True)
            db = self.gui.library_view.model().db
            ids = [db.id(row.row()) for row in rows]
        from calibre.ebooks.metadata.sources.update import update_sources
        from calibre.gui2.metadata.bulk_download import start_download
        update_sources()
        start_download(self.gui,
                       ids,
                       Dispatcher(self.metadata_downloaded),
                       ensure_fields=ensure_fields)

    def cleanup_bulk_download(self, tdir, *args):
        try:
            shutil.rmtree(tdir, ignore_errors=True)
        except:
            pass

    def metadata_downloaded(self, job):
        if job.failed:
            self.gui.job_exception(
                job, dialog_title=_('Failed to download metadata'))
            return
        from calibre.gui2.metadata.bulk_download import get_job_details
        (aborted, id_map, tdir, log_file, failed_ids, failed_covers,
         all_failed, det_msg, lm_map) = get_job_details(job)
        if aborted:
            return self.cleanup_bulk_download(tdir)
        if all_failed:
            num = len(failed_ids | failed_covers)
            self.cleanup_bulk_download(tdir)
            return error_dialog(
                self.gui,
                _('Download failed'),
                ngettext(
                    'Failed to download metadata or cover for the selected book.',
                    'Failed to download metadata or covers for any of the {} books.',
                    num).format(num),
                det_msg=det_msg,
                show=True)

        self.gui.status_bar.show_message(_('Metadata download completed'),
                                         3000)

        msg = '<p>' + ngettext(
            'Finished downloading metadata for the selected book.',
            'Finished downloading metadata for <b>{} books</b>.', len(id_map)).format(len(id_map)) + ' ' + \
            _('Proceed with updating the metadata in your library?')

        show_copy_button = False
        checkbox_msg = None
        if failed_ids or failed_covers:
            show_copy_button = True
            num = len(failed_ids.union(failed_covers))
            msg += '<p>' + _(
                'Could not download metadata and/or covers for %d of the books. Click'
                ' "Show details" to see which books.') % num
            checkbox_msg = _('Show the &failed books in the main book list '
                             'after updating metadata')

        if getattr(job, 'metadata_and_covers', None) == (False, True):
            # Only covers, remove failed cover downloads from id_map
            for book_id in failed_covers:
                if hasattr(id_map, 'discard'):
                    id_map.discard(book_id)
        payload = (id_map, tdir, log_file, lm_map,
                   failed_ids.union(failed_covers))
        review_apply = partial(self.apply_downloaded_metadata, True)
        normal_apply = partial(self.apply_downloaded_metadata, False)
        self.gui.proceed_question(
            normal_apply,
            payload,
            log_file,
            _('Download log'),
            _('Metadata download complete'),
            msg,
            icon='download-metadata.png',
            det_msg=det_msg,
            show_copy_button=show_copy_button,
            cancel_callback=partial(self.cleanup_bulk_download, tdir),
            log_is_file=True,
            checkbox_msg=checkbox_msg,
            checkbox_checked=False,
            action_callback=review_apply,
            action_label=_('Revie&w downloaded metadata'),
            action_icon=QIcon(I('auto_author_sort.png')))

    def apply_downloaded_metadata(self, review, payload, *args):
        good_ids, tdir, log_file, lm_map, failed_ids = payload
        if not good_ids:
            return
        restrict_to_failed = False

        modified = set()
        db = self.gui.current_db

        for i in good_ids:
            lm = db.metadata_last_modified(i, index_is_id=True)
            if lm is not None and lm_map[i] is not None and lm > lm_map[i]:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace('|', ',') for x in authors.split(',')]
                    title += ' - ' + authors_to_string(authors)
                modified.add(title)

        if modified:
            from calibre.utils.icu import lower

            modified = sorted(modified, key=lower)
            if not question_dialog(
                    self.gui,
                    _('Some books changed'),
                    '<p>' +
                    _('The metadata for some books in your library has'
                      ' changed since you started the download. If you'
                      ' proceed, some of those changes may be overwritten. '
                      'Click "Show details" to see the list of changed books. '
                      'Do you want to proceed?'),
                    det_msg='\n'.join(modified)):
                return

        id_map = {}
        for bid in good_ids:
            opf = os.path.join(tdir, '%d.mi' % bid)
            if not os.path.exists(opf):
                opf = None
            cov = os.path.join(tdir, '%d.cover' % bid)
            if not os.path.exists(cov):
                cov = None
            id_map[bid] = (opf, cov)

        if review:

            def get_metadata(book_id):
                oldmi = db.get_metadata(book_id,
                                        index_is_id=True,
                                        get_cover=True,
                                        cover_as_data=True)
                opf, cov = id_map[book_id]
                if opf is None:
                    newmi = Metadata(oldmi.title, authors=tuple(oldmi.authors))
                else:
                    with open(opf, 'rb') as f:
                        newmi = OPF(f,
                                    basedir=os.path.dirname(opf),
                                    populate_spine=False).to_book_metadata()
                        newmi.cover, newmi.cover_data = None, (None, None)
                        for x in ('title', 'authors'):
                            if newmi.is_null(x):
                                # Title and author are set to null if they are
                                # the same as the originals as an optimization,
                                # we undo that, as it is confusing.
                                newmi.set(x, copy.copy(oldmi.get(x)))
                if cov:
                    with open(cov, 'rb') as f:
                        newmi.cover_data = ('jpg', f.read())
                return oldmi, newmi

            from calibre.gui2.metadata.diff import CompareMany
            d = CompareMany(
                set(id_map),
                get_metadata,
                db.field_metadata,
                parent=self.gui,
                window_title=_('Review downloaded metadata'),
                reject_button_tooltip=_(
                    'Discard downloaded metadata for this book'),
                accept_all_tooltip=_(
                    'Use the downloaded metadata for all remaining books'),
                reject_all_tooltip=_(
                    'Discard downloaded metadata for all remaining books'),
                revert_tooltip=_('Discard the downloaded value for: %s'),
                intro_msg=
                _('The downloaded metadata is on the left and the original metadata'
                  ' is on the right. If a downloaded value is blank or unknown,'
                  ' the original value is used.'),
                action_button=(_('&View book'), I('view.png'),
                               self.gui.iactions['View'].view_historical),
                db=db)
            if d.exec() == QDialog.DialogCode.Accepted:
                if d.mark_rejected:
                    failed_ids |= d.rejected_ids
                    restrict_to_failed = True
                nid_map = {}
                for book_id, (changed, mi) in iteritems(d.accepted):
                    if mi is None:  # discarded
                        continue
                    if changed:
                        opf, cov = id_map[book_id]
                        cfile = mi.cover
                        mi.cover, mi.cover_data = None, (None, None)
                        if opf is not None:
                            with open(opf, 'wb') as f:
                                f.write(metadata_to_opf(mi))
                        if cfile and cov:
                            shutil.copyfile(cfile, cov)
                            os.remove(cfile)
                    nid_map[book_id] = id_map[book_id]
                id_map = nid_map
            else:
                id_map = {}

        restrict_to_failed = restrict_to_failed or bool(args and args[0])
        restrict_to_failed = restrict_to_failed and bool(failed_ids)
        if restrict_to_failed:
            db.data.set_marked_ids(failed_ids)

        self.apply_metadata_changes(id_map,
                                    merge_comments=msprefs['append_comments'],
                                    icon='download-metadata.png',
                                    callback=partial(
                                        self.downloaded_metadata_applied, tdir,
                                        restrict_to_failed))

    def downloaded_metadata_applied(self, tdir, restrict_to_failed, *args):
        if restrict_to_failed:
            self.gui.search.set_search_string('marked:true')
        self.cleanup_bulk_download(tdir)

    # }}}

    def edit_metadata(self, checked, bulk=None):
        '''
        Edit metadata of selected books in library.
        '''
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _('Cannot edit metadata'),
                             _('No books selected'))
            d.exec()
            return
        row_list = [r.row() for r in rows]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        self.edit_metadata_for(row_list, ids, bulk=bulk)

    def edit_metadata_for(self, rows, book_ids, bulk=None):
        previous = self.gui.library_view.currentIndex()
        if bulk or (bulk is None and len(rows) > 1):
            return self.do_edit_bulk_metadata(rows, book_ids)

        current_row = 0
        row_list = rows
        editing_multiple = len(row_list) > 1

        if not editing_multiple:
            cr = row_list[0]
            row_list = \
                list(range(self.gui.library_view.model().rowCount(QModelIndex())))
            current_row = row_list.index(cr)

        view = self.gui.library_view.alternate_views.current_view
        try:
            hpos = view.horizontalScrollBar().value()
        except Exception:
            hpos = 0

        changed, rows_to_refresh = self.do_edit_metadata(
            row_list, current_row, editing_multiple)

        m = self.gui.library_view.model()

        if rows_to_refresh:
            m.refresh_rows(rows_to_refresh)

        if changed:
            self.refresh_books_after_metadata_edit(changed, previous)
        if self.gui.library_view.alternate_views.current_view is view:
            if hasattr(view, 'restore_hpos'):
                view.restore_hpos(hpos)
            else:
                view.horizontalScrollBar().setValue(hpos)

    def refresh_books_after_metadata_edit(self, book_ids, previous=None):
        m = self.gui.library_view.model()
        m.refresh_ids(list(book_ids))
        current = self.gui.library_view.currentIndex()
        self.gui.refresh_cover_browser()
        m.current_changed(current, previous or current)
        self.gui.tags_view.recount_with_position_based_index()
        qv = get_quickview_action_plugin()
        if qv:
            qv.refresh_quickview(current)

    def do_edit_metadata(self, row_list, current_row, editing_multiple):
        from calibre.gui2.metadata.single import edit_metadata
        db = self.gui.library_view.model().db
        changed, rows_to_refresh = edit_metadata(
            db,
            row_list,
            current_row,
            parent=self.gui,
            view_slot=self.view_format_callback,
            edit_slot=self.edit_format_callback,
            set_current_callback=self.set_current_callback,
            editing_multiple=editing_multiple)
        return changed, rows_to_refresh

    def set_current_callback(self, id_):
        db = self.gui.library_view.model().db
        current_row = db.row(id_)
        self.gui.library_view.set_current_row(current_row)
        self.gui.library_view.scroll_to_row(current_row)

    def view_format_callback(self, id_, fmt):
        view = self.gui.iactions['View']
        if id_ is None:
            view._view_file(fmt)
        else:
            db = self.gui.library_view.model().db
            view.view_format(db.row(id_), fmt)

    def edit_format_callback(self, id_, fmt):
        edit = self.gui.iactions['Tweak ePub']
        edit.ebook_edit_format(id_, fmt)

    def edit_bulk_metadata(self, checked):
        '''
        Edit metadata of selected books in library in bulk.
        '''
        rows = [
            r.row()
            for r in self.gui.library_view.selectionModel().selectedRows()
        ]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _('Cannot edit metadata'),
                             _('No books selected'))
            d.exec()
            return
        self.do_edit_bulk_metadata(rows, ids)

    def do_edit_bulk_metadata(self, rows, book_ids):
        # Prevent the TagView from updating due to signals from the database
        self.gui.tags_view.blockSignals(True)
        changed = False
        refresh_books = set(book_ids)
        try:
            current_tab = 0
            while True:
                dialog = MetadataBulkDialog(self.gui, rows,
                                            self.gui.library_view.model(),
                                            current_tab, refresh_books)
                if dialog.changed:
                    changed = True
                if not dialog.do_again:
                    break
                current_tab = dialog.central_widget.currentIndex()
        finally:
            self.gui.tags_view.blockSignals(False)
        if changed:
            refresh_books |= dialog.refresh_books
            m = self.gui.library_view.model()
            if gprefs['refresh_book_list_on_bulk_edit']:
                m.refresh(reset=False)
                m.research()
            else:
                m.refresh_ids(refresh_books)
            self.gui.tags_view.recount()
            self.gui.refresh_cover_browser()
            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 iteritems(merge_map):
            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.
        '''
        from calibre.gui2.dialogs.confirm_merge import confirm_merge
        if self.gui.current_view() is not self.gui.library_view:
            return
        rows = self.gui.library_view.indices_for_merge()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui,
                                _('Cannot merge books'),
                                _('No books selected'),
                                show=True)
        if len(rows) < 2:
            return error_dialog(
                self.gui,
                _('Cannot merge books'),
                _('At least two books must be selected for merging'),
                show=True)
        if not self.confirm_large_merge(len(rows)):
            return

        dest_id, src_ids = self.books_to_merge(rows)
        mi = self.gui.current_db.new_api.get_proxy_metadata(dest_id)
        title = mi.title
        hpos = self.gui.library_view.horizontalScrollBar().value()
        if safe_merge:
            if not 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):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
        elif merge_only_formats:
            if not confirm_merge(
                    '<p>' +
                    _('Book formats from the selected books will be merged '
                      'into the <b>first selected book</b> (%s). '
                      'Metadata in the first selected book will not be changed. '
                      'Author, Title and all other metadata will <i>not</i> be merged.<br><br>'
                      'After being merged, the second and subsequently '
                      'selected books, with any metadata they have will be <b>deleted</b>. <br><br>'
                      'All book formats of the first selected book will be kept '
                      '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_only_formats', self.gui, mi):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.delete_books_after_merge(src_ids)
        else:
            if not 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 '
                      'subsequently selected books will be <b>deleted</b>. <br><br>'
                      'All book formats of the first selected book will be kept '
                      '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):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
            self.delete_books_after_merge(src_ids)
            # leave the selection highlight on first selected book
            dest_row = rows[0].row()
            for row in rows:
                if row.row() < rows[0].row():
                    dest_row -= 1
            self.gui.library_view.set_current_row(dest_row)
        cr = self.gui.library_view.currentIndex().row()
        self.gui.library_view.model().refresh_ids((dest_id, ), cr)
        self.gui.library_view.horizontalScrollBar().setValue(hpos)

    def add_formats(self, dest_id, src_books, replace=False):
        for src_book in src_books:
            if src_book:
                fmt = os.path.splitext(src_book)[-1].replace('.', '').upper()
                with lopen(src_book, 'rb') as f:
                    self.gui.library_view.model().db.add_format(
                        dest_id,
                        fmt,
                        f,
                        index_is_id=True,
                        notify=False,
                        replace=replace)

    def formats_for_ids(self, ids):
        m = self.gui.library_view.model()
        ans = []
        for id_ in ids:
            dbfmts = m.db.formats(id_, index_is_id=True)
            if dbfmts:
                for fmt in dbfmts.split(','):
                    try:
                        path = m.db.format(id_,
                                           fmt,
                                           index_is_id=True,
                                           as_path=True)
                        ans.append(path)
                    except NoSuchFormat:
                        continue
        return ans

    def formats_for_books(self, rows):
        m = self.gui.library_view.model()
        return self.formats_for_ids(list(map(m.id, rows)))

    def books_to_merge(self, rows):
        src_ids = []
        m = self.gui.library_view.model()
        for i, row in enumerate(rows):
            id_ = m.id(row)
            if i == 0:
                dest_id = id_
            else:
                src_ids.append(id_)
        return [dest_id, src_ids]

    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):
        db = self.gui.library_view.model().db
        dest_mi = db.get_metadata(dest_id, index_is_id=True)
        merged_identifiers = db.get_identifiers(dest_id, index_is_id=True)
        orig_dest_comments = dest_mi.comments
        dest_cover = db.cover(dest_id, index_is_id=True)
        had_orig_cover = bool(dest_cover)

        def is_null_date(x):
            return x is None or is_date_undefined(x)

        for src_id in src_ids:
            src_mi = db.get_metadata(src_id, index_is_id=True)

            if src_mi.comments and orig_dest_comments != src_mi.comments:
                if not dest_mi.comments:
                    dest_mi.comments = src_mi.comments
                else:
                    dest_mi.comments = str(dest_mi.comments) + '\n\n' + str(
                        src_mi.comments)
            if src_mi.title and (not dest_mi.title
                                 or dest_mi.title == _('Unknown')):
                dest_mi.title = src_mi.title
            if (src_mi.authors and src_mi.authors[0] != _('Unknown')) and (
                    not dest_mi.authors or dest_mi.authors[0] == _('Unknown')):
                dest_mi.authors = src_mi.authors
                dest_mi.author_sort = src_mi.author_sort
            if src_mi.tags:
                if not dest_mi.tags:
                    dest_mi.tags = src_mi.tags
                else:
                    dest_mi.tags.extend(src_mi.tags)
            if not dest_cover:
                src_cover = db.cover(src_id, index_is_id=True)
                if src_cover:
                    dest_cover = src_cover
            if not dest_mi.publisher:
                dest_mi.publisher = src_mi.publisher
            if not dest_mi.rating:
                dest_mi.rating = src_mi.rating
            if not dest_mi.series:
                dest_mi.series = src_mi.series
                dest_mi.series_index = src_mi.series_index
            if is_null_date(
                    dest_mi.pubdate) and not is_null_date(src_mi.pubdate):
                dest_mi.pubdate = src_mi.pubdate

            src_identifiers = db.get_identifiers(src_id, index_is_id=True)
            src_identifiers.update(merged_identifiers)
            merged_identifiers = src_identifiers.copy()

        if merged_identifiers:
            dest_mi.set_identifiers(merged_identifiers)
        db.set_metadata(dest_id, dest_mi, ignore_errors=False)

        if not had_orig_cover and dest_cover:
            db.set_cover(dest_id, dest_cover)

        for key in db.field_metadata:  # loop thru all defined fields
            fm = db.field_metadata[key]
            if not fm['is_custom']:
                continue
            dt = fm['datatype']
            colnum = fm['colnum']
            # Get orig_dest_comments before it gets changed
            if dt == 'comments':
                orig_dest_value = db.get_custom(dest_id,
                                                num=colnum,
                                                index_is_id=True)

            for src_id in src_ids:
                dest_value = db.get_custom(dest_id,
                                           num=colnum,
                                           index_is_id=True)
                src_value = db.get_custom(src_id, num=colnum, index_is_id=True)
                if (dt == 'comments' and src_value
                        and src_value != orig_dest_value):
                    if not dest_value:
                        db.set_custom(dest_id, src_value, num=colnum)
                    else:
                        dest_value = str(dest_value) + '\n\n' + str(src_value)
                        db.set_custom(dest_id, dest_value, num=colnum)
                if (dt in {'bool', 'int', 'float', 'rating', 'datetime'}
                        and dest_value is None):
                    db.set_custom(dest_id, src_value, num=colnum)
                if (dt == 'series' and not dest_value and src_value):
                    src_index = db.get_custom_extra(src_id,
                                                    num=colnum,
                                                    index_is_id=True)
                    db.set_custom(dest_id,
                                  src_value,
                                  num=colnum,
                                  extra=src_index)
                if ((dt == 'enumeration' or
                     (dt == 'text' and not fm['is_multiple']))
                        and not dest_value):
                    db.set_custom(dest_id, src_value, num=colnum)
                if (dt == 'text' and fm['is_multiple'] and src_value):
                    if not dest_value:
                        dest_value = src_value
                    else:
                        dest_value.extend(src_value)
                    db.set_custom(dest_id, dest_value, num=colnum)

    # }}}

    def edit_device_collections(self, view, oncard=None):
        model = view.model()
        result = model.get_collections_with_ids()
        d = DeviceCategoryEditor(self.gui,
                                 tag_to_match=None,
                                 data=result,
                                 key=sort_key)
        d.exec()
        if d.result() == QDialog.DialogCode.Accepted:
            to_rename = d.to_rename  # dict of new text to old ids
            to_delete = d.to_delete  # list of ids
            for old_id, new_name in iteritems(to_rename):
                model.rename_collection(old_id, new_name=str(new_name))
            for item in to_delete:
                model.delete_collection_using_id(item)
            self.gui.upload_collections(model.db, view=view, oncard=oncard)
            view.reset()

    # Apply bulk metadata changes {{{
    def apply_metadata_changes(self,
                               id_map,
                               title=None,
                               msg='',
                               callback=None,
                               merge_tags=True,
                               merge_comments=False,
                               icon=None):
        '''
        Apply the metadata changes in id_map to the database synchronously
        id_map must be a mapping of ids to Metadata objects. Set any fields you
        do not want updated in the Metadata object to null. An easy way to do
        that is to create a metadata object as Metadata(_('Unknown')) and then
        only set the fields you want changed on this object.

        callback can be either None or a function accepting a single argument,
        in which case it is called after applying is complete with the list of
        changed ids.

        id_map can also be a mapping of ids to 2-tuple's where each 2-tuple
        contains the absolute paths to an OPF and cover file respectively. If
        either of the paths is None, then the corresponding metadata is not
        updated.
        '''
        if title is None:
            title = _('Applying changed metadata')
        self.apply_id_map = list(iteritems(id_map))
        self.apply_current_idx = 0
        self.apply_failures = []
        self.applied_ids = set()
        self.apply_pd = None
        self.apply_callback = callback
        if len(self.apply_id_map) > 1:
            from calibre.gui2.dialogs.progress import ProgressDialog
            self.apply_pd = ProgressDialog(title,
                                           msg,
                                           min=0,
                                           max=len(self.apply_id_map) - 1,
                                           parent=self.gui,
                                           cancelable=False,
                                           icon=icon)
            self.apply_pd.setModal(True)
            self.apply_pd.show()
        self._am_merge_tags = merge_tags
        self._am_merge_comments = merge_comments
        self.do_one_apply()

    def do_one_apply(self):
        if self.apply_current_idx >= len(self.apply_id_map):
            return self.finalize_apply()

        i, mi = self.apply_id_map[self.apply_current_idx]
        if self.gui.current_db.has_id(i):
            if isinstance(mi, tuple):
                opf, cover = mi
                if opf:
                    mi = OPF(open(opf, 'rb'),
                             basedir=os.path.dirname(opf),
                             populate_spine=False).to_book_metadata()
                    self.apply_mi(i, mi)
                if cover:
                    self.gui.current_db.set_cover(i,
                                                  open(cover, 'rb'),
                                                  notify=False,
                                                  commit=False)
                    self.applied_ids.add(i)
            else:
                self.apply_mi(i, mi)

        self.apply_current_idx += 1
        if self.apply_pd is not None:
            self.apply_pd.value += 1
        QTimer.singleShot(5, self.do_one_apply)

    def apply_mi(self, book_id, mi):
        db = self.gui.current_db

        try:
            set_title = not mi.is_null('title')
            set_authors = not mi.is_null('authors')
            idents = db.get_identifiers(book_id, index_is_id=True)
            if mi.identifiers:
                idents.update(mi.identifiers)
            mi.identifiers = idents
            if mi.is_null('series'):
                mi.series_index = None
            if self._am_merge_tags:
                old_tags = db.tags(book_id, index_is_id=True)
                if old_tags:
                    tags = [x.strip() for x in old_tags.split(',')
                            ] + (mi.tags if mi.tags else [])
                    mi.tags = list(set(tags))
            if self._am_merge_comments:
                old_comments = db.new_api.field_for('comments', book_id)
                if old_comments and mi.comments and old_comments != mi.comments:
                    mi.comments = merge_comments(old_comments, mi.comments)
            db.set_metadata(book_id,
                            mi,
                            commit=False,
                            set_title=set_title,
                            set_authors=set_authors,
                            notify=False)
            self.applied_ids.add(book_id)
        except:
            import traceback
            self.apply_failures.append((book_id, traceback.format_exc()))

        try:
            if mi.cover:
                os.remove(mi.cover)
        except:
            pass

    def finalize_apply(self):
        db = self.gui.current_db
        db.commit()

        if self.apply_pd is not None:
            self.apply_pd.hide()

        if self.apply_failures:
            msg = []
            for i, tb in self.apply_failures:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace('|', ',') for x in authors.split(',')]
                    title += ' - ' + authors_to_string(authors)
                msg.append(title + '\n\n' + tb + '\n' + ('*' * 80))

            error_dialog(self.gui,
                         _('Some failures'),
                         _('Failed to apply updated metadata for some books'
                           ' in your library. Click "Show details" to see '
                           'details.'),
                         det_msg='\n\n'.join(msg),
                         show=True)
        changed_books = len(self.applied_ids or ())
        self.refresh_gui(self.applied_ids)

        self.apply_id_map = []
        self.apply_pd = None
        try:
            if callable(self.apply_callback):
                self.apply_callback(list(self.applied_ids))
        finally:
            self.apply_callback = None
        if changed_books:
            QApplication.alert(self.gui, 2000)

    def refresh_gui(self,
                    book_ids,
                    covers_changed=True,
                    tag_browser_changed=True):
        if book_ids:
            cr = self.gui.library_view.currentIndex().row()
            self.gui.library_view.model().refresh_ids(list(book_ids), cr)
            if covers_changed:
                self.gui.refresh_cover_browser()
            if tag_browser_changed:
                self.gui.tags_view.recount()

    # }}}

    def remove_metadata_item(self, book_id, field, value):
        db = self.gui.current_db.new_api
        fm = db.field_metadata[field]
        affected_books = set()
        if field == 'identifiers':
            identifiers = db.field_for(field, book_id)
            if identifiers.pop(value, False) is not False:
                affected_books = db.set_field(field, {book_id: identifiers})
        elif field == 'authors':
            authors = db.field_for(field, book_id)
            new_authors = [x for x in authors if x != value] or [_('Unknown')]
            if new_authors != authors:
                affected_books = db.set_field(field, {book_id: new_authors})
        elif fm['is_multiple']:
            item_id = db.get_item_id(field, value)
            if item_id is not None:
                affected_books = db.remove_items(field, (item_id, ), {book_id})
        else:
            affected_books = db.set_field(field, {book_id: ''})
        if affected_books:
            self.refresh_books_after_metadata_edit(affected_books)

    def set_cover_from_format(self, book_id, fmt):
        from calibre.ebooks.metadata.meta import get_metadata
        from calibre.utils.config import prefs
        fmt = fmt.lower()
        cdata = None
        db = self.gui.current_db.new_api
        if fmt == 'pdf':
            pdfpath = db.format_abspath(book_id, fmt)
            if pdfpath is None:
                return error_dialog(
                    self.gui,
                    _('Format file missing'),
                    _('Cannot read cover as the %s file is missing from this book'
                      ) % 'PDF',
                    show=True)
            from calibre.gui2.metadata.pdf_covers import PDFCovers
            d = PDFCovers(pdfpath, parent=self.gui)
            ret = d.exec()
            if ret == QDialog.DialogCode.Accepted:
                cpath = d.cover_path
                if cpath:
                    with open(cpath, 'rb') as f:
                        cdata = f.read()
            d.cleanup()
            if ret != QDialog.DialogCode.Accepted:
                return
        else:
            stream = BytesIO()
            try:
                db.copy_format_to(book_id, fmt, stream)
            except NoSuchFormat:
                return error_dialog(
                    self.gui,
                    _('Format file missing'),
                    _('Cannot read cover as the %s file is missing from this book'
                      ) % fmt.upper(),
                    show=True)
            old = prefs['read_file_metadata']
            if not old:
                prefs['read_file_metadata'] = True
            try:
                stream.seek(0)
                mi = get_metadata(stream, fmt)
            except Exception:
                import traceback
                return error_dialog(
                    self.gui,
                    _('Could not read metadata'),
                    _('Could not read metadata from %s format') % fmt.upper(),
                    det_msg=traceback.format_exc(),
                    show=True)
            finally:
                if old != prefs['read_file_metadata']:
                    prefs['read_file_metadata'] = old
            if mi.cover and os.access(mi.cover, os.R_OK):
                with open(mi.cover, 'rb') as f:
                    cdata = f.read()
            elif mi.cover_data[1] is not None:
                cdata = mi.cover_data[1]
            if cdata is None:
                return error_dialog(self.gui,
                                    _('Could not read cover'),
                                    _('Could not read cover from %s format') %
                                    fmt.upper(),
                                    show=True)
        db.set_cover({book_id: cdata})
        current_idx = self.gui.library_view.currentIndex()
        self.gui.library_view.model().current_changed(current_idx, current_idx)
        self.gui.refresh_cover_browser()
Example #47
0
class CopyToLibraryAction(InterfaceAction):

    name = 'Copy To Library'
    action_spec = (_('Copy to library'), 'lt.png',
            _('Copy selected books to the specified library'), None)
    popup_type = QToolButton.InstantPopup
    dont_add_to = frozenset(['context-menu-device'])
    action_type = 'current'
    action_add_menu = True

    def genesis(self):
        self.menu = self.qaction.menu()

    @property
    def stats(self):
        return self.gui.iactions['Choose Library'].stats

    def library_changed(self, db):
        self.build_menus()

    def initialization_complete(self):
        self.library_changed(self.gui.library_view.model().db)

    def location_selected(self, loc):
        enabled = loc == 'library'
        self.qaction.setEnabled(enabled)

    def build_menus(self):
        self.menu.clear()
        if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
            self.menu.addAction('disabled', self.cannot_do_dialog)
            return
        db = self.gui.library_view.model().db
        locations = list(self.stats.locations(db))
        for name, loc in locations:
            self.menu.addAction(name, partial(self.copy_to_library,
                loc))
            self.menu.addAction(name + ' ' + _('(delete after copy)'),
                    partial(self.copy_to_library, loc, delete_after=True))
            self.menu.addSeparator()

        self.menu.addAction(_('Choose library by path...'), self.choose_library)
        self.qaction.setVisible(bool(locations))

    def choose_library(self):
        d = ChooseLibrary(self.gui)
        if d.exec_() == d.Accepted:
            path, delete_after = d.args
            if not path:
                return
            db = self.gui.library_view.model().db
            current = os.path.normcase(os.path.abspath(db.library_path))
            if current == os.path.normcase(os.path.abspath(path)):
                return error_dialog(self.gui, _('Cannot copy'),
                    _('Cannot copy to current library.'), show=True)
            self.copy_to_library(path, delete_after)

    def copy_to_library(self, loc, delete_after=False):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _('Cannot copy'),
                    _('No books selected'), show=True)
        ids = list(map(self.gui.library_view.model().id, rows))
        db = self.gui.library_view.model().db
        if not db.exists_at(loc):
            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)

        self.worker = Worker(ids, db, loc, Dispatcher(progress),
                             Dispatcher(self.pd.accept), delete_after)
        self.worker.start()

        self.pd.exec_()

        donemsg = _('Copied %(num)d books to %(loc)s')
        if delete_after:
            donemsg = _('Moved %(num)d books to %(loc)s')

        if self.worker.error is not None:
            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()

                v.model().delete_books_by_id(self.worker.processed,
                        permanent=True)
                self.gui.iactions['Remove Books'].library_ids_deleted(
                        self.worker.processed, row)

    def cannot_do_dialog(self):
        warning_dialog(self.gui, _('Not allowed'),
                    _('You cannot use other libraries while using the environment'
                      ' variable CALIBRE_OVERRIDE_DATABASE_PATH.'), show=True)
Example #48
0
class EditMetadataAction(InterfaceAction):

    name = 'Edit Metadata'
    action_spec = (_('Edit metadata'), 'edit_input.png', _('Change the title/author/cover etc. of books'), _('E'))
    action_type = 'current'
    action_add_menu = True

    accepts_drops = True

    def accept_enter_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def accept_drag_move_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def drop_event(self, event, mime_data):
        mime = 'application/calibre+from_library'
        if mime_data.hasFormat(mime):
            self.dropped_ids = tuple(map(int, str(mime_data.data(mime)).split()))
            QTimer.singleShot(1, self.do_drop)
            return True
        return False

    def do_drop(self):
        book_ids = self.dropped_ids
        del self.dropped_ids
        if book_ids:
            db = self.gui.library_view.model().db
            rows = [db.row(i) for i in book_ids]
            self.edit_metadata_for(rows, book_ids)

    def genesis(self):
        md = self.qaction.menu()
        cm = partial(self.create_menu_action, md)
        cm('individual', _('Edit metadata individually'), icon=self.qaction.icon(),
                triggered=partial(self.edit_metadata, False, bulk=False))
        md.addSeparator()
        cm('bulk', _('Edit metadata in bulk'),
                triggered=partial(self.edit_metadata, False, bulk=True))
        md.addSeparator()
        cm('download', _('Download metadata and covers'),
                triggered=partial(self.download_metadata, ids=None),
                shortcut='Ctrl+D')
        self.metadata_menu = md

        mb = QMenu()
        cm2 = partial(self.create_menu_action, mb)
        cm2('merge delete', _('Merge into first selected book - delete others'),
                triggered=self.merge_books)
        mb.addSeparator()
        cm2('merge keep', _('Merge into first selected book - keep others'),
                triggered=partial(self.merge_books, safe_merge=True),
                shortcut='Alt+M')
        mb.addSeparator()
        cm2('merge formats', _('Merge only formats into first selected book - delete others'),
                triggered=partial(self.merge_books, merge_only_formats=True),
                shortcut='Alt+Shift+M')
        self.merge_menu = mb
        md.addSeparator()
        self.action_merge = cm('merge', _('Merge book records'), icon='merge_books.png',
            shortcut=_('M'), triggered=self.merge_books)
        self.action_merge.setMenu(mb)

        self.qaction.triggered.connect(self.edit_metadata)

    def location_selected(self, loc):
        enabled = loc == 'library'
        self.qaction.setEnabled(enabled)
        self.action_merge.setEnabled(enabled)

    # Download metadata {{{
    def download_metadata(self, ids=None, ensure_fields=None):
        if ids is None:
            rows = self.gui.library_view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                return error_dialog(self.gui, _('Cannot download metadata'),
                            _('No books selected'), show=True)
            db = self.gui.library_view.model().db
            ids = [db.id(row.row()) for row in rows]
        from calibre.gui2.metadata.bulk_download import start_download
        start_download(self.gui, ids,
                Dispatcher(self.metadata_downloaded),
                ensure_fields=ensure_fields)

    def cleanup_bulk_download(self, tdir, *args):
        try:
            shutil.rmtree(tdir, ignore_errors=True)
        except:
            pass

    def metadata_downloaded(self, job):
        if job.failed:
            self.gui.job_exception(job, dialog_title=_('Failed to download metadata'))
            return
        from calibre.gui2.metadata.bulk_download import get_job_details
        (aborted, id_map, tdir, log_file, failed_ids, failed_covers, all_failed,
                det_msg, lm_map) = get_job_details(job)
        if aborted:
            return self.cleanup_bulk_download(tdir)
        if all_failed:
            num = len(failed_ids | failed_covers)
            self.cleanup_bulk_download(tdir)
            return error_dialog(self.gui, _('Download failed'),
            _('Failed to download metadata or covers for any of the %d'
               ' book(s).') % num, det_msg=det_msg, show=True)

        self.gui.status_bar.show_message(_('Metadata download completed'), 3000)

        msg = '<p>' + _('Finished downloading metadata for <b>%d book(s)</b>. '
            'Proceed with updating the metadata in your library?')%len(id_map)

        show_copy_button = False
        checkbox_msg = None
        if failed_ids or failed_covers:
            show_copy_button = True
            num = len(failed_ids.union(failed_covers))
            msg += '<p>'+_('Could not download metadata and/or covers for %d of the books. Click'
                    ' "Show details" to see which books.')%num
            checkbox_msg = _('Show the &failed books in the main book list '
                    'after updating metadata')

        payload = (id_map, tdir, log_file, lm_map,
                failed_ids.union(failed_covers))
        self.gui.proceed_question(self.apply_downloaded_metadata, payload,
                log_file, _('Download log'), _('Download complete'), msg,
                det_msg=det_msg, show_copy_button=show_copy_button,
                cancel_callback=partial(self.cleanup_bulk_download, tdir),
                log_is_file=True, checkbox_msg=checkbox_msg,
                checkbox_checked=False)

    def apply_downloaded_metadata(self, payload, *args):
        good_ids, tdir, log_file, lm_map, failed_ids = payload
        if not good_ids:
            return

        modified = set()
        db = self.gui.current_db

        for i in good_ids:
            lm = db.metadata_last_modified(i, index_is_id=True)
            if lm is not None and lm_map[i] is not None and lm > lm_map[i]:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace('|', ',') for x in authors.split(',')]
                    title += ' - ' + authors_to_string(authors)
                modified.add(title)

        if modified:
            from calibre.utils.icu import lower

            modified = sorted(modified, key=lower)
            if not question_dialog(self.gui, _('Some books changed'), '<p>'+
                    _('The metadata for some books in your library has'
                        ' changed since you started the download. If you'
                        ' proceed, some of those changes may be overwritten. '
                        'Click "Show details" to see the list of changed books. '
                        'Do you want to proceed?'), det_msg='\n'.join(modified)):
                return

        id_map = {}
        for bid in good_ids:
            opf = os.path.join(tdir, '%d.mi'%bid)
            if not os.path.exists(opf):
                opf = None
            cov = os.path.join(tdir, '%d.cover'%bid)
            if not os.path.exists(cov):
                cov = None
            id_map[bid] = (opf, cov)

        restrict_to_failed = bool(args and args[0])
        if restrict_to_failed:
            db.data.set_marked_ids(failed_ids)

        self.apply_metadata_changes(id_map,
                callback=partial(self.downloaded_metadata_applied, tdir,
                    restrict_to_failed))

    def downloaded_metadata_applied(self, tdir, restrict_to_failed, *args):
        if restrict_to_failed:
            self.gui.search.set_search_string('marked:true')
        self.cleanup_bulk_download(tdir)

    # }}}

    def edit_metadata(self, checked, bulk=None):
        '''
        Edit metadata of selected books in library.
        '''
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _('Cannot edit metadata'),
                             _('No books selected'))
            d.exec_()
            return
        row_list = [r.row() for r in rows]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        self.edit_metadata_for(row_list, ids, bulk=bulk)

    def edit_metadata_for(self, rows, book_ids, bulk=None):
        previous = self.gui.library_view.currentIndex()
        if bulk or (bulk is None and len(rows) > 1):
            return self.do_edit_bulk_metadata(rows, book_ids)

        current_row = 0
        row_list = rows

        if len(row_list) == 1:
            cr = row_list[0]
            row_list = \
                list(range(self.gui.library_view.model().rowCount(QModelIndex())))
            current_row = row_list.index(cr)

        changed, rows_to_refresh = self.do_edit_metadata(row_list, current_row)

        m = self.gui.library_view.model()

        if rows_to_refresh:
            m.refresh_rows(rows_to_refresh)

        if changed:
            m.refresh_ids(list(changed))
            current = self.gui.library_view.currentIndex()
            if self.gui.cover_flow:
                self.gui.cover_flow.dataChanged()
            m.current_changed(current, previous)
            self.gui.tags_view.recount()

    def do_edit_metadata(self, row_list, current_row):
        from calibre.gui2.metadata.single import edit_metadata
        db = self.gui.library_view.model().db
        changed, rows_to_refresh = edit_metadata(db, row_list, current_row,
                parent=self.gui, view_slot=self.view_format_callback,
                set_current_callback=self.set_current_callback)
        return changed, rows_to_refresh

    def set_current_callback(self, id_):
        db = self.gui.library_view.model().db
        current_row = db.row(id_)
        self.gui.library_view.set_current_row(current_row)
        self.gui.library_view.scroll_to_row(current_row)

    def view_format_callback(self, id_, fmt):
        view = self.gui.iactions['View']
        if id_ is None:
            view._view_file(fmt)
        else:
            db = self.gui.library_view.model().db
            view.view_format(db.row(id_), fmt)

    def edit_bulk_metadata(self, checked):
        '''
        Edit metadata of selected books in library in bulk.
        '''
        rows = [r.row() for r in
                self.gui.library_view.selectionModel().selectedRows()]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _('Cannot edit metadata'),
                    _('No books selected'))
            d.exec_()
            return
        self.do_edit_bulk_metadata(rows, ids)

    def do_edit_bulk_metadata(self, rows, book_ids):
        # Prevent the TagView from updating due to signals from the database
        self.gui.tags_view.blockSignals(True)
        changed = False
        try:
            current_tab = 0
            while True:
                dialog = MetadataBulkDialog(self.gui, rows,
                                self.gui.library_view.model(), current_tab)
                if dialog.changed:
                    changed = True
                if not dialog.do_again:
                    break
                current_tab = dialog.central_widget.currentIndex()
        finally:
            self.gui.tags_view.blockSignals(False)
        if changed:
            m = self.gui.library_view.model()
            m.refresh(reset=False)
            m.research()
            self.gui.tags_view.recount()
            if self.gui.cover_flow:
                self.gui.cover_flow.dataChanged()
            self.gui.library_view.select_rows(book_ids)

    # Merge books {{{
    def merge_books(self, safe_merge=False, merge_only_formats=False):
        '''
        Merge selected books in library.
        '''
        if self.gui.stack.currentIndex() != 0:
            return
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _('Cannot merge books'),
                                _('No books selected'), show=True)
        if len(rows) < 2:
            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):
                return

        dest_id, src_ids = self.books_to_merge(rows)
        title = self.gui.library_view.model().db.title(dest_id, index_is_id=True)
        if safe_merge:
            if not confirm('<p>'+_(
                'Book formats and metadata from the selected books '
                'will be added to the <b>first selected book</b> (%s). '
                'ISBN will <i>not</i> be merged.<br><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):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
        elif merge_only_formats:
            if not confirm('<p>'+_(
                'Book formats from the selected books will be merged '
                'into the <b>first selected book</b> (%s). '
                'Metadata in the first selected book will not be changed. '
                'Author, Title, ISBN and all other metadata will <i>not</i> be merged.<br><br>'
                'After merger the second and subsequently '
                'selected books, with any metadata they have will be <b>deleted</b>. <br><br>'
                'All book formats of the first selected book will be kept '
                '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_only_formats', self.gui):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.delete_books_after_merge(src_ids)
        else:
            if not confirm('<p>'+_(
                'Book formats and metadata from the selected books will be merged '
                'into the <b>first selected book</b> (%s). '
                'ISBN will <i>not</i> be merged.<br><br>'
                'After merger the second and '
                'subsequently selected books will be <b>deleted</b>. <br><br>'
                'All book formats of the first selected book will be kept '
                '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):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
            self.delete_books_after_merge(src_ids)
            # leave the selection highlight on first selected book
            dest_row = rows[0].row()
            for row in rows:
                if row.row() < rows[0].row():
                    dest_row -= 1
            ci = self.gui.library_view.model().index(dest_row, 0)
            if ci.isValid():
                self.gui.library_view.setCurrentIndex(ci)
                self.gui.library_view.model().current_changed(ci, ci)

    def add_formats(self, dest_id, src_books, replace=False):
        for src_book in src_books:
            if src_book:
                fmt = os.path.splitext(src_book)[-1].replace('.', '').upper()
                with open(src_book, 'rb') as f:
                    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):
        m = self.gui.library_view.model()
        ans = []
        for id_ in map(m.id, rows):
            dbfmts = m.db.formats(id_, index_is_id=True)
            if dbfmts:
                for fmt in dbfmts.split(','):
                    try:
                        path = m.db.format(id_, fmt, index_is_id=True,
                                as_path=True)
                        ans.append(path)
                    except NoSuchFormat:
                        continue
        return ans

    def books_to_merge(self, rows):
        src_ids = []
        m = self.gui.library_view.model()
        for i, row in enumerate(rows):
            id_ = m.id(row)
            if i == 0:
                dest_id = id_
            else:
                src_ids.append(id_)
        return [dest_id, src_ids]

    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):
        db = self.gui.library_view.model().db
        dest_mi = db.get_metadata(dest_id, index_is_id=True)
        orig_dest_comments = dest_mi.comments
        dest_cover = db.cover(dest_id, index_is_id=True)
        had_orig_cover = bool(dest_cover)
        for src_id in src_ids:
            src_mi = db.get_metadata(src_id, index_is_id=True)
            if src_mi.comments and orig_dest_comments != src_mi.comments:
                if not dest_mi.comments:
                    dest_mi.comments = src_mi.comments
                else:
                    dest_mi.comments = unicode(dest_mi.comments) + u'\n\n' + unicode(src_mi.comments)
            if src_mi.title and (not dest_mi.title or
                    dest_mi.title == _('Unknown')):
                dest_mi.title = src_mi.title
            if src_mi.title and (not dest_mi.authors or dest_mi.authors[0] ==
                    _('Unknown')):
                dest_mi.authors = src_mi.authors
                dest_mi.author_sort = src_mi.author_sort
            if src_mi.tags:
                if not dest_mi.tags:
                    dest_mi.tags = src_mi.tags
                else:
                    dest_mi.tags.extend(src_mi.tags)
            if not dest_cover:
                src_cover = db.cover(src_id, index_is_id=True)
                if src_cover:
                    dest_cover = src_cover
            if not dest_mi.publisher:
                dest_mi.publisher = src_mi.publisher
            if not dest_mi.rating:
                dest_mi.rating = src_mi.rating
            if not dest_mi.series:
                dest_mi.series = src_mi.series
                dest_mi.series_index = src_mi.series_index
        db.set_metadata(dest_id, dest_mi, ignore_errors=False)
        if not had_orig_cover and dest_cover:
            db.set_cover(dest_id, dest_cover)

        for key in db.field_metadata:  # loop thru all defined fields
            fm = db.field_metadata[key]
            if not fm['is_custom']:
                continue
            dt = fm['datatype']
            colnum = fm['colnum']
            # Get orig_dest_comments before it gets changed
            if dt == 'comments':
                orig_dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True)

            for src_id in src_ids:
                dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True)
                src_value = db.get_custom(src_id, num=colnum, index_is_id=True)
                if (dt == 'comments' and src_value and src_value != orig_dest_value):
                    if not dest_value:
                        db.set_custom(dest_id, src_value, num=colnum)
                    else:
                        dest_value = unicode(dest_value) + u'\n\n' + unicode(src_value)
                        db.set_custom(dest_id, dest_value, num=colnum)
                if (dt in {'bool', 'int', 'float', 'rating', 'datetime'} and dest_value is None):
                    db.set_custom(dest_id, src_value, num=colnum)
                if (dt == 'series' and not dest_value and src_value):
                    src_index = db.get_custom_extra(src_id, num=colnum, index_is_id=True)
                    db.set_custom(dest_id, src_value, num=colnum, extra=src_index)
                if (dt == 'enumeration' or (dt == 'text' and not fm['is_multiple']) and not dest_value):
                    db.set_custom(dest_id, src_value, num=colnum)
                if (dt == 'text' and fm['is_multiple'] and src_value):
                    if not dest_value:
                        dest_value = src_value
                    else:
                        dest_value.extend(src_value)
                    db.set_custom(dest_id, dest_value, num=colnum)
    # }}}

    def edit_device_collections(self, view, oncard=None):
        model = view.model()
        result = model.get_collections_with_ids()
        d = DeviceCategoryEditor(self.gui, tag_to_match=None, data=result, key=sort_key)
        d.exec_()
        if d.result() == d.Accepted:
            to_rename = d.to_rename  # dict of new text to old ids
            to_delete = d.to_delete  # list of ids
            for old_id, new_name in to_rename.iteritems():
                model.rename_collection(old_id, new_name=unicode(new_name))
            for item in to_delete:
                model.delete_collection_using_id(item)
            self.gui.upload_collections(model.db, view=view, oncard=oncard)
            view.reset()

    # Apply bulk metadata changes {{{
    def apply_metadata_changes(self, id_map, title=None, msg='', callback=None,
            merge_tags=True):
        '''
        Apply the metadata changes in id_map to the database synchronously
        id_map must be a mapping of ids to Metadata objects. Set any fields you
        do not want updated in the Metadata object to null. An easy way to do
        that is to create a metadata object as Metadata(_('Unknown')) and then
        only set the fields you want changed on this object.

        callback can be either None or a function accepting a single argument,
        in which case it is called after applying is complete with the list of
        changed ids.

        id_map can also be a mapping of ids to 2-tuple's where each 2-tuple
        contains the absolute paths to an OPF and cover file respectively. If
        either of the paths is None, then the corresponding metadata is not
        updated.
        '''
        if title is None:
            title = _('Applying changed metadata')
        self.apply_id_map = list(id_map.iteritems())
        self.apply_current_idx = 0
        self.apply_failures = []
        self.applied_ids = set()
        self.apply_pd = None
        self.apply_callback = callback
        if len(self.apply_id_map) > 1:
            from calibre.gui2.dialogs.progress import ProgressDialog
            self.apply_pd = ProgressDialog(title, msg, min=0,
                    max=len(self.apply_id_map)-1, parent=self.gui,
                    cancelable=False)
            self.apply_pd.setModal(True)
            self.apply_pd.show()
        self._am_merge_tags = True
        self.do_one_apply()

    def do_one_apply(self):
        if self.apply_current_idx >= len(self.apply_id_map):
            return self.finalize_apply()

        i, mi = self.apply_id_map[self.apply_current_idx]
        if self.gui.current_db.has_id(i):
            if isinstance(mi, tuple):
                opf, cover = mi
                if opf:
                    mi = OPF(open(opf, 'rb'), basedir=os.path.dirname(opf),
                            populate_spine=False).to_book_metadata()
                    self.apply_mi(i, mi)
                if cover:
                    self.gui.current_db.set_cover(i, open(cover, 'rb'),
                            notify=False, commit=False)
                    self.applied_ids.add(i)
            else:
                self.apply_mi(i, mi)

        self.apply_current_idx += 1
        if self.apply_pd is not None:
            self.apply_pd.value += 1
        QTimer.singleShot(50, self.do_one_apply)

    def apply_mi(self, book_id, mi):
        db = self.gui.current_db

        try:
            set_title = not mi.is_null('title')
            set_authors = not mi.is_null('authors')
            idents = db.get_identifiers(book_id, index_is_id=True)
            if mi.identifiers:
                idents.update(mi.identifiers)
            mi.identifiers = idents
            if mi.is_null('series'):
                mi.series_index = None
            if self._am_merge_tags:
                old_tags = db.tags(book_id, index_is_id=True)
                if old_tags:
                    tags = [x.strip() for x in old_tags.split(',')] + (
                            mi.tags if mi.tags else [])
                    mi.tags = list(set(tags))
            db.set_metadata(book_id, mi, commit=False, set_title=set_title,
                    set_authors=set_authors, notify=False)
            self.applied_ids.add(book_id)
        except:
            import traceback
            self.apply_failures.append((book_id, traceback.format_exc()))

        try:
            if mi.cover:
                os.remove(mi.cover)
        except:
            pass

    def finalize_apply(self):
        db = self.gui.current_db
        db.commit()

        if self.apply_pd is not None:
            self.apply_pd.hide()

        if self.apply_failures:
            msg = []
            for i, tb in self.apply_failures:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace('|', ',') for x in authors.split(',')]
                    title += ' - ' + authors_to_string(authors)
                msg.append(title+'\n\n'+tb+'\n'+('*'*80))

            error_dialog(self.gui, _('Some failures'),
                _('Failed to apply updated metadata for some books'
                    ' in your library. Click "Show Details" to see '
                    'details.'), det_msg='\n\n'.join(msg), show=True)
        if self.applied_ids:
            cr = self.gui.library_view.currentIndex().row()
            self.gui.library_view.model().refresh_ids(
                list(self.applied_ids), cr)
            if self.gui.cover_flow:
                self.gui.cover_flow.dataChanged()
            self.gui.tags_view.recount()

        self.apply_id_map = []
        self.apply_pd = None
        try:
            if callable(self.apply_callback):
                self.apply_callback(list(self.applied_ids))
        finally:
            self.apply_callback = None
Example #49
0
class CopyToLibraryAction(InterfaceAction):

    name = 'Copy To Library'
    action_spec = (_('Copy to library'), 'lt.png',
            _('Copy selected books to the specified library'), None)
    popup_type = QToolButton.InstantPopup
    dont_add_to = frozenset(['context-menu-device'])
    action_type = 'current'
    action_add_menu = True

    def genesis(self):
        self.menu = self.qaction.menu()

    @property
    def stats(self):
        return self.gui.iactions['Choose Library'].stats

    def library_changed(self, db):
        self.build_menus()

    def initialization_complete(self):
        self.library_changed(self.gui.library_view.model().db)

    def location_selected(self, loc):
        enabled = loc == 'library'
        self.qaction.setEnabled(enabled)

    def build_menus(self):
        self.menu.clear()
        if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
            self.menu.addAction('disabled', self.cannot_do_dialog)
            return
        db = self.gui.library_view.model().db
        locations = list(self.stats.locations(db))
        for name, loc in locations:
            self.menu.addAction(name, partial(self.copy_to_library,
                loc))
            self.menu.addAction(name + ' ' + _('(delete after copy)'),
                    partial(self.copy_to_library, loc, delete_after=True))
            self.menu.addSeparator()

        self.menu.addAction(_('Choose library by path...'), self.choose_library)
        self.qaction.setVisible(bool(locations))

    def choose_library(self):
        d = ChooseLibrary(self.gui)
        if d.exec_() == d.Accepted:
            path, delete_after = d.args
            if not path:
                return
            db = self.gui.library_view.model().db
            current = os.path.normcase(os.path.abspath(db.library_path))
            if current == os.path.normcase(os.path.abspath(path)):
                return error_dialog(self.gui, _('Cannot copy'),
                    _('Cannot copy to current library.'), show=True)
            self.copy_to_library(path, delete_after)

    def copy_to_library(self, loc, delete_after=False):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _('Cannot copy'),
                    _('No books selected'), show=True)
        ids = list(map(self.gui.library_view.model().id, rows))
        db = self.gui.library_view.model().db
        if not db.exists_at(loc):
            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)

        self.worker = Worker(ids, db, loc, Dispatcher(progress),
                             Dispatcher(self.pd.accept), delete_after)
        self.worker.start()

        self.pd.exec_()

        donemsg = _('Copied %(num)d books to %(loc)s')
        if delete_after:
            donemsg = _('Moved %(num)d books to %(loc)s')

        if self.worker.error is not None:
            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()

                v.model().delete_books_by_id(self.worker.processed,
                        permanent=True)
                self.gui.iactions['Remove Books'].library_ids_deleted(
                        self.worker.processed, row)

    def cannot_do_dialog(self):
        warning_dialog(self.gui, _('Not allowed'),
                    _('You cannot use other libraries while using the environment'
                      ' variable CALIBRE_OVERRIDE_DATABASE_PATH.'), show=True)
Example #50
0
class Adder(QObject):  # {{{

    ADD_TIMEOUT = 900  # seconds (15 minutes)

    def __init__(self, parent, db, callback, spare_server=None):
        QObject.__init__(self, parent)
        self.pd = ProgressDialog(_("Adding..."), parent=parent)
        self.pd.setMaximumWidth(min(600, int(available_width() * 0.75)))
        self.spare_server = spare_server
        self.db = db
        self.pd.setModal(True)
        self.pd.show()
        self._parent = parent
        self.rfind = self.worker = None
        self.callback = callback
        self.callback_called = False
        self.pd.canceled_signal.connect(self.canceled)

    def add_recursive(self, root, single=True):
        if os.path.exists(root) and os.path.isfile(root) and root.lower().rpartition(".")[-1] in {"zip", "rar"}:
            self.path = tdir = PersistentTemporaryDirectory("_arcv_")
        else:
            self.path = root
            tdir = None
        self.pd.set_msg(_("Searching in all sub-directories..."))
        self.pd.set_min(0)
        self.pd.set_max(0)
        self.pd.value = 0
        self.rfind = RecursiveFind(self, self.db, root, single, tdir=tdir)
        self.rfind.update.connect(self.pd.set_msg, type=Qt.QueuedConnection)
        self.rfind.found.connect(self.add, type=Qt.QueuedConnection)
        self.rfind.start()

    def add(self, books):
        if isinstance(books, basestring):
            error_dialog(
                self.pd, _("Path error"), _("The specified directory could not be processed."), det_msg=books, show=True
            )
            return self.canceled()
        if not books:
            info_dialog(self.pd, _("No books"), _("No books found"), show=True)
            return self.canceled()
        books = [[b] if isinstance(b, basestring) else b for b in books]
        restricted = set()
        for i in xrange(len(books)):
            files = books[i]
            restrictedi = set(f for f in files if not os.access(f, os.R_OK))
            if restrictedi:
                files = [f for f in files if os.access(f, os.R_OK)]
                books[i] = files
            restricted |= restrictedi
        if restrictedi:
            det_msg = u"\n".join(restrictedi)
            warning_dialog(
                self.pd,
                _("No permission"),
                _(
                    "Cannot add some files as you do not have "
                    " permission to access them. Click Show"
                    " Details to see the list of such files."
                ),
                det_msg=det_msg,
                show=True,
            )
        books = list(filter(None, books))
        if not books:
            return self.canceled()
        self.rfind = None
        from calibre.ebooks.metadata.worker import read_metadata

        self.rq = Queue()
        tasks = []
        self.ids = {}
        self.nmap = {}
        self.duplicates = []
        for i, b in enumerate(books):
            tasks.append((i, b))
            self.ids[i] = b
            self.nmap[i] = os.path.basename(b[0])
        self.worker = read_metadata(tasks, self.rq, spare_server=self.spare_server)
        self.pd.set_min(0)
        self.pd.set_max(len(self.ids))
        self.pd.value = 0
        self.db_adder = DBAdder(self, self.db, self.ids, self.nmap)
        self.db_adder.start()
        self.last_added_at = time.time()
        self.entry_count = len(self.ids)
        self.continue_updating = True
        single_shot(self.update)

    def canceled(self):
        self.continue_updating = False
        if self.rfind is not None:
            self.rfind.canceled = True
        if self.worker is not None:
            self.worker.canceled = True
        if hasattr(self, "db_adder"):
            self.db_adder.end()
        self.pd.hide()
        if not self.callback_called:
            self.callback(self.paths, self.names, self.infos)
            self.callback_called = True

    def duplicates_processed(self):
        self.db_adder.end()
        if not self.callback_called:
            self.callback(self.paths, self.names, self.infos)
            self.callback_called = True
        if hasattr(self, "__p_d"):
            self.__p_d.hide()

    def update(self):
        if self.entry_count <= 0:
            self.continue_updating = False
            self.pd.hide()
            self.process_duplicates()
            return

        try:
            id, opf, cover = self.rq.get_nowait()
            self.db_adder.input_queue.put((id, opf, cover))
            self.last_added_at = time.time()
        except Empty:
            pass

        try:
            title = self.db_adder.output_queue.get_nowait()
            self.pd.value += 1
            self.pd.set_msg(_("Added") + " " + title)
            self.last_added_at = time.time()
            self.entry_count -= 1
        except Empty:
            pass

        if (time.time() - self.last_added_at) > self.ADD_TIMEOUT:
            self.continue_updating = False
            self.pd.hide()
            self.db_adder.end()
            if not self.callback_called:
                self.callback([], [], [])
                self.callback_called = True
            error_dialog(
                self._parent,
                _("Adding failed"),
                _(
                    "The add books process seems to have hung."
                    " Try restarting calibre and adding the "
                    "books in smaller increments, until you "
                    "find the problem book."
                ),
                show=True,
            )

        if self.continue_updating:
            single_shot(self.update)

    def process_duplicates(self):
        duplicates = self.db_adder.duplicates
        if not duplicates:
            return self.duplicates_processed()
        self.pd.hide()
        from calibre.gui2.dialogs.duplicates import DuplicatesQuestion

        self.__d_q = d = DuplicatesQuestion(self.db, duplicates, self._parent)
        duplicates = tuple(d.duplicates)
        if duplicates:
            pd = QProgressDialog(_("Adding duplicates..."), "", 0, len(duplicates), self._parent)
            pd.setCancelButton(None)
            pd.setValue(0)
            pd.show()
            self.__p_d = pd
            self.__d_a = DuplicatesAdder(self._parent, self.db, duplicates, self.db_adder)
            self.__d_a.added.connect(pd.setValue)
            self.__d_a.adding_done.connect(self.duplicates_processed)
        else:
            return self.duplicates_processed()

    def cleanup(self):
        if hasattr(self, "pd"):
            self.pd.hide()
        if hasattr(self, "worker") and hasattr(self.worker, "tdir") and self.worker.tdir is not None:
            if os.path.exists(self.worker.tdir):
                try:
                    shutil.rmtree(self.worker.tdir)
                except:
                    pass
        self._parent = None
        self.pd.setParent(None)
        del self.pd
        self.pd = None
        if hasattr(self, "db_adder"):
            self.db_adder.setParent(None)
            del self.db_adder
            self.db_adder = None

    @property
    def number_of_books_added(self):
        return getattr(getattr(self, "db_adder", None), "number_of_books_added", 0)

    @property
    def merged_books(self):
        return getattr(getattr(self, "db_adder", None), "merged_books", set([]))

    @property
    def critical(self):
        return getattr(getattr(self, "db_adder", None), "critical", {})

    @property
    def paths(self):
        return getattr(getattr(self, "db_adder", None), "paths", [])

    @property
    def names(self):
        return getattr(getattr(self, "db_adder", None), "names", [])

    @property
    def infos(self):
        return getattr(getattr(self, "db_adder", None), "infos", [])
Example #51
0
class AddAction(InterfaceAction):

    name = 'Add Books'
    action_spec = (
        _('Add books'), 'add_book.png',
        _('Add books to the calibre library/device from files on your computer'
          ), _('A'))
    action_type = 'current'
    action_add_menu = True
    action_menu_clone_qaction = _('Add books from a single directory')

    def genesis(self):
        self._add_filesystem_book = self.Dispatcher(self.__add_filesystem_book)
        self.add_menu = self.qaction.menu()
        ma = partial(self.create_menu_action, self.add_menu)
        ma(
            'recursive-single',
            _('Add books from directories, including '
              'sub-directories (One book per directory, assumes every ebook '
              'file is the same book in a different format)')
        ).triggered.connect(self.add_recursive_single)
        ma(
            'recursive-multiple',
            _('Add books from directories, including '
              'sub-directories (Multiple books per directory, assumes every '
              'ebook file is a different book)')).triggered.connect(
                  self.add_recursive_multiple)
        arm = self.add_archive_menu = self.add_menu.addMenu(
            _('Add multiple books from archive (ZIP/RAR)'))
        self.create_menu_action(
            arm, 'recursive-single-archive',
            _('One book per directory in the archive')).triggered.connect(
                partial(self.add_archive, True))
        self.create_menu_action(
            arm, 'recursive-multiple-archive',
            _('Multiple books per directory in the archive')
        ).triggered.connect(partial(self.add_archive, False))
        self.add_menu.addSeparator()
        ma('add-empty',
           _('Add Empty book. (Book entry with no formats)'),
           shortcut='Shift+Ctrl+E').triggered.connect(self.add_empty)
        ma('add-isbn',
           _('Add from ISBN')).triggered.connect(self.add_from_isbn)
        self.add_menu.addSeparator()
        ma('add-formats',
           _('Add files to selected book records'),
           triggered=self.add_formats,
           shortcut='Shift+A')
        arm = self.add_archive_menu = self.add_menu.addMenu(
            _('Add an empty file to selected book records'))
        from calibre.ebooks.oeb.polish.create import valid_empty_formats
        for fmt in sorted(valid_empty_formats):
            self.create_menu_action(
                arm, 'add-empty-' + fmt,
                _('Add empty {}').format(fmt.upper())).triggered.connect(
                    partial(self.add_empty_format, fmt))
        self.add_menu.addSeparator()
        ma('add-config',
           _('Control the adding of books'),
           triggered=self.add_config)

        self.qaction.triggered.connect(self.add_books)

    def location_selected(self, loc):
        enabled = loc == 'library'
        for action in list(self.add_menu.actions())[1:]:
            action.setEnabled(enabled)

    def add_config(self):
        self.gui.iactions['Preferences'].do_config(
            initial_plugin=('Import/Export', 'Adding'),
            close_after_initial=True)

    def add_formats(self, *args):
        if self.gui.stack.currentIndex() != 0:
            return
        view = self.gui.library_view
        rows = view.selectionModel().selectedRows()
        if not rows:
            return error_dialog(self.gui,
                                _('No books selected'),
                                _('Cannot add files as no books are selected'),
                                show=True)
        ids = [view.model().id(r) for r in rows]

        if len(ids) > 1 and not question_dialog(
                self.gui, _('Are you sure?'),
                _('Are you sure you want to add the same'
                  ' files to all %d books? If the format'
                  ' already exists for a book, it will be replaced.') %
                len(ids)):
            return

        books = choose_files(self.gui,
                             'add formats dialog dir',
                             _('Select book files'),
                             filters=get_filters())
        if not books:
            return

        db = view.model().db
        if len(ids) == 1:
            formats = db.formats(ids[0], index_is_id=True)
            if formats:
                formats = {x.upper() for x in formats.split(',')}
                nformats = {f.rpartition('.')[-1].upper() for f in books}
                override = formats.intersection(nformats)
                if override:
                    title = db.title(ids[0], index_is_id=True)
                    msg = _(
                        'The {0} format(s) will be replaced in the book {1}. Are you sure?'
                    ).format(', '.join(override), title)
                    if not confirm(msg,
                                   'confirm_format_override_on_add',
                                   title=_('Are you sure?'),
                                   parent=self.gui):
                        return

        fmt_map = {
            os.path.splitext(fpath)[1][1:].upper(): fpath
            for fpath in books
        }

        for id_ in ids:
            for fmt, fpath in fmt_map.iteritems():
                if fmt:
                    db.add_format_with_hooks(id_,
                                             fmt,
                                             fpath,
                                             index_is_id=True,
                                             notify=True)
        current_idx = self.gui.library_view.currentIndex()
        if current_idx.isValid():
            view.model().current_changed(current_idx, current_idx)

    def add_empty_format(self, format_):
        if self.gui.stack.currentIndex() != 0:
            return
        view = self.gui.library_view
        rows = view.selectionModel().selectedRows()
        if not rows:
            return error_dialog(self.gui,
                                _('No books selected'),
                                _('Cannot add files as no books are selected'),
                                show=True)

        ids = [view.model().id(r) for r in rows]

        if len(ids) > 1 and not question_dialog(
                self.gui, _('Are you sure?'),
                _('Are you sure you want to add the same'
                  ' empty file to all %d books? If the format'
                  ' already exists for a book, it will be replaced.') %
                len(ids)):
            return

        db = self.gui.library_view.model().db
        if len(ids) == 1:
            formats = db.formats(ids[0], index_is_id=True)
            if formats:
                formats = {x.lower() for x in formats.split(',')}
                if format_ in formats:
                    title = db.title(ids[0], index_is_id=True)
                    msg = _(
                        'The {0} format will be replaced in the book {1}. Are you sure?'
                    ).format(format_, title)
                    if not confirm(msg,
                                   'confirm_format_override_on_add',
                                   title=_('Are you sure?'),
                                   parent=self.gui):
                        return

        for id_ in ids:
            from calibre.ebooks.oeb.polish.create import create_book
            pt = PersistentTemporaryFile(suffix='.' + format_)
            pt.close()
            try:
                mi = db.new_api.get_metadata(id_,
                                             get_cover=False,
                                             get_user_categories=False,
                                             cover_as_data=False)
                create_book(mi, pt.name, fmt=format_)
                db.add_format_with_hooks(id_,
                                         format_,
                                         pt.name,
                                         index_is_id=True,
                                         notify=True)
            finally:
                os.remove(pt.name)

        current_idx = self.gui.library_view.currentIndex()
        if current_idx.isValid():
            view.model().current_changed(current_idx, current_idx)

    def add_archive(self, single):
        paths = choose_files(self.gui,
                             'recursive-archive-add',
                             _('Choose archive file'),
                             filters=[(_('Archives'), ('zip', 'rar'))],
                             all_files=False,
                             select_only_single_file=False)
        if paths:
            self.do_add_recursive(paths, single, list_of_archives=True)

    def add_recursive(self, single):
        root = choose_dir(self.gui, 'recursive book import root dir dialog',
                          _('Select root folder'))
        if not root:
            return
        lp = os.path.normcase(os.path.abspath(
            self.gui.current_db.library_path))
        if lp.startswith(os.path.normcase(os.path.abspath(root)) + os.pathsep):
            return error_dialog(
                self.gui,
                _('Cannot add'),
                _('Cannot add books from the folder: %s as it contains the currently opened calibre library'
                  ) % root,
                show=True)
        self.do_add_recursive(root, single)

    def do_add_recursive(self, root, single, list_of_archives=False):
        from calibre.gui2.add import Adder
        Adder(root,
              single_book_per_directory=single,
              db=self.gui.current_db,
              list_of_archives=list_of_archives,
              callback=self._files_added,
              parent=self.gui,
              pool=self.gui.spare_pool())

    def add_recursive_single(self, *args):
        '''
        Add books from the local filesystem to either the library or the device
        recursively assuming one book per folder.
        '''
        self.add_recursive(True)

    def add_recursive_multiple(self, *args):
        '''
        Add books from the local filesystem to either the library or the device
        recursively assuming multiple books per folder.
        '''
        self.add_recursive(False)

    def add_empty(self, *args):
        '''
        Add an empty book item to the library. This does not import any formats
        from a book file.
        '''
        author = series = title = None
        index = self.gui.library_view.currentIndex()
        if index.isValid():
            raw = index.model().db.authors(index.row())
            if raw:
                authors = [a.strip().replace('|', ',') for a in raw.split(',')]
                if authors:
                    author = authors[0]
            series = index.model().db.series(index.row())
            title = index.model().db.title(index.row())
        dlg = AddEmptyBookDialog(self.gui,
                                 self.gui.library_view.model().db,
                                 author,
                                 series,
                                 dup_title=title)
        if dlg.exec_() == dlg.Accepted:
            temp_files = []
            num = dlg.qty_to_add
            series = dlg.selected_series
            title = dlg.selected_title or _('Unknown')
            db = self.gui.library_view.model().db
            ids, orig_fmts = [], []
            if dlg.duplicate_current_book:
                origmi = db.get_metadata(index.row(),
                                         get_cover=True,
                                         cover_as_data=True)
                if dlg.copy_formats.isChecked():
                    book_id = db.id(index.row())
                    orig_fmts = tuple(
                        db.new_api.format(book_id, fmt, as_path=True)
                        for fmt in db.new_api.formats(book_id))

            for x in xrange(num):
                if dlg.duplicate_current_book:
                    mi = origmi
                else:
                    mi = MetaInformation(title, dlg.selected_authors)
                    if series:
                        mi.series = series
                        mi.series_index = db.get_next_series_num_for(series)
                fmts = []
                empty_format = gprefs.get('create_empty_format_file', '')
                if dlg.duplicate_current_book and dlg.copy_formats.isChecked():
                    fmts = orig_fmts
                elif empty_format:
                    from calibre.ebooks.oeb.polish.create import create_book
                    pt = PersistentTemporaryFile(suffix='.' + empty_format)
                    pt.close()
                    temp_files.append(pt.name)
                    create_book(mi, pt.name, fmt=empty_format)
                    fmts = [pt.name]
                ids.append(db.import_book(mi, fmts))
            tuple(map(os.remove, orig_fmts))
            self.gui.library_view.model().books_added(num)
            self.gui.refresh_cover_browser()
            self.gui.tags_view.recount()
            if ids:
                ids.reverse()
                self.gui.library_view.select_rows(ids)
            for path in temp_files:
                os.remove(path)

    def add_isbns(self, books, add_tags=[]):
        self.isbn_books = list(books)
        self.add_by_isbn_ids = set()
        self.isbn_add_tags = add_tags
        QTimer.singleShot(10, self.do_one_isbn_add)
        self.isbn_add_dialog = ProgressDialog(
            _('Adding'),
            _('Creating book records from ISBNs'),
            max=len(books),
            cancelable=False,
            parent=self.gui)
        self.isbn_add_dialog.exec_()

    def do_one_isbn_add(self):
        try:
            db = self.gui.library_view.model().db

            try:
                x = self.isbn_books.pop(0)
            except IndexError:
                self.gui.library_view.model().books_added(
                    self.isbn_add_dialog.value)
                self.isbn_add_dialog.accept()
                self.gui.iactions['Edit Metadata'].download_metadata(
                    ids=self.add_by_isbn_ids,
                    ensure_fields=frozenset(['title', 'authors']))
                return

            mi = MetaInformation(None)
            mi.isbn = x['isbn']
            if self.isbn_add_tags:
                mi.tags = list(self.isbn_add_tags)
            fmts = [] if x['path'] is None else [x['path']]
            self.add_by_isbn_ids.add(db.import_book(mi, fmts))
            self.isbn_add_dialog.value += 1
            QTimer.singleShot(10, self.do_one_isbn_add)
        except:
            self.isbn_add_dialog.accept()
            raise

    def files_dropped(self, paths):
        to_device = self.gui.stack.currentIndex() != 0
        self._add_books(paths, to_device)

    def remote_file_dropped_on_book(self, url, fname):
        if self.gui.current_view() is not self.gui.library_view:
            return
        db = self.gui.library_view.model().db
        current_idx = self.gui.library_view.currentIndex()
        if not current_idx.isValid():
            return
        cid = db.id(current_idx.row())
        from calibre.gui2.dnd import DownloadDialog
        d = DownloadDialog(url, fname, self.gui)
        d.start_download()
        if d.err is None:
            self.files_dropped_on_book(None, [d.fpath], cid=cid)

    def files_dropped_on_book(self, event, paths, cid=None, do_confirm=True):
        accept = False
        if self.gui.current_view() is not self.gui.library_view:
            return
        db = self.gui.library_view.model().db
        cover_changed = False
        current_idx = self.gui.library_view.currentIndex()
        if cid is None:
            if not current_idx.isValid():
                return
            cid = db.id(current_idx.row()) if cid is None else cid
        formats = []
        from calibre.gui2.dnd import image_extensions
        for path in paths:
            ext = os.path.splitext(path)[1].lower()
            if ext:
                ext = ext[1:]
            if ext in image_extensions():
                pmap = QPixmap()
                pmap.load(path)
                if not pmap.isNull():
                    accept = True
                    db.set_cover(cid, pmap)
                    cover_changed = True
            else:
                formats.append((ext, path))
                accept = True
        if accept and event is not None:
            event.accept()
        if do_confirm and formats:
            if not confirm(_(
                    'You have dropped some files onto the book <b>%s</b>. This will'
                    ' add or replace the files for this book. Do you want to proceed?'
            ) % db.title(cid, index_is_id=True),
                           'confirm_drop_on_book',
                           parent=self.gui):
                formats = []
        for ext, path in formats:
            db.add_format_with_hooks(cid, ext, path, index_is_id=True)
        if current_idx.isValid():
            self.gui.library_view.model().current_changed(
                current_idx, current_idx)
        if cover_changed:
            self.gui.refresh_cover_browser()

    def __add_filesystem_book(self, paths, allow_device=True):
        if isinstance(paths, basestring):
            paths = [paths]
        books = [
            path for path in map(os.path.abspath, paths)
            if os.access(path, os.R_OK)
        ]

        if books:
            to_device = allow_device and self.gui.stack.currentIndex() != 0
            self._add_books(books, to_device)
            if to_device:
                self.gui.status_bar.show_message(
                    _('Uploading books to device.'), 2000)

    def add_filesystem_book(self, paths, allow_device=True):
        self._add_filesystem_book(paths, allow_device=allow_device)

    def add_from_isbn(self, *args):
        from calibre.gui2.dialogs.add_from_isbn import AddFromISBN
        d = AddFromISBN(self.gui)
        if d.exec_() == d.Accepted and d.books:
            self.add_isbns(d.books, add_tags=d.set_tags)

    def add_books(self, *args):
        '''
        Add books from the local filesystem to either the library or the device.
        '''
        filters = get_filters()
        to_device = self.gui.stack.currentIndex() != 0
        if to_device:
            fmts = self.gui.device_manager.device.settings().format_map
            filters = [(_('Supported books'), fmts)]

        books = choose_files(self.gui,
                             'add books dialog dir',
                             _('Select books'),
                             filters=filters)
        if not books:
            return
        self._add_books(books, to_device)

    def _add_books(self, paths, to_device, on_card=None):
        if on_card is None:
            on_card = 'carda' if self.gui.stack.currentIndex() == 2 else \
                      'cardb' if self.gui.stack.currentIndex() == 3 else None
        if not paths:
            return
        from calibre.gui2.add import Adder
        Adder(paths,
              db=None if to_device else self.gui.current_db,
              parent=self.gui,
              callback=partial(self._files_added, on_card=on_card),
              pool=self.gui.spare_pool())

    def _files_added(self, adder, on_card=None):
        if adder.items:
            paths, infos, names = [], [], []
            for mi, cover_path, format_paths in adder.items:
                mi.cover = cover_path
                paths.append(format_paths[0]), infos.append(mi)
                names.append(ascii_filename(os.path.basename(paths[-1])))
            self.gui.upload_books(paths, names, infos, on_card=on_card)
            self.gui.status_bar.show_message(_('Uploading books to device.'),
                                             2000)
            return

        if adder.number_of_books_added > 0:
            self.gui.library_view.model().books_added(
                adder.number_of_books_added)
            self.gui.library_view.set_current_row(0)
            self.gui.refresh_cover_browser()
            self.gui.tags_view.recount()

        if adder.merged_books:
            merged = defaultdict(list)
            for title, author in adder.merged_books:
                merged[author].append(title)
            lines = []
            for author in sorted(merged, key=sort_key):
                lines.append(author)
                for title in sorted(merged[author], key=sort_key):
                    lines.append('\t' + title)
                lines.append('')
            info_dialog(
                self.gui,
                _('Merged some books'),
                _('The following %d duplicate books were found and incoming '
                  'book formats were processed and merged into your '
                  'Calibre database according to your automerge '
                  'settings:') % len(adder.merged_books),
                det_msg='\n'.join(lines),
                show=True)

        if adder.number_of_books_added > 0 or adder.merged_books:
            # The formats of the current book could have changed if
            # automerge is enabled
            current_idx = self.gui.library_view.currentIndex()
            if current_idx.isValid():
                self.gui.library_view.model().current_changed(
                    current_idx, current_idx)

    def _add_from_device_adder(self, adder, on_card=None, model=None):
        self._files_added(adder, on_card=on_card)
        # set the in-library flags, and as a consequence send the library's
        # metadata for this book to the device. This sets the uuid to the
        # correct value. Note that set_books_in_library might sync_booklists
        self.gui.set_books_in_library(booklists=[model.db], reset=True)
        self.gui.refresh_ondevice()

    def add_books_from_device(self, view, paths=None):
        backloading_err = self.gui.device_manager.device.BACKLOADING_ERROR_MESSAGE
        if backloading_err is not None:
            return error_dialog(self.gui,
                                _('Add to library'),
                                backloading_err,
                                show=True)
        if paths is None:
            rows = view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                d = error_dialog(self.gui, _('Add to library'),
                                 _('No book selected'))
                d.exec_()
                return
            paths = [p for p in view.model().paths(rows) if p is not None]
        ve = self.gui.device_manager.device.VIRTUAL_BOOK_EXTENSIONS

        def ext(x):
            ans = os.path.splitext(x)[1]
            ans = ans[1:] if len(ans) > 1 else ans
            return ans.lower()

        remove = set([p for p in paths if ext(p) in ve])
        if remove:
            paths = [p for p in paths if p not in remove]
            vmsg = getattr(
                self.gui.device_manager.device,
                'VIRTUAL_BOOK_EXTENSION_MESSAGE', None) or _(
                    'The following books are virtual and cannot be added'
                    ' to the calibre library:')
            info_dialog(self.gui,
                        _('Not Implemented'),
                        vmsg,
                        '\n'.join(remove),
                        show=True)
            if not paths:
                return
        if not paths or len(paths) == 0:
            d = error_dialog(self.gui, _('Add to library'),
                             _('No book files found'))
            d.exec_()
            return

        self.gui.device_manager.prepare_addable_books(
            self.Dispatcher(partial(self.books_prepared, view)), paths)
        self.bpd = ProgressDialog(_('Downloading books'),
                                  msg=_('Downloading books from device'),
                                  parent=self.gui,
                                  cancelable=False)
        QTimer.singleShot(1000, self.show_bpd)

    def show_bpd(self):
        if self.bpd is not None:
            self.bpd.show()

    def books_prepared(self, view, job):
        self.bpd.hide()
        self.bpd = None
        if job.exception is not None:
            self.gui.device_job_exception(job)
            return
        paths = job.result
        ok_paths = [x for x in paths if isinstance(x, basestring)]
        failed_paths = [x for x in paths if isinstance(x, tuple)]
        if failed_paths:
            if not ok_paths:
                msg = _('Could not download files from the device')
                typ = error_dialog
            else:
                msg = _('Could not download some files from the device')
                typ = warning_dialog
            det_msg = [
                x[0] + '\n    ' + as_unicode(x[1]) for x in failed_paths
            ]
            det_msg = '\n\n'.join(det_msg)
            typ(self.gui,
                _('Could not download files'),
                msg,
                det_msg=det_msg,
                show=True)

        if ok_paths:
            from calibre.gui2.add import Adder
            callback = partial(self._add_from_device_adder,
                               on_card=None,
                               model=view.model())
            Adder(ok_paths,
                  db=self.gui.current_db,
                  parent=self.gui,
                  callback=callback,
                  pool=self.gui.spare_pool())
Example #52
0
class AddAction(InterfaceAction):

    name = 'Add Books'
    action_spec = (_('Add books'), 'add_book.png',
            _('Add books to the calibre library/device from files on your computer')
            , _('A'))
    action_type = 'current'
    action_add_menu = True
    action_menu_clone_qaction = _('Add books from a single directory')

    def genesis(self):
        self._add_filesystem_book = self.Dispatcher(self.__add_filesystem_book)
        self.add_menu = self.qaction.menu()
        ma = partial(self.create_menu_action, self.add_menu)
        ma('recursive-single', _('Add books from directories, including '
            'sub-directories (One book per directory, assumes every ebook '
            'file is the same book in a different format)')).triggered.connect(
            self.add_recursive_single)
        ma('recursive-multiple', _('Add books from directories, including '
            'sub directories (Multiple books per directory, assumes every '
            'ebook file is a different book)')).triggered.connect(
                    self.add_recursive_multiple)
        self.add_menu.addSeparator()
        ma('add-empty', _('Add Empty book. (Book entry with no formats)'),
                shortcut=_('Shift+Ctrl+E')).triggered.connect(self.add_empty)
        ma('add-isbn', _('Add from ISBN')).triggered.connect(self.add_from_isbn)
        self.add_menu.addSeparator()
        ma('add-formats', _('Add files to selected book records'),
                triggered=self.add_formats, shortcut=_('Shift+A'))
        self.add_menu.addSeparator()
        ma('add-config', _('Control the adding of books'),
                triggered=self.add_config)

        self.qaction.triggered.connect(self.add_books)

    def location_selected(self, loc):
        enabled = loc == 'library'
        for action in list(self.add_menu.actions())[1:]:
            action.setEnabled(enabled)

    def add_config(self):
        self.gui.iactions['Preferences'].do_config(
            initial_plugin=('Import/Export', 'Adding'),
            close_after_initial=True)

    def add_formats(self, *args):
        if self.gui.stack.currentIndex() != 0:
            return
        view = self.gui.library_view
        rows = view.selectionModel().selectedRows()
        if not rows:
            return error_dialog(self.gui, _('No books selected'),
                    _('Cannot add files as no books are selected'), show=True)
        ids = [view.model().id(r) for r in rows]

        if len(ids) > 1 and not question_dialog(self.gui,
                _('Are you sure'),
            _('Are you sure you want to add the same'
                ' files to all %d books? If the format'
                ' already exists for a book, it will be replaced.')%len(ids)):
                return

        books = choose_files(self.gui, 'add formats dialog dir',
                _('Select book files'), filters=get_filters())
        if not books:
            return

        db = view.model().db
        for id_ in ids:
            for fpath in books:
                fmt = os.path.splitext(fpath)[1][1:].upper()
                if fmt:
                    db.add_format_with_hooks(id_, fmt, fpath, index_is_id=True,
                        notify=True)
        current_idx = self.gui.library_view.currentIndex()
        if current_idx.isValid():
            view.model().current_changed(current_idx, current_idx)


    def add_recursive(self, single):
        root = choose_dir(self.gui, 'recursive book import root dir dialog',
                          'Select root folder')
        if not root:
            return
        from calibre.gui2.add import Adder
        self._adder = Adder(self.gui,
                self.gui.library_view.model().db,
                self.Dispatcher(self._files_added), spare_server=self.gui.spare_server)
        self.gui.tags_view.disable_recounting = True
        self._adder.add_recursive(root, single)

    def add_recursive_single(self, *args):
        '''
        Add books from the local filesystem to either the library or the device
        recursively assuming one book per folder.
        '''
        self.add_recursive(True)

    def add_recursive_multiple(self, *args):
        '''
        Add books from the local filesystem to either the library or the device
        recursively assuming multiple books per folder.
        '''
        self.add_recursive(False)

    def add_empty(self, *args):
        '''
        Add an empty book item to the library. This does not import any formats
        from a book file.
        '''
        author = None
        index = self.gui.library_view.currentIndex()
        if index.isValid():
            raw = index.model().db.authors(index.row())
            if raw:
                authors = [a.strip().replace('|', ',') for a in raw.split(',')]
                if authors:
                    author = authors[0]
        dlg = AddEmptyBookDialog(self.gui, self.gui.library_view.model().db, author)
        if dlg.exec_() == dlg.Accepted:
            num = dlg.qty_to_add
            for x in xrange(num):
                mi = MetaInformation(_('Unknown'), dlg.selected_authors)
                self.gui.library_view.model().db.import_book(mi, [])
            self.gui.library_view.model().books_added(num)
            if hasattr(self.gui, 'db_images'):
                self.gui.db_images.reset()
            self.gui.tags_view.recount()

    def add_isbns(self, books, add_tags=[]):
        self.isbn_books = list(books)
        self.add_by_isbn_ids = set()
        self.isbn_add_tags = add_tags
        QTimer.singleShot(10, self.do_one_isbn_add)
        self.isbn_add_dialog = ProgressDialog(_('Adding'),
                _('Creating book records from ISBNs'), max=len(books),
                cancelable=False, parent=self.gui)
        self.isbn_add_dialog.exec_()

    def do_one_isbn_add(self):
        try:
            db = self.gui.library_view.model().db

            try:
                x = self.isbn_books.pop(0)
            except IndexError:
                self.gui.library_view.model().books_added(self.isbn_add_dialog.value)
                self.isbn_add_dialog.accept()
                self.gui.iactions['Edit Metadata'].download_metadata(
                    ids=self.add_by_isbn_ids, ensure_fields=frozenset(['title',
                        'authors']))
                return


            mi = MetaInformation(None)
            mi.isbn = x['isbn']
            if self.isbn_add_tags:
                mi.tags = list(self.isbn_add_tags)
            fmts = [] if x['path'] is None else [x['path']]
            self.add_by_isbn_ids.add(db.import_book(mi, fmts))
            self.isbn_add_dialog.value += 1
            QTimer.singleShot(10, self.do_one_isbn_add)
        except:
            self.isbn_add_dialog.accept()
            raise

    def files_dropped(self, paths):
        to_device = self.gui.stack.currentIndex() != 0
        self._add_books(paths, to_device)

    def remote_file_dropped_on_book(self, url, fname):
        if self.gui.current_view() is not self.gui.library_view:
            return
        db = self.gui.library_view.model().db
        current_idx = self.gui.library_view.currentIndex()
        if not current_idx.isValid(): return
        cid = db.id(current_idx.row())
        from calibre.gui2.dnd import DownloadDialog
        d = DownloadDialog(url, fname, self.gui)
        d.start_download()
        if d.err is None:
            self.files_dropped_on_book(None, [d.fpath], cid=cid)

    def files_dropped_on_book(self, event, paths, cid=None):
        accept = False
        if self.gui.current_view() is not self.gui.library_view:
            return
        db = self.gui.library_view.model().db
        cover_changed = False
        current_idx = self.gui.library_view.currentIndex()
        if cid is None:
            if not current_idx.isValid(): return
            cid = db.id(current_idx.row()) if cid is None else cid
        for path in paths:
            ext = os.path.splitext(path)[1].lower()
            if ext:
                ext = ext[1:]
            if ext in IMAGE_EXTENSIONS:
                pmap = QPixmap()
                pmap.load(path)
                if not pmap.isNull():
                    accept = True
                    db.set_cover(cid, pmap)
                    cover_changed = True
            elif ext in BOOK_EXTENSIONS:
                db.add_format_with_hooks(cid, ext, path, index_is_id=True)
                accept = True
        if accept and event is not None:
            event.accept()
        if current_idx.isValid():
            self.gui.library_view.model().current_changed(current_idx, current_idx)
        if cover_changed:
            if self.gui.cover_flow:
                self.gui.cover_flow.dataChanged()

    def __add_filesystem_book(self, paths, allow_device=True):
        if isinstance(paths, basestring):
            paths = [paths]
        books = [path for path in map(os.path.abspath, paths) if os.access(path,
            os.R_OK)]

        if books:
            to_device = allow_device and self.gui.stack.currentIndex() != 0
            self._add_books(books, to_device)
            if to_device:
                self.gui.status_bar.show_message(\
                        _('Uploading books to device.'), 2000)


    def add_filesystem_book(self, paths, allow_device=True):
        self._add_filesystem_book(paths, allow_device=allow_device)

    def add_from_isbn(self, *args):
        from calibre.gui2.dialogs.add_from_isbn import AddFromISBN
        d = AddFromISBN(self.gui)
        if d.exec_() == d.Accepted:
            self.add_isbns(d.books, add_tags=d.set_tags)

    def add_books(self, *args):
        '''
        Add books from the local filesystem to either the library or the device.
        '''
        filters = get_filters()
        to_device = self.gui.stack.currentIndex() != 0
        if to_device:
            fmts = self.gui.device_manager.device.settings().format_map
            filters = [(_('Supported books'), fmts)]

        books = choose_files(self.gui, 'add books dialog dir',
                _('Select books'), filters=filters)
        if not books:
            return
        self._add_books(books, to_device)

    def _add_books(self, paths, to_device, on_card=None):
        if on_card is None:
            on_card = 'carda' if self.gui.stack.currentIndex() == 2 else \
                      'cardb' if self.gui.stack.currentIndex() == 3 else None
        if not paths:
            return
        from calibre.gui2.add import Adder
        self.__adder_func = partial(self._files_added, on_card=on_card)
        self._adder = Adder(self.gui,
                None if to_device else self.gui.library_view.model().db,
                self.Dispatcher(self.__adder_func), spare_server=self.gui.spare_server)
        self.gui.tags_view.disable_recounting = True
        self._adder.add(paths)

    def _files_added(self, paths=[], names=[], infos=[], on_card=None):
        self.gui.tags_view.disable_recounting = False
        if paths:
            self.gui.upload_books(paths,
                                list(map(ascii_filename, names)),
                                infos, on_card=on_card)
            self.gui.status_bar.show_message(
                    _('Uploading books to device.'), 2000)
        if getattr(self._adder, 'number_of_books_added', 0) > 0:
            self.gui.library_view.model().books_added(self._adder.number_of_books_added)
            self.gui.library_view.set_current_row(0)
            if hasattr(self.gui, 'db_images'):
                self.gui.db_images.reset()
            self.gui.tags_view.recount()

        if getattr(self._adder, 'merged_books', False):
            books = u'\n'.join([x if isinstance(x, unicode) else
                    x.decode(preferred_encoding, 'replace') for x in
                    self._adder.merged_books])
            info_dialog(self.gui, _('Merged some books'),
                _('The following %d duplicate books were found and incoming '
                    'book formats were processed and merged into your '
                    'Calibre database according to your automerge '
                    'settings:')%len(self._adder.merged_books),
                    det_msg=books, show=True)

        if getattr(self._adder, 'number_of_books_added', 0) > 0 or \
                getattr(self._adder, 'merged_books', False):
            # The formats of the current book could have changed if
            # automerge is enabled
            current_idx = self.gui.library_view.currentIndex()
            if current_idx.isValid():
                self.gui.library_view.model().current_changed(current_idx,
                        current_idx)

        if getattr(self._adder, 'critical', None):
            det_msg = []
            for name, log in self._adder.critical.items():
                if isinstance(name, str):
                    name = name.decode(filesystem_encoding, 'replace')
                det_msg.append(name+'\n'+log)

            warning_dialog(self.gui, _('Failed to read metadata'),
                    _('Failed to read metadata from the following')+':',
                    det_msg='\n\n'.join(det_msg), show=True)

        if hasattr(self._adder, 'cleanup'):
            self._adder.cleanup()
            self._adder.setParent(None)
            del self._adder
            self._adder = None

    def _add_from_device_adder(self, paths=[], names=[], infos=[],
                               on_card=None, model=None):
        self._files_added(paths, names, infos, on_card=on_card)
        # set the in-library flags, and as a consequence send the library's
        # metadata for this book to the device. This sets the uuid to the
        # correct value. Note that set_books_in_library might sync_booklists
        self.gui.set_books_in_library(booklists=[model.db], reset=True)
        self.gui.refresh_ondevice()

    def add_books_from_device(self, view, paths=None):
        backloading_err = self.gui.device_manager.device.BACKLOADING_ERROR_MESSAGE
        if backloading_err is not None:
            return error_dialog(self.gui, _('Add to library'), backloading_err,
                    show=True)
        if paths is None:
            rows = view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                d = error_dialog(self.gui, _('Add to library'), _('No book selected'))
                d.exec_()
                return
            paths = [p for p in view.model().paths(rows) if p is not None]
        ve = self.gui.device_manager.device.VIRTUAL_BOOK_EXTENSIONS
        def ext(x):
            ans = os.path.splitext(x)[1]
            ans = ans[1:] if len(ans) > 1 else ans
            return ans.lower()
        remove = set([p for p in paths if ext(p) in ve])
        if remove:
            paths = [p for p in paths if p not in remove]
            info_dialog(self.gui,  _('Not Implemented'),
                        _('The following books are virtual and cannot be added'
                          ' to the calibre library:'), '\n'.join(remove),
                        show=True)
            if not paths:
                return
        if not paths or len(paths) == 0:
            d = error_dialog(self.gui, _('Add to library'), _('No book files found'))
            d.exec_()
            return

        self.gui.device_manager.prepare_addable_books(self.Dispatcher(partial(
            self.books_prepared, view)), paths)
        self.bpd = ProgressDialog(_('Downloading books'),
                msg=_('Downloading books from device'), parent=self.gui,
                cancelable=False)
        QTimer.singleShot(1000, self.show_bpd)

    def show_bpd(self):
        if self.bpd is not None:
            self.bpd.show()

    def books_prepared(self, view, job):
        self.bpd.hide()
        self.bpd = None
        if job.exception is not None:
            self.gui.device_job_exception(job)
            return
        paths = job.result
        ok_paths = [x for x in paths if isinstance(x, basestring)]
        failed_paths = [x for x in paths if isinstance(x, tuple)]
        if failed_paths:
            if not ok_paths:
                msg = _('Could not download files from the device')
                typ = error_dialog
            else:
                msg = _('Could not download some files from the device')
                typ = warning_dialog
            det_msg = [x[0]+ '\n    ' + as_unicode(x[1]) for x in failed_paths]
            det_msg = '\n\n'.join(det_msg)
            typ(self.gui, _('Could not download files'), msg, det_msg=det_msg,
                    show=True)

        if ok_paths:
            from calibre.gui2.add import Adder
            self.__adder_func = partial(self._add_from_device_adder, on_card=None,
                                                        model=view.model())
            self._adder = Adder(self.gui, self.gui.library_view.model().db,
                    self.Dispatcher(self.__adder_func), spare_server=self.gui.spare_server)
            self._adder.add(ok_paths)
Example #53
0
class Polish(QDialog):  # {{{

    def __init__(self, db, book_id_map, parent=None):
        from calibre.ebooks.oeb.polish.main import HELP
        QDialog.__init__(self, parent)
        self.db, self.book_id_map = weakref.ref(db), book_id_map
        self.setWindowIcon(QIcon(I('polish.png')))
        title = _('Polish book')
        if len(book_id_map) > 1:
            title = _('Polish %d books')%len(book_id_map)
        self.setWindowTitle(title)

        self.help_text = {
            'polish': _('<h3>About Polishing books</h3>%s')%HELP['about'].format(
                _('''<p>If you have both EPUB and ORIGINAL_EPUB in your book,
                  then polishing will run on ORIGINAL_EPUB (the same for other
                  ORIGINAL_* formats).  So if you
                  want Polishing to not run on the ORIGINAL_* format, delete the
                  ORIGINAL_* format before running it.</p>''')
            ),

            'embed':_('<h3>Embed referenced fonts</h3>%s')%HELP['embed'],
            'subset':_('<h3>Subsetting fonts</h3>%s')%HELP['subset'],

            'smarten_punctuation':
            _('<h3>Smarten punctuation</h3>%s')%HELP['smarten_punctuation'],

            'metadata':_('<h3>Updating metadata</h3>'
                         '<p>This will update all metadata <i>except</i> the cover in the'
                         ' e-book files to match the current metadata in the'
                         ' calibre library.</p>'
                         ' <p>Note that most e-book'
                         ' formats are not capable of supporting all the'
                         ' metadata in calibre.</p><p>There is a separate option to'
                         ' update the cover.</p>'),
            'do_cover': _('<h3>Update cover</h3><p>Update the covers in the e-book files to match the'
                        ' current cover in the calibre library.</p>'
                        '<p>If the e-book file does not have'
                        ' an identifiable cover, a new cover is inserted.</p>'
                        ),
            'jacket':_('<h3>Book jacket</h3>%s')%HELP['jacket'],
            'remove_jacket':_('<h3>Remove book jacket</h3>%s')%HELP['remove_jacket'],
            'remove_unused_css':_('<h3>Remove unused CSS rules</h3>%s')%HELP['remove_unused_css'],
            'compress_images': _('<h3>Losslessly compress images</h3>%s') % HELP['compress_images'],
            'upgrade_book': _('<h3>Upgrade book internals</h3>%s') % HELP['upgrade_book'],
        }

        self.l = l = QGridLayout()
        self.setLayout(l)

        self.la = la = QLabel('<b>'+_('Select actions to perform:'))
        l.addWidget(la, 0, 0, 1, 2)

        count = 0
        self.all_actions = OrderedDict([
            ('embed', _('&Embed all referenced fonts')),
            ('subset', _('&Subset all embedded fonts')),
            ('smarten_punctuation', _('Smarten &punctuation')),
            ('metadata', _('Update &metadata in the book files')),
            ('do_cover', _('Update the &cover in the book files')),
            ('jacket', _('Add/replace metadata as a "book &jacket" page')),
            ('remove_jacket', _('&Remove a previously inserted book jacket')),
            ('remove_unused_css', _('Remove &unused CSS rules from the book')),
            ('compress_images', _('Losslessly &compress images')),
            ('upgrade_book', _('&Upgrade book internals')),
        ])
        prefs = gprefs.get('polishing_settings', {})
        for name, text in self.all_actions.iteritems():
            count += 1
            x = QCheckBox(text, self)
            x.setChecked(prefs.get(name, False))
            x.stateChanged.connect(partial(self.option_toggled, name))
            l.addWidget(x, count, 0, 1, 1)
            setattr(self, 'opt_'+name, x)
            la = QLabel(' <a href="#%s">%s</a>'%(name, _('About')))
            setattr(self, 'label_'+name, x)
            la.linkActivated.connect(self.help_link_activated)
            l.addWidget(la, count, 1, 1, 1)

        count += 1
        l.addItem(QSpacerItem(10, 10, vPolicy=QSizePolicy.Expanding), count, 1, 1, 2)

        la = self.help_label = QLabel('')
        self.help_link_activated('#polish')
        la.setWordWrap(True)
        la.setTextFormat(Qt.RichText)
        la.setFrameShape(QFrame.StyledPanel)
        la.setAlignment(Qt.AlignLeft|Qt.AlignTop)
        la.setLineWidth(2)
        la.setStyleSheet('QLabel { margin-left: 75px }')
        l.addWidget(la, 0, 2, count+1, 1)
        l.setColumnStretch(2, 1)

        self.show_reports = sr = QCheckBox(_('Show &report'), self)
        sr.setChecked(gprefs.get('polish_show_reports', True))
        sr.setToolTip(textwrap.fill(_('Show a report of all the actions performed'
                        ' after polishing is completed')))
        l.addWidget(sr, count+1, 0, 1, 1)
        self.bb = bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel)
        bb.accepted.connect(self.accept)
        bb.rejected.connect(self.reject)
        self.save_button = sb = bb.addButton(_('&Save Settings'), bb.ActionRole)
        sb.clicked.connect(self.save_settings)
        self.load_button = lb = bb.addButton(_('&Load Settings'), bb.ActionRole)
        self.load_menu = QMenu(lb)
        lb.setMenu(self.load_menu)
        self.all_button = b = bb.addButton(_('Select &all'), bb.ActionRole)
        b.clicked.connect(partial(self.select_all, True))
        self.none_button = b = bb.addButton(_('Select &none'), bb.ActionRole)
        b.clicked.connect(partial(self.select_all, False))
        l.addWidget(bb, count+1, 1, 1, -1)
        self.setup_load_button()

        self.resize(QSize(950, 600))

    def select_all(self, enable):
        for action in self.all_actions:
            x = getattr(self, 'opt_'+action)
            x.blockSignals(True)
            x.setChecked(enable)
            x.blockSignals(False)

    def save_settings(self):
        if not self.something_selected:
            return error_dialog(self, _('No actions selected'),
                _('You must select at least one action before saving'),
                                show=True)
        name, ok = QInputDialog.getText(self, _('Choose name'),
                _('Choose a name for these settings'))
        if ok:
            name = unicode(name).strip()
            if name:
                settings = {ac:getattr(self, 'opt_'+ac).isChecked() for ac in
                            self.all_actions}
                saved = gprefs.get('polish_settings', {})
                saved[name] = settings
                gprefs.set('polish_settings', saved)
                self.setup_load_button()

    def setup_load_button(self):
        saved = gprefs.get('polish_settings', {})
        m = self.load_menu
        m.clear()
        self.__actions = []
        a = self.__actions.append
        for name in sorted(saved):
            a(m.addAction(name, partial(self.load_settings, name)))
        m.addSeparator()
        a(m.addAction(_('Remove saved settings'), self.clear_settings))
        self.load_button.setEnabled(bool(saved))

    def clear_settings(self):
        gprefs.set('polish_settings', {})
        self.setup_load_button()

    def load_settings(self, name):
        saved = gprefs.get('polish_settings', {}).get(name, {})
        for action in self.all_actions:
            checked = saved.get(action, False)
            x = getattr(self, 'opt_'+action)
            x.blockSignals(True)
            x.setChecked(checked)
            x.blockSignals(False)

    def option_toggled(self, name, state):
        if state == Qt.Checked:
            self.help_label.setText(self.help_text[name])

    def help_link_activated(self, link):
        link = unicode(link)[1:]
        self.help_label.setText(self.help_text[link])

    @property
    def something_selected(self):
        for action in self.all_actions:
            if getattr(self, 'opt_'+action).isChecked():
                return True
        return False

    def accept(self):
        self.actions = ac = {}
        saved_prefs = {}
        gprefs['polish_show_reports'] = bool(self.show_reports.isChecked())
        something = False
        for action in self.all_actions:
            ac[action] = saved_prefs[action] = bool(getattr(self, 'opt_'+action).isChecked())
            if ac[action]:
                something = True
        if ac['jacket'] and not ac['metadata']:
            if not question_dialog(self, _('Must update metadata'),
                _('You have selected the option to add metadata as '
                  'a "book jacket". For this option to work, you '
                  'must also select the option to update metadata in'
                  ' the book files. Do you want to select it?')):
                return
            ac['metadata'] = saved_prefs['metadata'] = True
            self.opt_metadata.setChecked(True)
        if ac['jacket'] and ac['remove_jacket']:
            if not question_dialog(self, _('Add or remove jacket?'), _(
                    'You have chosen to both add and remove the metadata jacket.'
                    ' This will result in the final book having no jacket. Is this'
                    ' what you want?')):
                return
        if not something:
            return error_dialog(self, _('No actions selected'),
                _('You must select at least one action, or click Cancel.'),
                                show=True)
        gprefs['polishing_settings'] = saved_prefs
        self.queue_files()
        return super(Polish, self).accept()

    def queue_files(self):
        self.tdir = PersistentTemporaryDirectory('_queue_polish')
        self.jobs = []
        if len(self.book_id_map) <= 5:
            for i, (book_id, formats) in enumerate(self.book_id_map.iteritems()):
                self.do_book(i+1, book_id, formats)
        else:
            self.queue = [(i+1, id_) for i, id_ in enumerate(self.book_id_map)]
            self.pd = ProgressDialog(_('Queueing books for polishing'),
                                     max=len(self.queue), parent=self)
            QTimer.singleShot(0, self.do_one)
            self.pd.exec_()

    def do_one(self):
        if not self.queue:
            self.pd.accept()
            return
        if self.pd.canceled:
            self.jobs = []
            self.pd.reject()
            return
        num, book_id = self.queue.pop()
        try:
            self.do_book(num, book_id, self.book_id_map[book_id])
        except:
            self.pd.reject()
            raise
        else:
            self.pd.set_value(num)
            QTimer.singleShot(0, self.do_one)

    def do_book(self, num, book_id, formats):
        base = os.path.join(self.tdir, unicode(book_id))
        os.mkdir(base)
        db = self.db()
        opf = os.path.join(base, 'metadata.opf')
        with open(opf, 'wb') as opf_file:
            mi = create_opf_file(db, book_id, opf_file=opf_file)[0]
        data = {'opf':opf, 'files':[]}
        for action in self.actions:
            data[action] = bool(getattr(self, 'opt_'+action).isChecked())
        cover = os.path.join(base, 'cover.jpg')
        if db.copy_cover_to(book_id, cover, index_is_id=True):
            data['cover'] = cover
        is_orig = {}
        for fmt in formats:
            ext = fmt.replace('ORIGINAL_', '').lower()
            is_orig[ext.upper()] = 'ORIGINAL_' in fmt
            with open(os.path.join(base, '%s.%s'%(book_id, ext)), 'wb') as f:
                db.copy_format_to(book_id, fmt, f, index_is_id=True)
                data['files'].append(f.name)

        desc = ngettext(_('Polish %s')%mi.title,
                        _('Polish book %(nums)s of %(tot)s (%(title)s)')%dict(
                            nums=num, tot=len(self.book_id_map),
                            title=mi.title), len(self.book_id_map))
        if hasattr(self, 'pd'):
            self.pd.set_msg(_('Queueing book %(nums)s of %(tot)s (%(title)s)')%dict(
                            nums=num, tot=len(self.book_id_map), title=mi.title))

        self.jobs.append((desc, data, book_id, base, is_orig))
Example #54
0
class Saver(QObject):

    do_one_signal = pyqtSignal()

    def __init__(self, book_ids, db, opts, root, parent=None, pool=None):
        QObject.__init__(self, parent)
        self.db = db.new_api
        self.plugboards = self.db.pref('plugboards', {})
        self.template_functions = self.db.pref('user_template_functions', [])
        load_user_template_functions('', self.template_functions)
        self.collected_data = {}
        self.errors = defaultdict(list)
        self._book_id_data = {}
        self.all_book_ids = frozenset(book_ids)
        self.pd = ProgressDialog(_('Saving %d books...') %
                                 len(self.all_book_ids),
                                 _('Collecting metadata...'),
                                 min=0,
                                 max=0,
                                 parent=parent,
                                 icon='save.png')
        self.do_one_signal.connect(self.tick, type=Qt.QueuedConnection)
        self.do_one = self.do_one_collect
        self.ids_to_collect = iter(self.all_book_ids)
        self.tdir = PersistentTemporaryDirectory('_save_to_disk')
        self.pool = pool

        self.pd.show()
        self.root, self.opts, self.path_length = sanitize_args(root, opts)
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()

    def tick(self):
        if self.pd.canceled:
            self.pd.close()
            self.pd.deleteLater()
            self.break_cycles()
            return
        self.do_one()

    def break_cycles(self):
        shutil.rmtree(self.tdir, ignore_errors=True)
        if self.pool is not None:
            self.pool.shutdown()
        self.setParent(None)
        self.jobs = self.pool = self.plugboards = self.template_functions = self.collected_data = self.all_book_ids = self.pd = self.db = None  # noqa
        self.deleteLater()

    def book_id_data(self, book_id):
        ans = self._book_id_data.get(book_id)
        if ans is None:
            try:
                ans = BookId(self.db.field_for('title', book_id),
                             self.db.field_for('authors', book_id))
            except Exception:
                ans = BookId((_('Unknown') + ' (%d)' % book_id),
                             (_('Unknown'), ))
            self._book_id_data[book_id] = ans
        return ans

    def do_one_collect(self):
        try:
            book_id = next(self.ids_to_collect)
        except StopIteration:
            self.collection_finished()
            return
        try:
            self.collect_data(book_id)
        except Exception:
            self.errors[book_id].append(('critical', traceback.format_exc()))
        self.do_one_signal.emit()

    def collect_data(self, book_id):
        mi = self.db.get_metadata(book_id)
        self._book_id_data[book_id] = BookId(mi.title, mi.authors)
        components = get_path_components(self.opts, mi, book_id,
                                         self.path_length)
        self.collected_data[book_id] = (mi, components, {
            fmt.lower()
            for fmt in self.db.formats(book_id)
        })

    def collection_finished(self):
        self.do_one = self.do_one_write
        ensure_unique_components(self.collected_data)
        self.ids_to_write = iter(self.collected_data)
        self.pd.title = _('Copying files and writing metadata...'
                          ) if self.opts.update_metadata else _(
                              'Copying files...')
        self.pd.max = len(self.collected_data)
        self.pd.value = 0
        if self.opts.update_metadata:
            all_fmts = {
                fmt
                for data in self.collected_data.itervalues() for fmt in data[2]
            }
            plugboards_cache = {
                fmt: find_plugboard(plugboard_save_to_disk_value, fmt,
                                    self.plugboards)
                for fmt in all_fmts
            }
            self.pool = Pool(
                name='SaveToDisk') if self.pool is None else self.pool
            try:
                self.pool.set_common_data(plugboards_cache)
            except Failure as err:
                error_dialog(
                    self.pd,
                    _('Critical failure'),
                    _('Could not save books to disk, click "Show details" for more information'
                      ),
                    det_msg=unicode(err.failure_message) + '\n' +
                    unicode(err.details),
                    show=True)
                self.pd.canceled = True
        self.do_one_signal.emit()

    def do_one_write(self):
        try:
            book_id = next(self.ids_to_write)
        except StopIteration:
            self.writing_finished()
            return
        if not self.opts.update_metadata:
            self.pd.msg = self.book_id_data(book_id).title
            self.pd.value += 1
        try:
            self.write_book(book_id, *self.collected_data[book_id])
        except Exception:
            self.errors[book_id].append(('critical', traceback.format_exc()))
        self.consume_results()
        self.do_one_signal.emit()

    def consume_results(self):
        if self.pool is not None:
            while True:
                try:
                    worker_result = self.pool.results.get_nowait()
                except Empty:
                    break
                book_id = worker_result.id
                if worker_result.is_terminal_failure:
                    error_dialog(
                        self.pd,
                        _('Critical failure'),
                        _('The update metadata worker process crashed while processing'
                          ' the book %s. Saving is aborted.') %
                        self.book_id_data(book_id).title,
                        show=True)
                    self.pd.canceled = True
                    return
                result = worker_result.result
                self.pd.value += 1
                self.pd.msg = self.book_id_data(book_id).title
                if result.err is not None:
                    self.errors[book_id].append(
                        ('metadata', (None,
                                      result.err + '\n' + result.traceback)))
                if result.value:
                    for fmt, tb in result.value:
                        self.errors[book_id].append(('metadata', (fmt, tb)))

    def write_book(self, book_id, mi, components, fmts):
        base_path = os.path.join(self.root, *components)
        base_dir = os.path.dirname(base_path)
        if self.opts.formats and self.opts.formats != 'all':
            asked_formats = {
                x.lower().strip()
                for x in self.opts.formats.split(',')
            }
            fmts = asked_formats.intersection(fmts)
            if not fmts:
                self.errors[book_id].append(
                    ('critical', _('Requested formats not available')))
                return

        if not fmts and not self.opts.write_opf and not self.opts.save_cover:
            return

        # On windows python incorrectly raises an access denied exception
        # when trying to create the root of a drive, like C:\
        if os.path.dirname(base_dir) != base_dir:
            try:
                os.makedirs(base_dir)
            except EnvironmentError as err:
                if err.errno != errno.EEXIST:
                    raise

        if self.opts.update_metadata:
            d = {}
            d['last_modified'] = mi.last_modified.isoformat()

        cdata = self.db.cover(book_id)
        mi.cover, mi.cover_data = None, (None, None)

        if cdata:
            fname = None
            if self.opts.save_cover:
                fname = base_path + os.extsep + 'jpg'
                mi.cover = os.path.basename(fname)
            elif self.opts.update_metadata:
                fname = os.path.join(self.tdir, '%d.jpg' % book_id)

            if fname:
                with lopen(fname, 'wb') as f:
                    f.write(cdata)
                if self.opts.update_metadata:
                    d['cover'] = fname

        fname = None
        if self.opts.write_opf:
            fname = base_path + os.extsep + 'opf'
        elif self.opts.update_metadata:
            fname = os.path.join(self.tdir, '%d.opf' % book_id)
        if fname:
            opf = metadata_to_opf(mi)
            with lopen(fname, 'wb') as f:
                f.write(opf)
            if self.opts.update_metadata:
                d['opf'] = fname
        mi.cover, mi.cover_data = None, (None, None)
        if self.opts.update_metadata:
            d['fmts'] = []
        for fmt in fmts:
            try:
                fmtpath = self.write_fmt(book_id, fmt, base_path)
                if fmtpath and self.opts.update_metadata and can_set_metadata(
                        fmt):
                    d['fmts'].append(fmtpath)
            except Exception:
                self.errors[book_id].append(
                    ('fmt', (fmt, traceback.format_exc())))
        if self.opts.update_metadata:
            if d['fmts']:
                try:
                    self.pool(book_id, 'calibre.library.save_to_disk',
                              'update_serialized_metadata', d)
                except Failure as err:
                    error_dialog(
                        self.pd,
                        _('Critical failure'),
                        _('Could not save books to disk, click "Show details" for more information'
                          ),
                        det_msg=unicode(err.failure_message) + '\n' +
                        unicode(err.details),
                        show=True)
                    self.pd.canceled = True
            else:
                self.pd.value += 1
                self.pd.msg = self.book_id_data(book_id).title

    def write_fmt(self, book_id, fmt, base_path):
        fmtpath = base_path + os.extsep + fmt
        written = False
        with lopen(fmtpath, 'w+b') as f:
            try:
                self.db.copy_format_to(book_id, fmt, f)
                written = True
            except NoSuchFormat:
                self.errors[book_id].append(
                    ('fmt', (fmt,
                             _('No %s format file present') % fmt.upper())))
        if not written:
            os.remove(fmtpath)
        if written:
            return fmtpath

    def writing_finished(self):
        if not self.opts.update_metadata:
            self.updating_metadata_finished()
        else:
            self.do_one = self.do_one_update
            self.do_one_signal.emit()

    def do_one_update(self):
        self.consume_results()
        try:
            self.pool.wait_for_tasks(0.1)
        except Failure as err:
            error_dialog(
                self.pd,
                _('Critical failure'),
                _('Could not save books to disk, click "Show details" for more information'
                  ),
                det_msg=unicode(err.failure_message) + '\n' +
                unicode(err.details),
                show=True)
            self.pd.canceled = True
        except RuntimeError:
            pass  # tasks not completed
        else:
            self.consume_results()
            return self.updating_metadata_finished()
        self.do_one_signal.emit()

    def updating_metadata_finished(self):
        if DEBUG:
            prints('Saved %d books in %.1f seconds' %
                   (len(self.all_book_ids), time.time() - self.start_time))
        self.pd.close()
        self.pd.deleteLater()
        self.report()
        self.break_cycles()
        if gprefs['show_files_after_save']:
            open_local_file(self.root)

    def format_report(self):
        report = []
        a = report.append

        def indent(text):
            text = force_unicode(text)
            return '\xa0\xa0\xa0\xa0' + '\n\xa0\xa0\xa0\xa0'.join(
                text.splitlines())

        for book_id, errors in self.errors.iteritems():
            types = {t for t, data in errors}
            title, authors = self.book_id_data(
                book_id).title, authors_to_string(
                    self.book_id_data(book_id).authors[:1])
            if report:
                a('\n' + ('_' * 70) + '\n')
            if 'critical' in types:
                a(
                    _('Failed to save: {0} by {1} to disk, with error:').
                    format(title, authors))
                for t, tb in errors:
                    if t == 'critical':
                        a(indent(tb))
            else:
                errs = defaultdict(list)
                for t, data in errors:
                    errs[t].append(data)
                for fmt, tb in errs['fmt']:
                    a(
                        _('Failed to save the {2} format of: {0} by {1} to disk, with error:'
                          ).format(title, authors, fmt.upper()))
                    a(indent(tb)), a('')
                for fmt, tb in errs['metadata']:
                    if fmt:
                        a(
                            _('Failed to update the metadata in the {2} format of: {0} by {1}, with error:'
                              ).format(title, authors, fmt.upper()))
                    else:
                        a(
                            _('Failed to update the metadata in all formats of: {0} by {1}, with error:'
                              ).format(title, authors))
                    a(indent(tb)), a('')
        return '\n'.join(report)

    def report(self):
        if not self.errors:
            return
        err_types = {
            e[0]
            for errors in self.errors.itervalues() for e in errors
        }
        if err_types == {'metadata'}:
            msg = _(
                'Failed to update metadata in some books, click "Show details" for more information'
            )
            d = warning_dialog
        elif len(self.errors) == len(self.all_book_ids):
            msg = _(
                'Failed to save any books to disk, click "Show details" for more information'
            )
            d = error_dialog
        else:
            msg = _(
                'Failed to save some books to disk, click "Show details" for more information'
            )
            d = warning_dialog
        d(self.parent(),
          _('Error while saving'),
          msg,
          det_msg=self.format_report(),
          show=True)
Example #55
0
class Saver(QObject):

    do_one_signal = pyqtSignal()

    def __init__(self, book_ids, db, opts, root, parent=None, pool=None):
        QObject.__init__(self, parent)
        self.db = db.new_api
        self.plugboards = self.db.pref('plugboards', {})
        self.template_functions = self.db.pref('user_template_functions', [])
        load_user_template_functions('', self.template_functions)
        self.collected_data = {}
        self.errors = defaultdict(list)
        self._book_id_data = {}
        self.all_book_ids = frozenset(book_ids)
        self.pd = ProgressDialog(_('Saving %d books...') % len(self.all_book_ids), _('Collecting metadata...'), min=0, max=0, parent=parent, icon='save.png')
        self.do_one_signal.connect(self.tick, type=Qt.QueuedConnection)
        self.do_one = self.do_one_collect
        self.ids_to_collect = iter(self.all_book_ids)
        self.tdir = PersistentTemporaryDirectory('_save_to_disk')
        self.pool = pool

        self.pd.show()
        self.root, self.opts, self.path_length = sanitize_args(root, opts)
        self.do_one_signal.emit()
        if DEBUG:
            self.start_time = time.time()

    def tick(self):
        if self.pd.canceled:
            self.pd.close()
            self.pd.deleteLater()
            self.break_cycles()
            return
        self.do_one()

    def break_cycles(self):
        shutil.rmtree(self.tdir, ignore_errors=True)
        if self.pool is not None:
            self.pool.shutdown()
        self.setParent(None)
        self.jobs = self.pool = self.plugboards = self.template_functions = self.collected_data = self.all_book_ids = self.pd = self.db = None  # noqa
        self.deleteLater()

    def book_id_data(self, book_id):
        ans = self._book_id_data.get(book_id)
        if ans is None:
            try:
                ans = BookId(self.db.field_for('title', book_id), self.db.field_for('authors', book_id))
            except Exception:
                ans = BookId((_('Unknown') + ' (%d)' % book_id), (_('Unknown'),))
            self._book_id_data[book_id] = ans
        return ans

    def do_one_collect(self):
        try:
            book_id = next(self.ids_to_collect)
        except StopIteration:
            self.collection_finished()
            return
        try:
            self.collect_data(book_id)
        except Exception:
            self.errors[book_id].append(('critical', traceback.format_exc()))
        self.do_one_signal.emit()

    def collect_data(self, book_id):
        mi = self.db.get_metadata(book_id)
        self._book_id_data[book_id] = BookId(mi.title, mi.authors)
        components = get_path_components(self.opts, mi, book_id, self.path_length)
        self.collected_data[book_id] = (mi, components, {fmt.lower() for fmt in self.db.formats(book_id)})

    def collection_finished(self):
        self.do_one = self.do_one_write
        ensure_unique_components(self.collected_data)
        self.ids_to_write = iter(self.collected_data)
        self.pd.title = _('Copying files and writing metadata...') if self.opts.update_metadata else _(
            'Copying files...')
        self.pd.max = len(self.collected_data)
        self.pd.value = 0
        if self.opts.update_metadata:
            all_fmts = {fmt for data in itervalues(self.collected_data) for fmt in data[2]}
            plugboards_cache = {fmt:find_plugboard(plugboard_save_to_disk_value, fmt, self.plugboards) for fmt in all_fmts}
            self.pool = Pool(name='SaveToDisk') if self.pool is None else self.pool
            try:
                self.pool.set_common_data(plugboards_cache)
            except Failure as err:
                error_dialog(self.pd, _('Critical failure'), _(
                    'Could not save books to disk, click "Show details" for more information'),
                    det_msg=unicode_type(err.failure_message) + '\n' + unicode_type(err.details), show=True)
                self.pd.canceled = True
        self.do_one_signal.emit()

    def do_one_write(self):
        try:
            book_id = next(self.ids_to_write)
        except StopIteration:
            self.writing_finished()
            return
        if not self.opts.update_metadata:
            self.pd.msg = self.book_id_data(book_id).title
            self.pd.value += 1
        try:
            self.write_book(book_id, *self.collected_data[book_id])
        except Exception:
            self.errors[book_id].append(('critical', traceback.format_exc()))
        self.consume_results()
        self.do_one_signal.emit()

    def consume_results(self):
        if self.pool is not None:
            while True:
                try:
                    worker_result = self.pool.results.get_nowait()
                except Empty:
                    break
                book_id = worker_result.id
                if worker_result.is_terminal_failure:
                    error_dialog(self.pd, _('Critical failure'), _(
                        'The update metadata worker process crashed while processing'
                        ' the book %s. Saving is aborted.') % self.book_id_data(book_id).title, show=True)
                    self.pd.canceled = True
                    return
                result = worker_result.result
                self.pd.value += 1
                self.pd.msg = self.book_id_data(book_id).title
                if result.err is not None:
                    self.errors[book_id].append(('metadata', (None, result.err + '\n' + result.traceback)))
                if result.value:
                    for fmt, tb in result.value:
                        self.errors[book_id].append(('metadata', (fmt, tb)))

    def write_book(self, book_id, mi, components, fmts):
        base_path = os.path.join(self.root, *components)
        base_dir = os.path.dirname(base_path)
        if self.opts.formats and self.opts.formats != 'all':
            asked_formats = {x.lower().strip() for x in self.opts.formats.split(',')}
            fmts = asked_formats.intersection(fmts)
            if not fmts:
                self.errors[book_id].append(('critical', _('Requested formats not available')))
                return

        if not fmts and not self.opts.write_opf and not self.opts.save_cover:
            return

        # On windows python incorrectly raises an access denied exception
        # when trying to create the root of a drive, like C:\
        if os.path.dirname(base_dir) != base_dir:
            try:
                os.makedirs(base_dir)
            except EnvironmentError as err:
                if err.errno != errno.EEXIST:
                    raise

        if self.opts.update_metadata:
            d = {}
            d['last_modified'] = mi.last_modified.isoformat()

        cdata = self.db.cover(book_id)
        mi.cover, mi.cover_data = None, (None, None)

        if cdata:
            fname = None
            if self.opts.save_cover:
                fname = base_path + os.extsep + 'jpg'
                mi.cover = os.path.basename(fname)
            elif self.opts.update_metadata:
                fname = os.path.join(self.tdir, '%d.jpg' % book_id)

            if fname:
                with lopen(fname, 'wb') as f:
                    f.write(cdata)
                if self.opts.update_metadata:
                    d['cover'] = fname

        fname = None
        if self.opts.write_opf:
            fname = base_path + os.extsep + 'opf'
        elif self.opts.update_metadata:
            fname = os.path.join(self.tdir, '%d.opf' % book_id)
        if fname:
            opf = metadata_to_opf(mi)
            with lopen(fname, 'wb') as f:
                f.write(opf)
            if self.opts.update_metadata:
                d['opf'] = fname
        mi.cover, mi.cover_data = None, (None, None)
        if self.opts.update_metadata:
            d['fmts'] = []
        for fmt in fmts:
            try:
                fmtpath = self.write_fmt(book_id, fmt, base_path)
                if fmtpath and self.opts.update_metadata and can_set_metadata(fmt):
                    d['fmts'].append(fmtpath)
            except Exception:
                self.errors[book_id].append(('fmt', (fmt, traceback.format_exc())))
        if self.opts.update_metadata:
            if d['fmts']:
                try:
                    self.pool(book_id, 'calibre.library.save_to_disk', 'update_serialized_metadata', d)
                except Failure as err:
                    error_dialog(self.pd, _('Critical failure'), _(
                        'Could not save books to disk, click "Show details" for more information'),
                        det_msg=unicode_type(err.failure_message) + '\n' + unicode_type(err.details), show=True)
                    self.pd.canceled = True
            else:
                self.pd.value += 1
                self.pd.msg = self.book_id_data(book_id).title

    def write_fmt(self, book_id, fmt, base_path):
        fmtpath = base_path + os.extsep + fmt
        written = False
        with lopen(fmtpath, 'w+b') as f:
            try:
                self.db.copy_format_to(book_id, fmt, f)
                written = True
            except NoSuchFormat:
                self.errors[book_id].append(('fmt', (fmt, _('No %s format file present') % fmt.upper())))
        if not written:
            os.remove(fmtpath)
        if written:
            return fmtpath

    def writing_finished(self):
        if not self.opts.update_metadata:
            self.updating_metadata_finished()
        else:
            self.do_one = self.do_one_update
            self.do_one_signal.emit()

    def do_one_update(self):
        self.consume_results()
        try:
            self.pool.wait_for_tasks(0.1)
        except Failure as err:
            error_dialog(self.pd, _('Critical failure'), _(
                'Could not save books to disk, click "Show details" for more information'),
                det_msg=unicode_type(err.failure_message) + '\n' + unicode_type(err.details), show=True)
            self.pd.canceled = True
        except RuntimeError:
            pass  # tasks not completed
        else:
            self.consume_results()
            return self.updating_metadata_finished()
        self.do_one_signal.emit()

    def updating_metadata_finished(self):
        if DEBUG:
            prints('Saved %d books in %.1f seconds' % (len(self.all_book_ids), time.time() - self.start_time))
        self.pd.close()
        self.pd.deleteLater()
        self.report()
        self.break_cycles()
        if gprefs['show_files_after_save']:
            open_local_file(self.root)

    def format_report(self):
        report = []
        a = report.append

        def indent(text):
            text = force_unicode(text)
            return '\xa0\xa0\xa0\xa0' + '\n\xa0\xa0\xa0\xa0'.join(text.splitlines())

        for book_id, errors in iteritems(self.errors):
            types = {t for t, data in errors}
            title, authors = self.book_id_data(book_id).title, authors_to_string(self.book_id_data(book_id).authors[:1])
            if report:
                a('\n' + ('_'*70) + '\n')
            if 'critical' in types:
                a(_('Failed to save: {0} by {1} to disk, with error:').format(title, authors))
                for t, tb in errors:
                    if t == 'critical':
                        a(indent(tb))
            else:
                errs = defaultdict(list)
                for t, data in errors:
                    errs[t].append(data)
                for fmt, tb in errs['fmt']:
                    a(_('Failed to save the {2} format of: {0} by {1} to disk, with error:').format(title, authors, fmt.upper()))
                    a(indent(tb)), a('')
                for fmt, tb in errs['metadata']:
                    if fmt:
                        a(_('Failed to update the metadata in the {2} format of: {0} by {1}, with error:').format(title, authors, fmt.upper()))
                    else:
                        a(_('Failed to update the metadata in all formats of: {0} by {1}, with error:').format(title, authors))
                    a(indent(tb)), a('')
        return '\n'.join(report)

    def report(self):
        if not self.errors:
            return
        err_types = {e[0] for errors in itervalues(self.errors) for e in errors}
        if err_types == {'metadata'}:
            msg = _('Failed to update metadata in some books, click "Show details" for more information')
            d = warning_dialog
        elif len(self.errors) == len(self.all_book_ids):
            msg = _('Failed to save any books to disk, click "Show details" for more information')
            d = error_dialog
        else:
            msg = _('Failed to save some books to disk, click "Show details" for more information')
            d = warning_dialog
        d(self.parent(), _('Error while saving'), msg, det_msg=self.format_report(), show=True)
Example #56
0
class EditMetadataAction(InterfaceAction):

    name = "Edit Metadata"
    action_spec = (_("Edit metadata"), "edit_input.png", _("Change the title/author/cover etc. of books"), _("E"))
    action_type = "current"
    action_add_menu = True

    accepts_drops = True

    def accept_enter_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def accept_drag_move_event(self, event, mime_data):
        if mime_data.hasFormat("application/calibre+from_library"):
            return True
        return False

    def drop_event(self, event, mime_data):
        mime = "application/calibre+from_library"
        if mime_data.hasFormat(mime):
            self.dropped_ids = tuple(map(int, str(mime_data.data(mime)).split()))
            QTimer.singleShot(1, self.do_drop)
            return True
        return False

    def do_drop(self):
        book_ids = self.dropped_ids
        del self.dropped_ids
        if book_ids:
            db = self.gui.library_view.model().db
            rows = [db.row(i) for i in book_ids]
            self.edit_metadata_for(rows, book_ids)

    def genesis(self):
        md = self.qaction.menu()
        cm = partial(self.create_menu_action, md)
        cm(
            "individual",
            _("Edit metadata individually"),
            icon=self.qaction.icon(),
            triggered=partial(self.edit_metadata, False, bulk=False),
        )
        md.addSeparator()
        cm("bulk", _("Edit metadata in bulk"), triggered=partial(self.edit_metadata, False, bulk=True))
        md.addSeparator()
        cm(
            "download",
            _("Download metadata and covers"),
            triggered=partial(self.download_metadata, ids=None),
            shortcut="Ctrl+D",
        )
        self.metadata_menu = md

        mb = QMenu()
        cm2 = partial(self.create_menu_action, mb)
        cm2("merge delete", _("Merge into first selected book - delete others"), triggered=self.merge_books)
        mb.addSeparator()
        cm2(
            "merge keep",
            _("Merge into first selected book - keep others"),
            triggered=partial(self.merge_books, safe_merge=True),
            shortcut="Alt+M",
        )
        mb.addSeparator()
        cm2(
            "merge formats",
            _("Merge only formats into first selected book - delete others"),
            triggered=partial(self.merge_books, merge_only_formats=True),
            shortcut="Alt+Shift+M",
        )
        self.merge_menu = mb
        md.addSeparator()
        self.action_merge = cm(
            "merge", _("Merge book records"), icon="merge_books.png", shortcut=_("M"), triggered=self.merge_books
        )
        self.action_merge.setMenu(mb)

        self.qaction.triggered.connect(self.edit_metadata)

    def location_selected(self, loc):
        enabled = loc == "library"
        self.qaction.setEnabled(enabled)
        self.action_merge.setEnabled(enabled)

    # Download metadata {{{
    def download_metadata(self, ids=None, ensure_fields=None):
        if ids is None:
            rows = self.gui.library_view.selectionModel().selectedRows()
            if not rows or len(rows) == 0:
                return error_dialog(self.gui, _("Cannot download metadata"), _("No books selected"), show=True)
            db = self.gui.library_view.model().db
            ids = [db.id(row.row()) for row in rows]
        from calibre.gui2.metadata.bulk_download import start_download

        start_download(self.gui, ids, Dispatcher(self.metadata_downloaded), ensure_fields=ensure_fields)

    def cleanup_bulk_download(self, tdir, *args):
        try:
            shutil.rmtree(tdir, ignore_errors=True)
        except:
            pass

    def metadata_downloaded(self, job):
        if job.failed:
            self.gui.job_exception(job, dialog_title=_("Failed to download metadata"))
            return
        from calibre.gui2.metadata.bulk_download import get_job_details

        (aborted, id_map, tdir, log_file, failed_ids, failed_covers, all_failed, det_msg, lm_map) = get_job_details(job)
        if aborted:
            return self.cleanup_bulk_download(tdir)
        if all_failed:
            num = len(failed_ids | failed_covers)
            self.cleanup_bulk_download(tdir)
            return error_dialog(
                self.gui,
                _("Download failed"),
                _("Failed to download metadata or covers for any of the %d" " book(s).") % num,
                det_msg=det_msg,
                show=True,
            )

        self.gui.status_bar.show_message(_("Metadata download completed"), 3000)

        msg = "<p>" + _(
            "Finished downloading metadata for <b>%d book(s)</b>. "
            "Proceed with updating the metadata in your library?"
        ) % len(id_map)

        show_copy_button = False
        checkbox_msg = None
        if failed_ids or failed_covers:
            show_copy_button = True
            num = len(failed_ids.union(failed_covers))
            msg += (
                "<p>"
                + _(
                    "Could not download metadata and/or covers for %d of the books. Click"
                    ' "Show details" to see which books.'
                )
                % num
            )
            checkbox_msg = _("Show the &failed books in the main book list " "after updating metadata")

        if getattr(job, "metadata_and_covers", None) == (False, True):
            # Only covers, remove failed cover downloads from id_map
            for book_id in failed_covers:
                if hasattr(id_map, "discard"):
                    id_map.discard(book_id)
        payload = (id_map, tdir, log_file, lm_map, failed_ids.union(failed_covers))
        review_apply = partial(self.apply_downloaded_metadata, True)
        normal_apply = partial(self.apply_downloaded_metadata, False)
        self.gui.proceed_question(
            normal_apply,
            payload,
            log_file,
            _("Download log"),
            _("Metadata download complete"),
            msg,
            icon="download-metadata.png",
            det_msg=det_msg,
            show_copy_button=show_copy_button,
            cancel_callback=partial(self.cleanup_bulk_download, tdir),
            log_is_file=True,
            checkbox_msg=checkbox_msg,
            checkbox_checked=False,
            action_callback=review_apply,
            action_label=_("Revie&w downloaded metadata"),
            action_icon=QIcon(I("auto_author_sort.png")),
        )

    def apply_downloaded_metadata(self, review, payload, *args):
        good_ids, tdir, log_file, lm_map, failed_ids = payload
        if not good_ids:
            return
        restrict_to_failed = False

        modified = set()
        db = self.gui.current_db

        for i in good_ids:
            lm = db.metadata_last_modified(i, index_is_id=True)
            if lm is not None and lm_map[i] is not None and lm > lm_map[i]:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace("|", ",") for x in authors.split(",")]
                    title += " - " + authors_to_string(authors)
                modified.add(title)

        if modified:
            from calibre.utils.icu import lower

            modified = sorted(modified, key=lower)
            if not question_dialog(
                self.gui,
                _("Some books changed"),
                "<p>"
                + _(
                    "The metadata for some books in your library has"
                    " changed since you started the download. If you"
                    " proceed, some of those changes may be overwritten. "
                    'Click "Show details" to see the list of changed books. '
                    "Do you want to proceed?"
                ),
                det_msg="\n".join(modified),
            ):
                return

        id_map = {}
        for bid in good_ids:
            opf = os.path.join(tdir, "%d.mi" % bid)
            if not os.path.exists(opf):
                opf = None
            cov = os.path.join(tdir, "%d.cover" % bid)
            if not os.path.exists(cov):
                cov = None
            id_map[bid] = (opf, cov)

        if review:

            def get_metadata(book_id):
                oldmi = db.get_metadata(book_id, index_is_id=True, get_cover=True, cover_as_data=True)
                opf, cov = id_map[book_id]
                if opf is None:
                    newmi = Metadata(oldmi.title, authors=tuple(oldmi.authors))
                else:
                    with open(opf, "rb") as f:
                        newmi = OPF(f, basedir=os.path.dirname(opf), populate_spine=False).to_book_metadata()
                        newmi.cover, newmi.cover_data = None, (None, None)
                        for x in ("title", "authors"):
                            if newmi.is_null(x):
                                # Title and author are set to null if they are
                                # the same as the originals as an optimization,
                                # we undo that, as it is confusing.
                                newmi.set(x, copy.copy(oldmi.get(x)))
                if cov:
                    with open(cov, "rb") as f:
                        newmi.cover_data = ("jpg", f.read())
                return oldmi, newmi

            from calibre.gui2.metadata.diff import CompareMany

            d = CompareMany(
                set(id_map),
                get_metadata,
                db.field_metadata,
                parent=self.gui,
                window_title=_("Review downloaded metadata"),
                reject_button_tooltip=_("Discard downloaded metadata for this book"),
                accept_all_tooltip=_("Use the downloaded metadata for all remaining books"),
                reject_all_tooltip=_("Discard downloaded metadata for all remaining books"),
                revert_tooltip=_("Discard the downloaded value for: %s"),
                intro_msg=_(
                    "The downloaded metadata is on the left and the original metadata"
                    " is on the right. If a downloaded value is blank or unknown,"
                    " the original value is used."
                ),
                action_button=(_("&View Book"), I("view.png"), self.gui.iactions["View"].view_historical),
                db=db,
            )
            if d.exec_() == d.Accepted:
                if d.mark_rejected:
                    failed_ids |= d.rejected_ids
                    restrict_to_failed = True
                nid_map = {}
                for book_id, (changed, mi) in d.accepted.iteritems():
                    if mi is None:  # discarded
                        continue
                    if changed:
                        opf, cov = id_map[book_id]
                        cfile = mi.cover
                        mi.cover, mi.cover_data = None, (None, None)
                        if opf is not None:
                            with open(opf, "wb") as f:
                                f.write(metadata_to_opf(mi))
                        if cfile and cov:
                            shutil.copyfile(cfile, cov)
                            os.remove(cfile)
                    nid_map[book_id] = id_map[book_id]
                id_map = nid_map
            else:
                id_map = {}

        restrict_to_failed = restrict_to_failed or bool(args and args[0])
        restrict_to_failed = restrict_to_failed and bool(failed_ids)
        if restrict_to_failed:
            db.data.set_marked_ids(failed_ids)

        self.apply_metadata_changes(
            id_map,
            merge_comments=msprefs["append_comments"],
            icon="download-metadata.png",
            callback=partial(self.downloaded_metadata_applied, tdir, restrict_to_failed),
        )

    def downloaded_metadata_applied(self, tdir, restrict_to_failed, *args):
        if restrict_to_failed:
            self.gui.search.set_search_string("marked:true")
        self.cleanup_bulk_download(tdir)

    # }}}

    def edit_metadata(self, checked, bulk=None):
        """
        Edit metadata of selected books in library.
        """
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _("Cannot edit metadata"), _("No books selected"))
            d.exec_()
            return
        row_list = [r.row() for r in rows]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        self.edit_metadata_for(row_list, ids, bulk=bulk)

    def edit_metadata_for(self, rows, book_ids, bulk=None):
        previous = self.gui.library_view.currentIndex()
        if bulk or (bulk is None and len(rows) > 1):
            return self.do_edit_bulk_metadata(rows, book_ids)

        current_row = 0
        row_list = rows
        editing_multiple = len(row_list) > 1

        if not editing_multiple:
            cr = row_list[0]
            row_list = list(range(self.gui.library_view.model().rowCount(QModelIndex())))
            current_row = row_list.index(cr)

        view = self.gui.library_view.alternate_views.current_view
        try:
            hpos = view.horizontalScrollBar().value()
        except Exception:
            hpos = 0

        changed, rows_to_refresh = self.do_edit_metadata(row_list, current_row, editing_multiple)

        m = self.gui.library_view.model()

        if rows_to_refresh:
            m.refresh_rows(rows_to_refresh)

        if changed:
            self.refresh_books_after_metadata_edit(changed, previous)
        if self.gui.library_view.alternate_views.current_view is view:
            if hasattr(view, "restore_hpos"):
                view.restore_hpos(hpos)
            else:
                view.horizontalScrollBar().setValue(hpos)

    def refresh_books_after_metadata_edit(self, book_ids, previous=None):
        m = self.gui.library_view.model()
        m.refresh_ids(list(book_ids))
        current = self.gui.library_view.currentIndex()
        self.gui.refresh_cover_browser()
        m.current_changed(current, previous or current)
        self.gui.tags_view.recount()

    def do_edit_metadata(self, row_list, current_row, editing_multiple):
        from calibre.gui2.metadata.single import edit_metadata

        db = self.gui.library_view.model().db
        changed, rows_to_refresh = edit_metadata(
            db,
            row_list,
            current_row,
            parent=self.gui,
            view_slot=self.view_format_callback,
            set_current_callback=self.set_current_callback,
            editing_multiple=editing_multiple,
        )
        return changed, rows_to_refresh

    def set_current_callback(self, id_):
        db = self.gui.library_view.model().db
        current_row = db.row(id_)
        self.gui.library_view.set_current_row(current_row)
        self.gui.library_view.scroll_to_row(current_row)

    def view_format_callback(self, id_, fmt):
        view = self.gui.iactions["View"]
        if id_ is None:
            view._view_file(fmt)
        else:
            db = self.gui.library_view.model().db
            view.view_format(db.row(id_), fmt)

    def edit_bulk_metadata(self, checked):
        """
        Edit metadata of selected books in library in bulk.
        """
        rows = [r.row() for r in self.gui.library_view.selectionModel().selectedRows()]
        m = self.gui.library_view.model()
        ids = [m.id(r) for r in rows]
        if not rows or len(rows) == 0:
            d = error_dialog(self.gui, _("Cannot edit metadata"), _("No books selected"))
            d.exec_()
            return
        self.do_edit_bulk_metadata(rows, ids)

    def do_edit_bulk_metadata(self, rows, book_ids):
        # Prevent the TagView from updating due to signals from the database
        self.gui.tags_view.blockSignals(True)
        changed = False
        refresh_books = set(book_ids)
        try:
            current_tab = 0
            while True:
                dialog = MetadataBulkDialog(self.gui, rows, self.gui.library_view.model(), current_tab, refresh_books)
                if dialog.changed:
                    changed = True
                if not dialog.do_again:
                    break
                current_tab = dialog.central_widget.currentIndex()
        finally:
            self.gui.tags_view.blockSignals(False)
        if changed:
            refresh_books |= dialog.refresh_books
            m = self.gui.library_view.model()
            if gprefs["refresh_book_list_on_bulk_edit"]:
                m.refresh(reset=False)
                m.research()
            else:
                m.refresh_ids(refresh_books)
            self.gui.tags_view.recount()
            self.gui.refresh_cover_browser()
            self.gui.library_view.select_rows(book_ids)

    # Merge books {{{
    def merge_books(self, safe_merge=False, merge_only_formats=False):
        """
        Merge selected books in library.
        """
        from calibre.gui2.dialogs.confirm_merge import confirm_merge

        if self.gui.stack.currentIndex() != 0:
            return
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui, _("Cannot merge books"), _("No books selected"), show=True)
        if len(rows) < 2:
            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,
            ):
                return

        dest_id, src_ids = self.books_to_merge(rows)
        mi = self.gui.current_db.new_api.get_proxy_metadata(dest_id)
        title = mi.title
        hpos = self.gui.library_view.horizontalScrollBar().value()
        if safe_merge:
            if not 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,
            ):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
        elif merge_only_formats:
            if not confirm_merge(
                "<p>"
                + _(
                    "Book formats from the selected books will be merged "
                    "into the <b>first selected book</b> (%s). "
                    "Metadata in the first selected book will not be changed. "
                    "Author, Title and all other metadata will <i>not</i> be merged.<br><br>"
                    "After being merged, the second and subsequently "
                    "selected books, with any metadata they have will be <b>deleted</b>. <br><br>"
                    "All book formats of the first selected book will be kept "
                    "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_only_formats",
                self.gui,
                mi,
            ):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.delete_books_after_merge(src_ids)
        else:
            if not 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 "
                    "subsequently selected books will be <b>deleted</b>. <br><br>"
                    "All book formats of the first selected book will be kept "
                    "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,
            ):
                return
            self.add_formats(dest_id, self.formats_for_books(rows))
            self.merge_metadata(dest_id, src_ids)
            self.delete_books_after_merge(src_ids)
            # leave the selection highlight on first selected book
            dest_row = rows[0].row()
            for row in rows:
                if row.row() < rows[0].row():
                    dest_row -= 1
            self.gui.library_view.set_current_row(dest_row)
        cr = self.gui.library_view.currentIndex().row()
        self.gui.library_view.model().refresh_ids((dest_id,), cr)
        self.gui.library_view.horizontalScrollBar().setValue(hpos)

    def add_formats(self, dest_id, src_books, replace=False):
        for src_book in src_books:
            if src_book:
                fmt = os.path.splitext(src_book)[-1].replace(".", "").upper()
                with lopen(src_book, "rb") as f:
                    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):
        m = self.gui.library_view.model()
        ans = []
        for id_ in map(m.id, rows):
            dbfmts = m.db.formats(id_, index_is_id=True)
            if dbfmts:
                for fmt in dbfmts.split(","):
                    try:
                        path = m.db.format(id_, fmt, index_is_id=True, as_path=True)
                        ans.append(path)
                    except NoSuchFormat:
                        continue
        return ans

    def books_to_merge(self, rows):
        src_ids = []
        m = self.gui.library_view.model()
        for i, row in enumerate(rows):
            id_ = m.id(row)
            if i == 0:
                dest_id = id_
            else:
                src_ids.append(id_)
        return [dest_id, src_ids]

    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):
        db = self.gui.library_view.model().db
        dest_mi = db.get_metadata(dest_id, index_is_id=True)
        merged_identifiers = db.get_identifiers(dest_id, index_is_id=True)
        orig_dest_comments = dest_mi.comments
        dest_cover = db.cover(dest_id, index_is_id=True)
        had_orig_cover = bool(dest_cover)

        def is_null_date(x):
            return x is None or is_date_undefined(x)

        for src_id in src_ids:
            src_mi = db.get_metadata(src_id, index_is_id=True)

            if src_mi.comments and orig_dest_comments != src_mi.comments:
                if not dest_mi.comments:
                    dest_mi.comments = src_mi.comments
                else:
                    dest_mi.comments = unicode(dest_mi.comments) + u"\n\n" + unicode(src_mi.comments)
            if src_mi.title and (not dest_mi.title or dest_mi.title == _("Unknown")):
                dest_mi.title = src_mi.title
            if src_mi.title and (not dest_mi.authors or dest_mi.authors[0] == _("Unknown")):
                dest_mi.authors = src_mi.authors
                dest_mi.author_sort = src_mi.author_sort
            if src_mi.tags:
                if not dest_mi.tags:
                    dest_mi.tags = src_mi.tags
                else:
                    dest_mi.tags.extend(src_mi.tags)
            if not dest_cover:
                src_cover = db.cover(src_id, index_is_id=True)
                if src_cover:
                    dest_cover = src_cover
            if not dest_mi.publisher:
                dest_mi.publisher = src_mi.publisher
            if not dest_mi.rating:
                dest_mi.rating = src_mi.rating
            if not dest_mi.series:
                dest_mi.series = src_mi.series
                dest_mi.series_index = src_mi.series_index
            if is_null_date(dest_mi.pubdate) and not is_null_date(src_mi.pubdate):
                dest_mi.pubdate = src_mi.pubdate

            src_identifiers = db.get_identifiers(src_id, index_is_id=True)
            src_identifiers.update(merged_identifiers)
            merged_identifiers = src_identifiers.copy()

        if merged_identifiers:
            dest_mi.set_identifiers(merged_identifiers)
        db.set_metadata(dest_id, dest_mi, ignore_errors=False)

        if not had_orig_cover and dest_cover:
            db.set_cover(dest_id, dest_cover)

        for key in db.field_metadata:  # loop thru all defined fields
            fm = db.field_metadata[key]
            if not fm["is_custom"]:
                continue
            dt = fm["datatype"]
            colnum = fm["colnum"]
            # Get orig_dest_comments before it gets changed
            if dt == "comments":
                orig_dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True)

            for src_id in src_ids:
                dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True)
                src_value = db.get_custom(src_id, num=colnum, index_is_id=True)
                if dt == "comments" and src_value and src_value != orig_dest_value:
                    if not dest_value:
                        db.set_custom(dest_id, src_value, num=colnum)
                    else:
                        dest_value = unicode(dest_value) + u"\n\n" + unicode(src_value)
                        db.set_custom(dest_id, dest_value, num=colnum)
                if dt in {"bool", "int", "float", "rating", "datetime"} and dest_value is None:
                    db.set_custom(dest_id, src_value, num=colnum)
                if dt == "series" and not dest_value and src_value:
                    src_index = db.get_custom_extra(src_id, num=colnum, index_is_id=True)
                    db.set_custom(dest_id, src_value, num=colnum, extra=src_index)
                if (dt == "enumeration" or (dt == "text" and not fm["is_multiple"])) and not dest_value:
                    db.set_custom(dest_id, src_value, num=colnum)
                if dt == "text" and fm["is_multiple"] and src_value:
                    if not dest_value:
                        dest_value = src_value
                    else:
                        dest_value.extend(src_value)
                    db.set_custom(dest_id, dest_value, num=colnum)

    # }}}

    def edit_device_collections(self, view, oncard=None):
        model = view.model()
        result = model.get_collections_with_ids()
        d = DeviceCategoryEditor(self.gui, tag_to_match=None, data=result, key=sort_key)
        d.exec_()
        if d.result() == d.Accepted:
            to_rename = d.to_rename  # dict of new text to old ids
            to_delete = d.to_delete  # list of ids
            for old_id, new_name in to_rename.iteritems():
                model.rename_collection(old_id, new_name=unicode(new_name))
            for item in to_delete:
                model.delete_collection_using_id(item)
            self.gui.upload_collections(model.db, view=view, oncard=oncard)
            view.reset()

    # Apply bulk metadata changes {{{
    def apply_metadata_changes(
        self, id_map, title=None, msg="", callback=None, merge_tags=True, merge_comments=False, icon=None
    ):
        """
        Apply the metadata changes in id_map to the database synchronously
        id_map must be a mapping of ids to Metadata objects. Set any fields you
        do not want updated in the Metadata object to null. An easy way to do
        that is to create a metadata object as Metadata(_('Unknown')) and then
        only set the fields you want changed on this object.

        callback can be either None or a function accepting a single argument,
        in which case it is called after applying is complete with the list of
        changed ids.

        id_map can also be a mapping of ids to 2-tuple's where each 2-tuple
        contains the absolute paths to an OPF and cover file respectively. If
        either of the paths is None, then the corresponding metadata is not
        updated.
        """
        if title is None:
            title = _("Applying changed metadata")
        self.apply_id_map = list(id_map.iteritems())
        self.apply_current_idx = 0
        self.apply_failures = []
        self.applied_ids = set()
        self.apply_pd = None
        self.apply_callback = callback
        if len(self.apply_id_map) > 1:
            from calibre.gui2.dialogs.progress import ProgressDialog

            self.apply_pd = ProgressDialog(
                title, msg, min=0, max=len(self.apply_id_map) - 1, parent=self.gui, cancelable=False, icon=icon
            )
            self.apply_pd.setModal(True)
            self.apply_pd.show()
        self._am_merge_tags = merge_tags
        self._am_merge_comments = merge_comments
        self.do_one_apply()

    def do_one_apply(self):
        if self.apply_current_idx >= len(self.apply_id_map):
            return self.finalize_apply()

        i, mi = self.apply_id_map[self.apply_current_idx]
        if self.gui.current_db.has_id(i):
            if isinstance(mi, tuple):
                opf, cover = mi
                if opf:
                    mi = OPF(open(opf, "rb"), basedir=os.path.dirname(opf), populate_spine=False).to_book_metadata()
                    self.apply_mi(i, mi)
                if cover:
                    self.gui.current_db.set_cover(i, open(cover, "rb"), notify=False, commit=False)
                    self.applied_ids.add(i)
            else:
                self.apply_mi(i, mi)

        self.apply_current_idx += 1
        if self.apply_pd is not None:
            self.apply_pd.value += 1
        QTimer.singleShot(5, self.do_one_apply)

    def apply_mi(self, book_id, mi):
        db = self.gui.current_db

        try:
            set_title = not mi.is_null("title")
            set_authors = not mi.is_null("authors")
            idents = db.get_identifiers(book_id, index_is_id=True)
            if mi.identifiers:
                idents.update(mi.identifiers)
            mi.identifiers = idents
            if mi.is_null("series"):
                mi.series_index = None
            if self._am_merge_tags:
                old_tags = db.tags(book_id, index_is_id=True)
                if old_tags:
                    tags = [x.strip() for x in old_tags.split(",")] + (mi.tags if mi.tags else [])
                    mi.tags = list(set(tags))
            if self._am_merge_comments:
                old_comments = db.new_api.field_for("comments", book_id)
                if old_comments and mi.comments and old_comments != mi.comments:
                    mi.comments = merge_comments(old_comments, mi.comments)
            db.set_metadata(book_id, mi, commit=False, set_title=set_title, set_authors=set_authors, notify=False)
            self.applied_ids.add(book_id)
        except:
            import traceback

            self.apply_failures.append((book_id, traceback.format_exc()))

        try:
            if mi.cover:
                os.remove(mi.cover)
        except:
            pass

    def finalize_apply(self):
        db = self.gui.current_db
        db.commit()

        if self.apply_pd is not None:
            self.apply_pd.hide()

        if self.apply_failures:
            msg = []
            for i, tb in self.apply_failures:
                title = db.title(i, index_is_id=True)
                authors = db.authors(i, index_is_id=True)
                if authors:
                    authors = [x.replace("|", ",") for x in authors.split(",")]
                    title += " - " + authors_to_string(authors)
                msg.append(title + "\n\n" + tb + "\n" + ("*" * 80))

            error_dialog(
                self.gui,
                _("Some failures"),
                _(
                    "Failed to apply updated metadata for some books"
                    ' in your library. Click "Show Details" to see '
                    "details."
                ),
                det_msg="\n\n".join(msg),
                show=True,
            )
        changed_books = len(self.applied_ids or ())
        self.refresh_gui(self.applied_ids)

        self.apply_id_map = []
        self.apply_pd = None
        try:
            if callable(self.apply_callback):
                self.apply_callback(list(self.applied_ids))
        finally:
            self.apply_callback = None
        if changed_books:
            QApplication.alert(self.gui, 2000)

    def refresh_gui(self, book_ids, covers_changed=True, tag_browser_changed=True):
        if book_ids:
            cr = self.gui.library_view.currentIndex().row()
            self.gui.library_view.model().refresh_ids(list(book_ids), cr)
            if covers_changed:
                self.gui.refresh_cover_browser()
            if tag_browser_changed:
                self.gui.tags_view.recount()

    # }}}

    def remove_metadata_item(self, book_id, field, value):
        db = self.gui.current_db.new_api
        fm = db.field_metadata[field]
        affected_books = set()
        if field == "identifiers":
            identifiers = db.field_for(field, book_id)
            if identifiers.pop(value, False) is not False:
                affected_books = db.set_field(field, {book_id: identifiers})
        elif fm["is_multiple"]:
            item_id = db.get_item_id(field, value)
            if item_id is not None:
                affected_books = db.remove_items(field, (item_id,), {book_id})
        else:
            affected_books = db.set_field(field, {book_id: ""})
        if affected_books:
            self.refresh_books_after_metadata_edit(affected_books)

    def set_cover_from_format(self, book_id, fmt):
        from calibre.utils.config import prefs
        from calibre.ebooks.metadata.meta import get_metadata

        fmt = fmt.lower()
        cdata = None
        db = self.gui.current_db.new_api
        if fmt == "pdf":
            pdfpath = db.format_abspath(book_id, fmt)
            if pdfpath is None:
                return error_dialog(
                    self.gui,
                    _("Format file missing"),
                    _("Cannot read cover as the %s file is missing from this book") % "PDF",
                    show=True,
                )
            from calibre.gui2.metadata.pdf_covers import PDFCovers

            d = PDFCovers(pdfpath, parent=self.gui)
            if d.exec_() == d.Accepted:
                cpath = d.cover_path
                if cpath:
                    with open(cpath, "rb") as f:
                        cdata = f.read()
            d.cleanup()
        else:
            stream = BytesIO()
            try:
                db.copy_format_to(book_id, fmt, stream)
            except NoSuchFormat:
                return error_dialog(
                    self.gui,
                    _("Format file missing"),
                    _("Cannot read cover as the %s file is missing from this book") % fmt.upper(),
                    show=True,
                )
            old = prefs["read_file_metadata"]
            if not old:
                prefs["read_file_metadata"] = True
            try:
                stream.seek(0)
                mi = get_metadata(stream, fmt)
            except Exception:
                import traceback

                return error_dialog(
                    self.gui,
                    _("Could not read metadata"),
                    _("Could not read metadata from %s format") % fmt.upper(),
                    det_msg=traceback.format_exc(),
                    show=True,
                )
            finally:
                if old != prefs["read_file_metadata"]:
                    prefs["read_file_metadata"] = old
            if mi.cover and os.access(mi.cover, os.R_OK):
                cdata = open(mi.cover).read()
            elif mi.cover_data[1] is not None:
                cdata = mi.cover_data[1]
            if cdata is None:
                return error_dialog(
                    self.gui,
                    _("Could not read cover"),
                    _("Could not read cover from %s format") % fmt.upper(),
                    show=True,
                )
        db.set_cover({book_id: cdata})
        current_idx = self.gui.library_view.currentIndex()
        self.gui.library_view.model().current_changed(current_idx, current_idx)
        self.gui.refresh_cover_browser()
Example #57
0
class Adder(QObject):  # {{{

    ADD_TIMEOUT = 900  # seconds (15 minutes)

    def __init__(self, parent, db, callback, spare_server=None):
        QObject.__init__(self, parent)
        self.pd = ProgressDialog(_('Adding...'), parent=parent)
        self.pd.setMaximumWidth(min(600, int(available_width() * 0.75)))
        self.spare_server = spare_server
        self.db = db
        self.pd.setModal(True)
        self.pd.show()
        self._parent = parent
        self.rfind = self.worker = None
        self.callback = callback
        self.callback_called = False
        self.pd.canceled_signal.connect(self.canceled)

    def add_recursive(self, root, single=True):
        self.path = root
        self.pd.set_msg(_('Searching in all sub-directories...'))
        self.pd.set_min(0)
        self.pd.set_max(0)
        self.pd.value = 0
        self.rfind = RecursiveFind(self, self.db, root, single)
        self.rfind.update.connect(self.pd.set_msg, type=Qt.QueuedConnection)
        self.rfind.found.connect(self.add, type=Qt.QueuedConnection)
        self.rfind.start()

    def add(self, books):
        if isinstance(books, basestring):
            error_dialog(self.pd,
                         _('Path error'),
                         _('The specified directory could not be processed.'),
                         det_msg=books,
                         show=True)
            return self.canceled()
        if not books:
            info_dialog(self.pd, _('No books'), _('No books found'), show=True)
            return self.canceled()
        books = [[b] if isinstance(b, basestring) else b for b in books]
        restricted = set()
        for i in xrange(len(books)):
            files = books[i]
            restrictedi = set(f for f in files if not os.access(f, os.R_OK))
            if restrictedi:
                files = [f for f in files if os.access(f, os.R_OK)]
                books[i] = files
            restricted |= restrictedi
        if restrictedi:
            det_msg = u'\n'.join(restrictedi)
            warning_dialog(self.pd,
                           _('No permission'),
                           _('Cannot add some files as you do not have '
                             ' permission to access them. Click Show'
                             ' Details to see the list of such files.'),
                           det_msg=det_msg,
                           show=True)
        books = list(filter(None, books))
        if not books:
            return self.canceled()
        self.rfind = None
        from calibre.ebooks.metadata.worker import read_metadata
        self.rq = Queue()
        tasks = []
        self.ids = {}
        self.nmap = {}
        self.duplicates = []
        for i, b in enumerate(books):
            tasks.append((i, b))
            self.ids[i] = b
            self.nmap[i] = os.path.basename(b[0])
        self.worker = read_metadata(tasks,
                                    self.rq,
                                    spare_server=self.spare_server)
        self.pd.set_min(0)
        self.pd.set_max(len(self.ids))
        self.pd.value = 0
        self.db_adder = DBAdder(self, self.db, self.ids, self.nmap)
        self.db_adder.start()
        self.last_added_at = time.time()
        self.entry_count = len(self.ids)
        self.continue_updating = True
        single_shot(self.update)

    def canceled(self):
        self.continue_updating = False
        if self.rfind is not None:
            self.rfind.canceled = True
        if self.worker is not None:
            self.worker.canceled = True
        if hasattr(self, 'db_adder'):
            self.db_adder.end()
        self.pd.hide()
        if not self.callback_called:
            self.callback(self.paths, self.names, self.infos)
            self.callback_called = True

    def duplicates_processed(self):
        self.db_adder.end()
        if not self.callback_called:
            self.callback(self.paths, self.names, self.infos)
            self.callback_called = True
        if hasattr(self, '__p_d'):
            self.__p_d.hide()

    def update(self):
        if self.entry_count <= 0:
            self.continue_updating = False
            self.pd.hide()
            self.process_duplicates()
            return

        try:
            id, opf, cover = self.rq.get_nowait()
            self.db_adder.input_queue.put((id, opf, cover))
            self.last_added_at = time.time()
        except Empty:
            pass

        try:
            title = self.db_adder.output_queue.get_nowait()
            self.pd.value += 1
            self.pd.set_msg(_('Added') + ' ' + title)
            self.last_added_at = time.time()
            self.entry_count -= 1
        except Empty:
            pass

        if (time.time() - self.last_added_at) > self.ADD_TIMEOUT:
            self.continue_updating = False
            self.pd.hide()
            self.db_adder.end()
            if not self.callback_called:
                self.callback([], [], [])
                self.callback_called = True
            error_dialog(self._parent,
                         _('Adding failed'),
                         _('The add books process seems to have hung.'
                           ' Try restarting calibre and adding the '
                           'books in smaller increments, until you '
                           'find the problem book.'),
                         show=True)

        if self.continue_updating:
            single_shot(self.update)

    def process_duplicates(self):
        duplicates = self.db_adder.duplicates
        if not duplicates:
            return self.duplicates_processed()
        self.pd.hide()
        files = [
            _('%(title)s by %(author)s') %
            dict(title=x[0].title, author=x[0].format_field('authors')[1])
            for x in duplicates
        ]
        if question_dialog(
                self._parent, _('Duplicates found!'),
                _('Books with the same title as the following already '
                  'exist in the database. Add them anyway?'),
                '\n'.join(files)):
            pd = QProgressDialog(_('Adding duplicates...'), '', 0,
                                 len(duplicates), self._parent)
            pd.setCancelButton(None)
            pd.setValue(0)
            pd.show()
            self.__p_d = pd
            self.__d_a = DuplicatesAdder(self._parent, self.db, duplicates,
                                         self.db_adder)
            self.__d_a.added.connect(pd.setValue)
            self.__d_a.adding_done.connect(self.duplicates_processed)
        else:
            return self.duplicates_processed()

    def cleanup(self):
        if hasattr(self, 'pd'):
            self.pd.hide()
        if hasattr(self, 'worker') and hasattr(self.worker, 'tdir') and \
                self.worker.tdir is not None:
            if os.path.exists(self.worker.tdir):
                try:
                    shutil.rmtree(self.worker.tdir)
                except:
                    pass
        self._parent = None
        self.pd.setParent(None)
        del self.pd
        self.pd = None
        if hasattr(self, 'db_adder'):
            self.db_adder.setParent(None)
            del self.db_adder
            self.db_adder = None

    @property
    def number_of_books_added(self):
        return getattr(getattr(self, 'db_adder', None),
                       'number_of_books_added', 0)

    @property
    def merged_books(self):
        return getattr(getattr(self, 'db_adder', None), 'merged_books',
                       set([]))

    @property
    def critical(self):
        return getattr(getattr(self, 'db_adder', None), 'critical', {})

    @property
    def paths(self):
        return getattr(getattr(self, 'db_adder', None), 'paths', [])

    @property
    def names(self):
        return getattr(getattr(self, 'db_adder', None), 'names', [])

    @property
    def infos(self):
        return getattr(getattr(self, 'db_adder', None), 'infos', [])
Example #58
0
class CopyToLibraryAction(InterfaceAction):

    name = 'Copy To Library'
    action_spec = (_('Copy to library'), 'copy-to-library.png',
                   _('Copy selected books to the specified library'), None)
    popup_type = QToolButton.InstantPopup
    dont_add_to = frozenset(['context-menu-device'])
    action_type = 'current'
    action_add_menu = True

    def genesis(self):
        self.menu = self.qaction.menu()

    @property
    def stats(self):
        return self.gui.iactions['Choose Library'].stats

    def library_changed(self, db):
        self.build_menus()

    def initialization_complete(self):
        self.library_changed(self.gui.library_view.model().db)

    def location_selected(self, loc):
        enabled = loc == 'library'
        self.qaction.setEnabled(enabled)

    def build_menus(self):
        self.menu.clear()
        if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
            self.menu.addAction('disabled', self.cannot_do_dialog)
            return
        db = self.gui.library_view.model().db
        locations = list(self.stats.locations(db))
        if len(locations) > 5:
            self.menu.addAction(_('Choose library...'), self.choose_library)
            self.menu.addSeparator()
        for name, loc in locations:
            name = name.replace('&', '&&')
            self.menu.addAction(name, partial(self.copy_to_library, loc))
            self.menu.addAction(
                name + ' ' + _('(delete after copy)'),
                partial(self.copy_to_library, loc, delete_after=True))
            self.menu.addSeparator()
        if len(locations) <= 5:
            self.menu.addAction(_('Choose library...'), self.choose_library)

        self.qaction.setVisible(bool(locations))
        if isosx:
            # The cloned action has to have its menu updated
            self.qaction.changed.emit()

    def choose_library(self):
        db = self.gui.library_view.model().db
        locations = list(self.stats.locations(db))
        d = ChooseLibrary(self.gui, locations)
        if d.exec_() == d.Accepted:
            path, delete_after = d.args
            if not path:
                return
            db = self.gui.library_view.model().db
            current = os.path.normcase(os.path.abspath(db.library_path))
            if current == os.path.normcase(os.path.abspath(path)):
                return error_dialog(self.gui,
                                    _('Cannot copy'),
                                    _('Cannot copy to current library.'),
                                    show=True)
            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):
        rows = self.gui.library_view.selectionModel().selectedRows()
        if not rows or len(rows) == 0:
            return error_dialog(self.gui,
                                _('Cannot copy'),
                                _('No books selected'),
                                show=True)
        ids = list(map(self.gui.library_view.model().id, rows))
        db = self.gui.library_view.model().db
        if not db.exists_at(loc):
            return error_dialog(self.gui,
                                _('No library'),
                                _('No library found at %s') % loc,
                                show=True)

        # 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. We also do not want the user defined
        # formatter functions because loading them can poison the template cache
        global libraries_with_checked_columns

        from calibre.db.legacy import create_backend
        newdb = create_backend(loc, load_user_formatter_functions=False)

        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 k not in newdb_meta:
                        missing_columns.append(k)
                    elif not self._column_is_compatible(m, newdb_meta[k]):
                        # Note that composite columns are always assumed to be
                        # compatible. No attempt is made to copy the template
                        # from the source to the destination.
                        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
        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=True,
                                 icon='lt.png')

        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,
                             add_duplicates)
        self.worker.start()
        self.pd.canceled_signal.connect(self.worker.cancel_processing)

        self.pd.exec_()
        self.pd.canceled_signal.disconnect()

        if self.worker.left_after_cancel:
            msg = _(
                'The copying process was interrupted. {} books were copied.'
            ).format(len(self.worker.processed))
            if delete_after:
                msg += ' ' + _('No books were deleted from this library.')
            msg += ' ' + _(
                'The best way to resume this operation is to re-copy all the books with the option to'
                ' "Check for duplicates when copying to library" in Preferences->Import/export->Adding books turned on.'
            )
            warning_dialog(self.gui, _('Canceled'), msg, show=True)
            return

        if self.worker.error is not None:
            e, tb = self.worker.error
            error_dialog(self.gui,
                         _('Failed'),
                         _('Could not copy books: ') + e,
                         det_msg=tb,
                         show=True)
            return

        if delete_after:
            donemsg = ngettext('Moved the book to {loc}',
                               'Moved {num} books to {loc}',
                               len(self.worker.processed))
        else:
            donemsg = ngettext('Copied the book to {loc}',
                               'Copied {num} books to {loc}',
                               len(self.worker.processed))

        self.gui.status_bar.show_message(
            donemsg.format(num=len(self.worker.processed), 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->Import/export->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)

        if self.worker.failed_books:

            def fmt_err(book_id):
                err, tb = self.worker.failed_books[book_id]
                title = db.title(book_id, index_is_id=True)
                return _('Copying: {0} failed, with error:\n{1}').format(
                    title, tb)

            title, msg = _('Failed to copy some books'), _(
                'Could not copy some books, click "Show Details" for more information.'
            )
            tb = '\n\n'.join(map(fmt_err, self.worker.failed_books))
            tb = ngettext('Failed to copy a book, see below for details',
                          'Failed to copy {} books, see below for details',
                          len(self.worker.failed_books)).format(
                              len(self.worker.failed_books)) + '\n\n' + tb
            if len(ids) == len(self.worker.failed_books):
                title, msg = _('Failed to copy books'), _(
                    'Could not copy any books, click "Show Details" for more information.'
                )
            error_dialog(self.gui, title, msg, det_msg=tb, show=True)
        return self.worker.duplicate_ids

    def cannot_do_dialog(self):
        warning_dialog(
            self.gui,
            _('Not allowed'),
            _('You cannot use other libraries while using the environment'
              ' variable CALIBRE_OVERRIDE_DATABASE_PATH.'),
            show=True)