diff --git a/src/calibre/ebooks/oeb/polish/container.py b/src/calibre/ebooks/oeb/polish/container.py index 9eaed68855..757815e520 100644 --- a/src/calibre/ebooks/oeb/polish/container.py +++ b/src/calibre/ebooks/oeb/polish/container.py @@ -429,7 +429,7 @@ class Container(object): # {{{ def raw_data(self, name, decode=True): ans = self.open(name).read() mime = self.mime_map.get(name, guess_type(name)) - if decode and (mime in OEB_STYLES or mime in OEB_DOCS or mime[-4:] in {'+xml', '/xml'}): + if decode and (mime in OEB_STYLES or mime in OEB_DOCS or mime == 'text/plain' or mime[-4:] in {'+xml', '/xml'}): ans = self.decode(ans) return ans diff --git a/src/calibre/gui2/tweak_book/diff/main.py b/src/calibre/gui2/tweak_book/diff/main.py index 3adfa10d58..ba95ca6110 100644 --- a/src/calibre/gui2/tweak_book/diff/main.py +++ b/src/calibre/gui2/tweak_book/diff/main.py @@ -6,5 +6,235 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2014, Kovid Goyal ' +from functools import partial +from PyQt4.Qt import ( + QGridLayout, QToolButton, QIcon, QRadioButton, QMenu, QApplication, Qt, + QSize, QWidget, QLabel, QStackedLayout, QPainter, QRect, QVBoxLayout, + QCursor, QEventLoop) +from calibre.gui2 import info_dialog +from calibre.gui2.progress_indicator import ProgressIndicator +from calibre.gui2.tweak_book.editor import syntax_from_mime +from calibre.gui2.tweak_book.diff.view import DiffView +from calibre.gui2.tweak_book.widgets import Dialog +from calibre.gui2.widgets2 import HistoryLineEdit2 +from calibre.utils.icu import numeric_sort_key + +class BusyWidget(QWidget): # {{{ + + def __init__(self, parent): + QWidget.__init__(self, parent) + l = QVBoxLayout() + self.setLayout(l) + l.addStretch(10) + self.pi = ProgressIndicator(self, 128) + l.addWidget(self.pi, alignment=Qt.AlignHCenter) + self.dummy = QLabel('

