From c69316ecb5ec40b217665b310e1deee6e25aa9c8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 9 Dec 2013 10:23:59 +0530 Subject: [PATCH] Start work on the check book tool --- .../ebooks/oeb/polish/check/__init__.py | 10 ++ src/calibre/ebooks/oeb/polish/check/base.py | 56 ++++++++ src/calibre/ebooks/oeb/polish/check/main.py | 35 +++++ .../ebooks/oeb/polish/check/parsing.py | 46 +++++++ src/calibre/gui2/tweak_book/boss.py | 33 +++++ src/calibre/gui2/tweak_book/check.py | 126 ++++++++++++++++++ src/calibre/gui2/tweak_book/editor/text.py | 16 ++- src/calibre/gui2/tweak_book/editor/widget.py | 8 ++ src/calibre/gui2/tweak_book/ui.py | 36 ++++- 9 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 src/calibre/ebooks/oeb/polish/check/__init__.py create mode 100644 src/calibre/ebooks/oeb/polish/check/base.py create mode 100644 src/calibre/ebooks/oeb/polish/check/main.py create mode 100644 src/calibre/ebooks/oeb/polish/check/parsing.py create mode 100644 src/calibre/gui2/tweak_book/check.py diff --git a/src/calibre/ebooks/oeb/polish/check/__init__.py b/src/calibre/ebooks/oeb/polish/check/__init__.py new file mode 100644 index 0000000000..2dfbaa2fb8 --- /dev/null +++ b/src/calibre/ebooks/oeb/polish/check/__init__.py @@ -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 ' + + + diff --git a/src/calibre/ebooks/oeb/polish/check/base.py b/src/calibre/ebooks/oeb/polish/check/base.py new file mode 100644 index 0000000000..92bb84686c --- /dev/null +++ b/src/calibre/ebooks/oeb/polish/check/base.py @@ -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 ' + +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 + diff --git a/src/calibre/ebooks/oeb/polish/check/main.py b/src/calibre/ebooks/oeb/polish/check/main.py new file mode 100644 index 0000000000..6aaeeed427 --- /dev/null +++ b/src/calibre/ebooks/oeb/polish/check/main.py @@ -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 ' + +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 + diff --git a/src/calibre/ebooks/oeb/polish/check/parsing.py b/src/calibre/ebooks/oeb/polish/check/parsing.py new file mode 100644 index 0000000000..515632ed87 --- /dev/null +++ b/src/calibre/ebooks/oeb/polish/check/parsing.py @@ -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 ' + +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 [] + diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index dd9aa95aa4..a8be47d7e1 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -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(): diff --git a/src/calibre/gui2/tweak_book/check.py b/src/calibre/gui2/tweak_book/check.py new file mode 100644 index 0000000000..d984f3054d --- /dev/null +++ b/src/calibre/gui2/tweak_book/check.py @@ -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 ' + +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('

%s

%s

' % ( + 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( + '''

%s

+

%s

+ + ''' % (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() diff --git a/src/calibre/gui2/tweak_book/editor/text.py b/src/calibre/gui2/tweak_book/editor/text.py index a038306e4d..c4d5c18383 100644 --- a/src/calibre/gui2/tweak_book/editor/text.py +++ b/src/calibre/gui2/tweak_book/editor/text.py @@ -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() diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py index 05520d258e..88d991c433 100644 --- a/src/calibre/gui2/tweak_book/editor/widget.py +++ b/src/calibre/gui2/tweak_book/editor/widget.py @@ -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'), _( diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py index f9e37c031f..e886999b7d 100644 --- a/src/calibre/gui2/tweak_book/ui.py +++ b/src/calibre/gui2/tweak_book/ui.py @@ -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)