%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/metadata/ |
Current File : //lib/calibre/calibre/gui2/metadata/diff.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' import os import weakref from collections import OrderedDict, namedtuple from functools import partial from qt.core import ( QAction, QApplication, QCheckBox, QColor, QDialog, QDialogButtonBox, QFont, QGridLayout, QHBoxLayout, QIcon, QKeySequence, QLabel, QMenu, QPainter, QPen, QPixmap, QScrollArea, QSize, QSizePolicy, QStackedLayout, Qt, QToolButton, QVBoxLayout, QWidget, pyqtSignal ) from calibre import fit_image from calibre.ebooks.metadata import authors_to_sort_string, fmt_sidx, title_sort from calibre.gui2 import gprefs, pixmap_to_data from calibre.gui2.comments_editor import Editor from calibre.gui2.complete2 import LineEdit as EditWithComplete from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.languages import LanguagesEdit as LE from calibre.gui2.metadata.basic_widgets import PubdateEdit, RatingEdit from calibre.gui2.widgets2 import RightClickButton from calibre.ptempfile import PersistentTemporaryFile from calibre.utils.date import UNDEFINED_DATE from polyglot.builtins import iteritems, itervalues Widgets = namedtuple('Widgets', 'new old label button') # Widgets {{{ class LineEdit(EditWithComplete): changed = pyqtSignal() def __init__(self, field, is_new, parent, metadata, extra): EditWithComplete.__init__(self, parent) self.is_new = is_new self.field = field self.metadata = metadata if not is_new: self.setReadOnly(True) else: sep = metadata['is_multiple']['list_to_ui'] if metadata['is_multiple'] else None self.set_separator(sep) self.textChanged.connect(self.changed) @property def value(self): val = str(self.text()).strip() ism = self.metadata['is_multiple'] if ism: if not val: val = [] else: val = val.strip(ism['list_to_ui'].strip()) val = [x.strip() for x in val.split(ism['list_to_ui']) if x.strip()] return val @value.setter def value(self, val): ism = self.metadata['is_multiple'] if ism: if not val: val = '' else: val = ism['list_to_ui'].join(val) self.setText(val) self.setCursorPosition(0) def from_mi(self, mi): val = mi.get(self.field, default='') or '' self.value = val def to_mi(self, mi): val = self.value mi.set(self.field, val) if self.field == 'title': mi.set('title_sort', title_sort(val, lang=mi.language)) elif self.field == 'authors': mi.set('author_sort', authors_to_sort_string(val)) @property def current_val(self): return str(self.text()) @current_val.setter def current_val(self, val): self.setText(val) self.setCursorPosition(0) def set_undoable(self, val): self.selectAll() self.insert(val) self.setCursorPosition(0) @property def is_blank(self): val = self.current_val.strip() if self.field in {'title', 'authors'}: return val in {'', _('Unknown')} return not val def same_as(self, other): return self.current_val == other.current_val class LanguagesEdit(LE): changed = pyqtSignal() def __init__(self, field, is_new, parent, metadata, extra): LE.__init__(self, parent=parent) self.is_new = is_new self.field = field self.metadata = metadata self.textChanged.connect(self.changed) if not is_new: self.lineEdit().setReadOnly(True) @property def current_val(self): return self.lang_codes @current_val.setter def current_val(self, val): self.lang_codes = val def from_mi(self, mi): self.lang_codes = mi.languages def to_mi(self, mi): mi.languages = self.lang_codes @property def is_blank(self): return not self.current_val def same_as(self, other): return self.current_val == other.current_val def set_undoable(self, val): self.set_lang_codes(val, True) class RatingsEdit(RatingEdit): changed = pyqtSignal() def __init__(self, field, is_new, parent, metadata, extra): RatingEdit.__init__(self, parent) self.is_new = is_new self.field = field self.metadata = metadata self.currentIndexChanged.connect(self.changed) def from_mi(self, mi): self.current_val = mi.get(self.field, default=0) def to_mi(self, mi): mi.set(self.field, self.current_val) @property def is_blank(self): return self.current_val == 0 def same_as(self, other): return self.current_val == other.current_val class DateEdit(PubdateEdit): changed = pyqtSignal() def __init__(self, field, is_new, parent, metadata, extra): PubdateEdit.__init__(self, parent, create_clear_button=False) self.is_new = is_new self.field = field self.metadata = metadata self.setDisplayFormat(extra) self.dateTimeChanged.connect(self.changed) if not is_new: self.setReadOnly(True) def from_mi(self, mi): self.current_val = mi.get(self.field, default=None) def to_mi(self, mi): mi.set(self.field, self.current_val) @property def is_blank(self): return self.current_val.year <= UNDEFINED_DATE.year def same_as(self, other): return self.text() == other.text() def set_undoable(self, val): self.set_value(val) class SeriesEdit(LineEdit): def __init__(self, *args, **kwargs): LineEdit.__init__(self, *args, **kwargs) self.dbref = None self.item_selected.connect(self.insert_series_index) def from_mi(self, mi): series = mi.get(self.field, default='') series_index = mi.get(self.field + '_index', default=1.0) val = '' if series: val = f'{series} [{mi.format_series_index(series_index)}]' self.setText(val) self.setCursorPosition(0) def to_mi(self, mi): val = str(self.text()).strip() try: series_index = float(val.rpartition('[')[-1].rstrip(']').strip()) except: series_index = 1.0 series = val.rpartition('[')[0].strip() or val.rpartition('[')[-1].strip() or None mi.set(self.field, series) mi.set(self.field + '_index', series_index) def set_db(self, db): self.dbref = weakref.ref(db) def insert_series_index(self, series): db = self.dbref() if db is None or not series: return num = db.get_next_series_num_for(series) sidx = fmt_sidx(num) self.setText(self.text() + ' [%s]' % sidx) class IdentifiersEdit(LineEdit): def from_mi(self, mi): self.as_dict = mi.identifiers def to_mi(self, mi): mi.set_identifiers(self.as_dict) @property def as_dict(self): parts = (x.strip() for x in self.current_val.split(',') if x.strip()) return {k:v for k, v in iteritems({x.partition(':')[0].strip():x.partition(':')[-1].strip() for x in parts}) if k and v} @as_dict.setter def as_dict(self, val): val = (f'{k}:{v}' for k, v in iteritems(val)) self.setText(', '.join(val)) self.setCursorPosition(0) class CommentsEdit(Editor): changed = pyqtSignal() def __init__(self, field, is_new, parent, metadata, extra): Editor.__init__(self, parent, one_line_toolbar=False) self.set_minimum_height_for_editor(150) self.is_new = is_new self.field = field self.metadata = metadata self.hide_tabs() if not is_new: self.hide_toolbars() self.set_readonly(True) @property def current_val(self): return self.html @current_val.setter def current_val(self, val): self.html = val or '' self.changed.emit() def set_undoable(self, val): self.set_html(val, allow_undo=True) self.changed.emit() def from_mi(self, mi): val = mi.get(self.field, default='') self.current_val = val def to_mi(self, mi): mi.set(self.field, self.current_val) def sizeHint(self): return QSize(450, 200) @property def is_blank(self): return not self.current_val.strip() def same_as(self, other): return self.current_val == other.current_val class CoverView(QWidget): changed = pyqtSignal() zoom_requested = pyqtSignal(object) def __init__(self, field, is_new, parent, metadata, extra): QWidget.__init__(self, parent) self.is_new = is_new self.field = field self.metadata = metadata self.pixmap = None self.blank = QPixmap(I('blank.png')) self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.PolicyFlag.GrowFlag|QSizePolicy.PolicyFlag.ExpandFlag) self.sizePolicy().setHeightForWidth(True) def mouseDoubleClickEvent(self, ev): if self.pixmap and not self.pixmap.isNull(): self.zoom_requested.emit(self.pixmap) @property def is_blank(self): return self.pixmap is None @property def current_val(self): return self.pixmap @current_val.setter def current_val(self, val): self.pixmap = val self.changed.emit() self.update() def from_mi(self, mi): p = getattr(mi, 'cover', None) if p and os.path.exists(p): pmap = QPixmap() with open(p, 'rb') as f: pmap.loadFromData(f.read()) if not pmap.isNull(): self.pixmap = pmap self.update() self.changed.emit() return cd = getattr(mi, 'cover_data', (None, None)) if cd and cd[1]: pmap = QPixmap() pmap.loadFromData(cd[1]) if not pmap.isNull(): self.pixmap = pmap self.update() self.changed.emit() return self.pixmap = None self.update() self.changed.emit() def to_mi(self, mi): mi.cover, mi.cover_data = None, (None, None) if self.pixmap is not None and not self.pixmap.isNull(): with PersistentTemporaryFile('.jpg') as pt: pt.write(pixmap_to_data(self.pixmap)) mi.cover = pt.name def same_as(self, other): return self.current_val == other.current_val def sizeHint(self): return QSize(225, 300) def paintEvent(self, event): pmap = self.blank if self.pixmap is None or self.pixmap.isNull() else self.pixmap target = self.rect() scaled, width, height = fit_image(pmap.width(), pmap.height(), target.width(), target.height()) target.setRect(target.x(), target.y(), width, height) p = QPainter(self) p.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform) p.drawPixmap(target, pmap) if self.pixmap is not None and not self.pixmap.isNull(): sztgt = target.adjusted(0, 0, 0, -4) f = p.font() f.setBold(True) p.setFont(f) sz = '\u00a0%d x %d\u00a0'%(self.pixmap.width(), self.pixmap.height()) flags = int(Qt.AlignmentFlag.AlignBottom|Qt.AlignmentFlag.AlignRight|Qt.TextFlag.TextSingleLine) szrect = p.boundingRect(sztgt, flags, sz) p.fillRect(szrect.adjusted(0, 0, 0, 4), QColor(0, 0, 0, 200)) p.setPen(QPen(QColor(255,255,255))) p.drawText(sztgt, flags, sz) p.end() # }}} class CompareSingle(QWidget): zoom_requested = pyqtSignal(object) def __init__( self, field_metadata, parent=None, revert_tooltip=None, datetime_fmt='MMMM yyyy', blank_as_equal=True, fields=('title', 'authors', 'series', 'tags', 'rating', 'publisher', 'pubdate', 'identifiers', 'languages', 'comments', 'cover'), db=None): QWidget.__init__(self, parent) self.l = l = QGridLayout() # l.setContentsMargins(0, 0, 0, 0) self.setLayout(l) revert_tooltip = revert_tooltip or _('Revert %s') self.current_mi = None self.changed_font = QFont(QApplication.font()) self.changed_font.setBold(True) self.changed_font.setItalic(True) self.blank_as_equal = blank_as_equal self.widgets = OrderedDict() row = 0 for field in fields: m = field_metadata[field] dt = m['datatype'] extra = None if 'series' in {field, dt}: cls = SeriesEdit elif field == 'identifiers': cls = IdentifiersEdit elif field == 'languages': cls = LanguagesEdit elif 'comments' in {field, dt}: cls = CommentsEdit elif 'rating' in {field, dt}: cls = RatingsEdit elif dt == 'datetime': extra = datetime_fmt cls = DateEdit elif field == 'cover': cls = CoverView elif dt in {'text', 'enum'}: cls = LineEdit else: continue neww = cls(field, True, self, m, extra) neww.setObjectName(field) connect_lambda(neww.changed, self, lambda self: self.changed(self.sender().objectName())) if isinstance(neww, EditWithComplete): try: neww.update_items_cache(db.new_api.all_field_names(field)) except ValueError: pass # A one-one field like title if isinstance(neww, SeriesEdit): neww.set_db(db.new_api) oldw = cls(field, False, self, m, extra) newl = QLabel('&%s:' % m['name']) newl.setBuddy(neww) button = RightClickButton(self) button.setIcon(QIcon(I('back.png'))) button.setObjectName(field) connect_lambda(button.clicked, self, lambda self: self.revert(self.sender().objectName())) button.setToolTip(revert_tooltip % m['name']) if field == 'identifiers': button.m = m = QMenu(button) button.setMenu(m) button.setPopupMode(QToolButton.ToolButtonPopupMode.DelayedPopup) m.addAction(button.toolTip()).triggered.connect(button.click) m.actions()[0].setIcon(button.icon()) m.addAction(_('Merge identifiers')).triggered.connect(self.merge_identifiers) m.actions()[1].setIcon(QIcon(I('merge.png'))) elif field == 'tags': button.m = m = QMenu(button) button.setMenu(m) button.setPopupMode(QToolButton.ToolButtonPopupMode.DelayedPopup) m.addAction(button.toolTip()).triggered.connect(button.click) m.actions()[0].setIcon(button.icon()) m.addAction(_('Merge tags')).triggered.connect(self.merge_tags) m.actions()[1].setIcon(QIcon(I('merge.png'))) if cls is CoverView: neww.zoom_requested.connect(self.zoom_requested) oldw.zoom_requested.connect(self.zoom_requested) self.widgets[field] = Widgets(neww, oldw, newl, button) for i, w in enumerate((newl, neww, button, oldw)): c = i if i < 2 else i + 1 if w is oldw: c += 1 l.addWidget(w, row, c) row += 1 if 'comments' in self.widgets and not gprefs.get('diff_widget_show_comments_controls', True): self.widgets['comments'].new.hide_toolbars() def save_comments_controls_state(self): if 'comments' in self.widgets: vis = self.widgets['comments'].new.toolbars_visible if vis != gprefs.get('diff_widget_show_comments_controls', True): gprefs.set('diff_widget_show_comments_controls', vis) def changed(self, field): w = self.widgets[field] if not w.new.same_as(w.old) and (not self.blank_as_equal or not w.new.is_blank): w.label.setFont(self.changed_font) else: w.label.setFont(QApplication.font()) def revert(self, field): widgets = self.widgets[field] neww, oldw = widgets[:2] if hasattr(neww, 'set_undoable'): neww.set_undoable(oldw.current_val) else: neww.current_val = oldw.current_val def merge_identifiers(self): widgets = self.widgets['identifiers'] neww, oldw = widgets[:2] val = neww.as_dict val.update(oldw.as_dict) neww.as_dict = val def merge_tags(self): widgets = self.widgets['tags'] neww, oldw = widgets[:2] val = oldw.value lval = {icu_lower(x) for x in val} extra = [x for x in neww.value if icu_lower(x) not in lval] if extra: neww.value = val + extra def __call__(self, oldmi, newmi): self.current_mi = newmi self.initial_vals = {} for field, widgets in iteritems(self.widgets): widgets.old.from_mi(oldmi) widgets.new.from_mi(newmi) self.initial_vals[field] = widgets.new.current_val def apply_changes(self): changed = False for field, widgets in iteritems(self.widgets): val = widgets.new.current_val if val != self.initial_vals[field]: widgets.new.to_mi(self.current_mi) changed = True if changed and not self.current_mi.languages: # this is needed because blank language setting # causes current UI language to be set widgets = self.widgets['languages'] neww, oldw = widgets[:2] if oldw.current_val: self.current_mi.languages = oldw.current_val return changed class ZoomedCover(QWidget): pixmap = None def paintEvent(self, event): pmap = self.pixmap if pmap is None: return target = self.rect() scaled, width, height = fit_image(pmap.width(), pmap.height(), target.width(), target.height()) dx = 0 if target.width() > width + 1: dx += (target.width() - width) // 2 target.setRect(target.x() + dx, target.y(), width, height) p = QPainter(self) p.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform) p.drawPixmap(target, pmap) class CoverZoom(QWidget): def __init__(self, parent): QWidget.__init__(self, parent) self.l = l = QVBoxLayout(self) self.cover = ZoomedCover(self) l.addWidget(self.cover) self.h = QHBoxLayout() l.addLayout(self.h) self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, self) self.size_label = QLabel(self) self.h.addWidget(self.size_label) self.h.addStretch(10) self.h.addWidget(self.bb) def set_pixmap(self, pixmap): self.cover.pixmap = pixmap self.size_label.setText(_('Cover size: {0}x{1}').format(pixmap.width(), pixmap.height())) self.cover.update() class CompareMany(QDialog): def __init__(self, ids, get_metadata, field_metadata, parent=None, window_title=None, reject_button_tooltip=None, accept_all_tooltip=None, reject_all_tooltip=None, revert_tooltip=None, intro_msg=None, action_button=None, **kwargs): QDialog.__init__(self, parent) self.stack = s = QStackedLayout(self) self.w = w = QWidget(self) self.l = l = QVBoxLayout(w) s.addWidget(w) self.next_called = False self.setWindowIcon(QIcon(I('auto_author_sort.png'))) self.get_metadata = get_metadata self.ids = list(ids) self.total = len(self.ids) self.accepted = OrderedDict() self.rejected_ids = set() self.window_title = window_title or _('Compare metadata') if intro_msg: self.la = la = QLabel(intro_msg) la.setWordWrap(True) l.addWidget(la) self.compare_widget = CompareSingle(field_metadata, parent=parent, revert_tooltip=revert_tooltip, **kwargs) self.sa = sa = QScrollArea() l.addWidget(sa) sa.setWidget(self.compare_widget) sa.setWidgetResizable(True) self.cover_zoom = cz = CoverZoom(self) cz.bb.rejected.connect(self.reject) s.addWidget(cz) self.compare_widget.zoom_requested.connect(self.show_zoomed_cover) self.bb = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel) bb.button(QDialogButtonBox.StandardButton.Cancel).setAutoDefault(False) bb.rejected.connect(self.reject) if self.total > 1: self.aarb = b = bb.addButton(_('&Accept all remaining'), QDialogButtonBox.ButtonRole.YesRole) b.setIcon(QIcon(I('ok.png'))), b.setAutoDefault(False) if accept_all_tooltip: b.setToolTip(accept_all_tooltip) b.clicked.connect(self.accept_all_remaining) self.rarb = b = bb.addButton(_('Re&ject all remaining'), QDialogButtonBox.ButtonRole.ActionRole) b.setIcon(QIcon(I('minus.png'))), b.setAutoDefault(False) if reject_all_tooltip: b.setToolTip(reject_all_tooltip) b.clicked.connect(self.reject_all_remaining) self.sb = b = bb.addButton(_('R&eject'), QDialogButtonBox.ButtonRole.ActionRole) ac = QAction(self) ac.setShortcut(QKeySequence(Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier | Qt.Key.Key_Right)) ac.triggered.connect(b.click) self.addAction(ac) b.setToolTip(_('Reject changes and move to next [{}]').format(ac.shortcut().toString(QKeySequence.SequenceFormat.NativeText))) connect_lambda(b.clicked, self, lambda self: self.next_item(False)) b.setIcon(QIcon(I('minus.png'))), b.setAutoDefault(False) if reject_button_tooltip: b.setToolTip(reject_button_tooltip) self.next_action = ac = QAction(self) ac.setShortcut(QKeySequence(Qt.KeyboardModifier.AltModifier | Qt.Key.Key_Right)) self.addAction(ac) if action_button is not None: self.acb = b = bb.addButton(action_button[0], QDialogButtonBox.ButtonRole.ActionRole) b.setIcon(QIcon(action_button[1])) self.action_button_action = action_button[2] b.clicked.connect(self.action_button_clicked) self.nb = b = bb.addButton(_('&Next') if self.total > 1 else _('&OK'), QDialogButtonBox.ButtonRole.ActionRole) if self.total > 1: b.setToolTip(_('Move to next [%s]') % self.next_action.shortcut().toString(QKeySequence.SequenceFormat.NativeText)) self.next_action.triggered.connect(b.click) b.setIcon(QIcon(I('forward.png' if self.total > 1 else 'ok.png'))) connect_lambda(b.clicked, self, lambda self: self.next_item(True)) b.setDefault(True), b.setAutoDefault(True) self.bbh = h = QHBoxLayout() h.setContentsMargins(0, 0, 0, 0) l.addLayout(h) self.markq = m = QCheckBox(_('&Mark rejected books')) m.setChecked(gprefs['metadata_diff_mark_rejected']) connect_lambda(m.stateChanged[int], self, lambda self: gprefs.set('metadata_diff_mark_rejected', self.markq.isChecked())) m.setToolTip(_('Mark rejected books in the book list after this dialog is closed')) h.addWidget(m), h.addWidget(bb) self.next_item(True) geom = (parent or self).screen().availableSize() width = max(700, min(950, geom.width()-50)) height = max(650, min(1000, geom.height()-100)) self.resize(QSize(width, height)) geom = gprefs.get('diff_dialog_geom', None) if geom is not None: QApplication.instance().safe_restore_geometry(self, geom) b.setFocus(Qt.FocusReason.OtherFocusReason) self.next_called = False def show_zoomed_cover(self, pixmap): self.cover_zoom.set_pixmap(pixmap) self.stack.setCurrentIndex(1) @property def mark_rejected(self): return self.markq.isChecked() def action_button_clicked(self): self.action_button_action(self.ids[0]) def accept(self): gprefs.set('diff_dialog_geom', bytearray(self.saveGeometry())) self.compare_widget.save_comments_controls_state() super().accept() def reject(self): if self.stack.currentIndex() == 1: self.stack.setCurrentIndex(0) return if self.next_called and not confirm(_( 'All reviewed changes will be lost! Are you sure you want to Cancel?'), 'confirm-metadata-diff-dialog-cancel'): return gprefs.set('diff_dialog_geom', bytearray(self.saveGeometry())) self.compare_widget.save_comments_controls_state() super().reject() @property def current_mi(self): return self.compare_widget.current_mi def next_item(self, accept): self.next_called = True if not self.ids: return self.accept() if self.current_mi is not None: changed = self.compare_widget.apply_changes() if self.current_mi is not None: old_id = self.ids.pop(0) if not accept: self.rejected_ids.add(old_id) self.accepted[old_id] = (changed, self.current_mi) if accept else (False, None) if not self.ids: return self.accept() self.setWindowTitle(self.window_title + _(' [%(num)d of %(tot)d]') % dict( num=(self.total - len(self.ids) + 1), tot=self.total)) oldmi, newmi = self.get_metadata(self.ids[0]) self.compare_widget(oldmi, newmi) def accept_all_remaining(self): self.next_item(True) for id_ in self.ids: oldmi, newmi = self.get_metadata(id_) self.accepted[id_] = (False, newmi) self.ids = [] self.accept() def reject_all_remaining(self): from calibre.gui2.dialogs.confirm_delete import confirm if not confirm(ngettext( 'Are you sure you want to reject the remaining result?', 'Are you sure you want to reject all {} remaining results?', len(self.ids)).format(len(self.ids)), 'confirm_metadata_review_reject', parent=self): return self.next_item(False) for id_ in self.ids: self.rejected_ids.add(id_) oldmi, newmi = self.get_metadata(id_) self.accepted[id_] = (False, None) self.ids = [] self.accept() def keyPressEvent(self, ev): if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): ev.accept() return return QDialog.keyPressEvent(self, ev) if __name__ == '__main__': from calibre.gui2 import Application from calibre.library import db app = Application([]) db = db() ids = sorted(db.all_ids(), reverse=True) ids = tuple(zip(ids[0::2], ids[1::2])) gm = partial(db.get_metadata, index_is_id=True, get_cover=True, cover_as_data=True) get_metadata = lambda x:list(map(gm, ids[x])) d = CompareMany(list(range(len(ids))), get_metadata, db.field_metadata, db=db) d.exec() for changed, mi in itervalues(d.accepted): if changed and mi is not None: print(mi)