\xa0') + l.addSpacing(10) + l.addWidget(self.dummy, alignment=Qt.AlignHCenter) + l.addStretch(10) + self.text = _('Calculating differences, please wait...') + + def paintEvent(self, ev): + br = ev.region().boundingRect() + QWidget.paintEvent(self, ev) + p = QPainter(self) + p.setClipRect(br) + f = p.font() + f.setBold(True) + f.setPointSize(20) + p.setFont(f) + p.setPen(Qt.SolidLine) + r = QRect(0, self.dummy.geometry().top() + 10, self.geometry().width(), 150) + p.drawText(r, Qt.AlignHCenter | Qt.AlignTop | Qt.TextSingleLine, self.text) + p.end() +# }}} + +class Cache(object): + + def __init__(self): + self._left, self._right = {}, {} + self.left, self.right = self._left.get, self._right.get + self.set_left, self.set_right = self._left.__setitem__, self._right.__setitem__ + +def changed_files(list_of_names1, list_of_names2, get_data1, get_data2): + list_of_names1, list_of_names2 = frozenset(list_of_names1), frozenset(list_of_names2) + changed_names = set() + cache = Cache() + common_names = list_of_names1.intersection(list_of_names2) + for name in common_names: + left, right = get_data1(name), get_data2(name) + if len(left) == len(right) and left == right: + continue + cache.set_left(name, left), cache.set_right(name, right) + changed_names.add(name) + removals = list_of_names1 - common_names + adds = set(list_of_names2 - common_names) + adata, rdata = {a:get_data2(a) for a in adds}, {r:get_data1(r) for r in removals} + ahash = {a:hash(d) for a, d in adata.iteritems()} + rhash = {r:hash(d) for r, d in rdata.iteritems()} + renamed_names, removed_names, added_names = {}, set(), set() + for name, rh in rhash.iteritems(): + for n, ah in ahash.iteritems(): + if ah == rh: + renamed_names[name] = n + adds.discard(n) + break + else: + cache.set_left(name, adata[name]) + removed_names.add(name) + for name in adds: + cache.set_right(name, adata[name]) + added_names.add(name) + return cache, changed_names, renamed_names, removed_names, added_names + +def container_diff(left, right): + cache, changed_names, renamed_names, removed_names, added_names = changed_files( + left.name_path_map, right.name_path_map, left.raw_data, right.raw_data) + + def syntax(container, name): + mt = container.mime_map[name] + return syntax_from_mime(name, mt) + + syntax_map = {name:syntax(left, name) for name in changed_names} + syntax_map.update({name:syntax(left, name) for name in renamed_names}) + syntax_map.update({name:syntax(right, name) for name in added_names}) + syntax_map.update({name:syntax(left, name) for name in removed_names}) + return cache, syntax_map, changed_names, renamed_names, removed_names, added_names + +def ebook_diff(path1, path2): + from calibre.ebooks.oeb.polish.container import get_container + left = get_container(path1, tweak_mode=True) + right = get_container(path2, tweak_mode=True) + return container_diff(left, right) + +class Diff(Dialog): + + def __init__(self, parent=None): + self.context = 3 + self.apply_diff_calls = [] + Dialog.__init__(self, _('Differences between books'), 'diff-dialog', parent=parent) + + def sizeHint(self): + geom = QApplication.instance().desktop().availableGeometry(self) + return QSize(int(0.9 * geom.width()), int(0.8 * geom.height())) + + def setup_ui(self): + self.stacks = st = QStackedLayout(self) + self.busy = BusyWidget(self) + self.w = QWidget(self) + st.addWidget(self.busy), st.addWidget(self.w) + + self.setLayout(st) + self.l = l = QGridLayout() + self.w.setLayout(l) + + self.view = v = DiffView(self) + l.addWidget(v, l.rowCount(), 0, 1, -1) + + self.search = s = HistoryLineEdit2(self) + s.initialize('diff_search_history') + l.addWidget(s, l.rowCount(), 0, 1, 1) + s.setPlaceholderText(_('Search')) + s.returnPressed.connect(partial(self.do_search, False)) + self.sbn = b = QToolButton(self) + b.setIcon(QIcon(I('arrow-down.png'))) + b.clicked.connect(partial(self.do_search, False)) + b.setToolTip(_('Find next match')) + l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1) + self.sbp = b = QToolButton(self) + b.setIcon(QIcon(I('arrow-up.png'))) + b.clicked.connect(partial(self.do_search, True)) + b.setToolTip(_('Find previous match')) + l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1) + self.lb = b = QRadioButton(_('Left panel'), self) + b.setToolTip(_('Perform search in the left panel')) + l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1) + self.rb = b = QRadioButton(_('Right panel'), self) + b.setToolTip(_('Perform search in the right panel')) + l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1) + b.setChecked(True) + self.pb = b = QToolButton(self) + b.setIcon(QIcon(I('config.png'))) + b.setToolTip(_('Change the amount of context shown around the changes')) + b.setPopupMode(b.InstantPopup) + m = QMenu(b) + b.setMenu(m) + for i in (3, 5, 10, 50): + m.addAction(_('Show %d lines of context around changes') % i, partial(self.change_context, i)) + m.addAction(_('Show all text'), partial(self.change_context, None)) + l.addWidget(b, l.rowCount() - 1, l.columnCount(), 1, 1) + + self.bb.setStandardButtons(self.bb.Close) + l.addWidget(self.bb, l.rowCount(), 0, 1, -1) + + self.view.setFocus(Qt.OtherFocusReason) + + def do_search(self, reverse): + pass + + def change_context(self, context): + if context == self.context: + return + self.context = context + with self: + self.view.clear() + for args, kwargs in self.apply_diff_calls: + kwargs['context'] = self.context + self.view.add_diff(*args, **kwargs) + self.view.finalize() + + def __enter__(self): + self.stacks.setCurrentIndex(0) + self.busy.pi.startAnimation() + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + QApplication.processEvents(QEventLoop.ExcludeUserInputEvents | QEventLoop.ExcludeSocketNotifiers) + + def __exit__(self, *args): + self.busy.pi.stopAnimation() + self.stacks.setCurrentIndex(1) + QApplication.restoreOverrideCursor() + + def ebook_diff(self, path1, path2): + with self: + self.apply_diff(_('The books are identical'), *ebook_diff(path1, path2)) + self.view.finalize() + + def apply_diff(self, identical_msg, cache, syntax_map, changed_names, renamed_names, removed_names, added_names): + self.view.clear() + self.apply_diff_calls = calls = [] + def add(args, kwargs): + self.view.add_diff(*args, **kwargs) + calls.append((args, kwargs)) + + if len(changed_names) + len(renamed_names) + len(removed_names) + len(added_names) < 1: + return info_dialog(self, _('No changes found'), identical_msg, show=True) + + for name in sorted(changed_names, key=numeric_sort_key): + args = (name, name, cache.left(name), cache.right(name)) + kwargs = {'syntax':syntax_map.get(name, None), 'context':self.context} + add(args, kwargs) + + for name in sorted(added_names, key=numeric_sort_key): + args = (_('[This file was added]'), name, None, cache.right(name)) + kwargs = {'syntax':syntax_map.get(name, None), 'context':self.context} + add(args, kwargs) + + for name in sorted(removed_names, key=numeric_sort_key): + args = (name, _('[This file was removed]'), cache.left(name), None) + kwargs = {'syntax':syntax_map.get(name, None), 'context':self.context} + add(args, kwargs) + + for name, new_name in sorted(renamed_names.iteritems(), key=lambda x:numeric_sort_key(x[0])): + args = (name, new_name, None, None) + kwargs = {'syntax':syntax_map.get(name, None), 'context':self.context} + add(args, kwargs) + +if __name__ == '__main__': + import sys + app = QApplication([]) + d = Diff() + d.show() + d.ebook_diff(sys.argv[-2], sys.argv[-1]) + app.exec_() diff --git a/src/calibre/gui2/tweak_book/diff/view.py b/src/calibre/gui2/tweak_book/diff/view.py index 9186184db0..965d8ab2ff 100644 --- a/src/calibre/gui2/tweak_book/diff/view.py +++ b/src/calibre/gui2/tweak_book/diff/view.py @@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2014, Kovid Goyal ' -import sys, re, unicodedata, os +import re, unicodedata from math import ceil from functools import partial from collections import namedtuple, OrderedDict @@ -21,9 +21,7 @@ from PyQt4.Qt import ( QImage, QPixmap, QMenu, QIcon) from calibre import human_readable, fit_image -from calibre.ebooks.oeb.polish.utils import guess_type from calibre.gui2.tweak_book import tprefs -from calibre.gui2.tweak_book.editor import syntax_from_mime from calibre.gui2.tweak_book.editor.text import PlainTextEdit, get_highlighter, default_font_family, LineNumbers from calibre.gui2.tweak_book.editor.themes import THEMES, default_theme, theme_color from calibre.utils.diff import get_sequence_matcher @@ -445,7 +443,7 @@ class DiffSplit(QSplitter): # {{{ def add_diff(self, left_name, right_name, left_text, right_text, context=None, syntax=None): left_text, right_text = left_text or '', right_text or '' - is_identical = len(left_text) == len(right_text) and left_text == right_text + is_identical = len(left_text) == len(right_text) and left_text == right_text and left_name == right_name is_text = isinstance(left_text, type('')) and isinstance(right_text, type('')) left_name = left_name or '[%s]'%_('This file was added') right_name = right_name or '[%s]'%_('This file was removed') @@ -462,6 +460,10 @@ class DiffSplit(QSplitter): # {{{ c = v.textCursor() c.movePosition(c.End) c.insertText('[%s]\n\n' % _('The files are identical')) + elif left_name != right_name and not left_text and not right_text: + self.add_text_diff(_('[This file was renamed to %s]') % right_name, _('[This file was renamed from %s]') % left_name, context, syntax) + for v in (self.left, self.right): + v.appendPlainText('\n') elif is_text: self.add_text_diff(left_text, right_text, context, syntax) elif syntax == 'raster_image': @@ -470,6 +472,8 @@ class DiffSplit(QSplitter): # {{{ text = '[%s]' % _('Binary file of size: %s') left_text, right_text = text % human_readable(len(left_text)), text % human_readable(len(right_text)) self.add_text_diff(left_text, right_text, None, None) + for v in (self.left, self.right): + v.appendPlainText('\n') # image diffs {{{ @property @@ -777,6 +781,7 @@ class DiffView(QWidget): # {{{ l.setMargin(0), l.setSpacing(0) self.view = DiffSplit(self) l.addWidget(self.view) + self.add_diff = self.view.add_diff self.scrollbar = QScrollBar(self) l.addWidget(self.scrollbar) self.syncing = False @@ -870,14 +875,14 @@ class DiffView(QWidget): # {{{ self.view.clear() self.changes = [] self.delta = 0 + self.scrollbar.setRange(0, 0) def adjust_range(self): ls, rs = self.view.left.verticalScrollBar(), self.view.right.verticalScrollBar() - page_step = self.view.left.verticalScrollBar().pageStep() self.scrollbar.setPageStep(min(ls.pageStep(), rs.pageStep())) self.scrollbar.setSingleStep(min(ls.singleStep(), rs.singleStep())) self.scrollbar.setRange(0, ls.maximum() + self.delta) - self.scrollbar.setVisible(self.scrollbar.maximum() > page_step) + self.scrollbar.setVisible(self.view.left.blockCount() > ls.pageStep() or self.view.right.blockCount() > rs.pageStep()) def finalize(self): self.view.finalize() @@ -914,24 +919,4 @@ class DiffView(QWidget): # {{{ self.scrollbar.setValue(self.scrollbar.value() + d * amount) # }}} -if __name__ == '__main__': - app = QApplication([]) - w = DiffView() - w.show() - for l, r in zip(sys.argv[1::2], sys.argv[2::2]): - raw1 = open(l, 'rb').read() - raw2 = open(r, 'rb').read() - syntax = syntax_from_mime(l, guess_type(l)) - if syntax is None and '.' not in os.path.basename(l): - # TODO: Add some kind of simple file type from contents detection. - syntax = 'text' # Assume text file - if syntax not in {'raster_image', None}: - try: - raw1, raw2 = raw1.decode('utf-8'), raw2.decode('utf-8') - except UnicodeDecodeError: - pass - w.view.add_diff(l, r, raw1, raw2, syntax=syntax, context=31) - w.finalize() - app.exec_() - # TODO: Add diff colors for other color schemes