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__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>' 7__docformat__ = 'restructuredtext en' 8 9DEBUG_DIALOG = False 10 11# Imports {{{ 12import os, time 13from threading import Thread, Event 14from operator import attrgetter 15from io import BytesIO 16 17from qt.core import ( 18 QStyledItemDelegate, QTextDocument, QRectF, QIcon, Qt, QApplication, 19 QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QStyle, QStackedWidget, 20 QWidget, QTableView, QGridLayout, QPalette, QTimer, pyqtSignal, 21 QAbstractTableModel, QSize, QListView, QPixmap, QModelIndex, 22 QAbstractListModel, QRect, QTextBrowser, QStringListModel, QMenu, QItemSelectionModel, 23 QCursor, QHBoxLayout, QPushButton, QSizePolicy, QSplitter, QAbstractItemView) 24 25from calibre.customize.ui import metadata_plugins 26from calibre.ebooks.metadata import authors_to_string, rating_to_stars 27from calibre.utils.logging import GUILog as Log 28from calibre.ebooks.metadata.sources.identify import urls_from_identifiers 29from calibre.ebooks.metadata.book.base import Metadata 30from calibre.ebooks.metadata.opf2 import OPF 31from calibre.gui2 import error_dialog, rating_font, gprefs 32from calibre.gui2.progress_indicator import SpinAnimator 33from calibre.gui2.widgets2 import HTMLDisplay 34from calibre.utils.date import (utcnow, fromordinal, format_date, 35 UNDEFINED_DATE, as_utc) 36from calibre.library.comments import comments_to_html 37from calibre import force_unicode 38from calibre.utils.ipc.simple_worker import fork_job, WorkerError 39from calibre.ptempfile import TemporaryDirectory 40from polyglot.builtins import iteritems, itervalues 41from polyglot.queue import Queue, Empty 42# }}} 43 44 45class RichTextDelegate(QStyledItemDelegate): # {{{ 46 47 def __init__(self, parent=None, max_width=160): 48 QStyledItemDelegate.__init__(self, parent) 49 self.max_width = max_width 50 self.dummy_model = QStringListModel([' '], self) 51 self.dummy_index = self.dummy_model.index(0) 52 53 def to_doc(self, index, option=None): 54 doc = QTextDocument() 55 if option is not None and option.state & QStyle.StateFlag.State_Selected: 56 p = option.palette 57 group = (QPalette.ColorGroup.Active if option.state & QStyle.StateFlag.State_Active else 58 QPalette.ColorGroup.Inactive) 59 c = p.color(group, QPalette.ColorRole.HighlightedText) 60 c = 'rgb(%d, %d, %d)'%c.getRgb()[:3] 61 doc.setDefaultStyleSheet(' * { color: %s }'%c) 62 doc.setHtml(index.data() or '') 63 return doc 64 65 def sizeHint(self, option, index): 66 doc = self.to_doc(index, option=option) 67 ans = doc.size().toSize() 68 if ans.width() > self.max_width - 10: 69 ans.setWidth(self.max_width) 70 ans.setHeight(ans.height()+10) 71 return ans 72 73 def paint(self, painter, option, index): 74 QStyledItemDelegate.paint(self, painter, option, self.dummy_index) 75 painter.save() 76 painter.setClipRect(QRectF(option.rect)) 77 painter.translate(option.rect.topLeft()) 78 self.to_doc(index, option).drawContents(painter) 79 painter.restore() 80# }}} 81 82 83class CoverDelegate(QStyledItemDelegate): # {{{ 84 85 ICON_SIZE = 150, 200 86 87 needs_redraw = pyqtSignal() 88 89 def __init__(self, parent): 90 QStyledItemDelegate.__init__(self, parent) 91 self.animator = SpinAnimator(self) 92 self.animator.updated.connect(self.needs_redraw) 93 self.color = parent.palette().color(QPalette.ColorRole.WindowText) 94 self.spinner_width = 64 95 96 def start_animation(self): 97 self.animator.start() 98 99 def stop_animation(self): 100 self.animator.stop() 101 102 def paint(self, painter, option, index): 103 QStyledItemDelegate.paint(self, painter, option, index) 104 style = QApplication.style() 105 waiting = self.animator.is_running() and bool(index.data(Qt.ItemDataRole.UserRole)) 106 if waiting: 107 rect = QRect(0, 0, self.spinner_width, self.spinner_width) 108 rect.moveCenter(option.rect.center()) 109 self.animator.draw(painter, rect, self.color) 110 else: 111 # Ensure the cover is rendered over any selection rect 112 style.drawItemPixmap(painter, option.rect, Qt.AlignmentFlag.AlignTop|Qt.AlignmentFlag.AlignHCenter, 113 QPixmap(index.data(Qt.ItemDataRole.DecorationRole))) 114 115# }}} 116 117 118class ResultsModel(QAbstractTableModel): # {{{ 119 120 COLUMNS = ( 121 '#', _('Title'), _('Published'), _('Has cover'), _('Has summary') 122 ) 123 HTML_COLS = (1, 2) 124 ICON_COLS = (3, 4) 125 126 def __init__(self, results, parent=None): 127 QAbstractTableModel.__init__(self, parent) 128 self.results = results 129 self.yes_icon = (QIcon(I('ok.png'))) 130 131 def rowCount(self, parent=None): 132 return len(self.results) 133 134 def columnCount(self, parent=None): 135 return len(self.COLUMNS) 136 137 def headerData(self, section, orientation, role): 138 if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: 139 try: 140 return (self.COLUMNS[section]) 141 except: 142 return None 143 return None 144 145 def data_as_text(self, book, col): 146 if col == 0: 147 return str(book.gui_rank+1) 148 if col == 1: 149 t = book.title if book.title else _('Unknown') 150 a = authors_to_string(book.authors) if book.authors else '' 151 return '<b>%s</b><br><i>%s</i>' % (t, a) 152 if col == 2: 153 d = format_date(book.pubdate, 'yyyy') if book.pubdate else _('Unknown') 154 p = book.publisher if book.publisher else '' 155 return '<b>%s</b><br><i>%s</i>' % (d, p) 156 157 def data(self, index, role): 158 row, col = index.row(), index.column() 159 try: 160 book = self.results[row] 161 except: 162 return None 163 if role == Qt.ItemDataRole.DisplayRole and col not in self.ICON_COLS: 164 res = self.data_as_text(book, col) 165 if res: 166 return (res) 167 return None 168 elif role == Qt.ItemDataRole.DecorationRole and col in self.ICON_COLS: 169 if col == 3 and getattr(book, 'has_cached_cover_url', False): 170 return self.yes_icon 171 if col == 4 and book.comments: 172 return self.yes_icon 173 elif role == Qt.ItemDataRole.UserRole: 174 return book 175 elif role == Qt.ItemDataRole.ToolTipRole and col == 3: 176 return ( 177 _('The "has cover" indication is not fully\n' 178 'reliable. Sometimes results marked as not\n' 179 'having a cover will find a cover in the download\n' 180 'cover stage, and vice versa.')) 181 182 return None 183 184 def sort(self, col, order=Qt.SortOrder.AscendingOrder): 185 key = lambda x: x 186 if col == 0: 187 key = attrgetter('gui_rank') 188 elif col == 1: 189 key = attrgetter('title') 190 elif col == 2: 191 def dategetter(x): 192 x = getattr(x, 'pubdate', None) 193 if x is None: 194 x = UNDEFINED_DATE 195 return as_utc(x) 196 key = dategetter 197 elif col == 3: 198 key = attrgetter('has_cached_cover_url') 199 elif key == 4: 200 key = lambda x: bool(x.comments) 201 202 self.beginResetModel() 203 self.results.sort(key=key, reverse=order==Qt.SortOrder.AscendingOrder) 204 self.endResetModel() 205 206# }}} 207 208 209class ResultsView(QTableView): # {{{ 210 211 show_details_signal = pyqtSignal(object) 212 book_selected = pyqtSignal(object) 213 214 def __init__(self, parent=None): 215 QTableView.__init__(self, parent) 216 self.rt_delegate = RichTextDelegate(self) 217 self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) 218 self.setAlternatingRowColors(True) 219 self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) 220 self.setIconSize(QSize(24, 24)) 221 self.clicked.connect(self.show_details) 222 self.doubleClicked.connect(self.select_index) 223 self.setSortingEnabled(True) 224 225 def show_results(self, results): 226 self._model = ResultsModel(results, self) 227 self.setModel(self._model) 228 for i in self._model.HTML_COLS: 229 self.setItemDelegateForColumn(i, self.rt_delegate) 230 self.resizeRowsToContents() 231 self.resizeColumnsToContents() 232 self.setFocus(Qt.FocusReason.OtherFocusReason) 233 idx = self.model().index(0, 0) 234 if idx.isValid() and self.model().rowCount() > 0: 235 self.show_details(idx) 236 sm = self.selectionModel() 237 sm.select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect|QItemSelectionModel.SelectionFlag.Rows) 238 239 def resize_delegate(self): 240 self.rt_delegate.max_width = int(self.width()/2.1) 241 self.resizeColumnsToContents() 242 243 def resizeEvent(self, ev): 244 ret = super().resizeEvent(ev) 245 self.resize_delegate() 246 return ret 247 248 def currentChanged(self, current, previous): 249 ret = QTableView.currentChanged(self, current, previous) 250 self.show_details(current) 251 return ret 252 253 def show_details(self, index): 254 f = rating_font() 255 book = self.model().data(index, Qt.ItemDataRole.UserRole) 256 parts = [ 257 '<center>', 258 '<h2>%s</h2>'%book.title, 259 '<div><i>%s</i></div>'%authors_to_string(book.authors), 260 ] 261 if not book.is_null('series'): 262 series = book.format_field('series') 263 if series[1]: 264 parts.append('<div>%s: %s</div>'%series) 265 if not book.is_null('rating'): 266 style = 'style=\'font-family:"%s"\''%f 267 parts.append('<div %s>%s</div>'%(style, rating_to_stars(int(2 * book.rating)))) 268 parts.append('</center>') 269 if book.identifiers: 270 urls = urls_from_identifiers(book.identifiers) 271 ids = ['<a href="%s">%s</a>'%(url, name) for name, ign, ign, url in urls] 272 if ids: 273 parts.append('<div><b>%s:</b> %s</div><br>'%(_('See at'), ', '.join(ids))) 274 if book.tags: 275 parts.append('<div>%s</div><div>\u00a0</div>'%', '.join(book.tags)) 276 if book.comments: 277 parts.append(comments_to_html(book.comments)) 278 279 self.show_details_signal.emit(''.join(parts)) 280 281 def select_index(self, index): 282 if self.model() is None: 283 return 284 if not index.isValid(): 285 index = self.model().index(0, 0) 286 book = self.model().data(index, Qt.ItemDataRole.UserRole) 287 self.book_selected.emit(book) 288 289 def get_result(self): 290 self.select_index(self.currentIndex()) 291 292 def keyPressEvent(self, ev): 293 if ev.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right): 294 ac = QAbstractItemView.CursorAction.MoveDown if ev.key() == Qt.Key.Key_Right else QAbstractItemView.CursorAction.MoveUp 295 index = self.moveCursor(ac, ev.modifiers()) 296 if index.isValid() and index != self.currentIndex(): 297 m = self.selectionModel() 298 m.select(index, QItemSelectionModel.SelectionFlag.Select|QItemSelectionModel.SelectionFlag.Current|QItemSelectionModel.SelectionFlag.Rows) 299 self.setCurrentIndex(index) 300 ev.accept() 301 return 302 return QTableView.keyPressEvent(self, ev) 303 304# }}} 305 306 307class Comments(HTMLDisplay): # {{{ 308 309 def __init__(self, parent=None): 310 HTMLDisplay.__init__(self, parent) 311 self.setAcceptDrops(False) 312 self.wait_timer = QTimer(self) 313 self.wait_timer.timeout.connect(self.update_wait) 314 self.wait_timer.setInterval(800) 315 self.dots_count = 0 316 self.anchor_clicked.connect(self.link_activated) 317 318 def link_activated(self, url): 319 from calibre.gui2 import open_url 320 if url.scheme() in {'http', 'https'}: 321 open_url(url) 322 323 def show_wait(self): 324 self.dots_count = 0 325 self.wait_timer.start() 326 self.update_wait() 327 328 def update_wait(self): 329 self.dots_count += 1 330 self.dots_count %= 10 331 self.dots_count = self.dots_count or 1 332 self.setHtml( 333 '<h2>'+_('Please wait')+ 334 '<br><span id="dots">{}</span></h2>'.format('.' * self.dots_count)) 335 336 def show_data(self, html): 337 self.wait_timer.stop() 338 339 def color_to_string(col): 340 ans = '#000000' 341 if col.isValid(): 342 col = col.toRgb() 343 if col.isValid(): 344 ans = str(col.name()) 345 return ans 346 347 c = color_to_string(QApplication.palette().color(QPalette.ColorGroup.Normal, 348 QPalette.ColorRole.WindowText)) 349 templ = '''\ 350 <html> 351 <head> 352 <style type="text/css"> 353 body, td {background-color: transparent; color: %s } 354 a { text-decoration: none; } 355 div.description { margin-top: 0; padding-top: 0; text-indent: 0 } 356 table { margin-bottom: 0; padding-bottom: 0; } 357 </style> 358 </head> 359 <body> 360 <div class="description"> 361 %%s 362 </div> 363 </body> 364 <html> 365 '''%(c,) 366 self.setHtml(templ%html) 367# }}} 368 369 370class IdentifyWorker(Thread): # {{{ 371 372 def __init__(self, log, abort, title, authors, identifiers, caches): 373 Thread.__init__(self) 374 self.daemon = True 375 376 self.log, self.abort = log, abort 377 self.title, self.authors, self.identifiers = (title, authors, 378 identifiers) 379 380 self.results = [] 381 self.error = None 382 self.caches = caches 383 384 def sample_results(self): 385 m1 = Metadata('The Great Gatsby', ['Francis Scott Fitzgerald']) 386 m2 = Metadata('The Great Gatsby - An extra long title to test resizing', ['F. Scott Fitzgerald']) 387 m1.has_cached_cover_url = True 388 m2.has_cached_cover_url = False 389 m1.comments = 'Some comments '*10 390 m1.tags = ['tag%d'%i for i in range(20)] 391 m1.rating = 4.4 392 m1.language = 'en' 393 m2.language = 'fr' 394 m1.pubdate = utcnow() 395 m2.pubdate = fromordinal(1000000) 396 m1.publisher = 'Publisher 1' 397 m2.publisher = 'Publisher 2' 398 399 return [m1, m2] 400 401 def run(self): 402 try: 403 if DEBUG_DIALOG: 404 self.results = self.sample_results() 405 else: 406 res = fork_job( 407 'calibre.ebooks.metadata.sources.worker', 408 'single_identify', (self.title, self.authors, 409 self.identifiers), no_output=True, abort=self.abort) 410 self.results, covers, caches, log_dump = res['result'] 411 self.results = [OPF(BytesIO(r), basedir=os.getcwd(), 412 populate_spine=False).to_book_metadata() for r in self.results] 413 for r, cov in zip(self.results, covers): 414 r.has_cached_cover_url = cov 415 self.caches.update(caches) 416 self.log.load(log_dump) 417 for i, result in enumerate(self.results): 418 result.gui_rank = i 419 except WorkerError as e: 420 self.error = force_unicode(e.orig_tb) 421 except: 422 import traceback 423 self.error = force_unicode(traceback.format_exc()) 424 425# }}} 426 427 428class IdentifyWidget(QWidget): # {{{ 429 430 rejected = pyqtSignal() 431 results_found = pyqtSignal() 432 book_selected = pyqtSignal(object, object) 433 434 def __init__(self, log, parent=None): 435 QWidget.__init__(self, parent) 436 self.log = log 437 self.abort = Event() 438 self.caches = {} 439 440 self.l = l = QVBoxLayout(self) 441 442 names = ['<b>'+p.name+'</b>' for p in metadata_plugins(['identify']) if 443 p.is_configured()] 444 self.top = QLabel('<p>'+_('calibre is downloading metadata from: ') + 445 ', '.join(names)) 446 self.top.setWordWrap(True) 447 l.addWidget(self.top) 448 449 self.splitter = s = QSplitter(self) 450 s.setChildrenCollapsible(False) 451 l.addWidget(s, 100) 452 self.results_view = ResultsView(self) 453 self.results_view.book_selected.connect(self.emit_book_selected) 454 self.get_result = self.results_view.get_result 455 s.addWidget(self.results_view) 456 457 self.comments_view = Comments(self) 458 s.addWidget(self.comments_view) 459 s.setStretchFactor(0, 2) 460 s.setStretchFactor(1, 1) 461 462 self.results_view.show_details_signal.connect(self.comments_view.show_data) 463 464 self.query = QLabel('download starting...') 465 self.query.setWordWrap(True) 466 l.addWidget(self.query) 467 468 self.comments_view.show_wait() 469 state = gprefs.get('metadata-download-identify-widget-splitter-state') 470 if state is not None: 471 s.restoreState(state) 472 473 def save_state(self): 474 gprefs['metadata-download-identify-widget-splitter-state'] = bytearray(self.splitter.saveState()) 475 476 def emit_book_selected(self, book): 477 self.book_selected.emit(book, self.caches) 478 479 def start(self, title=None, authors=None, identifiers={}): 480 self.log.clear() 481 self.log('Starting download') 482 parts, simple_desc = [], '' 483 if title: 484 parts.append('title:'+title) 485 simple_desc += _('Title: %s ') % title 486 if authors: 487 parts.append('authors:'+authors_to_string(authors)) 488 simple_desc += _('Authors: %s ') % authors_to_string(authors) 489 if identifiers: 490 x = ', '.join('%s:%s'%(k, v) for k, v in iteritems(identifiers)) 491 parts.append(x) 492 if 'isbn' in identifiers: 493 simple_desc += 'ISBN: %s' % identifiers['isbn'] 494 self.query.setText(simple_desc) 495 self.log(str(self.query.text())) 496 497 self.worker = IdentifyWorker(self.log, self.abort, title, 498 authors, identifiers, self.caches) 499 500 self.worker.start() 501 502 QTimer.singleShot(50, self.update) 503 504 def update(self): 505 if self.worker.is_alive(): 506 QTimer.singleShot(50, self.update) 507 else: 508 self.process_results() 509 510 def process_results(self): 511 if self.worker.error is not None: 512 error_dialog(self, _('Download failed'), 513 _('Failed to download metadata. Click ' 514 'Show Details to see details'), 515 show=True, det_msg=self.worker.error) 516 self.rejected.emit() 517 return 518 519 if not self.worker.results: 520 log = ''.join(self.log.plain_text) 521 error_dialog(self, _('No matches found'), '<p>' + 522 _('Failed to find any books that ' 523 'match your search. Try making the search <b>less ' 524 'specific</b>. For example, use only the author\'s ' 525 'last name and a single distinctive word from ' 526 'the title.<p>To see the full log, click "Show details".'), 527 show=True, det_msg=log) 528 self.rejected.emit() 529 return 530 531 self.results_view.show_results(self.worker.results) 532 self.results_found.emit() 533 534 def cancel(self): 535 self.abort.set() 536# }}} 537 538 539class CoverWorker(Thread): # {{{ 540 541 def __init__(self, log, abort, title, authors, identifiers, caches): 542 Thread.__init__(self, name='CoverWorker') 543 self.daemon = True 544 545 self.log, self.abort = log, abort 546 self.title, self.authors, self.identifiers = (title, authors, 547 identifiers) 548 self.caches = caches 549 550 self.rq = Queue() 551 self.error = None 552 553 def fake_run(self): 554 images = ['donate.png', 'config.png', 'column.png', 'eject.png', ] 555 time.sleep(2) 556 for pl, im in zip(metadata_plugins(['cover']), images): 557 self.rq.put((pl.name, 1, 1, 'png', I(im, data=True))) 558 559 def run(self): 560 try: 561 if DEBUG_DIALOG: 562 self.fake_run() 563 else: 564 self.run_fork() 565 except WorkerError as e: 566 self.error = force_unicode(e.orig_tb) 567 except: 568 import traceback 569 self.error = force_unicode(traceback.format_exc()) 570 571 def run_fork(self): 572 with TemporaryDirectory('_single_metadata_download') as tdir: 573 self.keep_going = True 574 t = Thread(target=self.monitor_tdir, args=(tdir,)) 575 t.daemon = True 576 t.start() 577 578 try: 579 res = fork_job('calibre.ebooks.metadata.sources.worker', 580 'single_covers', 581 (self.title, self.authors, self.identifiers, self.caches, 582 tdir), 583 no_output=True, abort=self.abort) 584 self.log.append_dump(res['result']) 585 finally: 586 self.keep_going = False 587 t.join() 588 589 def scan_once(self, tdir, seen): 590 for x in list(os.listdir(tdir)): 591 if x in seen: 592 continue 593 if x.endswith('.cover') and os.path.exists(os.path.join(tdir, 594 x+'.done')): 595 name = x.rpartition('.')[0] 596 try: 597 plugin_name, width, height, fmt = name.split(',,') 598 width, height = int(width), int(height) 599 with open(os.path.join(tdir, x), 'rb') as f: 600 data = f.read() 601 except: 602 import traceback 603 traceback.print_exc() 604 else: 605 seen.add(x) 606 self.rq.put((plugin_name, width, height, fmt, data)) 607 608 def monitor_tdir(self, tdir): 609 seen = set() 610 while self.keep_going: 611 time.sleep(1) 612 self.scan_once(tdir, seen) 613 # One last scan after the download process has ended 614 self.scan_once(tdir, seen) 615 616# }}} 617 618 619class CoversModel(QAbstractListModel): # {{{ 620 621 def __init__(self, current_cover, parent=None): 622 QAbstractListModel.__init__(self, parent) 623 624 if current_cover is None: 625 current_cover = QPixmap(I('default_cover.png')) 626 current_cover.setDevicePixelRatio(QApplication.instance().devicePixelRatio()) 627 628 self.blank = QIcon(I('blank.png')).pixmap(*CoverDelegate.ICON_SIZE) 629 self.cc = current_cover 630 self.reset_covers(do_reset=False) 631 632 def reset_covers(self, do_reset=True): 633 self.covers = [self.get_item(_('Current cover'), self.cc)] 634 self.plugin_map = {} 635 for i, plugin in enumerate(metadata_plugins(['cover'])): 636 self.covers.append((plugin.name+'\n'+_('Searching...'), 637 (self.blank), None, True)) 638 self.plugin_map[plugin] = [i+1] 639 640 if do_reset: 641 self.beginResetModel(), self.endResetModel() 642 643 def get_item(self, src, pmap, waiting=False): 644 sz = '%dx%d'%(pmap.width(), pmap.height()) 645 text = (src + '\n' + sz) 646 scaled = pmap.scaled( 647 int(CoverDelegate.ICON_SIZE[0] * pmap.devicePixelRatio()), int(CoverDelegate.ICON_SIZE[1] * pmap.devicePixelRatio()), 648 Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) 649 scaled.setDevicePixelRatio(pmap.devicePixelRatio()) 650 return (text, (scaled), pmap, waiting) 651 652 def rowCount(self, parent=None): 653 return len(self.covers) 654 655 def data(self, index, role): 656 try: 657 text, pmap, cover, waiting = self.covers[index.row()] 658 except: 659 return None 660 if role == Qt.ItemDataRole.DecorationRole: 661 return pmap 662 if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.ToolTipRole: 663 return text 664 if role == Qt.ItemDataRole.UserRole: 665 return waiting 666 return None 667 668 def plugin_for_index(self, index): 669 row = index.row() if hasattr(index, 'row') else index 670 for k, v in iteritems(self.plugin_map): 671 if row in v: 672 return k 673 674 def clear_failed(self): 675 # Remove entries that are still waiting 676 good = [] 677 pmap = {} 678 679 def keygen(x): 680 pmap = x[2] 681 if pmap is None: 682 return 1 683 return pmap.width()*pmap.height() 684 dcovers = sorted(self.covers[1:], key=keygen, reverse=True) 685 cmap = {i:self.plugin_for_index(i) for i in range(len(self.covers))} 686 for i, x in enumerate(self.covers[0:1] + dcovers): 687 if not x[-1]: 688 good.append(x) 689 plugin = cmap[i] 690 if plugin is not None: 691 try: 692 pmap[plugin].append(len(good) - 1) 693 except KeyError: 694 pmap[plugin] = [len(good)-1] 695 self.covers = good 696 self.plugin_map = pmap 697 self.beginResetModel(), self.endResetModel() 698 699 def pointer_from_index(self, index): 700 row = index.row() if hasattr(index, 'row') else index 701 try: 702 return self.covers[row][2] 703 except IndexError: 704 pass 705 706 def index_from_pointer(self, pointer): 707 for r, (text, scaled, pmap, waiting) in enumerate(self.covers): 708 if pointer == pmap: 709 return self.index(r) 710 return self.index(0) 711 712 def load_pixmap(self, data): 713 pmap = QPixmap() 714 pmap.loadFromData(data) 715 pmap.setDevicePixelRatio(QApplication.instance().devicePixelRatio()) 716 return pmap 717 718 def update_result(self, plugin_name, width, height, data): 719 if plugin_name.endswith('}'): 720 # multi cover plugin 721 plugin_name = plugin_name.partition('{')[0] 722 plugin = [plugin for plugin in self.plugin_map if plugin.name == plugin_name] 723 if not plugin: 724 return 725 plugin = plugin[0] 726 last_row = max(self.plugin_map[plugin]) 727 pmap = self.load_pixmap(data) 728 if pmap.isNull(): 729 return 730 self.beginInsertRows(QModelIndex(), last_row, last_row) 731 for rows in itervalues(self.plugin_map): 732 for i in range(len(rows)): 733 if rows[i] >= last_row: 734 rows[i] += 1 735 self.plugin_map[plugin].insert(-1, last_row) 736 self.covers.insert(last_row, self.get_item(plugin_name, pmap, waiting=False)) 737 self.endInsertRows() 738 else: 739 # single cover plugin 740 idx = None 741 for plugin, rows in iteritems(self.plugin_map): 742 if plugin.name == plugin_name: 743 idx = rows[0] 744 break 745 if idx is None: 746 return 747 pmap = self.load_pixmap(data) 748 if pmap.isNull(): 749 return 750 self.covers[idx] = self.get_item(plugin_name, pmap, waiting=False) 751 self.dataChanged.emit(self.index(idx), self.index(idx)) 752 753 def cover_pixmap(self, index): 754 row = index.row() 755 if row > 0 and row < len(self.covers): 756 pmap = self.covers[row][2] 757 if pmap is not None and not pmap.isNull(): 758 return pmap 759 760# }}} 761 762 763class CoversView(QListView): # {{{ 764 765 chosen = pyqtSignal() 766 767 def __init__(self, current_cover, parent=None): 768 QListView.__init__(self, parent) 769 self.m = CoversModel(current_cover, self) 770 self.setModel(self.m) 771 772 self.setFlow(QListView.Flow.LeftToRight) 773 self.setWrapping(True) 774 self.setResizeMode(QListView.ResizeMode.Adjust) 775 self.setGridSize(QSize(190, 260)) 776 self.setIconSize(QSize(*CoverDelegate.ICON_SIZE)) 777 self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) 778 self.setViewMode(QListView.ViewMode.IconMode) 779 780 self.delegate = CoverDelegate(self) 781 self.setItemDelegate(self.delegate) 782 self.delegate.needs_redraw.connect(self.redraw_spinners, 783 type=Qt.ConnectionType.QueuedConnection) 784 785 self.doubleClicked.connect(self.chosen, type=Qt.ConnectionType.QueuedConnection) 786 self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) 787 self.customContextMenuRequested.connect(self.show_context_menu) 788 789 def redraw_spinners(self): 790 m = self.model() 791 for r in range(m.rowCount()): 792 idx = m.index(r) 793 if bool(m.data(idx, Qt.ItemDataRole.UserRole)): 794 m.dataChanged.emit(idx, idx) 795 796 def select(self, num): 797 current = self.model().index(num) 798 sm = self.selectionModel() 799 sm.select(current, QItemSelectionModel.SelectionFlag.SelectCurrent) 800 801 def start(self): 802 self.select(0) 803 self.delegate.start_animation() 804 805 def stop(self): 806 self.delegate.stop_animation() 807 808 def reset_covers(self): 809 self.m.reset_covers() 810 811 def clear_failed(self): 812 pointer = self.m.pointer_from_index(self.currentIndex()) 813 self.m.clear_failed() 814 if pointer is None: 815 self.select(0) 816 else: 817 self.select(self.m.index_from_pointer(pointer).row()) 818 819 def show_context_menu(self, point): 820 idx = self.currentIndex() 821 if idx and idx.isValid() and not idx.data(Qt.ItemDataRole.UserRole): 822 m = QMenu(self) 823 m.addAction(QIcon(I('view.png')), _('View this cover at full size'), self.show_cover) 824 m.addAction(QIcon(I('edit-copy.png')), _('Copy this cover to clipboard'), self.copy_cover) 825 m.exec(QCursor.pos()) 826 827 def show_cover(self): 828 idx = self.currentIndex() 829 pmap = self.model().cover_pixmap(idx) 830 if pmap is None and idx.row() == 0: 831 pmap = self.model().cc 832 if pmap is not None: 833 from calibre.gui2.image_popup import ImageView 834 d = ImageView(self, pmap, str(idx.data(Qt.ItemDataRole.DisplayRole) or ''), geom_name='metadata_download_cover_popup_geom') 835 d(use_exec=True) 836 837 def copy_cover(self): 838 idx = self.currentIndex() 839 pmap = self.model().cover_pixmap(idx) 840 if pmap is None and idx.row() == 0: 841 pmap = self.model().cc 842 if pmap is not None: 843 QApplication.clipboard().setPixmap(pmap) 844 845 def keyPressEvent(self, ev): 846 if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): 847 self.chosen.emit() 848 ev.accept() 849 return 850 return QListView.keyPressEvent(self, ev) 851 852# }}} 853 854 855class CoversWidget(QWidget): # {{{ 856 857 chosen = pyqtSignal() 858 finished = pyqtSignal() 859 860 def __init__(self, log, current_cover, parent=None): 861 QWidget.__init__(self, parent) 862 self.log = log 863 self.abort = Event() 864 865 self.l = l = QGridLayout() 866 self.setLayout(l) 867 868 self.msg = QLabel() 869 self.msg.setWordWrap(True) 870 l.addWidget(self.msg, 0, 0) 871 872 self.covers_view = CoversView(current_cover, self) 873 self.covers_view.chosen.connect(self.chosen) 874 l.addWidget(self.covers_view, 1, 0) 875 self.continue_processing = True 876 877 def reset_covers(self): 878 self.covers_view.reset_covers() 879 880 def start(self, book, current_cover, title, authors, caches): 881 self.continue_processing = True 882 self.abort.clear() 883 self.book, self.current_cover = book, current_cover 884 self.title, self.authors = title, authors 885 self.log('Starting cover download for:', book.title) 886 self.log('Query:', title, authors, self.book.identifiers) 887 self.msg.setText('<p>'+ 888 _('Downloading covers for <b>%s</b>, please wait...')%book.title) 889 self.covers_view.start() 890 891 self.worker = CoverWorker(self.log, self.abort, self.title, 892 self.authors, book.identifiers, caches) 893 self.worker.start() 894 QTimer.singleShot(50, self.check) 895 self.covers_view.setFocus(Qt.FocusReason.OtherFocusReason) 896 897 def check(self): 898 if self.worker.is_alive() and not self.abort.is_set(): 899 QTimer.singleShot(50, self.check) 900 try: 901 self.process_result(self.worker.rq.get_nowait()) 902 except Empty: 903 pass 904 else: 905 self.process_results() 906 907 def process_results(self): 908 while self.continue_processing: 909 try: 910 self.process_result(self.worker.rq.get_nowait()) 911 except Empty: 912 break 913 914 if self.continue_processing: 915 self.covers_view.clear_failed() 916 917 if self.worker.error and self.worker.error.strip(): 918 error_dialog(self, _('Download failed'), 919 _('Failed to download any covers, click' 920 ' "Show details" for details.'), 921 det_msg=self.worker.error, show=True) 922 923 num = self.covers_view.model().rowCount() 924 if num < 2: 925 txt = _('Could not find any covers for <b>%s</b>')%self.book.title 926 else: 927 if num == 2: 928 txt = _('Found a cover for {title}').format(title=self.title) 929 else: 930 txt = _( 931 'Found <b>{num}</b> covers for {title}. When the download completes,' 932 ' the covers will be sorted by size.').format( 933 title=self.title, num=num-1) 934 self.msg.setText(txt) 935 self.msg.setWordWrap(True) 936 self.covers_view.stop() 937 938 self.finished.emit() 939 940 def process_result(self, result): 941 if not self.continue_processing: 942 return 943 plugin_name, width, height, fmt, data = result 944 self.covers_view.model().update_result(plugin_name, width, height, data) 945 946 def cleanup(self): 947 self.covers_view.delegate.stop_animation() 948 self.continue_processing = False 949 950 def cancel(self): 951 self.cleanup() 952 self.abort.set() 953 954 def cover_pixmap(self): 955 idx = None 956 for i in self.covers_view.selectionModel().selectedIndexes(): 957 if i.isValid(): 958 idx = i 959 break 960 if idx is None: 961 idx = self.covers_view.currentIndex() 962 return self.covers_view.model().cover_pixmap(idx) 963 964# }}} 965 966 967class LogViewer(QDialog): # {{{ 968 969 def __init__(self, log, parent=None): 970 QDialog.__init__(self, parent) 971 self.log = log 972 self.l = l = QVBoxLayout() 973 self.setLayout(l) 974 975 self.tb = QTextBrowser(self) 976 l.addWidget(self.tb) 977 978 self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) 979 l.addWidget(self.bb) 980 self.copy_button = self.bb.addButton(_('Copy to clipboard'), 981 QDialogButtonBox.ButtonRole.ActionRole) 982 self.copy_button.clicked.connect(self.copy_to_clipboard) 983 self.copy_button.setIcon(QIcon(I('edit-copy.png'))) 984 self.bb.rejected.connect(self.reject) 985 self.bb.accepted.connect(self.accept) 986 987 self.setWindowTitle(_('Download log')) 988 self.setWindowIcon(QIcon(I('debug.png'))) 989 self.resize(QSize(800, 400)) 990 991 self.keep_updating = True 992 self.last_html = None 993 self.finished.connect(self.stop) 994 QTimer.singleShot(100, self.update_log) 995 996 self.show() 997 998 def copy_to_clipboard(self): 999 QApplication.clipboard().setText(''.join(self.log.plain_text)) 1000 1001 def stop(self, *args): 1002 self.keep_updating = False 1003 1004 def update_log(self): 1005 if not self.keep_updating: 1006 return 1007 html = self.log.html 1008 if html != self.last_html: 1009 self.last_html = html 1010 self.tb.setHtml('<pre style="font-family:monospace">%s</pre>'%html) 1011 QTimer.singleShot(1000, self.update_log) 1012 1013# }}} 1014 1015 1016class FullFetch(QDialog): # {{{ 1017 1018 def __init__(self, current_cover=None, parent=None): 1019 QDialog.__init__(self, parent) 1020 self.current_cover = current_cover 1021 self.log = Log() 1022 self.book = self.cover_pixmap = None 1023 1024 self.setWindowTitle(_('Downloading metadata...')) 1025 self.setWindowIcon(QIcon(I('download-metadata.png'))) 1026 1027 self.stack = QStackedWidget() 1028 self.l = l = QVBoxLayout() 1029 self.setLayout(l) 1030 l.addWidget(self.stack) 1031 1032 self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) 1033 self.h = h = QHBoxLayout() 1034 l.addLayout(h) 1035 self.bb.rejected.connect(self.reject) 1036 self.bb.accepted.connect(self.accept) 1037 self.ok_button = self.bb.button(QDialogButtonBox.StandardButton.Ok) 1038 self.ok_button.setEnabled(False) 1039 self.ok_button.clicked.connect(self.ok_clicked) 1040 self.prev_button = pb = QPushButton(QIcon(I('back.png')), _('&Back'), self) 1041 pb.clicked.connect(self.back_clicked) 1042 pb.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 1043 self.log_button = self.bb.addButton(_('&View log'), QDialogButtonBox.ButtonRole.ActionRole) 1044 self.log_button.clicked.connect(self.view_log) 1045 self.log_button.setIcon(QIcon(I('debug.png'))) 1046 self.prev_button.setVisible(False) 1047 h.addWidget(self.prev_button), h.addWidget(self.bb) 1048 1049 self.identify_widget = IdentifyWidget(self.log, self) 1050 self.identify_widget.rejected.connect(self.reject) 1051 self.identify_widget.results_found.connect(self.identify_results_found) 1052 self.identify_widget.book_selected.connect(self.book_selected) 1053 self.stack.addWidget(self.identify_widget) 1054 1055 self.covers_widget = CoversWidget(self.log, self.current_cover, parent=self) 1056 self.covers_widget.chosen.connect(self.ok_clicked) 1057 self.stack.addWidget(self.covers_widget) 1058 1059 self.resize(850, 600) 1060 geom = gprefs.get('metadata_single_gui_geom', None) 1061 if geom is not None and geom: 1062 QApplication.instance().safe_restore_geometry(self, geom) 1063 1064 self.finished.connect(self.cleanup) 1065 1066 def view_log(self): 1067 self._lv = LogViewer(self.log, self) 1068 1069 def book_selected(self, book, caches): 1070 self.prev_button.setVisible(True) 1071 self.book = book 1072 self.stack.setCurrentIndex(1) 1073 self.log('\n\n') 1074 self.covers_widget.start(book, self.current_cover, 1075 self.title, self.authors, caches) 1076 self.ok_button.setFocus() 1077 1078 def back_clicked(self): 1079 self.prev_button.setVisible(False) 1080 self.stack.setCurrentIndex(0) 1081 self.covers_widget.cancel() 1082 self.covers_widget.reset_covers() 1083 1084 def accept(self): 1085 # Prevent the usual dialog accept mechanisms from working 1086 gprefs['metadata_single_gui_geom'] = bytearray(self.saveGeometry()) 1087 self.identify_widget.save_state() 1088 if DEBUG_DIALOG: 1089 if self.stack.currentIndex() == 2: 1090 return QDialog.accept(self) 1091 else: 1092 if self.stack.currentIndex() == 1: 1093 return QDialog.accept(self) 1094 1095 def reject(self): 1096 gprefs['metadata_single_gui_geom'] = bytearray(self.saveGeometry()) 1097 self.identify_widget.cancel() 1098 self.covers_widget.cancel() 1099 return QDialog.reject(self) 1100 1101 def cleanup(self): 1102 self.covers_widget.cleanup() 1103 1104 def identify_results_found(self): 1105 self.ok_button.setEnabled(True) 1106 1107 def next_clicked(self, *args): 1108 gprefs['metadata_single_gui_geom'] = bytearray(self.saveGeometry()) 1109 self.identify_widget.get_result() 1110 1111 def ok_clicked(self, *args): 1112 self.cover_pixmap = self.covers_widget.cover_pixmap() 1113 if self.stack.currentIndex() == 0: 1114 self.next_clicked() 1115 return 1116 if DEBUG_DIALOG: 1117 if self.cover_pixmap is not None: 1118 self.w = QLabel() 1119 self.w.setPixmap(self.cover_pixmap) 1120 self.stack.addWidget(self.w) 1121 self.stack.setCurrentIndex(2) 1122 else: 1123 QDialog.accept(self) 1124 1125 def start(self, title=None, authors=None, identifiers={}): 1126 self.title, self.authors = title, authors 1127 self.identify_widget.start(title=title, authors=authors, 1128 identifiers=identifiers) 1129 return self.exec() 1130# }}} 1131 1132 1133class CoverFetch(QDialog): # {{{ 1134 1135 def __init__(self, current_cover=None, parent=None): 1136 QDialog.__init__(self, parent) 1137 self.current_cover = current_cover 1138 self.log = Log() 1139 self.cover_pixmap = None 1140 1141 self.setWindowTitle(_('Downloading cover...')) 1142 self.setWindowIcon(QIcon(I('default_cover.png'))) 1143 1144 self.l = l = QVBoxLayout() 1145 self.setLayout(l) 1146 1147 self.covers_widget = CoversWidget(self.log, self.current_cover, parent=self) 1148 self.covers_widget.chosen.connect(self.accept) 1149 l.addWidget(self.covers_widget) 1150 1151 self.resize(850, 600) 1152 1153 self.finished.connect(self.cleanup) 1154 1155 self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) 1156 l.addWidget(self.bb) 1157 self.log_button = self.bb.addButton(_('&View log'), QDialogButtonBox.ButtonRole.ActionRole) 1158 self.log_button.clicked.connect(self.view_log) 1159 self.log_button.setIcon(QIcon(I('debug.png'))) 1160 self.bb.rejected.connect(self.reject) 1161 self.bb.accepted.connect(self.accept) 1162 1163 geom = gprefs.get('single-cover-fetch-dialog-geometry', None) 1164 if geom is not None: 1165 QApplication.instance().safe_restore_geometry(self, geom) 1166 1167 def cleanup(self): 1168 self.covers_widget.cleanup() 1169 1170 def reject(self): 1171 gprefs.set('single-cover-fetch-dialog-geometry', bytearray(self.saveGeometry())) 1172 self.covers_widget.cancel() 1173 return QDialog.reject(self) 1174 1175 def accept(self, *args): 1176 gprefs.set('single-cover-fetch-dialog-geometry', bytearray(self.saveGeometry())) 1177 self.cover_pixmap = self.covers_widget.cover_pixmap() 1178 QDialog.accept(self) 1179 1180 def start(self, title, authors, identifiers): 1181 book = Metadata(title, authors) 1182 book.identifiers = identifiers 1183 self.covers_widget.start(book, self.current_cover, 1184 title, authors, {}) 1185 return self.exec() 1186 1187 def view_log(self): 1188 self._lv = LogViewer(self.log, self) 1189 1190# }}} 1191 1192 1193if __name__ == '__main__': 1194 from calibre.gui2 import Application 1195 DEBUG_DIALOG = True 1196 app = Application([]) 1197 d = FullFetch() 1198 d.start(title='great gatsby', authors=['fitzgerald'], identifiers={}) 1199