mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Start work on diff dialog
This commit is contained in:
parent
c4b82a9db8
commit
15bfb2aada
@ -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
|
||||
|
||||
|
@ -6,5 +6,235 @@ from __future__ import (unicode_literals, division, absolute_import,
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
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('<h2>\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_()
|
||||
|
@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
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
|
||||
|
Loading…
x
Reference in New Issue
Block a user