class Polish(QDialog): # {{{ def __init__(self, db, book_id_map, parent=None): from calibre.ebooks.oeb.polish.main import HELP QDialog.__init__(self, parent) self.db, self.book_id_map = weakref.ref(db), book_id_map self.setWindowIcon(QIcon(I('polish.png'))) title = _('Polish book') if len(book_id_map) > 1: title = _('Polish %d books') % len(book_id_map) self.setWindowTitle(title) self.help_text = { 'polish': _('<h3>About Polishing books</h3>%s') % HELP['about'].format( _('''<p>If you have both EPUB and ORIGINAL_EPUB in your book, then polishing will run on ORIGINAL_EPUB (the same for other ORIGINAL_* formats). So if you want Polishing to not run on the ORIGINAL_* format, delete the ORIGINAL_* format before running it.</p>''')), 'embed': _('<h3>Embed referenced fonts</h3>%s') % HELP['embed'], 'subset': _('<h3>Subsetting fonts</h3>%s') % HELP['subset'], 'smarten_punctuation': _('<h3>Smarten punctuation</h3>%s') % HELP['smarten_punctuation'], 'metadata': _('<h3>Updating metadata</h3>' '<p>This will update all metadata <i>except</i> the cover in the' ' e-book files to match the current metadata in the' ' calibre library.</p>' ' <p>Note that most e-book' ' formats are not capable of supporting all the' ' metadata in calibre.</p><p>There is a separate option to' ' update the cover.</p>'), 'do_cover': _('<h3>Update cover</h3><p>Update the covers in the e-book files to match the' ' current cover in the calibre library.</p>' '<p>If the e-book file does not have' ' an identifiable cover, a new cover is inserted.</p>'), 'jacket': _('<h3>Book jacket</h3>%s') % HELP['jacket'], 'remove_jacket': _('<h3>Remove book jacket</h3>%s') % HELP['remove_jacket'], 'remove_unused_css': _('<h3>Remove unused CSS rules</h3>%s') % HELP['remove_unused_css'], 'compress_images': _('<h3>Losslessly compress images</h3>%s') % HELP['compress_images'], 'add_soft_hyphens': _('<h3>Add soft-hyphens</h3>%s') % HELP['add_soft_hyphens'], 'remove_soft_hyphens': _('<h3>Remove soft-hyphens</h3>%s') % HELP['remove_soft_hyphens'], 'upgrade_book': _('<h3>Upgrade book internals</h3>%s') % HELP['upgrade_book'], } self.l = l = QGridLayout() self.setLayout(l) self.la = la = QLabel('<b>' + _('Select actions to perform:')) l.addWidget(la, 0, 0, 1, 2) count = 0 self.all_actions = OrderedDict([ ('embed', _('&Embed all referenced fonts')), ('subset', _('&Subset all embedded fonts')), ('smarten_punctuation', _('Smarten &punctuation')), ('metadata', _('Update &metadata in the book files')), ('do_cover', _('Update the &cover in the book files')), ('jacket', _('Add/replace metadata as a "book &jacket" page')), ('remove_jacket', _('&Remove a previously inserted book jacket')), ('remove_unused_css', _('Remove &unused CSS rules from the book')), ('compress_images', _('Losslessly &compress images')), ('add_soft_hyphens', _('Add s&oft hyphens')), ('remove_soft_hyphens', _('Remove so&ft hyphens')), ('upgrade_book', _('&Upgrade book internals')), ]) prefs = gprefs.get('polishing_settings', {}) for name, text in iteritems(self.all_actions): count += 1 x = QCheckBox(text, self) x.setChecked(prefs.get(name, False)) x.setObjectName(name) connect_lambda( x.stateChanged, self, lambda self, state: self.option_toggled( self.sender().objectName(), state)) l.addWidget(x, count, 0, 1, 1) setattr(self, 'opt_' + name, x) la = QLabel(' <a href="#%s">%s</a>' % (name, _('About'))) setattr(self, 'label_' + name, x) la.linkActivated.connect(self.help_link_activated) l.addWidget(la, count, 1, 1, 1) count += 1 l.addItem(QSpacerItem(10, 10, vPolicy=QSizePolicy.Policy.Expanding), count, 1, 1, 2) la = self.help_label = QLabel('') self.help_link_activated('#polish') la.setWordWrap(True) la.setTextFormat(Qt.TextFormat.RichText) la.setFrameShape(QFrame.Shape.StyledPanel) la.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) la.setLineWidth(2) la.setStyleSheet('QLabel { margin-left: 75px }') l.addWidget(la, 0, 2, count + 1, 1) l.setColumnStretch(2, 1) self.show_reports = sr = QCheckBox(_('Show &report'), self) sr.setChecked(gprefs.get('polish_show_reports', True)) sr.setToolTip( textwrap.fill( _('Show a report of all the actions performed' ' after polishing is completed'))) l.addWidget(sr, count + 1, 0, 1, 1) self.bb = bb = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) self.save_button = sb = bb.addButton( _('&Save settings'), QDialogButtonBox.ButtonRole.ActionRole) sb.clicked.connect(self.save_settings) self.load_button = lb = bb.addButton( _('&Load settings'), QDialogButtonBox.ButtonRole.ActionRole) self.load_menu = QMenu(lb) lb.setMenu(self.load_menu) self.all_button = b = bb.addButton( _('Select &all'), QDialogButtonBox.ButtonRole.ActionRole) connect_lambda(b.clicked, self, lambda self: self.select_all(True)) self.none_button = b = bb.addButton( _('Select &none'), QDialogButtonBox.ButtonRole.ActionRole) connect_lambda(b.clicked, self, lambda self: self.select_all(False)) l.addWidget(bb, count + 1, 1, 1, -1) self.setup_load_button() self.resize(QSize(950, 600)) def select_all(self, enable): for action in self.all_actions: x = getattr(self, 'opt_' + action) x.blockSignals(True) x.setChecked(enable) x.blockSignals(False) def save_settings(self): if not self.something_selected: return error_dialog( self, _('No actions selected'), _('You must select at least one action before saving'), show=True) name, ok = QInputDialog.getText(self, _('Choose name'), _('Choose a name for these settings')) if ok: name = str(name).strip() if name: settings = { ac: getattr(self, 'opt_' + ac).isChecked() for ac in self.all_actions } saved = gprefs.get('polish_settings', {}) saved[name] = settings gprefs.set('polish_settings', saved) self.setup_load_button() def setup_load_button(self): saved = gprefs.get('polish_settings', {}) m = self.load_menu m.clear() self.__actions = [] a = self.__actions.append for name in sorted(saved): a(m.addAction(name, partial(self.load_settings, name))) m.addSeparator() a(m.addAction(_('Remove saved settings'), self.clear_settings)) self.load_button.setEnabled(bool(saved)) def clear_settings(self): gprefs.set('polish_settings', {}) self.setup_load_button() def load_settings(self, name): saved = gprefs.get('polish_settings', {}).get(name, {}) for action in self.all_actions: checked = saved.get(action, False) x = getattr(self, 'opt_' + action) x.blockSignals(True) x.setChecked(checked) x.blockSignals(False) def option_toggled(self, name, state): if state == Qt.CheckState.Checked: self.help_label.setText(self.help_text[name]) def help_link_activated(self, link): link = str(link)[1:] self.help_label.setText(self.help_text[link]) @property def something_selected(self): for action in self.all_actions: if getattr(self, 'opt_' + action).isChecked(): return True return False def accept(self): self.actions = ac = {} saved_prefs = {} gprefs['polish_show_reports'] = bool(self.show_reports.isChecked()) something = False for action in self.all_actions: ac[action] = saved_prefs[action] = bool( getattr(self, 'opt_' + action).isChecked()) if ac[action]: something = True if ac['jacket'] and not ac['metadata']: if not question_dialog( self, _('Must update metadata'), _('You have selected the option to add metadata as ' 'a "book jacket". For this option to work, you ' 'must also select the option to update metadata in' ' the book files. Do you want to select it?')): return ac['metadata'] = saved_prefs['metadata'] = True self.opt_metadata.setChecked(True) if ac['jacket'] and ac['remove_jacket']: if not question_dialog( self, _('Add or remove jacket?'), _('You have chosen to both add and remove the metadata jacket.' ' This will result in the final book having no jacket. Is this' ' what you want?')): return if not something: return error_dialog( self, _('No actions selected'), _('You must select at least one action, or click Cancel.'), show=True) gprefs['polishing_settings'] = saved_prefs self.queue_files() return super().accept() def queue_files(self): self.tdir = PersistentTemporaryDirectory('_queue_polish') self.jobs = [] if len(self.book_id_map) <= 5: for i, (book_id, formats) in enumerate(iteritems(self.book_id_map)): self.do_book(i + 1, book_id, formats) else: self.queue = [(i + 1, id_) for i, id_ in enumerate(self.book_id_map)] self.pd = ProgressDialog(_('Queueing books for polishing'), max=len(self.queue), parent=self) QTimer.singleShot(0, self.do_one) self.pd.exec() def do_one(self): if not self.queue: self.pd.accept() return if self.pd.canceled: self.jobs = [] self.pd.reject() return num, book_id = self.queue.pop(0) try: self.do_book(num, book_id, self.book_id_map[book_id]) except: self.pd.reject() raise else: self.pd.set_value(num) QTimer.singleShot(0, self.do_one) def do_book(self, num, book_id, formats): base = os.path.join(self.tdir, str(book_id)) os.mkdir(base) db = self.db() opf = os.path.join(base, 'metadata.opf') with open(opf, 'wb') as opf_file: mi = create_opf_file(db, book_id, opf_file=opf_file)[0] data = {'opf': opf, 'files': []} for action in self.actions: data[action] = bool(getattr(self, 'opt_' + action).isChecked()) cover = os.path.join(base, 'cover.jpg') if db.copy_cover_to(book_id, cover, index_is_id=True): data['cover'] = cover is_orig = {} for fmt in formats: ext = fmt.replace('ORIGINAL_', '').lower() is_orig[ext.upper()] = 'ORIGINAL_' in fmt with open(os.path.join(base, '%s.%s' % (book_id, ext)), 'wb') as f: db.copy_format_to(book_id, fmt, f, index_is_id=True) data['files'].append(f.name) nums = num if hasattr(self, 'pd'): nums = self.pd.max - num desc = ngettext( _('Polish %s') % mi.title, _('Polish book %(nums)s of %(tot)s (%(title)s)') % dict(nums=nums, tot=len(self.book_id_map), title=mi.title), len(self.book_id_map)) if hasattr(self, 'pd'): self.pd.set_msg( _('Queueing book %(nums)s of %(tot)s (%(title)s)') % dict(nums=num, tot=len(self.book_id_map), title=mi.title)) self.jobs.append((desc, data, book_id, base, is_orig))
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 folder') 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-add', _('Add from folders and sub-folders')).triggered.connect( self.add_recursive_question) ma('archive-add-book', _('Add multiple books from archive (ZIP/RAR)')).triggered.connect( self.add_from_archive) 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') ma('add-formats-clipboard', _('Add files to selected book records from clipboard'), triggered=self.add_formats_from_clipboard, shortcut='Shift+Alt+A') ma('add-empty-format-to-books', _('Add an empty file to selected book records')).triggered.connect( self.add_empty_format_choose) 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 _check_add_formats_ok(self): if self.gui.current_view() is not self.gui.library_view: return [] view = self.gui.library_view rows = view.selectionModel().selectedRows() if not rows: 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] return ids def add_formats_from_clipboard(self): ids = self._check_add_formats_ok() if not ids: return md = QApplication.instance().clipboard().mimeData() files_to_add = [] images = [] if md.hasUrls(): for url in md.urls(): if url.isLocalFile(): path = url.toLocalFile() if os.access(path, os.R_OK): mt = guess_type(path)[0] if mt and mt.startswith('image/'): images.append(path) else: files_to_add.append(path) if not files_to_add and not images: return error_dialog( self.gui, _('No files in clipboard'), _('No files have been copied to the clipboard'), show=True) if files_to_add: self._add_formats(files_to_add, ids) if images: if len(ids) > 1 and not question_dialog( self.gui, _('Are you sure?'), _('Are you sure you want to set the same' ' cover for all %d books?') % len(ids)): return with lopen(images[0], 'rb') as f: cdata = f.read() self.gui.current_db.new_api.set_cover( {book_id: cdata for book_id in ids}) self.gui.refresh_cover_browser() m = self.gui.library_view.model() current = self.gui.library_view.currentIndex() m.current_changed(current, current) def add_formats(self, *args): ids = self._check_add_formats_ok() if not ids: return books = choose_files_and_remember_all_files(self.gui, 'add formats dialog dir', _('Select book files'), filters=get_filters()) if books: self._add_formats(books, ids) def _add_formats(self, paths, ids): 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 paths = list(map(make_long_path_useable, paths)) db = self.gui.current_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 paths} override = formats.intersection(nformats) if override: title = db.title(ids[0], index_is_id=True) msg = ngettext( 'The {0} format will be replaced in the book {1}. Are you sure?', 'The {0} formats will be replaced in the book {1}. Are you sure?', len(override)).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 paths } for id_ in ids: for fmt, fpath in iteritems(fmt_map): 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(): self.gui.library_view.model().current_changed( current_idx, current_idx) def is_ok_to_add_empty_formats(self): 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 return True def add_empty_format_choose(self): if not self.is_ok_to_add_empty_formats(): return from calibre.ebooks.oeb.polish.create import valid_empty_formats from calibre.gui2.dialogs.choose_format import ChooseFormatDialog d = ChooseFormatDialog(self.gui, _('Choose format of empty file'), sorted(valid_empty_formats)) if d.exec() != QDialog.DialogCode.Accepted or not d.format(): return self._add_empty_format(d.format()) def add_empty_format(self, format_): if not self.is_ok_to_add_empty_formats(): return self._add_empty_format(format_) def _add_empty_format(self, format_): view = self.gui.library_view rows = view.selectionModel().selectedRows() ids = [view.model().id(r) for r in rows] 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: self.add_empty_format_to_book(id_, format_) current_idx = self.gui.library_view.currentIndex() if current_idx.isValid(): view.model().current_changed(current_idx, current_idx) def add_empty_format_to_book(self, book_id, fmt): from calibre.ebooks.oeb.polish.create import create_book db = self.gui.current_db pt = PersistentTemporaryFile(suffix='.' + fmt.lower()) pt.close() try: mi = db.new_api.get_metadata(book_id, get_cover=False, get_user_categories=False, cover_as_data=False) create_book(mi, pt.name, fmt=fmt.lower()) db.add_format_with_hooks(book_id, fmt, pt.name, index_is_id=True, notify=True) finally: os.remove(pt.name) 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_from_archive(self): single = question_dialog( self.gui, _('Type of archive'), _('Will the archive have a single book per internal folder?')) 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_recursive_question(self): single = question_dialog( self.gui, _('Multi-file books?'), _('Assume all e-book files in a single folder are multiple formats of the same book?' )) self.add_recursive(single) 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() == QDialog.DialogCode.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 range(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)) for path in orig_fmts: os.remove(path) self.refresh_gui(num) if ids: ids.reverse() self.gui.library_view.select_rows(ids) for path in temp_files: os.remove(path) def check_for_existing_isbns(self, books): db = self.gui.current_db.new_api book_id_identifiers = db.all_field_for('identifiers', db.all_book_ids(tuple)) existing_isbns = { normalize_isbn(ids.get('isbn', '')): book_id for book_id, ids in book_id_identifiers.items() } existing_isbns.pop('', None) ok = [] duplicates = [] for book in books: q = normalize_isbn(book['isbn']) if q and q in existing_isbns: duplicates.append((book, existing_isbns[q])) else: ok.append(book) if duplicates: det_msg = '\n'.join( f'{book["isbn"]}: {db.field_for("title", book_id)}' for book, book_id in duplicates) if question_dialog( self.gui, _('Duplicates found'), _('Books with some of the specified ISBNs already exist in the calibre library.' ' Click "Show details" for the full list. Do you want to add them anyway?' ), det_msg=det_msg): ok += [x[0] for x in duplicates] return ok def add_isbns(self, books, add_tags=[], check_for_existing=False): books = list(books) if check_for_existing: books = self.check_for_existing_isbns(books) if not books: return self.isbn_books = books self.add_by_isbn_ids = set() self.isbn_add_tags = add_tags QTimer.singleShot(10, self.do_one_isbn_add) self.isbn_add_dialog = ProgressDialog( _('Adding'), _('Creating book records from ISBNs'), max=len(books), cancelable=False, parent=self.gui) self.isbn_add_dialog.exec() 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 image_exts = set(image_extensions()) - set( tweaks['cover_drop_exclude']) if iswindows: from calibre.gui2.add import resolve_windows_links paths = list( resolve_windows_links(paths, hwnd=int(self.gui.effectiveWinId()))) for path in paths: ext = os.path.splitext(path)[1].lower() if ext: ext = ext[1:] if ext in image_exts: 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() add_as_book = False if do_confirm and formats: ok, add_as_book = 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, extra_button=ngettext( 'Add as new book', 'Add as new books', len(formats))) if ok and add_as_book: add_as_book = [path for ext, path in formats] if not ok or add_as_book: 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() if add_as_book: self.files_dropped(add_as_book) def __add_filesystem_book(self, paths, allow_device=True): if isinstance(paths, string_or_bytes): 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() == QDialog.DialogCode.Accepted and d.books: self.add_isbns(d.books, add_tags=d.set_tags, check_for_existing=d.check_for_existing) 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_and_remember_all_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 refresh_gui(self, num, set_current_row=-1, recount=True): self.gui.library_view.model().books_added(num) if set_current_row > -1: self.gui.library_view.set_current_row(0) self.gui.refresh_cover_browser() if recount: self.gui.tags_view.recount() 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.refresh_gui(adder.number_of_books_added, set_current_row=0) 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( f'<b><i>{prepare_string_for_xml(author)}</i></b><ol style="margin-top: 0">' ) for title in sorted(merged[author]): lines.append(f'<li>{prepare_string_for_xml(title)}</li>') lines.append('</ol>') pm = ngettext('The following duplicate book was found.', 'The following {} duplicate books were found.', len(adder.merged_books)).format( len(adder.merged_books)) info_dialog( self.gui, _('Merged some books'), pm + ' ' + _('Incoming book formats were processed and merged into your ' 'calibre database according to your auto-merge ' 'settings. Click "Show details" to see the list of merged books.' ), det_msg='\n'.join(lines), show=True, only_copy_details=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 = {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, string_or_bytes)] 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 CopyToLibraryAction(InterfaceAction): name = 'Copy To Library' action_spec = (_('Copy to library'), 'copy-to-library.png', _('Copy selected books to the specified library'), None) popup_type = QToolButton.ToolButtonPopupMode.InstantPopup dont_add_to = frozenset(['context-menu-device']) action_type = 'current' action_add_menu = True def genesis(self): self.menu = self.qaction.menu() @property def stats(self): return self.gui.iactions['Choose Library'].stats def library_changed(self, db): self.build_menus() def initialization_complete(self): self.library_changed(self.gui.library_view.model().db) def location_selected(self, loc): enabled = loc == 'library' self.qaction.setEnabled(enabled) self.menuless_qaction.setEnabled(enabled) def build_menus(self): self.menu.clear() if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None): self.menu.addAction('disabled', self.cannot_do_dialog) return db = self.gui.library_view.model().db locations = list(self.stats.locations(db)) if len(locations) > 5: self.menu.addAction(_('Choose library...'), self.choose_library) self.menu.addSeparator() for name, loc in locations: name = name.replace('&', '&&') self.menu.addAction(name, partial(self.copy_to_library, loc)) self.menu.addAction( name + ' ' + _('(delete after copy)'), partial(self.copy_to_library, loc, delete_after=True)) self.menu.addSeparator() if len(locations) <= 5: self.menu.addAction(_('Choose library...'), self.choose_library) self.qaction.setVisible(bool(locations)) if ismacos: # The cloned action has to have its menu updated self.qaction.changed.emit() def choose_library(self): db = self.gui.library_view.model().db locations = list(self.stats.locations(db)) d = ChooseLibrary(self.gui, locations) if d.exec() == QDialog.DialogCode.Accepted: path, delete_after = d.args if not path: return db = self.gui.library_view.model().db current = os.path.normcase(os.path.abspath(db.library_path)) if current == os.path.normcase(os.path.abspath(path)): return error_dialog(self.gui, _('Cannot copy'), _('Cannot copy to current library.'), show=True) self.copy_to_library(path, delete_after) def _column_is_compatible(self, source_metadata, dest_metadata): return (source_metadata['datatype'] == dest_metadata['datatype'] and (source_metadata['datatype'] != 'text' or source_metadata['is_multiple'] == dest_metadata['is_multiple'])) def copy_to_library(self, loc, delete_after=False): rows = self.gui.library_view.selectionModel().selectedRows() if not rows or len(rows) == 0: return error_dialog(self.gui, _('Cannot copy'), _('No books selected'), show=True) ids = list(map(self.gui.library_view.model().id, rows)) db = self.gui.library_view.model().db if not db.exists_at(loc): return error_dialog(self.gui, _('No library'), _('No library found at %s') % loc, show=True) # Open the new db so we can check the custom columns. We use only the # backend since we only need the custom column definitions, not the # rest of the data in the db. We also do not want the user defined # formatter functions because loading them can poison the template cache global libraries_with_checked_columns from calibre.db.legacy import create_backend newdb = create_backend(loc, load_user_formatter_functions=False) continue_processing = True with closing(newdb): if newdb.library_id not in libraries_with_checked_columns[ db.library_id]: newdb_meta = newdb.field_metadata.custom_field_metadata() incompatible_columns = [] missing_columns = [] for k, m in db.field_metadata.custom_iteritems(): if k not in newdb_meta: missing_columns.append(k) elif not self._column_is_compatible(m, newdb_meta[k]): # Note that composite columns are always assumed to be # compatible. No attempt is made to copy the template # from the source to the destination. incompatible_columns.append(k) if missing_columns or incompatible_columns: continue_processing = ask_about_cc_mismatch( self.gui, db, newdb, missing_columns, incompatible_columns) if continue_processing: libraries_with_checked_columns[db.library_id].add( newdb.library_id) newdb.close() del newdb if not continue_processing: return duplicate_ids = self.do_copy(ids, db, loc, delete_after, False) if duplicate_ids: d = DuplicatesQuestion(self.gui, duplicate_ids, loc) if d.exec() == QDialog.DialogCode.Accepted: ids = d.ids if ids: self.do_copy(list(ids), db, loc, delete_after, add_duplicates=True) def do_copy(self, ids, db, loc, delete_after, add_duplicates=False): aname = _('Moving to') if delete_after else _('Copying to') dtitle = '%s %s' % (aname, os.path.basename(loc)) self.pd = ProgressDialog(dtitle, min=0, max=len(ids) - 1, parent=self.gui, cancelable=True, icon='lt.png') def progress(idx, title): self.pd.set_msg(title) self.pd.set_value(idx) self.worker = Worker(ids, db, loc, Dispatcher(progress), Dispatcher(self.pd.accept), delete_after, add_duplicates) self.worker.start() self.pd.canceled_signal.connect(self.worker.cancel_processing) self.pd.exec() self.pd.canceled_signal.disconnect() if self.worker.left_after_cancel: msg = _( 'The copying process was interrupted. {} books were copied.' ).format(len(self.worker.processed)) if delete_after: msg += ' ' + _('No books were deleted from this library.') msg += ' ' + _( 'The best way to resume this operation is to re-copy all the books with the option to' ' "Check for duplicates when copying to library" in Preferences->Import/export->Adding books turned on.' ) warning_dialog(self.gui, _('Canceled'), msg, show=True) return if self.worker.error is not None: e, tb = self.worker.error error_dialog(self.gui, _('Failed'), _('Could not copy books: ') + e, det_msg=tb, show=True) return if delete_after: donemsg = _('Moved the book to {loc}') if len( self.worker.processed) == 1 else _( 'Moved {num} books to {loc}') else: donemsg = _('Copied the book to {loc}') if len( self.worker.processed) == 1 else _( 'Copied {num} books to {loc}') self.gui.status_bar.show_message( donemsg.format(num=len(self.worker.processed), loc=loc), 2000) if self.worker.auto_merged_ids: books = '\n'.join(itervalues(self.worker.auto_merged_ids)) info_dialog( self.gui, _('Auto merged'), _('Some books were automatically merged into existing ' 'records in the target library. Click "Show ' 'details" to see which ones. This behavior is ' 'controlled by the Auto-merge option in ' 'Preferences->Import/export->Adding books->Adding actions.'), det_msg=books, show=True) done_ids = frozenset(self.worker.processed) - frozenset( self.worker.duplicate_ids) if delete_after and done_ids: v = self.gui.library_view ci = v.currentIndex() row = None if ci.isValid(): row = ci.row() v.model().delete_books_by_id(done_ids, permanent=True) self.gui.iactions['Remove Books'].library_ids_deleted( done_ids, row) if self.worker.failed_books: def fmt_err(book_id): err, tb = self.worker.failed_books[book_id] title = db.title(book_id, index_is_id=True) return _('Copying: {0} failed, with error:\n{1}').format( title, tb) title, msg = _('Failed to copy some books'), _( 'Could not copy some books, click "Show details" for more information.' ) tb = '\n\n'.join(map(fmt_err, self.worker.failed_books)) tb = ngettext('Failed to copy a book, see below for details', 'Failed to copy {} books, see below for details', len(self.worker.failed_books)).format( len(self.worker.failed_books)) + '\n\n' + tb if len(ids) == len(self.worker.failed_books): title, msg = _('Failed to copy books'), _( 'Could not copy any books, click "Show details" for more information.' ) error_dialog(self.gui, title, msg, det_msg=tb, show=True) return self.worker.duplicate_ids def cannot_do_dialog(self): warning_dialog( self.gui, _('Not allowed'), _('You cannot use other libraries while using the environment' ' variable CALIBRE_OVERRIDE_DATABASE_PATH.'), show=True)