mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Start work on the check book tool
This commit is contained in:
parent
ccd2fc524c
commit
c69316ecb5
10
src/calibre/ebooks/oeb/polish/check/__init__.py
Normal file
10
src/calibre/ebooks/oeb/polish/check/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
|
||||
|
56
src/calibre/ebooks/oeb/polish/check/base.py
Normal file
56
src/calibre/ebooks/oeb/polish/check/base.py
Normal file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from functools import partial
|
||||
|
||||
from calibre import detect_ncpus as cpu_count
|
||||
|
||||
DEBUG, INFO, WARN, ERROR, CRITICAL = xrange(5)
|
||||
|
||||
class BaseError(object):
|
||||
|
||||
HELP = ''
|
||||
|
||||
def __init__(self, msg, name, line=None, col=None):
|
||||
self.msg, self.line, self.col = msg, line, col
|
||||
self.name = name
|
||||
self.level = ERROR
|
||||
|
||||
def __str__(self):
|
||||
return '%s:%s (%s, %s):%s' % (self.__class__.__name__, self.name, self.line, self.col, self.msg)
|
||||
|
||||
__repr__ = __str__
|
||||
|
||||
class Worker(object):
|
||||
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
self.result = None
|
||||
self.tb = None
|
||||
|
||||
def worker(func, args):
|
||||
try:
|
||||
result = func(*args)
|
||||
tb = None
|
||||
except:
|
||||
result = None
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
return result, tb
|
||||
|
||||
def run_checkers(func, args_list):
|
||||
num = cpu_count()
|
||||
pool = ThreadPool(num)
|
||||
ans = []
|
||||
for result, tb in pool.map(partial(worker, func), args_list):
|
||||
if tb is not None:
|
||||
raise Exception('Failed to run worker: \n%s' % tb)
|
||||
ans.extend(result)
|
||||
return ans
|
||||
|
35
src/calibre/ebooks/oeb/polish/check/main.py
Normal file
35
src/calibre/ebooks/oeb/polish/check/main.py
Normal file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
from future_builtins import map
|
||||
|
||||
from calibre.ebooks.oeb.base import OEB_DOCS
|
||||
from calibre.ebooks.oeb.polish.container import guess_type
|
||||
from calibre.ebooks.oeb.polish.check.base import run_checkers
|
||||
from calibre.ebooks.oeb.polish.check.parsing import check_xml_parsing
|
||||
|
||||
def run_checks(container):
|
||||
|
||||
errors = []
|
||||
|
||||
# Check parsing
|
||||
XML_TYPES = frozenset(map(guess_type, ('a.xml', 'a.svg', 'a.opf', 'a.ncx')))
|
||||
xml_items, html_items = [], []
|
||||
for name, mt in container.mime_map.iteritems():
|
||||
items = None
|
||||
if mt in XML_TYPES:
|
||||
items = xml_items
|
||||
elif mt in OEB_DOCS:
|
||||
items = html_items
|
||||
if items is not None:
|
||||
items.append((name, mt, container.open(name, 'rb').read()))
|
||||
errors.extend(run_checkers(check_xml_parsing, xml_items))
|
||||
errors.extend(run_checkers(check_xml_parsing, html_items))
|
||||
|
||||
return errors
|
||||
|
46
src/calibre/ebooks/oeb/polish/check/parsing.py
Normal file
46
src/calibre/ebooks/oeb/polish/check/parsing.py
Normal file
@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
from lxml.etree import XMLParser, fromstring, XMLSyntaxError
|
||||
|
||||
from calibre.ebooks.oeb.polish.check.base import BaseError
|
||||
from calibre.ebooks.oeb.base import OEB_DOCS
|
||||
|
||||
class XMLParseError(BaseError):
|
||||
|
||||
HELP = _('A parsing error in an XML file means that the XML syntax in the file is incorrect.'
|
||||
' Such a file will most probably not open in an ebook reader. These errors can '
|
||||
' usually be fixed automatically, however, automatic fixing can sometimes '
|
||||
' "do the wrong thing".')
|
||||
|
||||
def __init__(self, msg, *args, **kwargs):
|
||||
BaseError.__init__(self, 'Parsing failed: ' + msg, *args, **kwargs)
|
||||
|
||||
class HTMLParseError(XMLParseError):
|
||||
|
||||
HELP = _('A parsing error in an HTML file means that the HTML syntax is incorrect.'
|
||||
' Most readers will automatically ignore such errors, but they may result in '
|
||||
' incorrect display of content. These errors can usually be fixed automatically,'
|
||||
' however, automatic fixing can sometimes "do the wrong thing".')
|
||||
|
||||
def check_xml_parsing(name, mt, raw):
|
||||
parser = XMLParser(recover=False)
|
||||
errcls = HTMLParseError if mt in OEB_DOCS else XMLParseError
|
||||
|
||||
try:
|
||||
fromstring(raw, parser=parser)
|
||||
except XMLSyntaxError as err:
|
||||
try:
|
||||
line, col = err.position
|
||||
except:
|
||||
line = col = None
|
||||
return [errcls(err.message, name, line, col)]
|
||||
except Exception as err:
|
||||
return [errcls(err.message, name)]
|
||||
return []
|
||||
|
@ -92,6 +92,8 @@ class Boss(QObject):
|
||||
self.gui.preview.split_start_requested.connect(self.split_start_requested)
|
||||
self.gui.preview.split_requested.connect(self.split_requested)
|
||||
self.gui.preview.link_clicked.connect(self.link_clicked)
|
||||
self.gui.check_book.item_activated.connect(self.check_item_activated)
|
||||
self.gui.check_book.check_requested.connect(self.check_requested)
|
||||
|
||||
def preferences(self):
|
||||
p = Preferences(self.gui)
|
||||
@ -680,6 +682,26 @@ class Boss(QObject):
|
||||
if anchor:
|
||||
editor.go_to_anchor(anchor)
|
||||
|
||||
@in_thread_job
|
||||
def check_item_activated(self, item):
|
||||
name = item.name
|
||||
if name in editors:
|
||||
editor = editors[name]
|
||||
self.gui.central.show_editor(editor)
|
||||
else:
|
||||
syntax = syntax_from_mime(name, current_container().mime_map[name])
|
||||
editor = self.edit_file(name, syntax)
|
||||
if editor.has_line_numbers:
|
||||
editor.go_to_line(item.line, item.col)
|
||||
editor.set_focus()
|
||||
|
||||
@in_thread_job
|
||||
def check_requested(self, *args):
|
||||
c = self.gui.check_book
|
||||
c.parent().show()
|
||||
c.parent().raise_()
|
||||
c.run_checks(current_container())
|
||||
|
||||
@in_thread_job
|
||||
def merge_requested(self, category, names, master):
|
||||
self.commit_all_editors_to_container()
|
||||
@ -733,6 +755,7 @@ class Boss(QObject):
|
||||
editor.data_changed.connect(self.editor_data_changed)
|
||||
editor.copy_available_state_changed.connect(self.editor_copy_available_state_changed)
|
||||
editor.cursor_position_changed.connect(self.sync_preview_to_editor)
|
||||
editor.cursor_position_changed.connect(self.update_cursor_position)
|
||||
if data is not None:
|
||||
if use_template:
|
||||
editor.init_from_template(data)
|
||||
@ -818,6 +841,7 @@ class Boss(QObject):
|
||||
|
||||
def apply_current_editor_state(self):
|
||||
ed = self.gui.central.current_editor
|
||||
self.gui.cursor_position_widget.update_position()
|
||||
if ed is not None:
|
||||
actions['editor-undo'].setEnabled(ed.undo_available)
|
||||
actions['editor-redo'].setEnabled(ed.redo_available)
|
||||
@ -832,9 +856,18 @@ class Boss(QObject):
|
||||
break
|
||||
if name is not None and getattr(ed, 'syntax', None) == 'html':
|
||||
self.gui.preview.show(name)
|
||||
if ed.has_line_numbers:
|
||||
self.gui.cursor_position_widget.update_position(*ed.cursor_position)
|
||||
else:
|
||||
actions['go-to-line-number'].setEnabled(False)
|
||||
|
||||
def update_cursor_position(self):
|
||||
ed = self.gui.central.current_editor
|
||||
if getattr(ed, 'has_line_numbers', False):
|
||||
self.gui.cursor_position_widget.update_position(*ed.cursor_position)
|
||||
else:
|
||||
self.gui.cursor_position_widget.update_position()
|
||||
|
||||
def editor_close_requested(self, editor):
|
||||
name = None
|
||||
for n, ed in editors.iteritems():
|
||||
|
126
src/calibre/gui2/tweak_book/check.py
Normal file
126
src/calibre/gui2/tweak_book/check.py
Normal file
@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
import sys
|
||||
|
||||
from PyQt4.Qt import (
|
||||
QIcon, Qt, QHBoxLayout, QListWidget, QTextBrowser, QWidget,
|
||||
QListWidgetItem, pyqtSignal, QApplication)
|
||||
|
||||
from calibre.ebooks.oeb.polish.check.base import WARN, INFO, DEBUG, ERROR, CRITICAL
|
||||
from calibre.ebooks.oeb.polish.check.main import run_checks
|
||||
|
||||
def icon_for_level(level):
|
||||
if level > WARN:
|
||||
icon = 'dialog_error.png'
|
||||
elif level == WARN:
|
||||
icon = 'dialog_warning.png'
|
||||
elif level == INFO:
|
||||
icon = 'dialog_information.png'
|
||||
else:
|
||||
icon = None
|
||||
return QIcon(I(icon)) if icon else QIcon()
|
||||
|
||||
class Check(QWidget):
|
||||
|
||||
item_activated = pyqtSignal(object)
|
||||
check_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
QWidget.__init__(self, parent)
|
||||
|
||||
self.l = l = QHBoxLayout(self)
|
||||
self.setLayout(l)
|
||||
self.items = i = QListWidget(self)
|
||||
self.items.setSpacing(2)
|
||||
self.items.itemDoubleClicked.connect(self.current_item_activated)
|
||||
self.items.currentItemChanged.connect(self.current_item_changed)
|
||||
l.addWidget(i)
|
||||
self.help = h = QTextBrowser(self)
|
||||
h.anchorClicked.connect(self.link_clicked)
|
||||
h.setOpenLinks(False)
|
||||
l.addWidget(h)
|
||||
h.setMaximumWidth(250)
|
||||
self.clear_help(_('Check has not been run'))
|
||||
|
||||
def clear_help(self, msg):
|
||||
self.help.setText('<h2>%s</h2><p><a href="run:check">%s</a></p>' % (
|
||||
msg, _('Run check')))
|
||||
|
||||
def link_clicked(self, url):
|
||||
url = unicode(url.toString())
|
||||
if url == 'activate:item':
|
||||
self.current_item_activated()
|
||||
elif url == 'run:check':
|
||||
self.check_requested.emit()
|
||||
|
||||
def current_item_activated(self, *args):
|
||||
i = self.items.currentItem()
|
||||
if i is not None:
|
||||
err = i.data(Qt.UserRole).toPyObject()
|
||||
self.item_activated.emit(err)
|
||||
|
||||
def current_item_changed(self, *args):
|
||||
i = self.items.currentItem()
|
||||
self.help.setText('')
|
||||
if i is not None:
|
||||
err = i.data(Qt.UserRole).toPyObject()
|
||||
header = {DEBUG:_('Debug'), INFO:_('Information'), WARN:_('Warning'), ERROR:_('Error'), CRITICAL:_('Error')}[err.level]
|
||||
loc = ''
|
||||
if err.line is not None:
|
||||
loc = _('line: %d') % err.line
|
||||
if err.col is not None:
|
||||
loc += ' column: %d' % err.col
|
||||
if loc:
|
||||
loc = ' (%s)' % loc
|
||||
self.help.setText(
|
||||
'''<h2 style="text-align:center">%s</h2>
|
||||
<p>%s</p>
|
||||
<div><a style="text-decoration:none" href="activate:item">%s %s</a></div>
|
||||
''' % (header, err.msg, err.name, loc))
|
||||
|
||||
def run_checks(self, container):
|
||||
from calibre.gui2.tweak_book.boss import BusyCursor
|
||||
with BusyCursor():
|
||||
self.show_busy()
|
||||
QApplication.processEvents()
|
||||
errors = run_checks(container)
|
||||
self.hide_busy()
|
||||
|
||||
for err in sorted(errors, key=lambda e:(100 - e.level, e.name)):
|
||||
i = QListWidgetItem(err.msg, self.items)
|
||||
i.setData(Qt.UserRole, err)
|
||||
i.setIcon(icon_for_level(err.level))
|
||||
if errors:
|
||||
self.items.item(0).setSelected(True)
|
||||
self.items.setCurrentRow(0)
|
||||
self.current_item_changed()
|
||||
else:
|
||||
self.clear_help(_('No problems found'))
|
||||
|
||||
def show_busy(self, msg=_('Running checks, please wait...')):
|
||||
self.help.setText(msg)
|
||||
self.items.clear()
|
||||
|
||||
def hide_busy(self):
|
||||
self.help.setText('')
|
||||
self.items.clear()
|
||||
|
||||
def main():
|
||||
from calibre.gui2 import Application
|
||||
from calibre.gui2.tweak_book.boss import get_container
|
||||
app = Application([]) # noqa
|
||||
path = sys.argv[-1]
|
||||
container = get_container(path)
|
||||
d = Check()
|
||||
d.run_checks(container)
|
||||
d.show()
|
||||
app.exec_()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
@ -181,7 +181,7 @@ class TextEdit(QPlainTextEdit):
|
||||
self.setTextCursor(c)
|
||||
self.ensureCursorVisible()
|
||||
|
||||
def go_to_line(self, lnum):
|
||||
def go_to_line(self, lnum, col=None):
|
||||
lnum = max(1, min(self.blockCount(), lnum))
|
||||
c = self.textCursor()
|
||||
c.clearSelection()
|
||||
@ -190,10 +190,16 @@ class TextEdit(QPlainTextEdit):
|
||||
c.movePosition(c.StartOfLine)
|
||||
c.movePosition(c.EndOfLine, c.KeepAnchor)
|
||||
text = unicode(c.selectedText())
|
||||
c.movePosition(c.StartOfLine)
|
||||
lt = text.lstrip()
|
||||
if text and lt and lt != text:
|
||||
c.movePosition(c.NextWord)
|
||||
if col is None:
|
||||
c.movePosition(c.StartOfLine)
|
||||
lt = text.lstrip()
|
||||
if text and lt and lt != text:
|
||||
c.movePosition(c.NextWord)
|
||||
else:
|
||||
c.setPosition(c.block().position() + col)
|
||||
if c.blockNumber() + 1 > lnum:
|
||||
c.movePosition(c.PreviousBlock)
|
||||
c.movePosition(c.EndOfBlock)
|
||||
self.setTextCursor(c)
|
||||
self.ensureCursorVisible()
|
||||
|
||||
|
@ -176,12 +176,20 @@ class Editor(QMainWindow):
|
||||
def _cursor_position_changed(self, *args):
|
||||
self.cursor_position_changed.emit()
|
||||
|
||||
@property
|
||||
def cursor_position(self):
|
||||
c = self.editor.textCursor()
|
||||
return (c.blockNumber() + 1, c.positionInBlock())
|
||||
|
||||
def cut(self):
|
||||
self.editor.cut()
|
||||
|
||||
def copy(self):
|
||||
self.editor.copy()
|
||||
|
||||
def go_to_line(self, line, col=None):
|
||||
self.editor.go_to_line(line, col=col)
|
||||
|
||||
def paste(self):
|
||||
if not self.editor.canPaste():
|
||||
return error_dialog(self, _('No text'), _(
|
||||
|
@ -10,7 +10,8 @@ from functools import partial
|
||||
|
||||
from PyQt4.Qt import (
|
||||
QDockWidget, Qt, QLabel, QIcon, QAction, QApplication, QWidget, QEvent,
|
||||
QVBoxLayout, QStackedWidget, QTabWidget, QImage, QPixmap, pyqtSignal, QMenu)
|
||||
QVBoxLayout, QStackedWidget, QTabWidget, QImage, QPixmap, pyqtSignal,
|
||||
QMenu, QHBoxLayout)
|
||||
|
||||
from calibre.constants import __appname__, get_version
|
||||
from calibre.gui2 import elided_text
|
||||
@ -22,6 +23,7 @@ from calibre.gui2.tweak_book.job import BlockingJob
|
||||
from calibre.gui2.tweak_book.boss import Boss
|
||||
from calibre.gui2.tweak_book.preview import Preview
|
||||
from calibre.gui2.tweak_book.search import SearchPanel
|
||||
from calibre.gui2.tweak_book.check import Check
|
||||
|
||||
class Central(QStackedWidget):
|
||||
|
||||
@ -155,6 +157,25 @@ class Central(QStackedWidget):
|
||||
|
||||
return True
|
||||
|
||||
class CursorPositionWidget(QWidget):
|
||||
|
||||
def __init__(self, parent):
|
||||
QWidget.__init__(self, parent)
|
||||
self.l = QHBoxLayout(self)
|
||||
self.setLayout(self.l)
|
||||
self.la = QLabel('')
|
||||
self.l.addWidget(self.la)
|
||||
self.l.setContentsMargins(0, 0, 0, 0)
|
||||
f = self.la.font()
|
||||
f.setBold(False)
|
||||
self.la.setFont(f)
|
||||
|
||||
def update_position(self, line=None, col=None):
|
||||
if line is None:
|
||||
self.la.setText('')
|
||||
else:
|
||||
self.la.setText(_('Line: {0} : {1}').format(line, col))
|
||||
|
||||
class Main(MainWindow):
|
||||
|
||||
APP_NAME = _('Tweak Book')
|
||||
@ -182,6 +203,8 @@ class Main(MainWindow):
|
||||
|
||||
self.status_bar = self.statusBar()
|
||||
self.status_bar.addPermanentWidget(self.boss.save_manager.status_widget)
|
||||
self.cursor_position_widget = CursorPositionWidget(self)
|
||||
self.status_bar.addPermanentWidget(self.cursor_position_widget)
|
||||
self.status_bar.addWidget(QLabel(_('{0} {1} created by {2}').format(__appname__, get_version(), 'Kovid Goyal')))
|
||||
f = self.status_bar.font()
|
||||
f.setBold(True)
|
||||
@ -259,6 +282,7 @@ class Main(MainWindow):
|
||||
_('Beautify current file'))
|
||||
self.action_pretty_all = reg('format-justify-fill.png', _('&Beautify all files'), partial(self.boss.pretty_print, False), 'pretty-all', (),
|
||||
_('Beautify all files'))
|
||||
self.action_check_book = reg('debug.png', _('&Check Book'), self.boss.check_requested, 'check-book', ('F7'), _('Check book for errors'))
|
||||
|
||||
# Polish actions
|
||||
group = _('Polish Book')
|
||||
@ -355,6 +379,7 @@ class Main(MainWindow):
|
||||
e.addAction(self.action_smarten_punctuation)
|
||||
e.addAction(self.action_fix_html_all)
|
||||
e.addAction(self.action_pretty_all)
|
||||
e.addAction(self.action_check_book)
|
||||
|
||||
e = b.addMenu(_('&View'))
|
||||
t = e.addMenu(_('Tool&bars'))
|
||||
@ -403,7 +428,7 @@ class Main(MainWindow):
|
||||
return b
|
||||
|
||||
a = create(_('Book tool bar'), 'global').addAction
|
||||
for x in ('new_file', 'open_book', 'global_undo', 'global_redo', 'save', 'create_checkpoint', 'toc'):
|
||||
for x in ('new_file', 'open_book', 'global_undo', 'global_redo', 'save', 'create_checkpoint', 'toc', 'check_book'):
|
||||
a(getattr(self, 'action_' + x))
|
||||
|
||||
a = create(_('Polish book tool bar'), 'polish').addAction
|
||||
@ -436,6 +461,13 @@ class Main(MainWindow):
|
||||
d.setWidget(self.preview)
|
||||
self.addDockWidget(Qt.RightDockWidgetArea, d)
|
||||
|
||||
d = create(_('Check Book'), 'check-book')
|
||||
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
|
||||
self.check_book = Check(self)
|
||||
d.setWidget(self.check_book)
|
||||
self.addDockWidget(Qt.TopDockWidgetArea, d)
|
||||
d.close() # By default the check window is closed
|
||||
|
||||
d = create(_('Inspector'), 'inspector')
|
||||
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
|
||||
d.setWidget(self.preview.inspector)
|
||||
|
Loading…
x
Reference in New Issue
Block a user