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), add_abort_button=True ): 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)
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)
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)
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
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)
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
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()
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())
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()
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', [])
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", [])
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))
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', [])
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()
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)
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()
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
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())
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))
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 = series = 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()) dlg = AddEmptyBookDialog(self.gui, self.gui.library_view.model().db, author, series) if dlg.exec_() == dlg.Accepted: num = dlg.qty_to_add series = dlg.selected_series db = self.gui.library_view.model().db ids = [] for x in xrange(num): mi = MetaInformation(_('Unknown'), dlg.selected_authors) if series: mi.series = series mi.series_index = db.get_next_series_num_for(series) ids.append(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() if ids: ids.reverse() self.gui.library_view.select_rows(ids) 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)