mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Edit Book: Add a 'Links' report to the Reports tool
This commit is contained in:
parent
0aa5efc7a4
commit
2495790d29
@ -11,7 +11,7 @@ from collections import namedtuple, defaultdict, Counter
|
|||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
from calibre import prepare_string_for_xml, force_unicode
|
from calibre import prepare_string_for_xml, force_unicode
|
||||||
from calibre.ebooks.oeb.base import XPath
|
from calibre.ebooks.oeb.base import XPath, xml2text
|
||||||
from calibre.ebooks.oeb.polish.container import OEB_DOCS, OEB_STYLES, OEB_FONTS
|
from calibre.ebooks.oeb.polish.container import OEB_DOCS, OEB_STYLES, OEB_FONTS
|
||||||
from calibre.ebooks.oeb.polish.css import build_selector, PSEUDO_PAT, MIN_SPACE_RE
|
from calibre.ebooks.oeb.polish.css import build_selector, PSEUDO_PAT, MIN_SPACE_RE
|
||||||
from calibre.ebooks.oeb.polish.spell import get_all_words
|
from calibre.ebooks.oeb.polish.spell import get_all_words
|
||||||
@ -95,6 +95,90 @@ def images_data(container, book_locale):
|
|||||||
posixpath.basename(name), len(image_data), *safe_img_data(container, name, mt)))
|
posixpath.basename(name), len(image_data), *safe_img_data(container, name, mt)))
|
||||||
return tuple(image_data)
|
return tuple(image_data)
|
||||||
|
|
||||||
|
def description_for_anchor(elem):
|
||||||
|
def check(x, min_len=4):
|
||||||
|
if x:
|
||||||
|
x = x.strip()
|
||||||
|
if len(x) >= min_len:
|
||||||
|
return x[:30]
|
||||||
|
|
||||||
|
desc = check(elem.get('title'))
|
||||||
|
if desc is not None:
|
||||||
|
return desc
|
||||||
|
desc = check(elem.text)
|
||||||
|
if desc is not None:
|
||||||
|
return desc
|
||||||
|
if len(elem) > 0:
|
||||||
|
desc = check(elem[0].text)
|
||||||
|
if desc is not None:
|
||||||
|
return desc
|
||||||
|
# Get full text for tags that have only a few descendants
|
||||||
|
for i, x in enumerate(elem.iterdescendants('*')):
|
||||||
|
if i > 5:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
desc = check(xml2text(elem), min_len=1)
|
||||||
|
if desc is not None:
|
||||||
|
return desc
|
||||||
|
|
||||||
|
def create_anchor_map(root, pat, name):
|
||||||
|
ans = {}
|
||||||
|
for elem in pat(root):
|
||||||
|
anchor = elem.get('id') or elem.get('name')
|
||||||
|
if anchor and anchor not in ans:
|
||||||
|
ans[anchor] = (LinkLocation(name, elem.sourceline, anchor), description_for_anchor(elem))
|
||||||
|
return ans
|
||||||
|
|
||||||
|
Anchor = namedtuple('Anchor', 'id location text')
|
||||||
|
L = namedtuple('Link', 'location text is_external href path_ok anchor_ok anchor ok')
|
||||||
|
def Link(location, text, is_external, href, path_ok, anchor_ok, anchor):
|
||||||
|
if is_external:
|
||||||
|
ok = None
|
||||||
|
else:
|
||||||
|
ok = path_ok and anchor_ok
|
||||||
|
return L(location, text, is_external, href, path_ok, anchor_ok, anchor, ok)
|
||||||
|
|
||||||
|
def links_data(container, book_locale):
|
||||||
|
anchor_map = {}
|
||||||
|
links = []
|
||||||
|
anchor_pat = XPath('//*[@id or @name]')
|
||||||
|
link_pat = XPath('//h:a[@href]')
|
||||||
|
for name, mt in container.mime_map.iteritems():
|
||||||
|
if mt in OEB_DOCS:
|
||||||
|
root = container.parsed(name)
|
||||||
|
anchor_map[name] = create_anchor_map(root, anchor_pat, name)
|
||||||
|
for a in link_pat(root):
|
||||||
|
href = a.get('href')
|
||||||
|
text = description_for_anchor(a)
|
||||||
|
if href:
|
||||||
|
base, frag = href.partition('#')[0::2]
|
||||||
|
if frag and not base:
|
||||||
|
dest = name
|
||||||
|
else:
|
||||||
|
dest = safe_href_to_name(container, href, name)
|
||||||
|
location = LinkLocation(name, a.sourceline, href)
|
||||||
|
links.append((base, frag, dest, location, text))
|
||||||
|
else:
|
||||||
|
links.append(('', '', None, location, text))
|
||||||
|
|
||||||
|
for base, frag, dest, location, text in links:
|
||||||
|
if dest is None:
|
||||||
|
link = Link(location, text, True, base, True, True, Anchor(frag, None, None))
|
||||||
|
else:
|
||||||
|
if dest in anchor_map:
|
||||||
|
loc = LinkLocation(dest, None, None)
|
||||||
|
if frag:
|
||||||
|
anchor = anchor_map[dest].get(frag)
|
||||||
|
if anchor is None:
|
||||||
|
link = Link(location, text, False, dest, True, False, Anchor(frag, loc, None))
|
||||||
|
else:
|
||||||
|
link = Link(location, text, False, dest, True, True, Anchor(frag, *anchor))
|
||||||
|
else:
|
||||||
|
link = Link(location, text, False, dest, True, True, Anchor(None, loc, None))
|
||||||
|
else:
|
||||||
|
link = Link(location, text, False, dest, False, False, Anchor(frag, None, None))
|
||||||
|
yield link
|
||||||
|
|
||||||
Word = namedtuple('Word', 'id word locale usage')
|
Word = namedtuple('Word', 'id word locale usage')
|
||||||
|
|
||||||
def words_data(container, book_locale):
|
def words_data(container, book_locale):
|
||||||
@ -235,7 +319,7 @@ def css_data(container, book_locale):
|
|||||||
def gather_data(container, book_locale):
|
def gather_data(container, book_locale):
|
||||||
timing = {}
|
timing = {}
|
||||||
data = {}
|
data = {}
|
||||||
for x in 'files chars images words css'.split():
|
for x in 'files chars images links words css'.split():
|
||||||
st = time.time()
|
st = time.time()
|
||||||
data[x] = globals()[x + '_data'](container, book_locale)
|
data[x] = globals()[x + '_data'](container, book_locale)
|
||||||
if isinstance(data[x], types.GeneratorType):
|
if isinstance(data[x], types.GeneratorType):
|
||||||
|
@ -12,9 +12,9 @@ from collections import namedtuple, OrderedDict
|
|||||||
from PyQt5.Qt import QObject, pyqtSignal, Qt
|
from PyQt5.Qt import QObject, pyqtSignal, Qt
|
||||||
|
|
||||||
from calibre import prepare_string_for_xml
|
from calibre import prepare_string_for_xml
|
||||||
from calibre.ebooks.oeb.base import xml2text
|
|
||||||
from calibre.ebooks.oeb.polish.container import OEB_STYLES, OEB_FONTS, name_to_href
|
from calibre.ebooks.oeb.polish.container import OEB_STYLES, OEB_FONTS, name_to_href
|
||||||
from calibre.ebooks.oeb.polish.parsing import parse
|
from calibre.ebooks.oeb.polish.parsing import parse
|
||||||
|
from calibre.ebooks.oeb.polish.report import description_for_anchor
|
||||||
from calibre.gui2 import is_gui_thread
|
from calibre.gui2 import is_gui_thread
|
||||||
from calibre.gui2.tweak_book import current_container, editors
|
from calibre.gui2.tweak_book import current_container, editors
|
||||||
from calibre.gui2.tweak_book.completion.utils import control, data, DataError
|
from calibre.gui2.tweak_book.completion.utils import control, data, DataError
|
||||||
@ -91,33 +91,6 @@ def complete_names(names_data, data_conn):
|
|||||||
descriptions = {href:d(name) for name, href in nmap.iteritems()}
|
descriptions = {href:d(name) for name, href in nmap.iteritems()}
|
||||||
return items, descriptions, {}
|
return items, descriptions, {}
|
||||||
|
|
||||||
|
|
||||||
def description_for_anchor(elem):
|
|
||||||
def check(x, min_len=4):
|
|
||||||
if x:
|
|
||||||
x = x.strip()
|
|
||||||
if len(x) >= min_len:
|
|
||||||
return x[:30]
|
|
||||||
|
|
||||||
desc = check(elem.get('title'))
|
|
||||||
if desc is not None:
|
|
||||||
return desc
|
|
||||||
desc = check(elem.text)
|
|
||||||
if desc is not None:
|
|
||||||
return desc
|
|
||||||
if len(elem) > 0:
|
|
||||||
desc = check(elem[0].text)
|
|
||||||
if desc is not None:
|
|
||||||
return desc
|
|
||||||
# Get full text for tags that have only a few descendants
|
|
||||||
for i, x in enumerate(elem.iterdescendants('*')):
|
|
||||||
if i > 5:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
desc = check(xml2text(elem), min_len=1)
|
|
||||||
if desc is not None:
|
|
||||||
return desc
|
|
||||||
|
|
||||||
def create_anchor_map(root):
|
def create_anchor_map(root):
|
||||||
ans = {}
|
ans = {}
|
||||||
for elem in root.xpath('//*[@id or @name]'):
|
for elem in root.xpath('//*[@id or @name]'):
|
||||||
|
@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
|
|||||||
__license__ = 'GPL v3'
|
__license__ = 'GPL v3'
|
||||||
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
import time
|
import time, textwrap, os
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from future_builtins import map
|
from future_builtins import map
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
@ -22,12 +22,12 @@ from PyQt5.Qt import (
|
|||||||
QListWidgetItem, QLineEdit, QStackedWidget, QSplitter, QByteArray, QPixmap,
|
QListWidgetItem, QLineEdit, QStackedWidget, QSplitter, QByteArray, QPixmap,
|
||||||
QStyledItemDelegate, QModelIndex, QRect, QStyle, QPalette, QTimer, QMenu,
|
QStyledItemDelegate, QModelIndex, QRect, QStyle, QPalette, QTimer, QMenu,
|
||||||
QAbstractItemModel, QTreeView, QFont, QRadioButton, QHBoxLayout,
|
QAbstractItemModel, QTreeView, QFont, QRadioButton, QHBoxLayout,
|
||||||
QFontDatabase, QComboBox)
|
QFontDatabase, QComboBox, QUrl, QWebView)
|
||||||
|
|
||||||
from calibre import human_readable, fit_image
|
from calibre import human_readable, fit_image
|
||||||
from calibre.constants import DEBUG
|
from calibre.constants import DEBUG
|
||||||
from calibre.ebooks.oeb.polish.report import gather_data, CSSEntry, CSSFileMatch, MatchLocation
|
from calibre.ebooks.oeb.polish.report import gather_data, CSSEntry, CSSFileMatch, MatchLocation
|
||||||
from calibre.gui2 import error_dialog, question_dialog, choose_save_file
|
from calibre.gui2 import error_dialog, question_dialog, choose_save_file, open_url
|
||||||
from calibre.gui2.tweak_book import current_container, tprefs, dictionaries
|
from calibre.gui2.tweak_book import current_container, tprefs, dictionaries
|
||||||
from calibre.gui2.tweak_book.widgets import Dialog
|
from calibre.gui2.tweak_book.widgets import Dialog
|
||||||
from calibre.gui2.progress_indicator import ProgressIndicator
|
from calibre.gui2.progress_indicator import ProgressIndicator
|
||||||
@ -112,6 +112,7 @@ class FilesView(QTableView):
|
|||||||
|
|
||||||
double_clicked = pyqtSignal(object)
|
double_clicked = pyqtSignal(object)
|
||||||
delete_requested = pyqtSignal(object, object)
|
delete_requested = pyqtSignal(object, object)
|
||||||
|
current_changed = pyqtSignal(object, object)
|
||||||
DELETE_POSSIBLE = True
|
DELETE_POSSIBLE = True
|
||||||
|
|
||||||
def __init__(self, model, parent=None):
|
def __init__(self, model, parent=None):
|
||||||
@ -126,6 +127,10 @@ class FilesView(QTableView):
|
|||||||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
self.customContextMenuRequested.connect(self.show_context_menu)
|
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):
|
def customize_context_menu(self, menu, selected_locations, current_location):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -479,6 +484,159 @@ class ImagesWidget(QWidget):
|
|||||||
self.files.save_table('image-files-table')
|
self.files.save_table('image-files-table')
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
# Links {{{
|
||||||
|
|
||||||
|
class LinksModel(FileCollection):
|
||||||
|
|
||||||
|
COLUMN_HEADERS = [_('OK'), _('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)
|
||||||
|
psk = numeric_sort_key
|
||||||
|
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.DisplayRole):
|
||||||
|
if role == SORT_ROLE:
|
||||||
|
try:
|
||||||
|
return self.sort_keys[index.row()][index.column()]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
elif role == Qt.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.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.UserRole:
|
||||||
|
try:
|
||||||
|
return self.links[index.row()]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class WebView(QWebView):
|
||||||
|
|
||||||
|
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.Vertical, self)
|
||||||
|
l.addWidget(s)
|
||||||
|
e.setPlaceholderText(_('Filter'))
|
||||||
|
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 = WebView(self)
|
||||||
|
s.addWidget(self.view)
|
||||||
|
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.setCollapsible(1, True)
|
||||||
|
s.setStretchFactor(0, 10)
|
||||||
|
|
||||||
|
def __call__(self, data):
|
||||||
|
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.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:
|
||||||
|
self.view.setHtml('<p>' + _('No destination found for this link'))
|
||||||
|
self.current_url = url
|
||||||
|
elif url != self.current_url:
|
||||||
|
self.current_url = url
|
||||||
|
self.view.setUrl(url)
|
||||||
|
|
||||||
|
def double_clicked(self, index):
|
||||||
|
link = index.data(Qt.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 {{{
|
# Words {{{
|
||||||
|
|
||||||
class WordsModel(FileCollection):
|
class WordsModel(FileCollection):
|
||||||
@ -952,6 +1110,10 @@ class ReportsWidget(QWidget):
|
|||||||
s.addWidget(c)
|
s.addWidget(c)
|
||||||
QListWidgetItem(_('Characters'), r)
|
QListWidgetItem(_('Characters'), r)
|
||||||
|
|
||||||
|
self.links = li = LinksWidget(self)
|
||||||
|
s.addWidget(li)
|
||||||
|
QListWidgetItem(_('Links'), r)
|
||||||
|
|
||||||
self.splitter.setStretchFactor(1, 500)
|
self.splitter.setStretchFactor(1, 500)
|
||||||
try:
|
try:
|
||||||
self.splitter.restoreState(read_state('splitter-state'))
|
self.splitter.restoreState(read_state('splitter-state'))
|
||||||
|
Loading…
x
Reference in New Issue
Block a user