1#!/usr/local/bin/python3.8 2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 3 4 5__license__ = 'GPL v3' 6__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' 7__docformat__ = 'restructuredtext en' 8 9import copy 10import os 11import shutil 12from functools import partial 13from io import BytesIO 14from qt.core import ( 15 QAction, QApplication, QDialog, QIcon, QMenu, QMimeData, QModelIndex, QTimer, 16 QUrl 17) 18 19from calibre.db.errors import NoSuchFormat 20from calibre.ebooks.metadata import authors_to_string 21from calibre.ebooks.metadata.book.base import Metadata 22from calibre.ebooks.metadata.opf2 import OPF, metadata_to_opf 23from calibre.ebooks.metadata.sources.prefs import msprefs 24from calibre.gui2 import Dispatcher, error_dialog, gprefs, question_dialog 25from calibre.gui2.actions import InterfaceAction 26from calibre.gui2.actions.show_quickview import get_quickview_action_plugin 27from calibre.gui2.dialogs.confirm_delete import confirm 28from calibre.gui2.dialogs.device_category_editor import DeviceCategoryEditor 29from calibre.gui2.dialogs.metadata_bulk import MetadataBulkDialog 30from calibre.library.comments import merge_comments 31from calibre.utils.config import tweaks 32from calibre.utils.date import is_date_undefined 33from calibre.utils.icu import sort_key 34from polyglot.builtins import iteritems 35 36 37class EditMetadataAction(InterfaceAction): 38 39 name = 'Edit Metadata' 40 action_spec = (_('Edit metadata'), 'edit_input.png', _('Change the title/author/cover etc. of books'), _('E')) 41 action_type = 'current' 42 action_add_menu = True 43 44 accepts_drops = True 45 46 def accept_enter_event(self, event, mime_data): 47 if mime_data.hasFormat("application/calibre+from_library"): 48 return True 49 return False 50 51 def accept_drag_move_event(self, event, mime_data): 52 if mime_data.hasFormat("application/calibre+from_library"): 53 return True 54 return False 55 56 def drop_event(self, event, mime_data): 57 mime = 'application/calibre+from_library' 58 if mime_data.hasFormat(mime): 59 self.dropped_ids = tuple(map(int, mime_data.data(mime).data().split())) 60 QTimer.singleShot(1, self.do_drop) 61 return True 62 return False 63 64 def do_drop(self): 65 book_ids = self.dropped_ids 66 del self.dropped_ids 67 if book_ids: 68 db = self.gui.library_view.model().db 69 rows = [db.row(i) for i in book_ids] 70 self.edit_metadata_for(rows, book_ids) 71 72 def genesis(self): 73 md = self.qaction.menu() 74 cm = partial(self.create_menu_action, md) 75 cm('individual', _('Edit metadata individually'), icon=self.qaction.icon(), 76 triggered=partial(self.edit_metadata, False, bulk=False)) 77 cm('bulk', _('Edit metadata in bulk'), 78 triggered=partial(self.edit_metadata, False, bulk=True)) 79 md.addSeparator() 80 cm('download', _('Download metadata and covers'), 81 triggered=partial(self.download_metadata, ids=None), 82 shortcut='Ctrl+D') 83 self.metadata_menu = md 84 85 self.metamerge_menu = mb = QMenu() 86 cm2 = partial(self.create_menu_action, mb) 87 cm2('merge delete', _('Merge into first selected book - delete others'), 88 triggered=self.merge_books) 89 mb.addSeparator() 90 cm2('merge keep', _('Merge into first selected book - keep others'), 91 triggered=partial(self.merge_books, safe_merge=True), 92 shortcut='Alt+M') 93 mb.addSeparator() 94 cm2('merge formats', _('Merge only formats into first selected book - delete others'), 95 triggered=partial(self.merge_books, merge_only_formats=True), 96 shortcut='Alt+Shift+M') 97 self.merge_menu = mb 98 md.addSeparator() 99 self.action_copy = cm('copy', _('Copy metadata'), icon='edit-copy.png', triggered=self.copy_metadata) 100 self.action_paste = cm('paste', _('Paste metadata'), icon='edit-paste.png', triggered=self.paste_metadata) 101 self.action_paste_ignore_excluded = ac = cm( 102 'paste_include_excluded_fields', _('Paste metadata including excluded fields'), icon='edit-paste.png', 103 triggered=self.paste_metadata_including_excluded_fields) 104 ac.setVisible(False) 105 self.action_merge = cm('merge', _('Merge book records'), icon='merge_books.png', 106 shortcut=_('M'), triggered=self.merge_books) 107 self.action_merge.setMenu(mb) 108 109 self.qaction.triggered.connect(self.edit_metadata) 110 ac = QAction(_('Copy URL to show book in calibre'), self.gui) 111 ac.setToolTip(_('Copy URLs to show the currently selected books in calibre, to the system clipboard')) 112 ac.triggered.connect(self.copy_show_link) 113 self.gui.addAction(ac) 114 self.gui.keyboard.register_shortcut( 115 self.unique_name + ' - ' + 'copy_show_book', 116 ac.text(), description=ac.toolTip(), 117 action=ac, group=self.action_spec[0]) 118 ac = QAction(_('Copy URL to open book in calibre'), self.gui) 119 ac.triggered.connect(self.copy_view_link) 120 ac.setToolTip(_('Copy URLs to open the currently selected books in calibre, to the system clipboard')) 121 self.gui.addAction(ac) 122 self.gui.keyboard.register_shortcut( 123 self.unique_name + ' - ' + 'copy_view_book', 124 ac.text(), description=ac.toolTip(), 125 action=ac, group=self.action_spec[0]) 126 127 def _copy_links(self, lines): 128 urls = QUrl.fromStringList(lines) 129 cb = QApplication.instance().clipboard() 130 md = QMimeData() 131 md.setText('\n'.join(lines)) 132 md.setUrls(urls) 133 cb.setMimeData(md) 134 135 def copy_show_link(self): 136 db = self.gui.current_db 137 ids = [db.id(row.row()) for row in self.gui.library_view.selectionModel().selectedRows()] 138 db = db.new_api 139 library_id = getattr(db, 'server_library_id', None) 140 if not library_id or not ids: 141 return 142 lines = [f'calibre://show-book/{library_id}/{book_id}' for book_id in ids] 143 self._copy_links(lines) 144 145 def copy_view_link(self): 146 from calibre.gui2.actions.view import preferred_format 147 db = self.gui.current_db 148 ids = [db.id(row.row()) for row in self.gui.library_view.selectionModel().selectedRows()] 149 db = db.new_api 150 library_id = getattr(db, 'server_library_id', None) 151 if not library_id or not ids: 152 return 153 lines = [] 154 for book_id in ids: 155 formats = db.new_api.formats(book_id, verify_formats=True) 156 if formats: 157 fmt = preferred_format(formats) 158 lines.append(f'calibre://view-book/{library_id}/{book_id}/{fmt}') 159 if lines: 160 self._copy_links(lines) 161 162 def location_selected(self, loc): 163 enabled = loc == 'library' 164 self.qaction.setEnabled(enabled) 165 self.menuless_qaction.setEnabled(enabled) 166 for action in self.metamerge_menu.actions() + self.metadata_menu.actions(): 167 action.setEnabled(enabled) 168 169 def copy_metadata(self): 170 rows = self.gui.library_view.selectionModel().selectedRows() 171 if not rows or len(rows) == 0: 172 return error_dialog(self.gui, _('Cannot copy metadata'), 173 _('No books selected'), show=True) 174 if len(rows) > 1: 175 return error_dialog(self.gui, _('Cannot copy metadata'), 176 _('Multiple books selected, can only copy from one book at a time.'), show=True) 177 db = self.gui.current_db 178 book_id = db.id(rows[0].row()) 179 mi = db.new_api.get_metadata(book_id) 180 md = QMimeData() 181 md.setText(str(mi)) 182 md.setData('application/calibre-book-metadata', bytearray(metadata_to_opf(mi, default_lang='und'))) 183 img = db.new_api.cover(book_id, as_image=True) 184 if img: 185 md.setImageData(img) 186 c = QApplication.clipboard() 187 c.setMimeData(md) 188 189 def paste_metadata(self): 190 self.do_paste() 191 192 def paste_metadata_including_excluded_fields(self): 193 self.do_paste(ignore_excluded_fields=True) 194 195 def do_paste(self, ignore_excluded_fields=False): 196 rows = self.gui.library_view.selectionModel().selectedRows() 197 if not rows or len(rows) == 0: 198 return error_dialog(self.gui, _('Cannot paste metadata'), 199 _('No books selected'), show=True) 200 c = QApplication.clipboard() 201 md = c.mimeData() 202 if not md.hasFormat('application/calibre-book-metadata'): 203 return error_dialog(self.gui, _('Cannot paste metadata'), 204 _('No copied metadata available'), show=True) 205 if len(rows) > 1: 206 if not confirm(_( 207 'You are pasting metadata onto <b>multiple books</b> ({num_of_books}). Are you' 208 ' sure you want to do that?').format(num_of_books=len(rows)), 'paste-onto-multiple', parent=self.gui): 209 return 210 data = bytes(md.data('application/calibre-book-metadata')) 211 mi = OPF(BytesIO(data), populate_spine=False, read_toc=False, try_to_guess_cover=False).to_book_metadata() 212 mi.application_id = mi.uuid_id = None 213 if ignore_excluded_fields: 214 exclude = set() 215 else: 216 exclude = set(tweaks['exclude_fields_on_paste']) 217 paste_cover = 'cover' not in exclude 218 cover = md.imageData() if paste_cover else None 219 exclude.discard('cover') 220 for field in exclude: 221 mi.set_null(field) 222 db = self.gui.current_db 223 book_ids = {db.id(r.row()) for r in rows} 224 title_excluded = 'title' in exclude 225 authors_excluded = 'authors' in exclude 226 for book_id in book_ids: 227 if title_excluded: 228 mi.title = db.new_api.field_for('title', book_id) 229 if authors_excluded: 230 mi.authors = db.new_api.field_for('authors', book_id) 231 db.new_api.set_metadata(book_id, mi, ignore_errors=True) 232 if cover: 233 db.new_api.set_cover({book_id: cover for book_id in book_ids}) 234 self.refresh_books_after_metadata_edit(book_ids) 235 236 # Download metadata {{{ 237 def download_metadata(self, ids=None, ensure_fields=None): 238 if ids is None: 239 rows = self.gui.library_view.selectionModel().selectedRows() 240 if not rows or len(rows) == 0: 241 return error_dialog(self.gui, _('Cannot download metadata'), 242 _('No books selected'), show=True) 243 db = self.gui.library_view.model().db 244 ids = [db.id(row.row()) for row in rows] 245 from calibre.ebooks.metadata.sources.update import update_sources 246 from calibre.gui2.metadata.bulk_download import start_download 247 update_sources() 248 start_download(self.gui, ids, 249 Dispatcher(self.metadata_downloaded), 250 ensure_fields=ensure_fields) 251 252 def cleanup_bulk_download(self, tdir, *args): 253 try: 254 shutil.rmtree(tdir, ignore_errors=True) 255 except: 256 pass 257 258 def metadata_downloaded(self, job): 259 if job.failed: 260 self.gui.job_exception(job, dialog_title=_('Failed to download metadata')) 261 return 262 from calibre.gui2.metadata.bulk_download import get_job_details 263 (aborted, id_map, tdir, log_file, failed_ids, failed_covers, all_failed, 264 det_msg, lm_map) = get_job_details(job) 265 if aborted: 266 return self.cleanup_bulk_download(tdir) 267 if all_failed: 268 num = len(failed_ids | failed_covers) 269 self.cleanup_bulk_download(tdir) 270 return error_dialog(self.gui, _('Download failed'), ngettext( 271 'Failed to download metadata or cover for the selected book.', 272 'Failed to download metadata or covers for any of the {} books.', num 273 ).format(num), det_msg=det_msg, show=True) 274 275 self.gui.status_bar.show_message(_('Metadata download completed'), 3000) 276 277 msg = '<p>' + ngettext( 278 'Finished downloading metadata for the selected book.', 279 'Finished downloading metadata for <b>{} books</b>.', len(id_map)).format(len(id_map)) + ' ' + \ 280 _('Proceed with updating the metadata in your library?') 281 282 show_copy_button = False 283 checkbox_msg = None 284 if failed_ids or failed_covers: 285 show_copy_button = True 286 num = len(failed_ids.union(failed_covers)) 287 msg += '<p>'+_('Could not download metadata and/or covers for %d of the books. Click' 288 ' "Show details" to see which books.')%num 289 checkbox_msg = _('Show the &failed books in the main book list ' 290 'after updating metadata') 291 292 if getattr(job, 'metadata_and_covers', None) == (False, True): 293 # Only covers, remove failed cover downloads from id_map 294 for book_id in failed_covers: 295 if hasattr(id_map, 'discard'): 296 id_map.discard(book_id) 297 payload = (id_map, tdir, log_file, lm_map, 298 failed_ids.union(failed_covers)) 299 review_apply = partial(self.apply_downloaded_metadata, True) 300 normal_apply = partial(self.apply_downloaded_metadata, False) 301 self.gui.proceed_question( 302 normal_apply, payload, log_file, _('Download log'), 303 _('Metadata download complete'), msg, icon='download-metadata.png', 304 det_msg=det_msg, show_copy_button=show_copy_button, 305 cancel_callback=partial(self.cleanup_bulk_download, tdir), 306 log_is_file=True, checkbox_msg=checkbox_msg, 307 checkbox_checked=False, action_callback=review_apply, 308 action_label=_('Revie&w downloaded metadata'), 309 action_icon=QIcon(I('auto_author_sort.png'))) 310 311 def apply_downloaded_metadata(self, review, payload, *args): 312 good_ids, tdir, log_file, lm_map, failed_ids = payload 313 if not good_ids: 314 return 315 restrict_to_failed = False 316 317 modified = set() 318 db = self.gui.current_db 319 320 for i in good_ids: 321 lm = db.metadata_last_modified(i, index_is_id=True) 322 if lm is not None and lm_map[i] is not None and lm > lm_map[i]: 323 title = db.title(i, index_is_id=True) 324 authors = db.authors(i, index_is_id=True) 325 if authors: 326 authors = [x.replace('|', ',') for x in authors.split(',')] 327 title += ' - ' + authors_to_string(authors) 328 modified.add(title) 329 330 if modified: 331 from calibre.utils.icu import lower 332 333 modified = sorted(modified, key=lower) 334 if not question_dialog(self.gui, _('Some books changed'), '<p>' + _( 335 'The metadata for some books in your library has' 336 ' changed since you started the download. If you' 337 ' proceed, some of those changes may be overwritten. ' 338 'Click "Show details" to see the list of changed books. ' 339 'Do you want to proceed?'), det_msg='\n'.join(modified)): 340 return 341 342 id_map = {} 343 for bid in good_ids: 344 opf = os.path.join(tdir, '%d.mi'%bid) 345 if not os.path.exists(opf): 346 opf = None 347 cov = os.path.join(tdir, '%d.cover'%bid) 348 if not os.path.exists(cov): 349 cov = None 350 id_map[bid] = (opf, cov) 351 352 if review: 353 def get_metadata(book_id): 354 oldmi = db.get_metadata(book_id, index_is_id=True, get_cover=True, cover_as_data=True) 355 opf, cov = id_map[book_id] 356 if opf is None: 357 newmi = Metadata(oldmi.title, authors=tuple(oldmi.authors)) 358 else: 359 with open(opf, 'rb') as f: 360 newmi = OPF(f, basedir=os.path.dirname(opf), populate_spine=False).to_book_metadata() 361 newmi.cover, newmi.cover_data = None, (None, None) 362 for x in ('title', 'authors'): 363 if newmi.is_null(x): 364 # Title and author are set to null if they are 365 # the same as the originals as an optimization, 366 # we undo that, as it is confusing. 367 newmi.set(x, copy.copy(oldmi.get(x))) 368 if cov: 369 with open(cov, 'rb') as f: 370 newmi.cover_data = ('jpg', f.read()) 371 return oldmi, newmi 372 from calibre.gui2.metadata.diff import CompareMany 373 d = CompareMany( 374 set(id_map), get_metadata, db.field_metadata, parent=self.gui, 375 window_title=_('Review downloaded metadata'), 376 reject_button_tooltip=_('Discard downloaded metadata for this book'), 377 accept_all_tooltip=_('Use the downloaded metadata for all remaining books'), 378 reject_all_tooltip=_('Discard downloaded metadata for all remaining books'), 379 revert_tooltip=_('Discard the downloaded value for: %s'), 380 intro_msg=_('The downloaded metadata is on the left and the original metadata' 381 ' is on the right. If a downloaded value is blank or unknown,' 382 ' the original value is used.'), 383 action_button=(_('&View book'), I('view.png'), self.gui.iactions['View'].view_historical), 384 db=db 385 ) 386 if d.exec() == QDialog.DialogCode.Accepted: 387 if d.mark_rejected: 388 failed_ids |= d.rejected_ids 389 restrict_to_failed = True 390 nid_map = {} 391 for book_id, (changed, mi) in iteritems(d.accepted): 392 if mi is None: # discarded 393 continue 394 if changed: 395 opf, cov = id_map[book_id] 396 cfile = mi.cover 397 mi.cover, mi.cover_data = None, (None, None) 398 if opf is not None: 399 with open(opf, 'wb') as f: 400 f.write(metadata_to_opf(mi)) 401 if cfile and cov: 402 shutil.copyfile(cfile, cov) 403 os.remove(cfile) 404 nid_map[book_id] = id_map[book_id] 405 id_map = nid_map 406 else: 407 id_map = {} 408 409 restrict_to_failed = restrict_to_failed or bool(args and args[0]) 410 restrict_to_failed = restrict_to_failed and bool(failed_ids) 411 if restrict_to_failed: 412 db.data.set_marked_ids(failed_ids) 413 414 self.apply_metadata_changes( 415 id_map, merge_comments=msprefs['append_comments'], icon='download-metadata.png', 416 callback=partial(self.downloaded_metadata_applied, tdir, restrict_to_failed)) 417 418 def downloaded_metadata_applied(self, tdir, restrict_to_failed, *args): 419 if restrict_to_failed: 420 self.gui.search.set_search_string('marked:true') 421 self.cleanup_bulk_download(tdir) 422 423 # }}} 424 425 def edit_metadata(self, checked, bulk=None): 426 ''' 427 Edit metadata of selected books in library. 428 ''' 429 rows = self.gui.library_view.selectionModel().selectedRows() 430 if not rows or len(rows) == 0: 431 d = error_dialog(self.gui, _('Cannot edit metadata'), 432 _('No books selected')) 433 d.exec() 434 return 435 row_list = [r.row() for r in rows] 436 m = self.gui.library_view.model() 437 ids = [m.id(r) for r in rows] 438 self.edit_metadata_for(row_list, ids, bulk=bulk) 439 440 def edit_metadata_for(self, rows, book_ids, bulk=None): 441 previous = self.gui.library_view.currentIndex() 442 if bulk or (bulk is None and len(rows) > 1): 443 return self.do_edit_bulk_metadata(rows, book_ids) 444 445 current_row = 0 446 row_list = rows 447 editing_multiple = len(row_list) > 1 448 449 if not editing_multiple: 450 cr = row_list[0] 451 row_list = \ 452 list(range(self.gui.library_view.model().rowCount(QModelIndex()))) 453 current_row = row_list.index(cr) 454 455 view = self.gui.library_view.alternate_views.current_view 456 try: 457 hpos = view.horizontalScrollBar().value() 458 except Exception: 459 hpos = 0 460 461 changed, rows_to_refresh = self.do_edit_metadata(row_list, current_row, editing_multiple) 462 463 m = self.gui.library_view.model() 464 465 if rows_to_refresh: 466 m.refresh_rows(rows_to_refresh) 467 468 if changed: 469 self.refresh_books_after_metadata_edit(changed, previous) 470 if self.gui.library_view.alternate_views.current_view is view: 471 if hasattr(view, 'restore_hpos'): 472 view.restore_hpos(hpos) 473 else: 474 view.horizontalScrollBar().setValue(hpos) 475 476 def refresh_books_after_metadata_edit(self, book_ids, previous=None): 477 m = self.gui.library_view.model() 478 m.refresh_ids(list(book_ids)) 479 current = self.gui.library_view.currentIndex() 480 self.gui.refresh_cover_browser() 481 m.current_changed(current, previous or current) 482 self.gui.tags_view.recount_with_position_based_index() 483 qv = get_quickview_action_plugin() 484 if qv: 485 qv.refresh_quickview(current) 486 487 def do_edit_metadata(self, row_list, current_row, editing_multiple): 488 from calibre.gui2.metadata.single import edit_metadata 489 db = self.gui.library_view.model().db 490 changed, rows_to_refresh = edit_metadata(db, row_list, current_row, 491 parent=self.gui, view_slot=self.view_format_callback, 492 edit_slot=self.edit_format_callback, 493 set_current_callback=self.set_current_callback, editing_multiple=editing_multiple) 494 return changed, rows_to_refresh 495 496 def set_current_callback(self, id_): 497 db = self.gui.library_view.model().db 498 current_row = db.row(id_) 499 self.gui.library_view.set_current_row(current_row) 500 self.gui.library_view.scroll_to_row(current_row) 501 502 def view_format_callback(self, id_, fmt): 503 view = self.gui.iactions['View'] 504 if id_ is None: 505 view._view_file(fmt) 506 else: 507 db = self.gui.library_view.model().db 508 view.view_format(db.row(id_), fmt) 509 510 def edit_format_callback(self, id_, fmt): 511 edit = self.gui.iactions['Tweak ePub'] 512 edit.ebook_edit_format(id_, fmt) 513 514 def edit_bulk_metadata(self, checked): 515 ''' 516 Edit metadata of selected books in library in bulk. 517 ''' 518 rows = [r.row() for r in 519 self.gui.library_view.selectionModel().selectedRows()] 520 m = self.gui.library_view.model() 521 ids = [m.id(r) for r in rows] 522 if not rows or len(rows) == 0: 523 d = error_dialog(self.gui, _('Cannot edit metadata'), 524 _('No books selected')) 525 d.exec() 526 return 527 self.do_edit_bulk_metadata(rows, ids) 528 529 def do_edit_bulk_metadata(self, rows, book_ids): 530 # Prevent the TagView from updating due to signals from the database 531 self.gui.tags_view.blockSignals(True) 532 changed = False 533 refresh_books = set(book_ids) 534 try: 535 current_tab = 0 536 while True: 537 dialog = MetadataBulkDialog(self.gui, rows, 538 self.gui.library_view.model(), current_tab, refresh_books) 539 if dialog.changed: 540 changed = True 541 if not dialog.do_again: 542 break 543 current_tab = dialog.central_widget.currentIndex() 544 finally: 545 self.gui.tags_view.blockSignals(False) 546 if changed: 547 refresh_books |= dialog.refresh_books 548 m = self.gui.library_view.model() 549 if gprefs['refresh_book_list_on_bulk_edit']: 550 m.refresh(reset=False) 551 m.research() 552 else: 553 m.refresh_ids(refresh_books) 554 self.gui.tags_view.recount() 555 self.gui.refresh_cover_browser() 556 self.gui.library_view.select_rows(book_ids) 557 558 # Merge books {{{ 559 560 def confirm_large_merge(self, num): 561 if num < 5: 562 return True 563 return confirm('<p>'+_( 564 'You are about to merge very many ({}) books. ' 565 'Are you <b>sure</b> you want to proceed?').format(num) + '</p>', 566 'merge_too_many_books', self.gui) 567 568 def books_dropped(self, merge_map): 569 for dest_id, src_ids in iteritems(merge_map): 570 if not self.confirm_large_merge(len(src_ids) + 1): 571 continue 572 from calibre.gui2.dialogs.confirm_merge import merge_drop 573 merge_metadata, merge_formats, delete_books = merge_drop(dest_id, src_ids, self.gui) 574 if merge_metadata is None: 575 return 576 if merge_formats: 577 self.add_formats(dest_id, self.formats_for_ids(list(src_ids))) 578 if merge_metadata: 579 self.merge_metadata(dest_id, src_ids) 580 if delete_books: 581 self.delete_books_after_merge(src_ids) 582 # leave the selection highlight on the target book 583 row = self.gui.library_view.ids_to_rows([dest_id])[dest_id] 584 self.gui.library_view.set_current_row(row) 585 586 def merge_books(self, safe_merge=False, merge_only_formats=False): 587 ''' 588 Merge selected books in library. 589 ''' 590 from calibre.gui2.dialogs.confirm_merge import confirm_merge 591 if self.gui.current_view() is not self.gui.library_view: 592 return 593 rows = self.gui.library_view.indices_for_merge() 594 if not rows or len(rows) == 0: 595 return error_dialog(self.gui, _('Cannot merge books'), 596 _('No books selected'), show=True) 597 if len(rows) < 2: 598 return error_dialog(self.gui, _('Cannot merge books'), 599 _('At least two books must be selected for merging'), 600 show=True) 601 if not self.confirm_large_merge(len(rows)): 602 return 603 604 dest_id, src_ids = self.books_to_merge(rows) 605 mi = self.gui.current_db.new_api.get_proxy_metadata(dest_id) 606 title = mi.title 607 hpos = self.gui.library_view.horizontalScrollBar().value() 608 if safe_merge: 609 if not confirm_merge('<p>'+_( 610 'Book formats and metadata from the selected books ' 611 'will be added to the <b>first selected book</b> (%s).<br> ' 612 'The second and subsequently selected books will not ' 613 'be deleted or changed.<br><br>' 614 'Please confirm you want to proceed.')%title + '</p>', 615 'merge_books_safe', self.gui, mi): 616 return 617 self.add_formats(dest_id, self.formats_for_books(rows)) 618 self.merge_metadata(dest_id, src_ids) 619 elif merge_only_formats: 620 if not confirm_merge('<p>'+_( 621 'Book formats from the selected books will be merged ' 622 'into the <b>first selected book</b> (%s). ' 623 'Metadata in the first selected book will not be changed. ' 624 'Author, Title and all other metadata will <i>not</i> be merged.<br><br>' 625 'After being merged, the second and subsequently ' 626 'selected books, with any metadata they have will be <b>deleted</b>. <br><br>' 627 'All book formats of the first selected book will be kept ' 628 'and any duplicate formats in the second and subsequently selected books ' 629 'will be permanently <b>deleted</b> from your calibre library.<br><br> ' 630 'Are you <b>sure</b> you want to proceed?')%title + '</p>', 631 'merge_only_formats', self.gui, mi): 632 return 633 self.add_formats(dest_id, self.formats_for_books(rows)) 634 self.delete_books_after_merge(src_ids) 635 else: 636 if not confirm_merge('<p>'+_( 637 'Book formats and metadata from the selected books will be merged ' 638 'into the <b>first selected book</b> (%s).<br><br>' 639 'After being merged, the second and ' 640 'subsequently selected books will be <b>deleted</b>. <br><br>' 641 'All book formats of the first selected book will be kept ' 642 'and any duplicate formats in the second and subsequently selected books ' 643 'will be permanently <b>deleted</b> from your calibre library.<br><br> ' 644 'Are you <b>sure</b> you want to proceed?')%title + '</p>', 645 'merge_books', self.gui, mi): 646 return 647 self.add_formats(dest_id, self.formats_for_books(rows)) 648 self.merge_metadata(dest_id, src_ids) 649 self.delete_books_after_merge(src_ids) 650 # leave the selection highlight on first selected book 651 dest_row = rows[0].row() 652 for row in rows: 653 if row.row() < rows[0].row(): 654 dest_row -= 1 655 self.gui.library_view.set_current_row(dest_row) 656 cr = self.gui.library_view.currentIndex().row() 657 self.gui.library_view.model().refresh_ids((dest_id,), cr) 658 self.gui.library_view.horizontalScrollBar().setValue(hpos) 659 660 def add_formats(self, dest_id, src_books, replace=False): 661 for src_book in src_books: 662 if src_book: 663 fmt = os.path.splitext(src_book)[-1].replace('.', '').upper() 664 with lopen(src_book, 'rb') as f: 665 self.gui.library_view.model().db.add_format(dest_id, fmt, f, index_is_id=True, 666 notify=False, replace=replace) 667 668 def formats_for_ids(self, ids): 669 m = self.gui.library_view.model() 670 ans = [] 671 for id_ in ids: 672 dbfmts = m.db.formats(id_, index_is_id=True) 673 if dbfmts: 674 for fmt in dbfmts.split(','): 675 try: 676 path = m.db.format(id_, fmt, index_is_id=True, 677 as_path=True) 678 ans.append(path) 679 except NoSuchFormat: 680 continue 681 return ans 682 683 def formats_for_books(self, rows): 684 m = self.gui.library_view.model() 685 return self.formats_for_ids(list(map(m.id, rows))) 686 687 def books_to_merge(self, rows): 688 src_ids = [] 689 m = self.gui.library_view.model() 690 for i, row in enumerate(rows): 691 id_ = m.id(row) 692 if i == 0: 693 dest_id = id_ 694 else: 695 src_ids.append(id_) 696 return [dest_id, src_ids] 697 698 def delete_books_after_merge(self, ids_to_delete): 699 self.gui.library_view.model().delete_books_by_id(ids_to_delete) 700 701 def merge_metadata(self, dest_id, src_ids): 702 db = self.gui.library_view.model().db 703 dest_mi = db.get_metadata(dest_id, index_is_id=True) 704 merged_identifiers = db.get_identifiers(dest_id, index_is_id=True) 705 orig_dest_comments = dest_mi.comments 706 dest_cover = db.cover(dest_id, index_is_id=True) 707 had_orig_cover = bool(dest_cover) 708 709 def is_null_date(x): 710 return x is None or is_date_undefined(x) 711 712 for src_id in src_ids: 713 src_mi = db.get_metadata(src_id, index_is_id=True) 714 715 if src_mi.comments and orig_dest_comments != src_mi.comments: 716 if not dest_mi.comments: 717 dest_mi.comments = src_mi.comments 718 else: 719 dest_mi.comments = str(dest_mi.comments) + '\n\n' + str(src_mi.comments) 720 if src_mi.title and (not dest_mi.title or dest_mi.title == _('Unknown')): 721 dest_mi.title = src_mi.title 722 if (src_mi.authors and src_mi.authors[0] != _('Unknown')) and (not dest_mi.authors or dest_mi.authors[0] == _('Unknown')): 723 dest_mi.authors = src_mi.authors 724 dest_mi.author_sort = src_mi.author_sort 725 if src_mi.tags: 726 if not dest_mi.tags: 727 dest_mi.tags = src_mi.tags 728 else: 729 dest_mi.tags.extend(src_mi.tags) 730 if not dest_cover: 731 src_cover = db.cover(src_id, index_is_id=True) 732 if src_cover: 733 dest_cover = src_cover 734 if not dest_mi.publisher: 735 dest_mi.publisher = src_mi.publisher 736 if not dest_mi.rating: 737 dest_mi.rating = src_mi.rating 738 if not dest_mi.series: 739 dest_mi.series = src_mi.series 740 dest_mi.series_index = src_mi.series_index 741 if is_null_date(dest_mi.pubdate) and not is_null_date(src_mi.pubdate): 742 dest_mi.pubdate = src_mi.pubdate 743 744 src_identifiers = db.get_identifiers(src_id, index_is_id=True) 745 src_identifiers.update(merged_identifiers) 746 merged_identifiers = src_identifiers.copy() 747 748 if merged_identifiers: 749 dest_mi.set_identifiers(merged_identifiers) 750 db.set_metadata(dest_id, dest_mi, ignore_errors=False) 751 752 if not had_orig_cover and dest_cover: 753 db.set_cover(dest_id, dest_cover) 754 755 for key in db.field_metadata: # loop thru all defined fields 756 fm = db.field_metadata[key] 757 if not fm['is_custom']: 758 continue 759 dt = fm['datatype'] 760 colnum = fm['colnum'] 761 # Get orig_dest_comments before it gets changed 762 if dt == 'comments': 763 orig_dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True) 764 765 for src_id in src_ids: 766 dest_value = db.get_custom(dest_id, num=colnum, index_is_id=True) 767 src_value = db.get_custom(src_id, num=colnum, index_is_id=True) 768 if (dt == 'comments' and src_value and src_value != orig_dest_value): 769 if not dest_value: 770 db.set_custom(dest_id, src_value, num=colnum) 771 else: 772 dest_value = str(dest_value) + '\n\n' + str(src_value) 773 db.set_custom(dest_id, dest_value, num=colnum) 774 if (dt in {'bool', 'int', 'float', 'rating', 'datetime'} and dest_value is None): 775 db.set_custom(dest_id, src_value, num=colnum) 776 if (dt == 'series' and not dest_value and src_value): 777 src_index = db.get_custom_extra(src_id, num=colnum, index_is_id=True) 778 db.set_custom(dest_id, src_value, num=colnum, extra=src_index) 779 if ((dt == 'enumeration' or (dt == 'text' and not fm['is_multiple'])) and not dest_value): 780 db.set_custom(dest_id, src_value, num=colnum) 781 if (dt == 'text' and fm['is_multiple'] and src_value): 782 if not dest_value: 783 dest_value = src_value 784 else: 785 dest_value.extend(src_value) 786 db.set_custom(dest_id, dest_value, num=colnum) 787 # }}} 788 789 def edit_device_collections(self, view, oncard=None): 790 model = view.model() 791 result = model.get_collections_with_ids() 792 d = DeviceCategoryEditor(self.gui, tag_to_match=None, data=result, key=sort_key) 793 d.exec() 794 if d.result() == QDialog.DialogCode.Accepted: 795 to_rename = d.to_rename # dict of new text to old ids 796 to_delete = d.to_delete # list of ids 797 for old_id, new_name in iteritems(to_rename): 798 model.rename_collection(old_id, new_name=str(new_name)) 799 for item in to_delete: 800 model.delete_collection_using_id(item) 801 self.gui.upload_collections(model.db, view=view, oncard=oncard) 802 view.reset() 803 804 # Apply bulk metadata changes {{{ 805 def apply_metadata_changes(self, id_map, title=None, msg='', callback=None, 806 merge_tags=True, merge_comments=False, icon=None): 807 ''' 808 Apply the metadata changes in id_map to the database synchronously 809 id_map must be a mapping of ids to Metadata objects. Set any fields you 810 do not want updated in the Metadata object to null. An easy way to do 811 that is to create a metadata object as Metadata(_('Unknown')) and then 812 only set the fields you want changed on this object. 813 814 callback can be either None or a function accepting a single argument, 815 in which case it is called after applying is complete with the list of 816 changed ids. 817 818 id_map can also be a mapping of ids to 2-tuple's where each 2-tuple 819 contains the absolute paths to an OPF and cover file respectively. If 820 either of the paths is None, then the corresponding metadata is not 821 updated. 822 ''' 823 if title is None: 824 title = _('Applying changed metadata') 825 self.apply_id_map = list(iteritems(id_map)) 826 self.apply_current_idx = 0 827 self.apply_failures = [] 828 self.applied_ids = set() 829 self.apply_pd = None 830 self.apply_callback = callback 831 if len(self.apply_id_map) > 1: 832 from calibre.gui2.dialogs.progress import ProgressDialog 833 self.apply_pd = ProgressDialog(title, msg, min=0, 834 max=len(self.apply_id_map)-1, parent=self.gui, 835 cancelable=False, icon=icon) 836 self.apply_pd.setModal(True) 837 self.apply_pd.show() 838 self._am_merge_tags = merge_tags 839 self._am_merge_comments = merge_comments 840 self.do_one_apply() 841 842 def do_one_apply(self): 843 if self.apply_current_idx >= len(self.apply_id_map): 844 return self.finalize_apply() 845 846 i, mi = self.apply_id_map[self.apply_current_idx] 847 if self.gui.current_db.has_id(i): 848 if isinstance(mi, tuple): 849 opf, cover = mi 850 if opf: 851 mi = OPF(open(opf, 'rb'), basedir=os.path.dirname(opf), 852 populate_spine=False).to_book_metadata() 853 self.apply_mi(i, mi) 854 if cover: 855 self.gui.current_db.set_cover(i, open(cover, 'rb'), 856 notify=False, commit=False) 857 self.applied_ids.add(i) 858 else: 859 self.apply_mi(i, mi) 860 861 self.apply_current_idx += 1 862 if self.apply_pd is not None: 863 self.apply_pd.value += 1 864 QTimer.singleShot(5, self.do_one_apply) 865 866 def apply_mi(self, book_id, mi): 867 db = self.gui.current_db 868 869 try: 870 set_title = not mi.is_null('title') 871 set_authors = not mi.is_null('authors') 872 idents = db.get_identifiers(book_id, index_is_id=True) 873 if mi.identifiers: 874 idents.update(mi.identifiers) 875 mi.identifiers = idents 876 if mi.is_null('series'): 877 mi.series_index = None 878 if self._am_merge_tags: 879 old_tags = db.tags(book_id, index_is_id=True) 880 if old_tags: 881 tags = [x.strip() for x in old_tags.split(',')] + ( 882 mi.tags if mi.tags else []) 883 mi.tags = list(set(tags)) 884 if self._am_merge_comments: 885 old_comments = db.new_api.field_for('comments', book_id) 886 if old_comments and mi.comments and old_comments != mi.comments: 887 mi.comments = merge_comments(old_comments, mi.comments) 888 db.set_metadata(book_id, mi, commit=False, set_title=set_title, 889 set_authors=set_authors, notify=False) 890 self.applied_ids.add(book_id) 891 except: 892 import traceback 893 self.apply_failures.append((book_id, traceback.format_exc())) 894 895 try: 896 if mi.cover: 897 os.remove(mi.cover) 898 except: 899 pass 900 901 def finalize_apply(self): 902 db = self.gui.current_db 903 db.commit() 904 905 if self.apply_pd is not None: 906 self.apply_pd.hide() 907 908 if self.apply_failures: 909 msg = [] 910 for i, tb in self.apply_failures: 911 title = db.title(i, index_is_id=True) 912 authors = db.authors(i, index_is_id=True) 913 if authors: 914 authors = [x.replace('|', ',') for x in authors.split(',')] 915 title += ' - ' + authors_to_string(authors) 916 msg.append(title+'\n\n'+tb+'\n'+('*'*80)) 917 918 error_dialog(self.gui, _('Some failures'), 919 _('Failed to apply updated metadata for some books' 920 ' in your library. Click "Show details" to see ' 921 'details.'), det_msg='\n\n'.join(msg), show=True) 922 changed_books = len(self.applied_ids or ()) 923 self.refresh_gui(self.applied_ids) 924 925 self.apply_id_map = [] 926 self.apply_pd = None 927 try: 928 if callable(self.apply_callback): 929 self.apply_callback(list(self.applied_ids)) 930 finally: 931 self.apply_callback = None 932 if changed_books: 933 QApplication.alert(self.gui, 2000) 934 935 def refresh_gui(self, book_ids, covers_changed=True, tag_browser_changed=True): 936 if book_ids: 937 cr = self.gui.library_view.currentIndex().row() 938 self.gui.library_view.model().refresh_ids( 939 list(book_ids), cr) 940 if covers_changed: 941 self.gui.refresh_cover_browser() 942 if tag_browser_changed: 943 self.gui.tags_view.recount() 944 945 # }}} 946 947 def remove_metadata_item(self, book_id, field, value): 948 db = self.gui.current_db.new_api 949 fm = db.field_metadata[field] 950 affected_books = set() 951 if field == 'identifiers': 952 identifiers = db.field_for(field, book_id) 953 if identifiers.pop(value, False) is not False: 954 affected_books = db.set_field(field, {book_id:identifiers}) 955 elif field == 'authors': 956 authors = db.field_for(field, book_id) 957 new_authors = [x for x in authors if x != value] or [_('Unknown')] 958 if new_authors != authors: 959 affected_books = db.set_field(field, {book_id:new_authors}) 960 elif fm['is_multiple']: 961 item_id = db.get_item_id(field, value) 962 if item_id is not None: 963 affected_books = db.remove_items(field, (item_id,), {book_id}) 964 else: 965 affected_books = db.set_field(field, {book_id:''}) 966 if affected_books: 967 self.refresh_books_after_metadata_edit(affected_books) 968 969 def set_cover_from_format(self, book_id, fmt): 970 from calibre.ebooks.metadata.meta import get_metadata 971 from calibre.utils.config import prefs 972 fmt = fmt.lower() 973 cdata = None 974 db = self.gui.current_db.new_api 975 if fmt == 'pdf': 976 pdfpath = db.format_abspath(book_id, fmt) 977 if pdfpath is None: 978 return error_dialog(self.gui, _('Format file missing'), _( 979 'Cannot read cover as the %s file is missing from this book') % 'PDF', show=True) 980 from calibre.gui2.metadata.pdf_covers import PDFCovers 981 d = PDFCovers(pdfpath, parent=self.gui) 982 ret = d.exec() 983 if ret == QDialog.DialogCode.Accepted: 984 cpath = d.cover_path 985 if cpath: 986 with open(cpath, 'rb') as f: 987 cdata = f.read() 988 d.cleanup() 989 if ret != QDialog.DialogCode.Accepted: 990 return 991 else: 992 stream = BytesIO() 993 try: 994 db.copy_format_to(book_id, fmt, stream) 995 except NoSuchFormat: 996 return error_dialog(self.gui, _('Format file missing'), _( 997 'Cannot read cover as the %s file is missing from this book') % fmt.upper(), show=True) 998 old = prefs['read_file_metadata'] 999 if not old: 1000 prefs['read_file_metadata'] = True 1001 try: 1002 stream.seek(0) 1003 mi = get_metadata(stream, fmt) 1004 except Exception: 1005 import traceback 1006 return error_dialog(self.gui, _('Could not read metadata'), 1007 _('Could not read metadata from %s format')%fmt.upper(), 1008 det_msg=traceback.format_exc(), show=True) 1009 finally: 1010 if old != prefs['read_file_metadata']: 1011 prefs['read_file_metadata'] = old 1012 if mi.cover and os.access(mi.cover, os.R_OK): 1013 with open(mi.cover, 'rb') as f: 1014 cdata = f.read() 1015 elif mi.cover_data[1] is not None: 1016 cdata = mi.cover_data[1] 1017 if cdata is None: 1018 return error_dialog(self.gui, _('Could not read cover'), 1019 _('Could not read cover from %s format')%fmt.upper(), show=True) 1020 db.set_cover({book_id:cdata}) 1021 current_idx = self.gui.library_view.currentIndex() 1022 self.gui.library_view.model().current_changed(current_idx, current_idx) 1023 self.gui.refresh_cover_browser() 1024