Start work on the check book tool

This commit is contained in:
Kovid Goyal 2013-12-09 10:23:59 +05:30
parent ccd2fc524c
commit c69316ecb5
9 changed files with 359 additions and 7 deletions

View 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>'

View 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

View 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

View 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 []

View File

@ -92,6 +92,8 @@ class Boss(QObject):
self.gui.preview.split_start_requested.connect(self.split_start_requested) self.gui.preview.split_start_requested.connect(self.split_start_requested)
self.gui.preview.split_requested.connect(self.split_requested) self.gui.preview.split_requested.connect(self.split_requested)
self.gui.preview.link_clicked.connect(self.link_clicked) 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): def preferences(self):
p = Preferences(self.gui) p = Preferences(self.gui)
@ -680,6 +682,26 @@ class Boss(QObject):
if anchor: if anchor:
editor.go_to_anchor(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 @in_thread_job
def merge_requested(self, category, names, master): def merge_requested(self, category, names, master):
self.commit_all_editors_to_container() self.commit_all_editors_to_container()
@ -733,6 +755,7 @@ class Boss(QObject):
editor.data_changed.connect(self.editor_data_changed) editor.data_changed.connect(self.editor_data_changed)
editor.copy_available_state_changed.connect(self.editor_copy_available_state_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.sync_preview_to_editor)
editor.cursor_position_changed.connect(self.update_cursor_position)
if data is not None: if data is not None:
if use_template: if use_template:
editor.init_from_template(data) editor.init_from_template(data)
@ -818,6 +841,7 @@ class Boss(QObject):
def apply_current_editor_state(self): def apply_current_editor_state(self):
ed = self.gui.central.current_editor ed = self.gui.central.current_editor
self.gui.cursor_position_widget.update_position()
if ed is not None: if ed is not None:
actions['editor-undo'].setEnabled(ed.undo_available) actions['editor-undo'].setEnabled(ed.undo_available)
actions['editor-redo'].setEnabled(ed.redo_available) actions['editor-redo'].setEnabled(ed.redo_available)
@ -832,9 +856,18 @@ class Boss(QObject):
break break
if name is not None and getattr(ed, 'syntax', None) == 'html': if name is not None and getattr(ed, 'syntax', None) == 'html':
self.gui.preview.show(name) self.gui.preview.show(name)
if ed.has_line_numbers:
self.gui.cursor_position_widget.update_position(*ed.cursor_position)
else: else:
actions['go-to-line-number'].setEnabled(False) 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): def editor_close_requested(self, editor):
name = None name = None
for n, ed in editors.iteritems(): for n, ed in editors.iteritems():

View 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()

View File

@ -181,7 +181,7 @@ class TextEdit(QPlainTextEdit):
self.setTextCursor(c) self.setTextCursor(c)
self.ensureCursorVisible() self.ensureCursorVisible()
def go_to_line(self, lnum): def go_to_line(self, lnum, col=None):
lnum = max(1, min(self.blockCount(), lnum)) lnum = max(1, min(self.blockCount(), lnum))
c = self.textCursor() c = self.textCursor()
c.clearSelection() c.clearSelection()
@ -190,10 +190,16 @@ class TextEdit(QPlainTextEdit):
c.movePosition(c.StartOfLine) c.movePosition(c.StartOfLine)
c.movePosition(c.EndOfLine, c.KeepAnchor) c.movePosition(c.EndOfLine, c.KeepAnchor)
text = unicode(c.selectedText()) text = unicode(c.selectedText())
c.movePosition(c.StartOfLine) if col is None:
lt = text.lstrip() c.movePosition(c.StartOfLine)
if text and lt and lt != text: lt = text.lstrip()
c.movePosition(c.NextWord) 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.setTextCursor(c)
self.ensureCursorVisible() self.ensureCursorVisible()

View File

@ -176,12 +176,20 @@ class Editor(QMainWindow):
def _cursor_position_changed(self, *args): def _cursor_position_changed(self, *args):
self.cursor_position_changed.emit() self.cursor_position_changed.emit()
@property
def cursor_position(self):
c = self.editor.textCursor()
return (c.blockNumber() + 1, c.positionInBlock())
def cut(self): def cut(self):
self.editor.cut() self.editor.cut()
def copy(self): def copy(self):
self.editor.copy() self.editor.copy()
def go_to_line(self, line, col=None):
self.editor.go_to_line(line, col=col)
def paste(self): def paste(self):
if not self.editor.canPaste(): if not self.editor.canPaste():
return error_dialog(self, _('No text'), _( return error_dialog(self, _('No text'), _(

View File

@ -10,7 +10,8 @@ from functools import partial
from PyQt4.Qt import ( from PyQt4.Qt import (
QDockWidget, Qt, QLabel, QIcon, QAction, QApplication, QWidget, QEvent, 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.constants import __appname__, get_version
from calibre.gui2 import elided_text 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.boss import Boss
from calibre.gui2.tweak_book.preview import Preview from calibre.gui2.tweak_book.preview import Preview
from calibre.gui2.tweak_book.search import SearchPanel from calibre.gui2.tweak_book.search import SearchPanel
from calibre.gui2.tweak_book.check import Check
class Central(QStackedWidget): class Central(QStackedWidget):
@ -155,6 +157,25 @@ class Central(QStackedWidget):
return True 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): class Main(MainWindow):
APP_NAME = _('Tweak Book') APP_NAME = _('Tweak Book')
@ -182,6 +203,8 @@ class Main(MainWindow):
self.status_bar = self.statusBar() self.status_bar = self.statusBar()
self.status_bar.addPermanentWidget(self.boss.save_manager.status_widget) 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'))) self.status_bar.addWidget(QLabel(_('{0} {1} created by {2}').format(__appname__, get_version(), 'Kovid Goyal')))
f = self.status_bar.font() f = self.status_bar.font()
f.setBold(True) f.setBold(True)
@ -259,6 +282,7 @@ class Main(MainWindow):
_('Beautify current file')) _('Beautify current file'))
self.action_pretty_all = reg('format-justify-fill.png', _('&Beautify all files'), partial(self.boss.pretty_print, False), 'pretty-all', (), self.action_pretty_all = reg('format-justify-fill.png', _('&Beautify all files'), partial(self.boss.pretty_print, False), 'pretty-all', (),
_('Beautify all files')) _('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 # Polish actions
group = _('Polish Book') group = _('Polish Book')
@ -355,6 +379,7 @@ class Main(MainWindow):
e.addAction(self.action_smarten_punctuation) e.addAction(self.action_smarten_punctuation)
e.addAction(self.action_fix_html_all) e.addAction(self.action_fix_html_all)
e.addAction(self.action_pretty_all) e.addAction(self.action_pretty_all)
e.addAction(self.action_check_book)
e = b.addMenu(_('&View')) e = b.addMenu(_('&View'))
t = e.addMenu(_('Tool&bars')) t = e.addMenu(_('Tool&bars'))
@ -403,7 +428,7 @@ class Main(MainWindow):
return b return b
a = create(_('Book tool bar'), 'global').addAction 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(getattr(self, 'action_' + x))
a = create(_('Polish book tool bar'), 'polish').addAction a = create(_('Polish book tool bar'), 'polish').addAction
@ -436,6 +461,13 @@ class Main(MainWindow):
d.setWidget(self.preview) d.setWidget(self.preview)
self.addDockWidget(Qt.RightDockWidgetArea, d) 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 = create(_('Inspector'), 'inspector')
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea) d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
d.setWidget(self.preview.inspector) d.setWidget(self.preview.inspector)