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