%PDF- %PDF-
| Direktori : /usr/lib/calibre/calibre/gui2/tweak_book/diff/ |
| Current File : //usr/lib/calibre/calibre/gui2/tweak_book/diff/main.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import sys, os, re, textwrap
from functools import partial
from qt.core import (
QGridLayout, QToolButton, QIcon, QRadioButton, QMenu, QApplication, Qt,
QSize, QWidget, QLabel, QStackedLayout, QPainter, QRect, QVBoxLayout,
QCursor, QEventLoop, QKeySequence, pyqtSignal, QTimer, QHBoxLayout, QDialogButtonBox)
from calibre.ebooks.oeb.polish.container import Container
from calibre.ebooks.oeb.polish.utils import guess_type
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.filenames import samefile
from calibre.utils.icu import numeric_sort_key
from polyglot.builtins import iteritems
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.AlignmentFlag.AlignHCenter)
self.dummy = QLabel('<h2>\xa0')
l.addSpacing(10)
l.addWidget(self.dummy, alignment=Qt.AlignmentFlag.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.PenStyle.SolidLine)
r = QRect(0, self.dummy.geometry().top() + 10, self.geometry().width(), 150)
p.drawText(r, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop | Qt.TextFlag.TextSingleLine, self.text)
p.end()
# }}}
class Cache:
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 iteritems(adata)}
rhash = {r:hash(d) for r, d in iteritems(rdata)}
renamed_names, removed_names, added_names = {}, set(), set()
for name, rh in iteritems(rhash):
for n, ah in iteritems(ahash):
if ah == rh:
renamed_names[name] = n
adds.discard(n)
break
else:
cache.set_left(name, rdata[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 get_decoded_raw(name):
from calibre.ebooks.chardet import xml_to_unicode, force_encoding
with open(name, 'rb') as f:
raw = f.read()
syntax = syntax_from_mime(name, guess_type(name))
if syntax is None:
try:
raw = raw.decode('utf-8')
except ValueError:
pass
elif syntax != 'raster_image':
if syntax in {'html', 'xml'}:
raw = xml_to_unicode(raw, verbose=True)[0]
else:
m = re.search(br"coding[:=]\s*([-\w.]+)", raw[:1024], flags=re.I)
if m is not None and m.group(1) != '8bit':
enc = m.group(1)
if enc == b'unicode':
enc = 'utf-8'
else:
enc = force_encoding(raw, verbose=True)
if isinstance(enc, bytes):
enc = enc.decode('utf-8', 'ignore')
try:
raw = raw.decode(enc)
except (LookupError, ValueError):
try:
raw = raw.decode('utf-8')
except ValueError:
pass
return raw, syntax
def string_diff(left, right, left_syntax=None, right_syntax=None, left_name='left', right_name='right'):
left, right = str(left), str(right)
cache = Cache()
cache.set_left(left_name, left), cache.set_right(right_name, right)
changed_names = {} if left == right else {left_name:right_name}
return cache, {left_name:left_syntax, right_name:right_syntax}, changed_names, {}, set(), set()
def file_diff(left, right):
(raw1, syntax1), (raw2, syntax2) = map(get_decoded_raw, (left, right))
if type(raw1) is not type(raw2):
with open(left, 'rb') as f1, open(right, 'rb') as f2:
raw1, raw2 = f1.read(), f2.read()
cache = Cache()
cache.set_left(left, raw1), cache.set_right(right, raw2)
changed_names = {} if raw1 == raw2 else {left:right}
return cache, {left:syntax1, right:syntax2}, changed_names, {}, set(), set()
def dir_diff(left, right):
ldata, rdata, lsmap, rsmap = {}, {}, {}, {}
for base, data, smap in ((left, ldata, lsmap), (right, rdata, rsmap)):
for dirpath, dirnames, filenames in os.walk(base):
for filename in filenames:
path = os.path.join(dirpath, filename)
name = os.path.relpath(path, base)
data[name], smap[name] = get_decoded_raw(path)
cache, changed_names, renamed_names, removed_names, added_names = changed_files(
ldata, rdata, ldata.get, rdata.get)
syntax_map = {name:lsmap[name] for name in changed_names}
syntax_map.update({name:lsmap[name] for name in renamed_names})
syntax_map.update({name:rsmap[name] for name in added_names})
syntax_map.update({name:lsmap[name] for name in removed_names})
return cache, syntax_map, changed_names, renamed_names, removed_names, added_names
def container_diff(left, right):
left_names, right_names = set(left.name_path_map), set(right.name_path_map)
if left.cloned or right.cloned:
# Since containers are often clones of each other, as a performance
# optimization, discard identical names that point to the same physical
# file, without needing to read the file's contents.
# First commit dirtied names
for c in (left, right):
Container.commit(c, keep_parsed=True)
samefile_names = {name for name in left_names & right_names if samefile(
left.name_path_map[name], right.name_path_map[name])}
left_names -= samefile_names
right_names -= samefile_names
cache, changed_names, renamed_names, removed_names, added_names = changed_files(
left_names, right_names, 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):
revert_requested = pyqtSignal()
line_activated = pyqtSignal(object, object, object)
def __init__(self, revert_button_msg=None, parent=None, show_open_in_editor=False, show_as_window=False):
self.context = 3
self.beautify = False
self.apply_diff_calls = []
self.show_open_in_editor = show_open_in_editor
self.revert_button_msg = revert_button_msg
Dialog.__init__(self, _('Differences between books'), 'diff-dialog', parent=parent)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMinMaxButtonsHint)
if show_as_window:
self.setWindowFlags(Qt.WindowType.Window)
self.view.line_activated.connect(self.line_activated)
def sizeHint(self):
geom = self.screen().availableSize()
return QSize(int(0.9 * geom.width()), int(0.8 * geom.height()))
def setup_ui(self):
self.setWindowIcon(QIcon(I('diff.png')))
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, show_open_in_editor=self.show_open_in_editor)
l.addWidget(v, l.rowCount(), 0, 1, -1)
r = l.rowCount()
self.bp = b = QToolButton(self)
b.setIcon(QIcon(I('back.png')))
connect_lambda(b.clicked, self, lambda self: self.view.next_change(-1))
b.setToolTip(_('Go to previous change') + ' [p]')
b.setText(_('&Previous change')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
l.addWidget(b, r, 0)
self.bn = b = QToolButton(self)
b.setIcon(QIcon(I('forward.png')))
connect_lambda(b.clicked, self, lambda self: self.view.next_change(1))
b.setToolTip(_('Go to next change') + ' [n]')
b.setText(_('&Next change')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
l.addWidget(b, r, 1)
self.search = s = HistoryLineEdit2(self)
s.initialize('diff_search_history')
l.addWidget(s, r, 2)
s.setPlaceholderText(_('Search for text'))
connect_lambda(s.returnPressed, self, lambda self: self.do_search(False))
self.sbn = b = QToolButton(self)
b.setIcon(QIcon(I('arrow-down.png')))
connect_lambda(b.clicked, self, lambda self: self.do_search(False))
b.setToolTip(_('Find next match'))
b.setText(_('Next &match')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
l.addWidget(b, r, 3)
self.sbp = b = QToolButton(self)
b.setIcon(QIcon(I('arrow-up.png')))
connect_lambda(b.clicked, self, lambda self: self.do_search(True))
b.setToolTip(_('Find previous match'))
b.setText(_('P&revious match')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
l.addWidget(b, r, 4)
self.lb = b = QRadioButton(_('Left panel'), self)
b.setToolTip(_('Perform search in the left panel'))
l.addWidget(b, r, 5)
self.rb = b = QRadioButton(_('Right panel'), self)
b.setToolTip(_('Perform search in the right panel'))
l.addWidget(b, r, 6)
b.setChecked(True)
self.pb = b = QToolButton(self)
b.setIcon(QIcon(I('config.png')))
b.setText(_('&Options')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
b.setToolTip(_('Change how the differences are displayed'))
b.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
m = QMenu(b)
b.setMenu(m)
cm = self.cm = QMenu(_('Lines of context around each change'))
for i in (3, 5, 10, 50):
cm.addAction(_('Show %d lines of context') % i, partial(self.change_context, i))
cm.addAction(_('Show all text'), partial(self.change_context, None))
self.beautify_action = m.addAction('', self.toggle_beautify)
self.set_beautify_action_text()
m.addMenu(cm)
l.addWidget(b, r, 7)
self.hl = QHBoxLayout()
l.addLayout(self.hl, l.rowCount(), 0, 1, -1)
self.names = QLabel('')
self.hl.addWidget(self.names, stretch=100)
if self.show_open_in_editor:
self.edit_msg = QLabel(_('Double click right side to edit'))
self.edit_msg.setToolTip(textwrap.fill(_(
'Double click on any change in the right panel to edit that location in the editor')))
self.hl.addWidget(self.edit_msg)
self.bb.setStandardButtons(QDialogButtonBox.StandardButton.Close)
if self.revert_button_msg is not None:
self.rvb = b = self.bb.addButton(self.revert_button_msg, QDialogButtonBox.ButtonRole.ActionRole)
b.setIcon(QIcon(I('edit-undo.png'))), b.setAutoDefault(False)
b.clicked.connect(self.revert_requested)
b.clicked.connect(self.reject)
self.bb.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
self.hl.addWidget(self.bb)
self.view.setFocus(Qt.FocusReason.OtherFocusReason)
def break_cycles(self):
self.view = None
for x in ('revert_requested', 'line_activated'):
try:
getattr(self, x).disconnect()
except:
pass
def do_search(self, reverse):
text = str(self.search.text())
if not text.strip():
return
v = self.view.view.left if self.lb.isChecked() else self.view.view.right
v.search(text, reverse=reverse)
def change_context(self, context):
if context == self.context:
return
self.context = context
self.refresh()
def refresh(self):
with self:
self.view.clear()
for args, kwargs in self.apply_diff_calls:
kwargs['context'] = self.context
kwargs['beautify'] = self.beautify
self.view.add_diff(*args, **kwargs)
self.view.finalize()
def toggle_beautify(self):
self.beautify = not self.beautify
self.set_beautify_action_text()
self.refresh()
def set_beautify_action_text(self):
self.beautify_action.setText(
_('Beautify files before comparing them') if not self.beautify else
_('Do not beautify files before comparing'))
def __enter__(self):
self.stacks.setCurrentIndex(0)
self.busy.setVisible(True)
self.busy.pi.startAnimation()
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
QApplication.processEvents(QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents | QEventLoop.ProcessEventsFlag.ExcludeSocketNotifiers)
def __exit__(self, *args):
self.busy.pi.stopAnimation()
self.stacks.setCurrentIndex(1)
QApplication.restoreOverrideCursor()
def set_names(self, names):
t = ''
if isinstance(names, tuple):
t = '%s <--> %s' % names
self.names.setText(t)
def ebook_diff(self, path1, path2, names=None):
self.set_names(names)
with self:
identical = self.apply_diff(_('The books are identical'), *ebook_diff(path1, path2))
self.view.finalize()
if identical:
self.reject()
def container_diff(self, left, right, identical_msg=None, names=None):
self.set_names(names)
with self:
identical = self.apply_diff(identical_msg or _('No changes found'), *container_diff(left, right))
self.view.finalize()
if identical:
self.reject()
def file_diff(self, left, right, identical_msg=None):
with self:
identical = self.apply_diff(identical_msg or _('The files are identical'), *file_diff(left, right))
self.view.finalize()
if identical:
self.reject()
def string_diff(self, left, right, **kw):
with self:
identical = self.apply_diff(kw.pop('identical_msg', None) or _('No differences found'), *string_diff(left, right, **kw))
self.view.finalize()
if identical:
self.reject()
def dir_diff(self, left, right, identical_msg=None):
with self:
identical = self.apply_diff(identical_msg or _('The folders are identical'), *dir_diff(left, right))
self.view.finalize()
if identical:
self.reject()
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:
self.busy.setVisible(False)
info_dialog(self, _('No changes found'), identical_msg, show=True)
self.busy.setVisible(True)
return True
kwargs = lambda name: {'context':self.context, 'beautify':self.beautify, 'syntax':syntax_map.get(name, None)}
if isinstance(changed_names, dict):
for name, other_name in sorted(iteritems(changed_names), key=lambda x:numeric_sort_key(x[0])):
args = (name, other_name, cache.left(name), cache.right(other_name))
add(args, kwargs(name))
else:
for name in sorted(changed_names, key=numeric_sort_key):
args = (name, name, cache.left(name), cache.right(name))
add(args, kwargs(name))
for name in sorted(added_names, key=numeric_sort_key):
args = (_('[%s was added]') % name, name, None, cache.right(name))
add(args, kwargs(name))
for name in sorted(removed_names, key=numeric_sort_key):
args = (name, _('[%s was removed]') % name, cache.left(name), None)
add(args, kwargs(name))
for name, new_name in sorted(iteritems(renamed_names), key=lambda x:numeric_sort_key(x[0])):
args = (name, new_name, None, None)
add(args, kwargs(name))
def keyPressEvent(self, ev):
if not self.view.handle_key(ev):
if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
return # The enter key is used by the search box, so prevent it closing the dialog
if ev.key() == Qt.Key.Key_Slash:
return self.search.setFocus(Qt.FocusReason.OtherFocusReason)
if ev.matches(QKeySequence.StandardKey.Copy):
text = self.view.view.left.selected_text + self.view.view.right.selected_text
if text:
QApplication.clipboard().setText(text)
return
if ev.matches(QKeySequence.StandardKey.FindNext):
self.sbn.click()
return
if ev.matches(QKeySequence.StandardKey.FindPrevious):
self.sbp.click()
return
return Dialog.keyPressEvent(self, ev)
def compare_books(path1, path2, revert_msg=None, revert_callback=None, parent=None, names=None):
d = Diff(parent=parent, revert_button_msg=revert_msg)
if revert_msg is not None:
d.revert_requested.connect(revert_callback)
QTimer.singleShot(0, partial(d.ebook_diff, path1, path2, names=names))
d.exec()
try:
d.revert_requested.disconnect()
except:
pass
d.break_cycles()
def main(args=sys.argv):
from calibre.gui2 import Application
left, right = args[-2:]
ext1, ext2 = left.rpartition('.')[-1].lower(), right.rpartition('.')[-1].lower()
if ext1.startswith('original_'):
ext1 = ext1.partition('_')[-1]
if ext2.startswith('original_'):
ext2 = ext2.partition('_')[-2]
if os.path.isdir(left):
attr = 'dir_diff'
elif (ext1, ext2) in {('epub', 'epub'), ('azw3', 'azw3')}:
attr = 'ebook_diff'
else:
attr = 'file_diff'
app = Application([]) # noqa
d = Diff(show_as_window=True)
func = getattr(d, attr)
QTimer.singleShot(0, lambda : func(left, right))
d.show()
app.exec()
return 0
if __name__ == '__main__':
main()