%PDF- %PDF-
| Direktori : /lib/calibre/calibre/gui2/tweak_book/ |
| Current File : //lib/calibre/calibre/gui2/tweak_book/reports.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import time, textwrap, os
from threading import Thread
from contextlib import suppress
from operator import itemgetter
from functools import partial, lru_cache
from collections import defaultdict
from csv import writer as csv_writer
from io import StringIO
import regex
from qt.core import (
QSize, QStackedLayout, QLabel, QVBoxLayout, Qt, QWidget, pyqtSignal,
QAbstractTableModel, QTableView, QSortFilterProxyModel, QIcon, QListWidget,
QListWidgetItem, QLineEdit, QStackedWidget, QSplitter, QByteArray, QPixmap,
QStyledItemDelegate, QModelIndex, QRect, QStyle, QPalette, QTimer, QMenu,
QAbstractItemModel, QTreeView, QFont, QRadioButton, QHBoxLayout,
QFontDatabase, QComboBox, QUrl, QAbstractItemView, QDialogButtonBox, QTextCursor)
from calibre import human_readable, fit_image
from calibre.constants import DEBUG
from calibre.ebooks.oeb.polish.report import (
gather_data, CSSEntry, CSSFileMatch, MatchLocation, ClassEntry,
ClassFileMatch, ClassElement, CSSRule, LinkLocation)
from calibre.gui2 import error_dialog, question_dialog, choose_save_file, open_url
from calibre.gui2.webengine import secure_webengine, RestartingWebEngineView
from calibre.gui2.tweak_book import current_container, tprefs, dictionaries
from calibre.gui2.tweak_book.widgets import Dialog
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.utils.icu import primary_contains, numeric_sort_key
from calibre.utils.unicode_names import character_name_from_code
from calibre.utils.localization import calibre_langcode_to_name, canonicalize_lang
from polyglot.builtins import iteritems, as_bytes
# Utils {{{
ROOT = QModelIndex()
def psk(x):
return QByteArray(numeric_sort_key(x))
def read_state(name, default=None):
data = tprefs.get('reports-ui-state')
if data is None:
tprefs['reports-ui-state'] = data = {}
return data.get(name, default)
def save_state(name, val):
data = tprefs.get('reports-ui-state')
if isinstance(val, QByteArray):
val = bytearray(val)
if data is None:
tprefs['reports-ui-state'] = data = {}
data[name] = val
SORT_ROLE = Qt.ItemDataRole.UserRole + 1
class ProxyModel(QSortFilterProxyModel):
def __init__(self, parent=None):
QSortFilterProxyModel.__init__(self, parent)
self._filter_text = None
self.setSortRole(SORT_ROLE)
def filter_text(self, text):
self._filter_text = text
self.setFilterFixedString(text)
def filterAcceptsRow(self, row, parent):
if not self._filter_text:
return True
sm = self.sourceModel()
for item in (sm.data(sm.index(row, c, parent)) or '' for c in range(sm.columnCount())):
if item and primary_contains(self._filter_text, item):
return True
return False
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole:
return section + 1
return QSortFilterProxyModel.headerData(self, section, orientation, role)
class FileCollection(QAbstractTableModel):
COLUMN_HEADERS = ()
alignments = ()
def __init__(self, parent=None):
self.files = self.sort_keys = ()
self.total_size = 0
QAbstractTableModel.__init__(self, parent)
def columnCount(self, parent=None):
return len(self.COLUMN_HEADERS)
def rowCount(self, parent=None):
return len(self.files)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation == Qt.Orientation.Horizontal:
if role == Qt.ItemDataRole.DisplayRole:
with suppress(IndexError):
return self.COLUMN_HEADERS[section]
elif role == Qt.ItemDataRole.TextAlignmentRole:
with suppress(IndexError):
return self.alignments[section]
return QAbstractTableModel.headerData(self, section, orientation, role)
def location(self, index):
try:
return self.files[index.row()].name
except (IndexError, AttributeError):
pass
class FilesView(QTableView):
double_clicked = pyqtSignal(object)
delete_requested = pyqtSignal(object, object)
current_changed = pyqtSignal(object, object)
DELETE_POSSIBLE = True
def __init__(self, model, parent=None):
QTableView.__init__(self, parent)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setAlternatingRowColors(True)
self.setSortingEnabled(True)
self.proxy = p = ProxyModel(self)
p.setSourceModel(model)
self.setModel(p)
self.doubleClicked.connect(self._double_clicked)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_context_menu)
def currentChanged(self, current, previous):
QTableView.currentChanged(self, current, previous)
self.current_changed.emit(*map(self.proxy.mapToSource, (current, previous)))
def customize_context_menu(self, menu, selected_locations, current_location):
pass
def resize_rows(self):
if self.model().rowCount() > 0:
num = min(5, self.model().rowCount())
h = 1000000
for i in range(num):
self.resizeRowToContents(i)
h = min(h, self.rowHeight(i))
self.verticalHeader().setDefaultSectionSize(h)
def _double_clicked(self, index):
index = self.proxy.mapToSource(index)
if index.isValid():
self.double_clicked.emit(index)
def keyPressEvent(self, ev):
if self.DELETE_POSSIBLE and ev.key() == Qt.Key.Key_Delete:
self.delete_selected()
ev.accept()
return
return QTableView.keyPressEvent(self, ev)
@property
def selected_locations(self):
return list(filter(None, (self.proxy.sourceModel().location(self.proxy.mapToSource(index)) for index in self.selectionModel().selectedIndexes())))
@property
def current_location(self):
index = self.selectionModel().currentIndex()
return self.proxy.sourceModel().location(self.proxy.mapToSource(index))
def delete_selected(self):
if self.DELETE_POSSIBLE:
locations = self.selected_locations
if locations:
names = frozenset(locations)
spine_names = {n for n, l in current_container().spine_names}
other_items = names - spine_names
spine_items = [(name, name in names) for name, is_linear in current_container().spine_names]
self.delete_requested.emit(spine_items, other_items)
def show_context_menu(self, pos):
pos = self.viewport().mapToGlobal(pos)
locations = self.selected_locations
m = QMenu(self)
if locations:
m.addAction(_('Delete selected files'), self.delete_selected)
self.customize_context_menu(m, locations, self.current_location)
if len(m.actions()) > 0:
m.exec(pos)
def to_csv(self):
buf = StringIO(newline='')
w = csv_writer(buf)
w.writerow(self.proxy.sourceModel().COLUMN_HEADERS)
cols = self.proxy.columnCount()
for r in range(self.proxy.rowCount()):
items = [self.proxy.index(r, c).data(Qt.ItemDataRole.DisplayRole) for c in range(cols)]
w.writerow(items)
return buf.getvalue()
def save_table(self, name):
save_state(name, bytearray(self.horizontalHeader().saveState()))
def restore_table(self, name, sort_column=0, sort_order=Qt.SortOrder.AscendingOrder):
h = self.horizontalHeader()
try:
h.restoreState(read_state(name))
except TypeError:
self.sortByColumn(sort_column, sort_order)
h.setSectionsMovable(True), h.setSectionsClickable(True)
h.setDragEnabled(True), h.setAcceptDrops(True)
h.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
# }}}
# Files {{{
class FilesModel(FileCollection):
COLUMN_HEADERS = (_('Folder'), _('Name'), _('Size (KB)'), _('Type'))
alignments = Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignRight, Qt.AlignmentFlag.AlignLeft
CATEGORY_NAMES = {
'image':_('Image'),
'text': _('Text'),
'font': _('Font'),
'style': _('Style'),
'opf': _('Metadata'),
'toc': _('Table of Contents'),
}
def __init__(self, parent=None):
FileCollection.__init__(self, parent)
self.images_size = self.fonts_size = 0
def __call__(self, data):
self.beginResetModel()
self.files = data['files']
self.total_size = sum(map(itemgetter(3), self.files))
self.images_size = sum(map(itemgetter(3), (f for f in self.files if f.category == 'image')))
self.fonts_size = sum(map(itemgetter(3), (f for f in self.files if f.category == 'font')))
self.sort_keys = tuple((psk(entry.dir), psk(entry.basename), entry.size, psk(self.CATEGORY_NAMES.get(entry.category, '')))
for entry in self.files)
self.endResetModel()
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == SORT_ROLE:
try:
return self.sort_keys[index.row()][index.column()]
except IndexError:
pass
elif role == Qt.ItemDataRole.DisplayRole:
col = index.column()
try:
entry = self.files[index.row()]
except IndexError:
return None
if col == 0:
return entry.dir
if col == 1:
return entry.basename
if col == 2:
sz = entry.size / 1024.
return '%.2f ' % sz
if col == 3:
return self.CATEGORY_NAMES.get(entry.category)
elif role == Qt.ItemDataRole.TextAlignmentRole:
return Qt.AlignVCenter | self.alignments[index.column()]
class FilesWidget(QWidget):
edit_requested = pyqtSignal(object)
delete_requested = pyqtSignal(object, object)
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QVBoxLayout(self)
self.filter_edit = e = QLineEdit(self)
l.addWidget(e)
e.setPlaceholderText(_('Filter'))
e.setClearButtonEnabled(True)
self.model = m = FilesModel(self)
self.files = f = FilesView(m, self)
self.to_csv = f.to_csv
f.delete_requested.connect(self.delete_requested)
f.double_clicked.connect(self.double_clicked)
e.textChanged.connect(f.proxy.filter_text)
l.addWidget(f)
self.summary = s = QLabel(self)
l.addWidget(s)
s.setText('\xa0')
self.files.restore_table('all-files-table', 1, Qt.SortOrder.AscendingOrder)
def __call__(self, data):
self.model(data)
self.files.resize_rows()
self.filter_edit.clear()
m = self.model
self.summary.setText(_('Total uncompressed size of all files: {0} :: Images: {1} :: Fonts: {2}').format(*map(
human_readable, (m.total_size, m.images_size, m.fonts_size))))
def double_clicked(self, index):
location = self.model.location(index)
if location is not None:
self.edit_requested.emit(location)
def save(self):
self.files.save_table('all-files-table')
# }}}
# Jump {{{
def jump_to_location(loc):
from calibre.gui2.tweak_book.boss import get_boss
boss = get_boss()
if boss is None:
return
name = loc.name
editor = boss.edit_file_requested(name)
if editor is None:
return
editor = editor.editor
if loc.line_number is not None:
block = editor.document().findBlockByNumber(loc.line_number - 1) # blockNumber() is zero based
if not block.isValid():
return
c = editor.textCursor()
c.setPosition(block.position(), QTextCursor.MoveMode.MoveAnchor)
editor.setTextCursor(c)
if loc.text_on_line is not None:
editor.find(regex.compile(regex.escape(loc.text_on_line)))
class Jump:
def __init__(self):
self.pos_map = defaultdict(lambda : -1)
def clear(self):
self.pos_map.clear()
def __call__(self, key, locations):
if len(locations):
self.pos_map[key] = (self.pos_map[key] + 1) % len(locations)
loc = locations[self.pos_map[key]]
jump_to_location(loc)
jump = Jump() # }}}
# Images {{{
class ImagesDelegate(QStyledItemDelegate):
MARGIN = 5
def __init__(self, *args):
QStyledItemDelegate.__init__(self, *args)
def sizeHint(self, option, index):
ans = QStyledItemDelegate.sizeHint(self, option, index)
entry = index.data(Qt.ItemDataRole.UserRole)
if entry is None:
return ans
th = self.parent().thumbnail_height
pmap = self.pixmap(th, entry._replace(usage=()), self.parent().devicePixelRatioF())
if pmap.isNull():
width = height = 0
else:
width, height = pmap.width(), pmap.height()
m = self.MARGIN * 2
return QSize(max(width + m, ans.width()), height + m + self.MARGIN + ans.height())
def paint(self, painter, option, index):
QStyledItemDelegate.paint(self, painter, option, ROOT)
entry = index.data(Qt.ItemDataRole.UserRole)
if entry is None:
return
painter.save()
th = self.parent().thumbnail_height
pmap = self.pixmap(th, entry._replace(usage=()), painter.device().devicePixelRatioF())
if pmap.isNull():
bottom = option.rect.top()
else:
m = 2 * self.MARGIN
x = option.rect.left() + (option.rect.width() - m - int(pmap.width()/pmap.devicePixelRatio())) // 2
painter.drawPixmap(x, option.rect.top() + self.MARGIN, pmap)
bottom = m + int(pmap.height() / pmap.devicePixelRatio()) + option.rect.top()
rect = QRect(option.rect.left(), bottom, option.rect.width(), option.rect.bottom() - bottom)
if option.state & QStyle.StateFlag.State_Selected:
painter.setPen(self.parent().palette().color(QPalette.ColorRole.HighlightedText))
painter.drawText(rect, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter, entry.basename)
painter.restore()
@lru_cache(maxsize=1024)
def pixmap(self, thumbnail_height, entry, dpr):
entry_ok = entry.width > 0 and entry.height > 0
entry_ok |= entry.mime_type == 'image/svg+xml'
pmap = QPixmap(current_container().name_to_abspath(entry.name)) if entry_ok > 0 else QPixmap()
if not pmap.isNull():
pmap.setDevicePixelRatio(dpr)
scaled, width, height = fit_image(pmap.width(), pmap.height(), thumbnail_height, thumbnail_height)
if scaled:
pmap = pmap.scaled(width, height, transformMode=Qt.TransformationMode.SmoothTransformation)
return pmap
class ImagesModel(FileCollection):
COLUMN_HEADERS = [_('Image'), _('Size (KB)'), _('Times used'), _('Resolution')]
alignments = Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignRight, Qt.AlignmentFlag.AlignRight, Qt.AlignmentFlag.AlignRight
def __init__(self, parent=None):
FileCollection.__init__(self, parent)
def __call__(self, data):
self.beginResetModel()
self.files = data['images']
self.total_size = sum(map(itemgetter(3), self.files))
self.sort_keys = tuple((psk(entry.basename), entry.size, len(entry.usage), (entry.width, entry.height))
for entry in self.files)
self.endResetModel()
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == SORT_ROLE:
try:
return self.sort_keys[index.row()][index.column()]
except IndexError:
pass
elif role == Qt.ItemDataRole.DisplayRole:
col = index.column()
try:
entry = self.files[index.row()]
except IndexError:
return None
if col == 0:
return entry.basename
if col == 1:
sz = entry.size / 1024.
return ('%.2f' % sz if int(sz) != sz else str(sz))
if col == 2:
return str(len(entry.usage))
if col == 3:
return '%d x %d' % (entry.width, entry.height)
elif role == Qt.ItemDataRole.UserRole:
try:
return self.files[index.row()]
except IndexError:
pass
elif role == Qt.TextAlignmentRole:
with suppress(IndexError):
return self.alignments[index.column()]
class ImagesWidget(QWidget):
edit_requested = pyqtSignal(object)
delete_requested = pyqtSignal(object, object)
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QVBoxLayout(self)
self.thumbnail_height = 64
self.filter_edit = e = QLineEdit(self)
l.addWidget(e)
e.setPlaceholderText(_('Filter'))
e.setClearButtonEnabled(True)
self.model = m = ImagesModel(self)
self.files = f = FilesView(m, self)
self.to_csv = f.to_csv
f.customize_context_menu = self.customize_context_menu
f.delete_requested.connect(self.delete_requested)
f.horizontalHeader().sortIndicatorChanged.connect(self.resize_to_contents)
self.delegate = ImagesDelegate(self)
f.setItemDelegateForColumn(0, self.delegate)
f.double_clicked.connect(self.double_clicked)
e.textChanged.connect(f.proxy.filter_text)
l.addWidget(f)
self.files.restore_table('image-files-table')
def __call__(self, data):
self.model(data)
self.filter_edit.clear()
self.delegate.pixmap.cache_clear()
self.files.resizeRowsToContents()
def resize_to_contents(self, *args):
QTimer.singleShot(0, self.files.resizeRowsToContents)
def double_clicked(self, index):
entry = index.data(Qt.ItemDataRole.UserRole)
if entry is not None:
jump((id(self), entry.id), entry.usage)
def customize_context_menu(self, menu, selected_locations, current_location):
if current_location is not None:
menu.addAction(_('Edit the image: %s') % current_location, partial(self.edit_requested.emit, current_location))
def save(self):
self.files.save_table('image-files-table')
# }}}
# Links {{{
class LinksModel(FileCollection):
COLUMN_HEADERS = ['✓', _('Source'), _('Source text'), _('Target'), _('Anchor'), _('Target text')]
def __init__(self, parent=None):
FileCollection.__init__(self, parent)
self.num_bad = 0
def __call__(self, data):
self.beginResetModel()
self.links = self.files = data['links']
self.total_size = len(self.links)
self.num_bad = sum(1 for link in self.links if link.ok is False)
self.sort_keys = tuple((
link.ok, psk(link.location.name), psk(link.text or ''), psk(link.href or ''), psk(link.anchor.id or ''), psk(link.anchor.text or ''))
for link in self.links)
self.endResetModel()
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == SORT_ROLE:
try:
return self.sort_keys[index.row()][index.column()]
except IndexError:
pass
elif role == Qt.ItemDataRole.DisplayRole:
col = index.column()
try:
link = self.links[index.row()]
except IndexError:
return None
if col == 0:
return {True:'✓', False:'✗'}.get(link.ok)
if col == 1:
return link.location.name
if col == 2:
return link.text
if col == 3:
return link.href
if col == 4:
return link.anchor.id
if col == 5:
return link.anchor.text
elif role == Qt.ItemDataRole.ToolTipRole:
col = index.column()
try:
link = self.links[index.row()]
except IndexError:
return None
if col == 0:
return {True:_('The link destination exists'), False:_('The link destination does not exist')}.get(
link.ok, _('The link destination could not be verified'))
if col == 2:
if link.text:
return textwrap.fill(link.text)
if col == 5:
if link.anchor.text:
return textwrap.fill(link.anchor.text)
elif role == Qt.ItemDataRole.UserRole:
try:
return self.links[index.row()]
except IndexError:
pass
class WebView(RestartingWebEngineView):
def sizeHint(self):
return QSize(600, 200)
class LinksWidget(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QVBoxLayout(self)
self.filter_edit = e = QLineEdit(self)
l.addWidget(e)
self.splitter = s = QSplitter(Qt.Orientation.Vertical, self)
l.addWidget(s)
e.setPlaceholderText(_('Filter'))
e.setClearButtonEnabled(True)
self.model = m = LinksModel(self)
self.links = f = FilesView(m, self)
f.DELETE_POSSIBLE = False
self.to_csv = f.to_csv
f.double_clicked.connect(self.double_clicked)
e.textChanged.connect(f.proxy.filter_text)
s.addWidget(f)
self.links.restore_table('links-table', sort_column=1)
self.view = None
self.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
self.ignore_current_change = False
self.current_url = None
f.current_changed.connect(self.current_changed)
try:
s.restoreState(read_state('links-view-splitter'))
except TypeError:
pass
s.setCollapsible(0, False)
s.setStretchFactor(0, 10)
def __call__(self, data):
if self.view is None:
self.view = WebView(self)
secure_webengine(self.view)
self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
self.splitter.addWidget(self.view)
self.splitter.setCollapsible(1, True)
self.ignore_current_change = True
self.model(data)
self.filter_edit.clear()
self.links.resize_rows()
self.view.setHtml('<p>'+_(
'Click entries above to see their destination here'))
self.ignore_current_change = False
def current_changed(self, current, previous):
link = current.data(Qt.ItemDataRole.UserRole)
if link is None:
return
url = None
if link.is_external:
if link.href:
frag = ('#' + link.anchor.id) if link.anchor.id else ''
url = QUrl(link.href + frag)
elif link.anchor.location:
path = current_container().name_to_abspath(link.anchor.location.name)
if path and os.path.exists(path):
url = QUrl.fromLocalFile(path)
if link.anchor.id:
url.setFragment(link.anchor.id)
if url is None:
if self.view:
self.view.setHtml('<p>' + _('No destination found for this link'))
self.current_url = url
elif url != self.current_url:
self.current_url = url
if self.view:
self.view.setUrl(url)
def double_clicked(self, index):
link = index.data(Qt.ItemDataRole.UserRole)
if link is None:
return
if index.column() < 3:
# Jump to source
jump_to_location(link.location)
else:
# Jump to destination
if link.is_external:
if link.href:
open_url(link.href)
elif link.anchor.location:
jump_to_location(link.anchor.location)
def save(self):
self.links.save_table('links-table')
save_state('links-view-splitter', bytearray(self.splitter.saveState()))
# }}}
# Words {{{
class WordsModel(FileCollection):
COLUMN_HEADERS = (_('Word'), _('Language'), _('Times used'))
alignments = Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignRight
total_words = 0
def __call__(self, data):
self.beginResetModel()
self.total_words, self.files = data['words']
self.total_size = len({entry.locale for entry in self.files})
lsk_cache = {}
def locale_sort_key(loc):
try:
return lsk_cache[loc]
except KeyError:
lsk_cache[loc] = psk(calibre_langcode_to_name(canonicalize_lang(loc[0])) + (loc[1] or ''))
return lsk_cache[loc]
self.sort_keys = tuple((psk(entry.word), locale_sort_key(entry.locale), len(entry.usage)) for entry in self.files)
self.endResetModel()
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == SORT_ROLE:
try:
return self.sort_keys[index.row()][index.column()]
except IndexError:
pass
elif role == Qt.ItemDataRole.DisplayRole:
col = index.column()
try:
entry = self.files[index.row()]
except IndexError:
return None
if col == 0:
return entry.word
if col == 1:
ans = calibre_langcode_to_name(canonicalize_lang(entry.locale.langcode)) or ''
if entry.locale.countrycode:
ans += ' (%s)' % entry.locale.countrycode
return ans
if col == 2:
return str(len(entry.usage))
elif role == Qt.ItemDataRole.UserRole:
try:
return self.files[index.row()]
except IndexError:
pass
elif role == Qt.TextAlignmentRole:
with suppress(IndexError):
return self.alignments[index.column()]
def location(self, index):
return None
class WordsWidget(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QVBoxLayout(self)
self.filter_edit = e = QLineEdit(self)
l.addWidget(e)
e.setPlaceholderText(_('Filter'))
e.setClearButtonEnabled(True)
self.model = m = WordsModel(self)
self.words = f = FilesView(m, self)
self.to_csv = f.to_csv
f.DELETE_POSSIBLE = False
f.double_clicked.connect(self.double_clicked)
e.textChanged.connect(f.proxy.filter_text)
l.addWidget(f)
self.summary = la = QLabel('\xa0')
l.addWidget(la)
self.words.restore_table('words-table')
def __call__(self, data):
self.model(data)
self.words.resize_rows()
self.filter_edit.clear()
self.summary.setText(_('Words: {2} :: Unique Words: :: {0} :: Languages: {1}').format(
self.model.rowCount(), self.model.total_size, self.model.total_words))
def double_clicked(self, index):
entry = index.data(Qt.ItemDataRole.UserRole)
if entry is not None:
from calibre.gui2.tweak_book.boss import get_boss
boss = get_boss()
if boss is not None:
boss.find_word((entry.word, entry.locale), entry.usage)
def save(self):
self.words.save_table('words-table')
# }}}
# Characters {{{
class CharsModel(FileCollection):
COLUMN_HEADERS = (_('Character'), _('Name'), _('Codepoint'), _('Times used'))
alignments = Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignRight
all_chars = ()
def __call__(self, data):
self.beginResetModel()
self.files = data['chars']
self.all_chars = tuple(entry.char for entry in self.files)
self.sort_keys = tuple((psk(entry.char), None, entry.codepoint, entry.count) for entry in self.files)
self.endResetModel()
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == SORT_ROLE:
if index.column() == 1:
return self.data(index)
try:
return self.sort_keys[index.row()][index.column()]
except IndexError:
pass
elif role == Qt.ItemDataRole.DisplayRole:
col = index.column()
try:
entry = self.files[index.row()]
except IndexError:
return None
if col == 0:
return entry.char
if col == 1:
return {0xa:'LINE FEED', 0xd:'CARRIAGE RETURN', 0x9:'TAB'}.get(entry.codepoint, character_name_from_code(entry.codepoint))
if col == 2:
return ('U+%04X' if entry.codepoint < 0x10000 else 'U+%06X') % entry.codepoint
if col == 3:
return str(entry.count)
elif role == Qt.ItemDataRole.UserRole:
try:
return self.files[index.row()]
except IndexError:
pass
elif role == Qt.TextAlignmentRole:
with suppress(IndexError):
return self.alignments[index.column()]
def location(self, index):
return None
class CharsWidget(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QVBoxLayout(self)
self.filter_edit = e = QLineEdit(self)
l.addWidget(e)
e.setPlaceholderText(_('Filter'))
e.setClearButtonEnabled(True)
self.model = m = CharsModel(self)
self.chars = f = FilesView(m, self)
self.to_csv = f.to_csv
f.DELETE_POSSIBLE = False
f.double_clicked.connect(self.double_clicked)
e.textChanged.connect(f.proxy.filter_text)
l.addWidget(f)
self.summary = la = QLineEdit(self)
la.setReadOnly(True)
la.setToolTip(_('All the characters in the book'))
l.addWidget(la)
self.chars.restore_table('chars-table')
def __call__(self, data):
self.model(data)
self.chars.resize_rows()
self.summary.setText(''.join(self.model.all_chars))
self.filter_edit.clear()
def double_clicked(self, index):
entry = index.data(Qt.ItemDataRole.UserRole)
if entry is not None:
self.find_next_location(entry)
def save(self):
self.chars.save_table('chars-table')
def find_next_location(self, entry):
from calibre.gui2.tweak_book.boss import get_boss
boss = get_boss()
if boss is None:
return
files = entry.usage
current_editor_name = boss.currently_editing
if current_editor_name not in files:
current_editor_name = None
else:
idx = files.index(current_editor_name)
before, after = files[:idx], files[idx+1:]
files = [current_editor_name] + after + before + [current_editor_name]
pat = regex.compile(regex.escape(entry.char))
for file_name in files:
from_cursor = False
if file_name == current_editor_name:
from_cursor = True
current_editor_name = None
ed = boss.edit_file_requested(file_name)
if ed is None:
return
if ed.editor.find_text(pat, complete=not from_cursor):
boss.show_editor(file_name)
return True
return False
# }}}
# CSS {{{
class CSSRulesModel(QAbstractItemModel):
def __init__(self, parent):
QAbstractItemModel.__init__(self, parent)
self.rules = ()
self.sort_on_count = True
self.num_size = 1
self.num_unused = 0
self.build_maps()
self.main_font = f = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
f.setBold(True), f.setPointSize(parent.font().pointSize() + 2)
self.italic_font = f = QFont(parent.font())
f.setItalic(True)
def build_maps(self):
self.parent_map = pm = {}
for i, entry in enumerate(self.rules):
container = entry.matched_files
pm[container] = (i, self.rules)
for i, child in enumerate(container):
gcontainer = child.locations
pm[gcontainer] = (i, container)
for i, gc in enumerate(gcontainer):
pm[gc] = (i, gcontainer)
def index(self, row, column, parent=ROOT):
container = self.to_container(self.index_to_entry(parent) or self.rules)
return self.createIndex(row, column, container) if -1 < row < len(container) else ROOT
def to_container(self, entry):
if isinstance(entry, CSSEntry):
return entry.matched_files
elif isinstance(entry, CSSFileMatch):
return entry.locations
return entry
def index_to_entry(self, index):
if index.isValid():
try:
return index.internalPointer()[index.row()]
except IndexError:
pass
def parent(self, index):
if not index.isValid():
return ROOT
parent = index.internalPointer()
if parent is self.rules or parent is None:
return ROOT
try:
pidx, grand_parent = self.parent_map[parent]
except KeyError:
return ROOT
return self.createIndex(pidx, 0, grand_parent)
def rowCount(self, parent=ROOT):
if not parent.isValid():
return len(self.rules)
entry = self.index_to_entry(parent)
c = self.to_container(entry)
return 0 if c is entry else len(c)
def columnCount(self, parent=ROOT):
return 1
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == SORT_ROLE:
entry = self.index_to_entry(index)
if isinstance(entry, CSSEntry):
return entry.count if self.sort_on_count else entry.sort_key
if isinstance(entry, CSSFileMatch):
return len(entry.locations) if self.sort_on_count else entry.sort_key
if isinstance(entry, MatchLocation):
return entry.sourceline
elif role == Qt.ItemDataRole.DisplayRole:
entry = self.index_to_entry(index)
if isinstance(entry, CSSEntry):
return f'[%{self.num_size}d] %s' % (entry.count, entry.rule.selector)
elif isinstance(entry, CSSFileMatch):
return _('{0} [{1} elements]').format(entry.file_name, len(entry.locations))
elif isinstance(entry, MatchLocation):
return f'{entry.tag} @ {entry.sourceline}'
elif role == Qt.ItemDataRole.UserRole:
return self.index_to_entry(index)
elif role == Qt.ItemDataRole.FontRole:
entry = self.index_to_entry(index)
if isinstance(entry, CSSEntry):
return self.main_font
elif isinstance(entry, CSSFileMatch):
return self.italic_font
def __call__(self, data):
self.beginResetModel()
self.rules = data['css']
self.num_unused = sum(1 for r in self.rules if r.count == 0)
try:
self.num_size = len(str(max(r.count for r in self.rules)))
except ValueError:
self.num_size = 1
self.build_maps()
self.endResetModel()
class CSSProxyModel(QSortFilterProxyModel):
def __init__(self, parent=None):
QSortFilterProxyModel.__init__(self, parent)
self._filter_text = None
self.setSortRole(SORT_ROLE)
def filter_text(self, text):
self._filter_text = text
self.setFilterFixedString(text)
def filterAcceptsRow(self, row, parent):
if not self._filter_text:
return True
sm = self.sourceModel()
entry = sm.index_to_entry(sm.index(row, 0, parent))
if not isinstance(entry, CSSEntry):
return True
return primary_contains(self._filter_text, entry.rule.selector)
class CSSWidget(QWidget):
SETTING_PREFIX = 'css-'
MODEL = CSSRulesModel
PROXY = CSSProxyModel
def read_state(self, name, default=None):
return read_state(self.SETTING_PREFIX+name, default)
def save_state(self, name, val):
return save_state(self.SETTING_PREFIX + name, val)
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QVBoxLayout(self)
self.h = h = QHBoxLayout()
self.filter_edit = e = QLineEdit(self)
l.addWidget(e)
e.setPlaceholderText(_('Filter'))
e.setClearButtonEnabled(True)
self.model = m = self.MODEL(self)
self.proxy = p = self.PROXY(self)
p.setSourceModel(m)
self.view = f = QTreeView(self)
f.setAlternatingRowColors(True)
f.setHeaderHidden(True), f.setExpandsOnDoubleClick(False)
f.setModel(p)
l.addWidget(f)
f.doubleClicked.connect(self.double_clicked)
e.textChanged.connect(p.filter_text)
l.addLayout(h)
h.addWidget(QLabel(_('Sort by:')))
self.counts_button = b = QRadioButton(_('&Counts'), self)
b.setChecked(self.read_state('sort-on-counts', True))
h.addWidget(b)
self.name_button = b = QRadioButton(_('&Name'), self)
b.setChecked(not self.read_state('sort-on-counts', True))
h.addWidget(b)
b.toggled.connect(self.resort)
h.addStrut(20)
self._sort_order = o = QComboBox(self)
o.addItems([_('Ascending'), _('Descending')])
o.setCurrentIndex(0 if self.read_state('sort-ascending', True) else 1)
o.setEditable(False)
o.currentIndexChanged[int].connect(self.resort)
h.addWidget(o)
h.addStretch(10)
self.summary = la = QLabel('\xa0')
h.addWidget(la)
@property
def sort_order(self):
return [Qt.SortOrder.AscendingOrder, Qt.SortOrder.DescendingOrder][self._sort_order.currentIndex()]
@sort_order.setter
def sort_order(self, val):
self._sort_order.setCurrentIndex({Qt.SortOrder.AscendingOrder:0}.get(val, 1))
def update_summary(self):
self.summary.setText(_('{0} rules, {1} unused').format(self.model.rowCount(), self.model.num_unused))
def __call__(self, data):
self.model(data)
self.update_summary()
self.filter_edit.clear()
self.resort()
def save(self):
self.save_state('sort-on-counts', self.counts_button.isChecked())
self.save_state('sort-ascending', self.sort_order == Qt.SortOrder.AscendingOrder)
def resort(self, *args):
self.model.sort_on_count = self.counts_button.isChecked()
self.proxy.sort(-1, self.sort_order) # for some reason the proxy model does not resort without this
self.proxy.sort(0, self.sort_order)
def to_csv(self):
buf = StringIO(newline='')
w = csv_writer(buf)
w.writerow([_('Style Rule'), _('Number of matches')])
for r in range(self.proxy.rowCount()):
entry = self.proxy.mapToSource(self.proxy.index(r, 0)).data(Qt.ItemDataRole.UserRole)
w.writerow([entry.rule.selector, entry.count])
return buf.getvalue()
def double_clicked(self, index):
from calibre.gui2.tweak_book.boss import get_boss
boss = get_boss()
if boss is None:
return
index = self.proxy.mapToSource(index)
entry = self.model.index_to_entry(index)
if entry is None:
return
self.handle_double_click(entry, index, boss)
def handle_double_click(self, entry, index, boss):
if isinstance(entry, CSSEntry):
loc = entry.rule.location
name, sourceline, col = loc
elif isinstance(entry, CSSFileMatch):
name, sourceline = entry.file_name, 0
else:
name = self.model.index_to_entry(index.parent()).file_name
sourceline = entry.sourceline
self.show_line(name, sourceline, boss)
def show_line(self, name, sourceline, boss):
editor = boss.edit_file_requested(name)
if editor is None:
return
editor = editor.editor
block = editor.document().findBlockByNumber(max(0, sourceline - 1)) # blockNumber() is zero based
c = editor.textCursor()
c.setPosition(block.position() if block.isValid() else 0)
editor.setTextCursor(c)
boss.show_editor(name)
# }}}
# Classes {{{
class ClassesModel(CSSRulesModel):
def __init__(self, parent):
self.classes = self.rules = ()
CSSRulesModel.__init__(self, parent)
self.sort_on_count = True
self.num_size = 1
self.num_unused = 0
self.build_maps()
def build_maps(self):
self.parent_map = pm = {}
for i, entry in enumerate(self.classes):
container = entry.matched_files
pm[container] = (i, self.classes)
for i, child in enumerate(container):
gcontainer = child.class_elements
pm[gcontainer] = (i, container)
for i, gc in enumerate(gcontainer):
ggcontainer = gc.matched_rules
pm[gc] = (i, gcontainer)
for i, ggc in enumerate(ggcontainer):
pm[ggc] = (i, ggcontainer)
def to_container(self, entry):
if isinstance(entry, ClassEntry):
return entry.matched_files
elif isinstance(entry, ClassFileMatch):
return entry.class_elements
elif isinstance(entry, ClassElement):
return entry.matched_rules
return entry
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if role == SORT_ROLE:
entry = self.index_to_entry(index)
if isinstance(entry, ClassEntry):
return entry.num_of_matches if self.sort_on_count else entry.sort_key
if isinstance(entry, ClassFileMatch):
return len(entry.class_elements) if self.sort_on_count else entry.sort_key
if isinstance(entry, ClassElement):
return entry.line_number
if isinstance(entry, CSSRule):
return entry.location.file_name
elif role == Qt.ItemDataRole.DisplayRole:
entry = self.index_to_entry(index)
if isinstance(entry, ClassEntry):
return f'[%{self.num_size}d] %s' % (entry.num_of_matches, entry.cls)
elif isinstance(entry, ClassFileMatch):
return _('{0} [{1} elements]').format(entry.file_name, len(entry.class_elements))
elif isinstance(entry, ClassElement):
return f'{entry.tag} @ {entry.line_number}'
elif isinstance(entry, CSSRule):
return f'{entry.selector} @ {entry.location.file_name}:{entry.location.line}'
elif role == Qt.ItemDataRole.UserRole:
return self.index_to_entry(index)
elif role == Qt.ItemDataRole.FontRole:
entry = self.index_to_entry(index)
if isinstance(entry, ClassEntry):
return self.main_font
elif isinstance(entry, ClassFileMatch):
return self.italic_font
def __call__(self, data):
self.beginResetModel()
self.rules = self.classes = tuple(data['classes'])
self.num_unused = sum(1 for ce in self.classes if ce.num_of_matches == 0)
try:
self.num_size = len(str(max(r.num_of_matches for r in self.classes)))
except ValueError:
self.num_size = 1
self.build_maps()
self.endResetModel()
class ClassProxyModel(CSSProxyModel):
def filterAcceptsRow(self, row, parent):
if not self._filter_text:
return True
sm = self.sourceModel()
entry = sm.index_to_entry(sm.index(row, 0, parent))
if not isinstance(entry, ClassEntry):
return True
return primary_contains(self._filter_text, entry.cls)
class ClassesWidget(CSSWidget):
SETTING_PREFIX = 'classes-'
MODEL = ClassesModel
PROXY = ClassProxyModel
def update_summary(self):
self.summary.setText(_('{0} classes, {1} unused').format(self.model.rowCount(), self.model.num_unused))
def to_csv(self):
buf = StringIO(newline='')
w = csv_writer(buf)
w.writerow([_('Class'), _('Number of matches')])
for r in range(self.proxy.rowCount()):
entry = self.proxy.mapToSource(self.proxy.index(r, 0)).data(Qt.ItemDataRole.UserRole)
w.writerow([entry.cls, entry.num_of_matches])
return buf.getvalue()
def handle_double_click(self, entry, index, boss):
if isinstance(entry, ClassEntry):
def uniq(vals):
vals = vals or ()
seen = set()
seen_add = seen.add
return tuple(x for x in vals if x not in seen and not seen_add(x))
rules = tuple(uniq([LinkLocation(rule.location.file_name, rule.location.line, None)
for cfm in entry.matched_files for ce in cfm.class_elements for rule in ce.matched_rules]))
if rules:
jump((id(self), id(entry)), rules)
return
elif isinstance(entry, ClassFileMatch):
name, sourceline = entry.file_name, 0
elif isinstance(entry, ClassElement):
return jump_to_location(entry)
else:
loc = entry.location
name, sourceline, col = loc
self.show_line(name, sourceline, boss)
# }}}
# Wrapper UI {{{
class ReportsWidget(QWidget):
edit_requested = pyqtSignal(object)
delete_requested = pyqtSignal(object, object)
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = QVBoxLayout(self)
self.splitter = l = QSplitter(self)
l.setChildrenCollapsible(False)
self.layout().addWidget(l)
self.reports = r = QListWidget(self)
l.addWidget(r)
self.stack = s = QStackedWidget(self)
l.addWidget(s)
r.currentRowChanged.connect(s.setCurrentIndex)
self.files = f = FilesWidget(self)
f.edit_requested.connect(self.edit_requested)
f.delete_requested.connect(self.delete_requested)
s.addWidget(f)
QListWidgetItem(_('Files'), r)
self.words = w = WordsWidget(self)
s.addWidget(w)
QListWidgetItem(_('Words'), r)
self.images = i = ImagesWidget(self)
i.edit_requested.connect(self.edit_requested)
i.delete_requested.connect(self.delete_requested)
s.addWidget(i)
QListWidgetItem(_('Images'), r)
self.css = c = CSSWidget(self)
s.addWidget(c)
QListWidgetItem(_('Style rules'), r)
self.css = c = ClassesWidget(self)
s.addWidget(c)
QListWidgetItem(_('Style classes'), r)
self.chars = c = CharsWidget(self)
s.addWidget(c)
QListWidgetItem(_('Characters'), r)
self.links = li = LinksWidget(self)
s.addWidget(li)
QListWidgetItem(_('Links'), r)
self.splitter.setStretchFactor(1, 500)
try:
self.splitter.restoreState(read_state('splitter-state'))
except TypeError:
pass
current_page = read_state('report-page')
if current_page is not None:
self.reports.setCurrentRow(current_page)
self.layout().setContentsMargins(0, 0, 0, 0)
for i in range(self.stack.count()):
self.stack.widget(i).layout().setContentsMargins(0, 0, 0, 0)
def __call__(self, data):
jump.clear()
for i in range(self.stack.count()):
st = time.time()
self.stack.widget(i)(data)
if DEBUG:
category = self.reports.item(i).data(Qt.ItemDataRole.DisplayRole)
print('Widget time for %12s: %.2fs seconds' % (category, time.time() - st))
def save(self):
save_state('splitter-state', bytearray(self.splitter.saveState()))
save_state('report-page', self.reports.currentRow())
for i in range(self.stack.count()):
self.stack.widget(i).save()
def to_csv(self):
w = self.stack.currentWidget()
category = self.reports.currentItem().data(Qt.ItemDataRole.DisplayRole)
if not hasattr(w, 'to_csv'):
return error_dialog(self, _('Not supported'), _(
'Export of %s data is not supported') % category, show=True)
data = w.to_csv()
fname = choose_save_file(self, 'report-csv-export', _('Choose a filename for the data'), filters=[
(_('CSV files'), ['csv'])], all_files=False, initial_filename='%s.csv' % category)
if fname:
with open(fname, 'wb') as f:
f.write(as_bytes(data))
class Reports(Dialog):
data_gathered = pyqtSignal(object, object)
edit_requested = pyqtSignal(object)
refresh_starting = pyqtSignal()
delete_requested = pyqtSignal(object, object)
def __init__(self, parent=None):
Dialog.__init__(self, _('Reports'), 'reports-dialog', parent=parent)
self.data_gathered.connect(self.display_data, type=Qt.ConnectionType.QueuedConnection)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
self.setWindowIcon(QIcon(I('reports.png')))
def setup_ui(self):
self.l = l = QVBoxLayout(self)
self.wait_stack = s = QStackedLayout()
l.addLayout(s)
l.addWidget(self.bb)
self.reports = r = ReportsWidget(self)
r.edit_requested.connect(self.edit_requested)
r.delete_requested.connect(self.confirm_delete)
self.pw = pw = QWidget(self)
s.addWidget(pw), s.addWidget(r)
pw.l = l = QVBoxLayout(pw)
self.pi = pi = ProgressIndicator(self, 256)
l.addStretch(1), l.addWidget(pi, alignment=Qt.AlignmentFlag.AlignHCenter), l.addSpacing(10)
pw.la = la = QLabel(_('Gathering data, please wait...'))
la.setStyleSheet('QLabel { font-size: 30pt; font-weight: bold }')
l.addWidget(la, alignment=Qt.AlignmentFlag.AlignHCenter), l.addStretch(1)
self.bb.setStandardButtons(QDialogButtonBox.StandardButton.Close)
self.refresh_button = b = self.bb.addButton(_('&Refresh'), QDialogButtonBox.ButtonRole.ActionRole)
b.clicked.connect(self.refresh)
b.setIcon(QIcon(I('view-refresh.png')))
self.save_button = b = self.bb.addButton(_('&Save'), QDialogButtonBox.ButtonRole.ActionRole)
b.clicked.connect(self.reports.to_csv)
b.setIcon(QIcon(I('save.png')))
b.setToolTip(_('Export the currently shown report as a CSV file'))
def sizeHint(self):
return QSize(950, 600)
def confirm_delete(self, spine_items, other_names):
spine_names = {name for name, remove in spine_items if remove}
if not question_dialog(self, _('Are you sure?'), _(
'Are you sure you want to delete the selected files?'), det_msg='\n'.join(spine_names | other_names)):
return
self.delete_requested.emit(spine_items, other_names)
QTimer.singleShot(10, self.refresh)
def refresh(self):
self.wait_stack.setCurrentIndex(0)
self.setCursor(Qt.CursorShape.BusyCursor)
self.pi.startAnimation()
self.refresh_starting.emit()
t = Thread(name='GatherReportData', target=self.gather_data)
t.daemon = True
t.start()
def gather_data(self):
try:
ok, data = True, gather_data(current_container(), dictionaries.default_locale)
except Exception:
import traceback
traceback.print_exc()
ok, data = False, traceback.format_exc()
self.data_gathered.emit(ok, data)
def display_data(self, ok, data):
self.wait_stack.setCurrentIndex(1)
self.unsetCursor()
self.pi.stopAnimation()
if not ok:
return error_dialog(self, _('Failed to gather data'), _(
'Failed to gather data for the report. Click "Show details" for more'
' information.'), det_msg=data, show=True)
data, timing = data
if DEBUG:
for x, t in sorted(iteritems(timing), key=itemgetter(1)):
print('Time for %6s data: %.3f seconds' % (x, t))
self.reports(data)
def accept(self):
with tprefs:
self.reports.save()
Dialog.accept(self)
def reject(self):
self.reports.save()
Dialog.reject(self)
# }}}
if __name__ == '__main__':
from calibre.gui2 import Application
import sys
app = Application([])
from calibre.gui2.tweak_book import set_current_container
from calibre.gui2.tweak_book.boss import get_container
set_current_container(get_container(sys.argv[-1]))
d = Reports()
d.refresh()
d.exec()
del d, app