%PDF- %PDF-
| Direktori : /proc/self/root/usr/lib/calibre/calibre/gui2/metadata/ |
| Current File : //proc/self/root/usr/lib/calibre/calibre/gui2/metadata/single_download.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
DEBUG_DIALOG = False
# Imports {{{
import os, time
from threading import Thread, Event
from operator import attrgetter
from io import BytesIO
from qt.core import (
QStyledItemDelegate, QTextDocument, QRectF, QIcon, Qt, QApplication,
QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QStyle, QStackedWidget,
QWidget, QTableView, QGridLayout, QPalette, QTimer, pyqtSignal,
QAbstractTableModel, QSize, QListView, QPixmap, QModelIndex,
QAbstractListModel, QRect, QTextBrowser, QStringListModel, QMenu, QItemSelectionModel,
QCursor, QHBoxLayout, QPushButton, QSizePolicy, QSplitter, QAbstractItemView)
from calibre.customize.ui import metadata_plugins
from calibre.ebooks.metadata import authors_to_string, rating_to_stars
from calibre.utils.logging import GUILog as Log
from calibre.ebooks.metadata.sources.identify import urls_from_identifiers
from calibre.ebooks.metadata.book.base import Metadata
from calibre.ebooks.metadata.opf2 import OPF
from calibre.gui2 import error_dialog, rating_font, gprefs
from calibre.gui2.progress_indicator import SpinAnimator
from calibre.gui2.widgets2 import HTMLDisplay
from calibre.utils.date import (utcnow, fromordinal, format_date,
UNDEFINED_DATE, as_utc)
from calibre.library.comments import comments_to_html
from calibre import force_unicode
from calibre.utils.ipc.simple_worker import fork_job, WorkerError
from calibre.ptempfile import TemporaryDirectory
from polyglot.builtins import iteritems, itervalues
from polyglot.queue import Queue, Empty
# }}}
class RichTextDelegate(QStyledItemDelegate): # {{{
def __init__(self, parent=None, max_width=160):
QStyledItemDelegate.__init__(self, parent)
self.max_width = max_width
self.dummy_model = QStringListModel([' '], self)
self.dummy_index = self.dummy_model.index(0)
def to_doc(self, index, option=None):
doc = QTextDocument()
if option is not None and option.state & QStyle.StateFlag.State_Selected:
p = option.palette
group = (QPalette.ColorGroup.Active if option.state & QStyle.StateFlag.State_Active else
QPalette.ColorGroup.Inactive)
c = p.color(group, QPalette.ColorRole.HighlightedText)
c = 'rgb(%d, %d, %d)'%c.getRgb()[:3]
doc.setDefaultStyleSheet(' * { color: %s }'%c)
doc.setHtml(index.data() or '')
return doc
def sizeHint(self, option, index):
doc = self.to_doc(index, option=option)
ans = doc.size().toSize()
if ans.width() > self.max_width - 10:
ans.setWidth(self.max_width)
ans.setHeight(ans.height()+10)
return ans
def paint(self, painter, option, index):
QStyledItemDelegate.paint(self, painter, option, self.dummy_index)
painter.save()
painter.setClipRect(QRectF(option.rect))
painter.translate(option.rect.topLeft())
self.to_doc(index, option).drawContents(painter)
painter.restore()
# }}}
class CoverDelegate(QStyledItemDelegate): # {{{
ICON_SIZE = 150, 200
needs_redraw = pyqtSignal()
def __init__(self, parent):
QStyledItemDelegate.__init__(self, parent)
self.animator = SpinAnimator(self)
self.animator.updated.connect(self.needs_redraw)
self.color = parent.palette().color(QPalette.ColorRole.WindowText)
self.spinner_width = 64
def start_animation(self):
self.animator.start()
def stop_animation(self):
self.animator.stop()
def paint(self, painter, option, index):
QStyledItemDelegate.paint(self, painter, option, index)
style = QApplication.style()
waiting = self.animator.is_running() and bool(index.data(Qt.ItemDataRole.UserRole))
if waiting:
rect = QRect(0, 0, self.spinner_width, self.spinner_width)
rect.moveCenter(option.rect.center())
self.animator.draw(painter, rect, self.color)
else:
# Ensure the cover is rendered over any selection rect
style.drawItemPixmap(painter, option.rect, Qt.AlignmentFlag.AlignTop|Qt.AlignmentFlag.AlignHCenter,
QPixmap(index.data(Qt.ItemDataRole.DecorationRole)))
# }}}
class ResultsModel(QAbstractTableModel): # {{{
COLUMNS = (
'#', _('Title'), _('Published'), _('Has cover'), _('Has summary')
)
HTML_COLS = (1, 2)
ICON_COLS = (3, 4)
def __init__(self, results, parent=None):
QAbstractTableModel.__init__(self, parent)
self.results = results
self.yes_icon = (QIcon(I('ok.png')))
def rowCount(self, parent=None):
return len(self.results)
def columnCount(self, parent=None):
return len(self.COLUMNS)
def headerData(self, section, orientation, role):
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
try:
return (self.COLUMNS[section])
except:
return None
return None
def data_as_text(self, book, col):
if col == 0:
return str(book.gui_rank+1)
if col == 1:
t = book.title if book.title else _('Unknown')
a = authors_to_string(book.authors) if book.authors else ''
return f'<b>{t}</b><br><i>{a}</i>'
if col == 2:
d = format_date(book.pubdate, 'yyyy') if book.pubdate else _('Unknown')
p = book.publisher if book.publisher else ''
return f'<b>{d}</b><br><i>{p}</i>'
def data(self, index, role):
row, col = index.row(), index.column()
try:
book = self.results[row]
except:
return None
if role == Qt.ItemDataRole.DisplayRole and col not in self.ICON_COLS:
res = self.data_as_text(book, col)
if res:
return (res)
return None
elif role == Qt.ItemDataRole.DecorationRole and col in self.ICON_COLS:
if col == 3 and getattr(book, 'has_cached_cover_url', False):
return self.yes_icon
if col == 4 and book.comments:
return self.yes_icon
elif role == Qt.ItemDataRole.UserRole:
return book
elif role == Qt.ItemDataRole.ToolTipRole and col == 3:
return (
_('The "has cover" indication is not fully\n'
'reliable. Sometimes results marked as not\n'
'having a cover will find a cover in the download\n'
'cover stage, and vice versa.'))
return None
def sort(self, col, order=Qt.SortOrder.AscendingOrder):
key = lambda x: x
if col == 0:
key = attrgetter('gui_rank')
elif col == 1:
key = attrgetter('title')
elif col == 2:
def dategetter(x):
x = getattr(x, 'pubdate', None)
if x is None:
x = UNDEFINED_DATE
return as_utc(x)
key = dategetter
elif col == 3:
key = attrgetter('has_cached_cover_url')
elif key == 4:
key = lambda x: bool(x.comments)
self.beginResetModel()
self.results.sort(key=key, reverse=order==Qt.SortOrder.AscendingOrder)
self.endResetModel()
# }}}
class ResultsView(QTableView): # {{{
show_details_signal = pyqtSignal(object)
book_selected = pyqtSignal(object)
def __init__(self, parent=None):
QTableView.__init__(self, parent)
self.rt_delegate = RichTextDelegate(self)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setAlternatingRowColors(True)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setIconSize(QSize(24, 24))
self.clicked.connect(self.show_details)
self.doubleClicked.connect(self.select_index)
self.setSortingEnabled(True)
def show_results(self, results):
self._model = ResultsModel(results, self)
self.setModel(self._model)
for i in self._model.HTML_COLS:
self.setItemDelegateForColumn(i, self.rt_delegate)
self.resizeRowsToContents()
self.resizeColumnsToContents()
self.setFocus(Qt.FocusReason.OtherFocusReason)
idx = self.model().index(0, 0)
if idx.isValid() and self.model().rowCount() > 0:
self.show_details(idx)
sm = self.selectionModel()
sm.select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect|QItemSelectionModel.SelectionFlag.Rows)
def resize_delegate(self):
self.rt_delegate.max_width = int(self.width()/2.1)
self.resizeColumnsToContents()
def resizeEvent(self, ev):
ret = super().resizeEvent(ev)
self.resize_delegate()
return ret
def currentChanged(self, current, previous):
ret = QTableView.currentChanged(self, current, previous)
self.show_details(current)
return ret
def show_details(self, index):
f = rating_font()
book = self.model().data(index, Qt.ItemDataRole.UserRole)
parts = [
'<center>',
'<h2>%s</h2>'%book.title,
'<div><i>%s</i></div>'%authors_to_string(book.authors),
]
if not book.is_null('series'):
series = book.format_field('series')
if series[1]:
parts.append('<div>%s: %s</div>'%series)
if not book.is_null('rating'):
style = 'style=\'font-family:"%s"\''%f
parts.append('<div %s>%s</div>'%(style, rating_to_stars(int(2 * book.rating))))
parts.append('</center>')
if book.identifiers:
urls = urls_from_identifiers(book.identifiers)
ids = ['<a href="%s">%s</a>'%(url, name) for name, ign, ign, url in urls]
if ids:
parts.append('<div><b>%s:</b> %s</div><br>'%(_('See at'), ', '.join(ids)))
if book.tags:
parts.append('<div>%s</div><div>\u00a0</div>'%', '.join(book.tags))
if book.comments:
parts.append(comments_to_html(book.comments))
self.show_details_signal.emit(''.join(parts))
def select_index(self, index):
if self.model() is None:
return
if not index.isValid():
index = self.model().index(0, 0)
book = self.model().data(index, Qt.ItemDataRole.UserRole)
self.book_selected.emit(book)
def get_result(self):
self.select_index(self.currentIndex())
def keyPressEvent(self, ev):
if ev.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right):
ac = QAbstractItemView.CursorAction.MoveDown if ev.key() == Qt.Key.Key_Right else QAbstractItemView.CursorAction.MoveUp
index = self.moveCursor(ac, ev.modifiers())
if index.isValid() and index != self.currentIndex():
m = self.selectionModel()
m.select(index, QItemSelectionModel.SelectionFlag.Select|QItemSelectionModel.SelectionFlag.Current|QItemSelectionModel.SelectionFlag.Rows)
self.setCurrentIndex(index)
ev.accept()
return
return QTableView.keyPressEvent(self, ev)
# }}}
class Comments(HTMLDisplay): # {{{
def __init__(self, parent=None):
HTMLDisplay.__init__(self, parent)
self.setAcceptDrops(False)
self.wait_timer = QTimer(self)
self.wait_timer.timeout.connect(self.update_wait)
self.wait_timer.setInterval(800)
self.dots_count = 0
self.anchor_clicked.connect(self.link_activated)
def link_activated(self, url):
from calibre.gui2 import open_url
if url.scheme() in {'http', 'https'}:
open_url(url)
def show_wait(self):
self.dots_count = 0
self.wait_timer.start()
self.update_wait()
def update_wait(self):
self.dots_count += 1
self.dots_count %= 10
self.dots_count = self.dots_count or 1
self.setHtml(
'<h2>'+_('Please wait')+
'<br><span id="dots">{}</span></h2>'.format('.' * self.dots_count))
def show_data(self, html):
self.wait_timer.stop()
def color_to_string(col):
ans = '#000000'
if col.isValid():
col = col.toRgb()
if col.isValid():
ans = str(col.name())
return ans
c = color_to_string(QApplication.palette().color(QPalette.ColorGroup.Normal,
QPalette.ColorRole.WindowText))
templ = '''\
<html>
<head>
<style type="text/css">
body, td {background-color: transparent; color: %s }
a { text-decoration: none; }
div.description { margin-top: 0; padding-top: 0; text-indent: 0 }
table { margin-bottom: 0; padding-bottom: 0; }
</style>
</head>
<body>
<div class="description">
%%s
</div>
</body>
<html>
'''%(c,)
self.setHtml(templ%html)
# }}}
class IdentifyWorker(Thread): # {{{
def __init__(self, log, abort, title, authors, identifiers, caches):
Thread.__init__(self)
self.daemon = True
self.log, self.abort = log, abort
self.title, self.authors, self.identifiers = (title, authors,
identifiers)
self.results = []
self.error = None
self.caches = caches
def sample_results(self):
m1 = Metadata('The Great Gatsby', ['Francis Scott Fitzgerald'])
m2 = Metadata('The Great Gatsby - An extra long title to test resizing', ['F. Scott Fitzgerald'])
m1.has_cached_cover_url = True
m2.has_cached_cover_url = False
m1.comments = 'Some comments '*10
m1.tags = ['tag%d'%i for i in range(20)]
m1.rating = 4.4
m1.language = 'en'
m2.language = 'fr'
m1.pubdate = utcnow()
m2.pubdate = fromordinal(1000000)
m1.publisher = 'Publisher 1'
m2.publisher = 'Publisher 2'
return [m1, m2]
def run(self):
try:
if DEBUG_DIALOG:
self.results = self.sample_results()
else:
res = fork_job(
'calibre.ebooks.metadata.sources.worker',
'single_identify', (self.title, self.authors,
self.identifiers), no_output=True, abort=self.abort)
self.results, covers, caches, log_dump = res['result']
self.results = [OPF(BytesIO(r), basedir=os.getcwd(),
populate_spine=False).to_book_metadata() for r in self.results]
for r, cov in zip(self.results, covers):
r.has_cached_cover_url = cov
self.caches.update(caches)
self.log.load(log_dump)
for i, result in enumerate(self.results):
result.gui_rank = i
except WorkerError as e:
self.error = force_unicode(e.orig_tb)
except:
import traceback
self.error = force_unicode(traceback.format_exc())
# }}}
class IdentifyWidget(QWidget): # {{{
rejected = pyqtSignal()
results_found = pyqtSignal()
book_selected = pyqtSignal(object, object)
def __init__(self, log, parent=None):
QWidget.__init__(self, parent)
self.log = log
self.abort = Event()
self.caches = {}
self.l = l = QVBoxLayout(self)
names = ['<b>'+p.name+'</b>' for p in metadata_plugins(['identify']) if
p.is_configured()]
self.top = QLabel('<p>'+_('calibre is downloading metadata from: ') +
', '.join(names))
self.top.setWordWrap(True)
l.addWidget(self.top)
self.splitter = s = QSplitter(self)
s.setChildrenCollapsible(False)
l.addWidget(s, 100)
self.results_view = ResultsView(self)
self.results_view.book_selected.connect(self.emit_book_selected)
self.get_result = self.results_view.get_result
s.addWidget(self.results_view)
self.comments_view = Comments(self)
s.addWidget(self.comments_view)
s.setStretchFactor(0, 2)
s.setStretchFactor(1, 1)
self.results_view.show_details_signal.connect(self.comments_view.show_data)
self.query = QLabel('download starting...')
self.query.setWordWrap(True)
self.query.setTextFormat(Qt.TextFormat.PlainText)
l.addWidget(self.query)
self.comments_view.show_wait()
state = gprefs.get('metadata-download-identify-widget-splitter-state')
if state is not None:
s.restoreState(state)
def save_state(self):
gprefs['metadata-download-identify-widget-splitter-state'] = bytearray(self.splitter.saveState())
def emit_book_selected(self, book):
self.book_selected.emit(book, self.caches)
def start(self, title=None, authors=None, identifiers={}):
self.log.clear()
self.log('Starting download')
parts, simple_desc = [], ''
if title:
parts.append('title:'+title)
simple_desc += _('Title: %s ') % title
if authors:
parts.append('authors:'+authors_to_string(authors))
simple_desc += _('Authors: %s ') % authors_to_string(authors)
if identifiers:
x = ', '.join('%s:%s'%(k, v) for k, v in iteritems(identifiers))
parts.append(x)
if 'isbn' in identifiers:
simple_desc += 'ISBN: %s' % identifiers['isbn']
self.query.setText(simple_desc)
self.log(str(self.query.text()))
self.worker = IdentifyWorker(self.log, self.abort, title,
authors, identifiers, self.caches)
self.worker.start()
QTimer.singleShot(50, self.update)
def update(self):
if self.worker.is_alive():
QTimer.singleShot(50, self.update)
else:
self.process_results()
def process_results(self):
if self.worker.error is not None:
error_dialog(self, _('Download failed'),
_('Failed to download metadata. Click '
'Show Details to see details'),
show=True, det_msg=self.worker.error)
self.rejected.emit()
return
if not self.worker.results:
log = ''.join(self.log.plain_text)
error_dialog(self, _('No matches found'), '<p>' +
_('Failed to find any books that '
'match your search. Try making the search <b>less '
'specific</b>. For example, use only the author\'s '
'last name and a single distinctive word from '
'the title.<p>To see the full log, click "Show details".'),
show=True, det_msg=log)
self.rejected.emit()
return
self.results_view.show_results(self.worker.results)
self.results_found.emit()
def cancel(self):
self.abort.set()
# }}}
class CoverWorker(Thread): # {{{
def __init__(self, log, abort, title, authors, identifiers, caches):
Thread.__init__(self, name='CoverWorker')
self.daemon = True
self.log, self.abort = log, abort
self.title, self.authors, self.identifiers = (title, authors,
identifiers)
self.caches = caches
self.rq = Queue()
self.error = None
def fake_run(self):
images = ['donate.png', 'config.png', 'column.png', 'eject.png', ]
time.sleep(2)
for pl, im in zip(metadata_plugins(['cover']), images):
self.rq.put((pl.name, 1, 1, 'png', I(im, data=True)))
def run(self):
try:
if DEBUG_DIALOG:
self.fake_run()
else:
self.run_fork()
except WorkerError as e:
self.error = force_unicode(e.orig_tb)
except:
import traceback
self.error = force_unicode(traceback.format_exc())
def run_fork(self):
with TemporaryDirectory('_single_metadata_download') as tdir:
self.keep_going = True
t = Thread(target=self.monitor_tdir, args=(tdir,))
t.daemon = True
t.start()
try:
res = fork_job('calibre.ebooks.metadata.sources.worker',
'single_covers',
(self.title, self.authors, self.identifiers, self.caches,
tdir),
no_output=True, abort=self.abort)
self.log.append_dump(res['result'])
finally:
self.keep_going = False
t.join()
def scan_once(self, tdir, seen):
for x in list(os.listdir(tdir)):
if x in seen:
continue
if x.endswith('.cover') and os.path.exists(os.path.join(tdir,
x+'.done')):
name = x.rpartition('.')[0]
try:
plugin_name, width, height, fmt = name.split(',,')
width, height = int(width), int(height)
with open(os.path.join(tdir, x), 'rb') as f:
data = f.read()
except:
import traceback
traceback.print_exc()
else:
seen.add(x)
self.rq.put((plugin_name, width, height, fmt, data))
def monitor_tdir(self, tdir):
seen = set()
while self.keep_going:
time.sleep(1)
self.scan_once(tdir, seen)
# One last scan after the download process has ended
self.scan_once(tdir, seen)
# }}}
class CoversModel(QAbstractListModel): # {{{
def __init__(self, current_cover, parent=None):
QAbstractListModel.__init__(self, parent)
if current_cover is None:
current_cover = QPixmap(I('default_cover.png'))
current_cover.setDevicePixelRatio(QApplication.instance().devicePixelRatio())
self.blank = QIcon(I('blank.png')).pixmap(*CoverDelegate.ICON_SIZE)
self.cc = current_cover
self.reset_covers(do_reset=False)
def reset_covers(self, do_reset=True):
self.covers = [self.get_item(_('Current cover'), self.cc)]
self.plugin_map = {}
for i, plugin in enumerate(metadata_plugins(['cover'])):
self.covers.append((plugin.name+'\n'+_('Searching...'),
(self.blank), None, True))
self.plugin_map[plugin] = [i+1]
if do_reset:
self.beginResetModel(), self.endResetModel()
def get_item(self, src, pmap, waiting=False):
sz = '%dx%d'%(pmap.width(), pmap.height())
text = (src + '\n' + sz)
scaled = pmap.scaled(
int(CoverDelegate.ICON_SIZE[0] * pmap.devicePixelRatio()), int(CoverDelegate.ICON_SIZE[1] * pmap.devicePixelRatio()),
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
scaled.setDevicePixelRatio(pmap.devicePixelRatio())
return (text, (scaled), pmap, waiting)
def rowCount(self, parent=None):
return len(self.covers)
def data(self, index, role):
try:
text, pmap, cover, waiting = self.covers[index.row()]
except:
return None
if role == Qt.ItemDataRole.DecorationRole:
return pmap
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.ToolTipRole:
return text
if role == Qt.ItemDataRole.UserRole:
return waiting
return None
def plugin_for_index(self, index):
row = index.row() if hasattr(index, 'row') else index
for k, v in iteritems(self.plugin_map):
if row in v:
return k
def clear_failed(self):
# Remove entries that are still waiting
good = []
pmap = {}
def keygen(x):
pmap = x[2]
if pmap is None:
return 1
return pmap.width()*pmap.height()
dcovers = sorted(self.covers[1:], key=keygen, reverse=True)
cmap = {i:self.plugin_for_index(i) for i in range(len(self.covers))}
for i, x in enumerate(self.covers[0:1] + dcovers):
if not x[-1]:
good.append(x)
plugin = cmap[i]
if plugin is not None:
try:
pmap[plugin].append(len(good) - 1)
except KeyError:
pmap[plugin] = [len(good)-1]
self.covers = good
self.plugin_map = pmap
self.beginResetModel(), self.endResetModel()
def pointer_from_index(self, index):
row = index.row() if hasattr(index, 'row') else index
try:
return self.covers[row][2]
except IndexError:
pass
def index_from_pointer(self, pointer):
for r, (text, scaled, pmap, waiting) in enumerate(self.covers):
if pointer == pmap:
return self.index(r)
return self.index(0)
def load_pixmap(self, data):
pmap = QPixmap()
pmap.loadFromData(data)
pmap.setDevicePixelRatio(QApplication.instance().devicePixelRatio())
return pmap
def update_result(self, plugin_name, width, height, data):
if plugin_name.endswith('}'):
# multi cover plugin
plugin_name = plugin_name.partition('{')[0]
plugin = [plugin for plugin in self.plugin_map if plugin.name == plugin_name]
if not plugin:
return
plugin = plugin[0]
last_row = max(self.plugin_map[plugin])
pmap = self.load_pixmap(data)
if pmap.isNull():
return
self.beginInsertRows(QModelIndex(), last_row, last_row)
for rows in itervalues(self.plugin_map):
for i in range(len(rows)):
if rows[i] >= last_row:
rows[i] += 1
self.plugin_map[plugin].insert(-1, last_row)
self.covers.insert(last_row, self.get_item(plugin_name, pmap, waiting=False))
self.endInsertRows()
else:
# single cover plugin
idx = None
for plugin, rows in iteritems(self.plugin_map):
if plugin.name == plugin_name:
idx = rows[0]
break
if idx is None:
return
pmap = self.load_pixmap(data)
if pmap.isNull():
return
self.covers[idx] = self.get_item(plugin_name, pmap, waiting=False)
self.dataChanged.emit(self.index(idx), self.index(idx))
def cover_pixmap(self, index):
row = index.row()
if row > 0 and row < len(self.covers):
pmap = self.covers[row][2]
if pmap is not None and not pmap.isNull():
return pmap
# }}}
class CoversView(QListView): # {{{
chosen = pyqtSignal()
def __init__(self, current_cover, parent=None):
QListView.__init__(self, parent)
self.m = CoversModel(current_cover, self)
self.setModel(self.m)
self.setFlow(QListView.Flow.LeftToRight)
self.setWrapping(True)
self.setResizeMode(QListView.ResizeMode.Adjust)
self.setGridSize(QSize(190, 260))
self.setIconSize(QSize(*CoverDelegate.ICON_SIZE))
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setViewMode(QListView.ViewMode.IconMode)
self.delegate = CoverDelegate(self)
self.setItemDelegate(self.delegate)
self.delegate.needs_redraw.connect(self.redraw_spinners,
type=Qt.ConnectionType.QueuedConnection)
self.doubleClicked.connect(self.chosen, type=Qt.ConnectionType.QueuedConnection)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_context_menu)
def redraw_spinners(self):
m = self.model()
for r in range(m.rowCount()):
idx = m.index(r)
if bool(m.data(idx, Qt.ItemDataRole.UserRole)):
m.dataChanged.emit(idx, idx)
def select(self, num):
current = self.model().index(num)
sm = self.selectionModel()
sm.select(current, QItemSelectionModel.SelectionFlag.SelectCurrent)
def start(self):
self.select(0)
self.delegate.start_animation()
def stop(self):
self.delegate.stop_animation()
def reset_covers(self):
self.m.reset_covers()
def clear_failed(self):
pointer = self.m.pointer_from_index(self.currentIndex())
self.m.clear_failed()
if pointer is None:
self.select(0)
else:
self.select(self.m.index_from_pointer(pointer).row())
def show_context_menu(self, point):
idx = self.currentIndex()
if idx and idx.isValid() and not idx.data(Qt.ItemDataRole.UserRole):
m = QMenu(self)
m.addAction(QIcon(I('view.png')), _('View this cover at full size'), self.show_cover)
m.addAction(QIcon(I('edit-copy.png')), _('Copy this cover to clipboard'), self.copy_cover)
m.exec(QCursor.pos())
def show_cover(self):
idx = self.currentIndex()
pmap = self.model().cover_pixmap(idx)
if pmap is None and idx.row() == 0:
pmap = self.model().cc
if pmap is not None:
from calibre.gui2.image_popup import ImageView
d = ImageView(self, pmap, str(idx.data(Qt.ItemDataRole.DisplayRole) or ''), geom_name='metadata_download_cover_popup_geom')
d(use_exec=True)
def copy_cover(self):
idx = self.currentIndex()
pmap = self.model().cover_pixmap(idx)
if pmap is None and idx.row() == 0:
pmap = self.model().cc
if pmap is not None:
QApplication.clipboard().setPixmap(pmap)
def keyPressEvent(self, ev):
if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
self.chosen.emit()
ev.accept()
return
return QListView.keyPressEvent(self, ev)
# }}}
class CoversWidget(QWidget): # {{{
chosen = pyqtSignal()
finished = pyqtSignal()
def __init__(self, log, current_cover, parent=None):
QWidget.__init__(self, parent)
self.log = log
self.abort = Event()
self.l = l = QGridLayout()
self.setLayout(l)
self.msg = QLabel()
self.msg.setWordWrap(True)
l.addWidget(self.msg, 0, 0)
self.covers_view = CoversView(current_cover, self)
self.covers_view.chosen.connect(self.chosen)
l.addWidget(self.covers_view, 1, 0)
self.continue_processing = True
def reset_covers(self):
self.covers_view.reset_covers()
def start(self, book, current_cover, title, authors, caches):
self.continue_processing = True
self.abort.clear()
self.book, self.current_cover = book, current_cover
self.title, self.authors = title, authors
self.log('Starting cover download for:', book.title)
self.log('Query:', title, authors, self.book.identifiers)
self.msg.setText('<p>'+
_('Downloading covers for <b>%s</b>, please wait...')%book.title)
self.covers_view.start()
self.worker = CoverWorker(self.log, self.abort, self.title,
self.authors, book.identifiers, caches)
self.worker.start()
QTimer.singleShot(50, self.check)
self.covers_view.setFocus(Qt.FocusReason.OtherFocusReason)
def check(self):
if self.worker.is_alive() and not self.abort.is_set():
QTimer.singleShot(50, self.check)
try:
self.process_result(self.worker.rq.get_nowait())
except Empty:
pass
else:
self.process_results()
def process_results(self):
while self.continue_processing:
try:
self.process_result(self.worker.rq.get_nowait())
except Empty:
break
if self.continue_processing:
self.covers_view.clear_failed()
if self.worker.error and self.worker.error.strip():
error_dialog(self, _('Download failed'),
_('Failed to download any covers, click'
' "Show details" for details.'),
det_msg=self.worker.error, show=True)
num = self.covers_view.model().rowCount()
if num < 2:
txt = _('Could not find any covers for <b>%s</b>')%self.book.title
else:
if num == 2:
txt = _('Found a cover for {title}').format(title=self.title)
else:
txt = _(
'Found <b>{num}</b> covers for {title}. When the download completes,'
' the covers will be sorted by size.').format(
title=self.title, num=num-1)
self.msg.setText(txt)
self.msg.setWordWrap(True)
self.covers_view.stop()
self.finished.emit()
def process_result(self, result):
if not self.continue_processing:
return
plugin_name, width, height, fmt, data = result
self.covers_view.model().update_result(plugin_name, width, height, data)
def cleanup(self):
self.covers_view.delegate.stop_animation()
self.continue_processing = False
def cancel(self):
self.cleanup()
self.abort.set()
def cover_pixmap(self):
idx = None
for i in self.covers_view.selectionModel().selectedIndexes():
if i.isValid():
idx = i
break
if idx is None:
idx = self.covers_view.currentIndex()
return self.covers_view.model().cover_pixmap(idx)
# }}}
class LogViewer(QDialog): # {{{
def __init__(self, log, parent=None):
QDialog.__init__(self, parent)
self.log = log
self.l = l = QVBoxLayout()
self.setLayout(l)
self.tb = QTextBrowser(self)
l.addWidget(self.tb)
self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
l.addWidget(self.bb)
self.copy_button = self.bb.addButton(_('Copy to clipboard'),
QDialogButtonBox.ButtonRole.ActionRole)
self.copy_button.clicked.connect(self.copy_to_clipboard)
self.copy_button.setIcon(QIcon(I('edit-copy.png')))
self.bb.rejected.connect(self.reject)
self.bb.accepted.connect(self.accept)
self.setWindowTitle(_('Download log'))
self.setWindowIcon(QIcon(I('debug.png')))
self.resize(QSize(800, 400))
self.keep_updating = True
self.last_html = None
self.finished.connect(self.stop)
QTimer.singleShot(100, self.update_log)
self.show()
def copy_to_clipboard(self):
QApplication.clipboard().setText(''.join(self.log.plain_text))
def stop(self, *args):
self.keep_updating = False
def update_log(self):
if not self.keep_updating:
return
html = self.log.html
if html != self.last_html:
self.last_html = html
self.tb.setHtml('<pre style="font-family:monospace">%s</pre>'%html)
QTimer.singleShot(1000, self.update_log)
# }}}
class FullFetch(QDialog): # {{{
def __init__(self, current_cover=None, parent=None):
QDialog.__init__(self, parent)
self.current_cover = current_cover
self.log = Log()
self.book = self.cover_pixmap = None
self.setWindowTitle(_('Downloading metadata...'))
self.setWindowIcon(QIcon(I('download-metadata.png')))
self.stack = QStackedWidget()
self.l = l = QVBoxLayout()
self.setLayout(l)
l.addWidget(self.stack)
self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
self.h = h = QHBoxLayout()
l.addLayout(h)
self.bb.rejected.connect(self.reject)
self.bb.accepted.connect(self.accept)
self.ok_button = self.bb.button(QDialogButtonBox.StandardButton.Ok)
self.ok_button.setEnabled(False)
self.ok_button.clicked.connect(self.ok_clicked)
self.prev_button = pb = QPushButton(QIcon(I('back.png')), _('&Back'), self)
pb.clicked.connect(self.back_clicked)
pb.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.log_button = self.bb.addButton(_('&View log'), QDialogButtonBox.ButtonRole.ActionRole)
self.log_button.clicked.connect(self.view_log)
self.log_button.setIcon(QIcon(I('debug.png')))
self.prev_button.setVisible(False)
h.addWidget(self.prev_button), h.addWidget(self.bb)
self.identify_widget = IdentifyWidget(self.log, self)
self.identify_widget.rejected.connect(self.reject)
self.identify_widget.results_found.connect(self.identify_results_found)
self.identify_widget.book_selected.connect(self.book_selected)
self.stack.addWidget(self.identify_widget)
self.covers_widget = CoversWidget(self.log, self.current_cover, parent=self)
self.covers_widget.chosen.connect(self.ok_clicked)
self.stack.addWidget(self.covers_widget)
self.resize(850, 600)
geom = gprefs.get('metadata_single_gui_geom', None)
if geom is not None and geom:
QApplication.instance().safe_restore_geometry(self, geom)
self.finished.connect(self.cleanup)
def view_log(self):
self._lv = LogViewer(self.log, self)
def book_selected(self, book, caches):
self.prev_button.setVisible(True)
self.book = book
self.stack.setCurrentIndex(1)
self.log('\n\n')
self.covers_widget.start(book, self.current_cover,
self.title, self.authors, caches)
self.ok_button.setFocus()
def back_clicked(self):
self.prev_button.setVisible(False)
self.stack.setCurrentIndex(0)
self.covers_widget.cancel()
self.covers_widget.reset_covers()
def accept(self):
# Prevent the usual dialog accept mechanisms from working
gprefs['metadata_single_gui_geom'] = bytearray(self.saveGeometry())
self.identify_widget.save_state()
if DEBUG_DIALOG:
if self.stack.currentIndex() == 2:
return QDialog.accept(self)
else:
if self.stack.currentIndex() == 1:
return QDialog.accept(self)
def reject(self):
gprefs['metadata_single_gui_geom'] = bytearray(self.saveGeometry())
self.identify_widget.cancel()
self.covers_widget.cancel()
return QDialog.reject(self)
def cleanup(self):
self.covers_widget.cleanup()
def identify_results_found(self):
self.ok_button.setEnabled(True)
def next_clicked(self, *args):
gprefs['metadata_single_gui_geom'] = bytearray(self.saveGeometry())
self.identify_widget.get_result()
def ok_clicked(self, *args):
self.cover_pixmap = self.covers_widget.cover_pixmap()
if self.stack.currentIndex() == 0:
self.next_clicked()
return
if DEBUG_DIALOG:
if self.cover_pixmap is not None:
self.w = QLabel()
self.w.setPixmap(self.cover_pixmap)
self.stack.addWidget(self.w)
self.stack.setCurrentIndex(2)
else:
QDialog.accept(self)
def start(self, title=None, authors=None, identifiers={}):
self.title, self.authors = title, authors
self.identify_widget.start(title=title, authors=authors,
identifiers=identifiers)
return self.exec()
# }}}
class CoverFetch(QDialog): # {{{
def __init__(self, current_cover=None, parent=None):
QDialog.__init__(self, parent)
self.current_cover = current_cover
self.log = Log()
self.cover_pixmap = None
self.setWindowTitle(_('Downloading cover...'))
self.setWindowIcon(QIcon(I('default_cover.png')))
self.l = l = QVBoxLayout()
self.setLayout(l)
self.covers_widget = CoversWidget(self.log, self.current_cover, parent=self)
self.covers_widget.chosen.connect(self.accept)
l.addWidget(self.covers_widget)
self.resize(850, 600)
self.finished.connect(self.cleanup)
self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok)
l.addWidget(self.bb)
self.log_button = self.bb.addButton(_('&View log'), QDialogButtonBox.ButtonRole.ActionRole)
self.log_button.clicked.connect(self.view_log)
self.log_button.setIcon(QIcon(I('debug.png')))
self.bb.rejected.connect(self.reject)
self.bb.accepted.connect(self.accept)
geom = gprefs.get('single-cover-fetch-dialog-geometry', None)
if geom is not None:
QApplication.instance().safe_restore_geometry(self, geom)
def cleanup(self):
self.covers_widget.cleanup()
def reject(self):
gprefs.set('single-cover-fetch-dialog-geometry', bytearray(self.saveGeometry()))
self.covers_widget.cancel()
return QDialog.reject(self)
def accept(self, *args):
gprefs.set('single-cover-fetch-dialog-geometry', bytearray(self.saveGeometry()))
self.cover_pixmap = self.covers_widget.cover_pixmap()
QDialog.accept(self)
def start(self, title, authors, identifiers):
book = Metadata(title, authors)
book.identifiers = identifiers
self.covers_widget.start(book, self.current_cover,
title, authors, {})
return self.exec()
def view_log(self):
self._lv = LogViewer(self.log, self)
# }}}
if __name__ == '__main__':
from calibre.gui2 import Application
DEBUG_DIALOG = True
app = Application([])
d = FullFetch()
d.start(title='great gatsby', authors=['fitzgerald'], identifiers={})