Implement a metadata comparison/merging tool

This commit is contained in:
Kovid Goyal 2013-04-30 17:22:22 +05:30
parent 7755ff72c3
commit 890b5e4c57
4 changed files with 515 additions and 29 deletions

View File

@ -66,6 +66,7 @@ class EditorWidget(QWebView): # {{{
def __init__(self, parent=None):
QWebView.__init__(self, parent)
self.readonly = False
self.comments_pat = re.compile(r'<!--.*?-->', re.DOTALL)
@ -163,7 +164,11 @@ class EditorWidget(QWebView): # {{{
self.page().linkClicked.connect(self.link_clicked)
self.setHtml('')
self.page().setContentEditable(True)
self.set_readonly(False)
def set_readonly(self, what):
self.readonly = what
self.page().setContentEditable(not self.readonly)
def clear_text(self, *args):
us = self.page().undoStack()
@ -313,7 +318,7 @@ class EditorWidget(QWebView): # {{{
# toList() is needed because PyQt on Debian is old/broken
for body in self.page().mainFrame().documentElement().findAll('body').toList():
body.setAttribute('style', style)
self.page().setContentEditable(True)
self.page().setContentEditable(not self.readonly)
def keyPressEvent(self, ev):
if ev.key() in (Qt.Key_Tab, Qt.Key_Escape, Qt.Key_Backtab):
@ -585,6 +590,7 @@ class Editor(QWidget): # {{{
self.tabs.addTab(self.code_edit, _('HTML Source'))
self.tabs.currentChanged[int].connect(self.change_tab)
self.highlighter = Highlighter(self.code_edit.document())
self.layout().setContentsMargins(0, 0, 0, 0)
# toolbar1 {{{
self.toolbar1.addAction(self.editor.action_undo)
@ -666,6 +672,12 @@ class Editor(QWidget): # {{{
self.toolbar2.setVisible(False)
self.toolbar3.setVisible(False)
def set_readonly(self, what):
self.editor.set_readonly(what)
def hide_tabs(self):
self.tabs.tabBar().setVisible(False)
# }}}
if __name__ == '__main__':

View File

@ -101,7 +101,7 @@ class TitleEdit(EnLineEdit):
getattr(db, 'set_'+ self.TITLE_ATTR)(id_, title, notify=False,
commit=False)
except (IOError, OSError) as err:
if getattr(err, 'errno', None) == errno.EACCES: # Permission denied
if getattr(err, 'errno', None) == errno.EACCES: # Permission denied
import traceback
fname = getattr(err, 'filename', None)
p = 'Locked file: %s\n\n'%fname if fname else ''
@ -273,7 +273,7 @@ class AuthorsEdit(EditWithComplete):
self.books_to_refresh |= db.set_authors(id_, authors, notify=False,
allow_case_change=True)
except (IOError, OSError) as err:
if getattr(err, 'errno', None) == errno.EACCES: # Permission denied
if getattr(err, 'errno', None) == errno.EACCES: # Permission denied
import traceback
fname = getattr(err, 'filename', None)
p = 'Locked file: %s\n\n'%fname if fname else ''
@ -485,7 +485,7 @@ class SeriesEdit(EditWithComplete):
def initialize(self, db, id_):
self.books_to_refresh = set([])
all_series = db.all_series()
all_series.sort(key=lambda x : sort_key(x[1]))
all_series.sort(key=lambda x: sort_key(x[1]))
self.update_items_cache([x[1] for x in all_series])
series_id = db.series_id(id_, index_is_id=True)
inval = ''
@ -586,7 +586,7 @@ class SeriesIndexEdit(QDoubleSpinBox):
# }}}
class BuddyLabel(QLabel): # {{{
class BuddyLabel(QLabel): # {{{
def __init__(self, buddy):
QLabel.__init__(self, buddy.LABEL)
@ -698,11 +698,11 @@ class FormatsManager(QWidget):
self.formats.setIconSize(QSize(32, 32))
self.formats.setMaximumWidth(200)
l.addWidget(self.cover_from_format_button, 0, 0, 1, 1)
l.addWidget(self.cover_from_format_button, 0, 0, 1, 1)
l.addWidget(self.metadata_from_format_button, 2, 0, 1, 1)
l.addWidget(self.add_format_button, 0, 2, 1, 1)
l.addWidget(self.remove_format_button, 2, 2, 1, 1)
l.addWidget(self.formats, 0, 1, 3, 1)
l.addWidget(self.add_format_button, 0, 2, 1, 1)
l.addWidget(self.remove_format_button, 2, 2, 1, 1)
l.addWidget(self.formats, 0, 1, 3, 1)
self.temp_files = []
@ -882,7 +882,7 @@ class FormatsManager(QWidget):
self.temp_files = []
# }}}
class Cover(ImageView): # {{{
class Cover(ImageView): # {{{
download_cover = pyqtSignal()
@ -1052,7 +1052,7 @@ class Cover(ImageView): # {{{
# }}}
class CommentsEdit(Editor): # {{{
class CommentsEdit(Editor): # {{{
@dynamic_property
def current_val(self):
@ -1076,7 +1076,7 @@ class CommentsEdit(Editor): # {{{
return True
# }}}
class RatingEdit(QSpinBox): # {{{
class RatingEdit(QSpinBox): # {{{
LABEL = _('&Rating:')
TOOLTIP = _('Rating of this book. 0-5 stars')
@ -1120,7 +1120,7 @@ class RatingEdit(QSpinBox): # {{{
# }}}
class TagsEdit(EditWithComplete): # {{{
class TagsEdit(EditWithComplete): # {{{
LABEL = _('Ta&gs:')
TOOLTIP = '<p>'+_('Tags categorize the book. This is particularly '
'useful while searching. <br><br>They can be any words '
@ -1174,7 +1174,6 @@ class TagsEdit(EditWithComplete): # {{{
self.current_val = d.tags
self.all_items = db.all_tags()
def commit(self, db, id_):
self.books_to_refresh |= db.set_tags(
id_, self.current_val, notify=False, commit=False,
@ -1183,7 +1182,7 @@ class TagsEdit(EditWithComplete): # {{{
# }}}
class LanguagesEdit(LE): # {{{
class LanguagesEdit(LE): # {{{
LABEL = _('&Languages:')
TOOLTIP = _('A comma separated list of languages for this book')
@ -1194,8 +1193,10 @@ class LanguagesEdit(LE): # {{{
@dynamic_property
def current_val(self):
def fget(self): return self.lang_codes
def fset(self, val): self.lang_codes = val
def fget(self):
return self.lang_codes
def fset(self, val):
self.lang_codes = val
return property(fget=fget, fset=fset)
def initialize(self, db, id_):
@ -1221,7 +1222,7 @@ class LanguagesEdit(LE): # {{{
return True
# }}}
class IdentifiersEdit(QLineEdit): # {{{
class IdentifiersEdit(QLineEdit): # {{{
LABEL = _('I&ds:')
BASE_TT = _('Edit the identifiers for this book. '
'For example: \n\n%s')%(
@ -1309,7 +1310,7 @@ class IdentifiersEdit(QLineEdit): # {{{
# }}}
class ISBNDialog(QDialog) : # {{{
class ISBNDialog(QDialog): # {{{
def __init__(self, parent, txt):
QDialog.__init__(self, parent)
@ -1320,7 +1321,7 @@ class ISBNDialog(QDialog) : # {{{
l.addWidget(w, 0, 0, 1, 2)
w = QLabel(_('ISBN:'))
l.addWidget(w, 1, 0, 1, 1)
self.line_edit = w = QLineEdit();
self.line_edit = w = QLineEdit()
w.setText(txt)
w.selectAll()
w.textChanged.connect(self.checkText)
@ -1361,7 +1362,7 @@ class ISBNDialog(QDialog) : # {{{
# }}}
class PublisherEdit(EditWithComplete): # {{{
class PublisherEdit(EditWithComplete): # {{{
LABEL = _('&Publisher:')
def __init__(self, parent):
@ -1388,7 +1389,7 @@ class PublisherEdit(EditWithComplete): # {{{
def initialize(self, db, id_):
self.books_to_refresh = set([])
all_publishers = db.all_publishers()
all_publishers.sort(key=lambda x : sort_key(x[1]))
all_publishers.sort(key=lambda x: sort_key(x[1]))
self.update_items_cache([x[1] for x in all_publishers])
publisher_id = db.publisher_id(id_, index_is_id=True)
inval = ''
@ -1421,7 +1422,7 @@ class DateEdit(QDateTimeEdit):
ATTR = 'timestamp'
TWEAK = 'gui_timestamp_display_format'
def __init__(self, parent):
def __init__(self, parent, create_clear_button=True):
QDateTimeEdit.__init__(self, parent)
self.setToolTip(self.TOOLTIP)
self.setWhatsThis(self.TOOLTIP)
@ -1435,10 +1436,11 @@ class DateEdit(QDateTimeEdit):
self.setCalendarWidget(self.cw)
self.setMinimumDateTime(UNDEFINED_QDATETIME)
self.setSpecialValueText(_('Undefined'))
self.clear_button = QToolButton(parent)
self.clear_button.setIcon(QIcon(I('trash.png')))
self.clear_button.setToolTip(_('Clear date'))
self.clear_button.clicked.connect(self.reset_date)
if create_clear_button:
self.clear_button = QToolButton(parent)
self.clear_button.setIcon(QIcon(I('trash.png')))
self.clear_button.setToolTip(_('Clear date'))
self.clear_button.clicked.connect(self.reset_date)
def reset_date(self, *args):
self.current_val = None

View File

@ -6,5 +6,477 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import os
from collections import OrderedDict, namedtuple
from functools import partial
from PyQt4.Qt import (
QDialog, QWidget, QGridLayout, QLineEdit, QLabel, QToolButton, QIcon,
QVBoxLayout, QDialogButtonBox, QApplication, pyqtSignal, QFont, QPixmap,
QSize, QPainter, Qt, QColor, QPen, QSizePolicy, QScrollArea, QFrame)
from calibre import fit_image
from calibre.ebooks.metadata import title_sort, authors_to_sort_string
from calibre.gui2 import pixmap_to_data, gprefs
from calibre.gui2.comments_editor import Editor
from calibre.gui2.metadata.basic_widgets import PubdateEdit, RatingEdit
from calibre.ptempfile import PersistentTemporaryFile
from calibre.utils.date import UNDEFINED_DATE
Widgets = namedtuple('Widgets', 'new old label button')
# Widgets {{{
class LineEdit(QLineEdit):
changed = pyqtSignal()
def __init__(self, field, is_new, parent, metadata, extra):
QLineEdit.__init__(self, parent)
self.is_new = is_new
self.field = field
self.metadata = metadata
if not is_new:
self.setReadOnly(True)
self.textChanged.connect(self.changed)
def from_mi(self, mi):
val = mi.get(self.field, default='') or ''
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 to_mi(self, mi):
val = unicode(self.text()).strip()
ism = self.metadata['is_multiple']
if ism:
if not val:
val = []
else:
val = [x.strip() for x in val.split(ism['list_to_ui']) if x.strip()]
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))
@dynamic_property
def current_val(self):
def fget(self):
return unicode(self.text())
def fset(self, val):
self.setText(val)
self.setCursorPosition(0)
return property(fget=fget, fset=fset)
@property
def is_blank(self):
return not self.current_val.strip()
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.valueChanged.connect(self.changed)
if not is_new:
self.setReadOnly(True)
def from_mi(self, mi):
val = (mi.get(self.field, default=0) or 0)/2
self.setValue(val)
def to_mi(self, mi):
mi.set(self.field, self.value() * 2)
@property
def is_blank(self):
return self.value() == 0
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 == UNDEFINED_DATE
class SeriesEdit(LineEdit):
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 = '%s [%s]' % (series, mi.format_series_index(series_index))
self.setText(val)
self.setCursorPosition(0)
def to_mi(self, mi):
val = unicode(self.text()).strip()
try:
series_index = float(val.rpartition('[')[-1].rstrip(']').strip())
except:
series_index = 1.0
series = val.rpartition('[')[0].strip() or None
mi.set(self.field, series)
mi.set(self.field + '_index', series_index)
class IdentifiersEdit(LineEdit):
def from_mi(self, mi):
val = ('%s:%s' % (k, v) for k, v in mi.identifiers.iteritems())
self.setText(', '.join(val))
def to_mi(self, mi):
parts = (x.strip() for x in self.current_val.split(',') if x.strip())
val = {x.partition(':')[0].strip():x.partition(':')[-1].strip() for x in parts}
mi.set_identifiers({k:v for k, v in val.iteritems() if k and v})
class CommentsEdit(Editor):
changed = pyqtSignal()
def __init__(self, field, is_new, parent, metadata, extra):
Editor.__init__(self, parent, one_line_toolbar=False)
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)
@dynamic_property
def current_val(self):
def fget(self):
return self.html
def fset(self, val):
self.html = val or ''
self.changed.emit()
return property(fget=fget, fset=fset)
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()
class CoverView(QWidget):
changed = pyqtSignal()
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.Preferred, QSizePolicy.GrowFlag|QSizePolicy.ExpandFlag)
self.sizePolicy().setHeightForWidth(True)
@property
def is_blank(self):
return self.pixmap is None
@dynamic_property
def current_val(self):
def fget(self):
return self.pixmap
def fset(self, val):
self.pixmap = val
self.changed.emit()
self.update()
return property(fget=fget, fset=fset)
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 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.Antialiasing | QPainter.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 = u'\u00a0%d x %d\u00a0'%(self.pixmap.width(), self.pixmap.height())
flags = Qt.AlignBottom|Qt.AlignRight|Qt.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):
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', 'comments', 'cover')):
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 '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.changed.connect(partial(self.changed, field))
oldw = cls(field, False, self, m, extra)
newl = QLabel('&%s:' % m['name'])
newl.setBuddy(neww)
button = QToolButton(self)
button.setIcon(QIcon(I('back.png')))
button.clicked.connect(partial(self.revert, field))
button.setToolTip(revert_tooltip % m['name'])
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
self.sep = f = QFrame(self)
f.setFrameShape(f.VLine)
l.addWidget(f, 0, 2, row, 1)
self.sep2 = f = QFrame(self)
f.setFrameShape(f.VLine)
l.addWidget(f, 0, 4, row, 1)
def changed(self, field):
w = self.widgets[field]
if w.new.current_val != w.old.current_val 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]
neww.current_val = oldw.current_val
def __call__(self, oldmi, newmi):
self.current_mi = newmi
self.initial_vals = {}
for field, widgets in self.widgets.iteritems():
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 self.widgets.iteritems():
val = widgets.new.current_val
if val != self.initial_vals[field]:
widgets.new.to_mi(self.current_mi)
changed = True
return changed
class CompareMany(QDialog):
def __init__(self, ids, get_metadata, field_metadata, parent=None, window_title=None, skip_button_tooltip=None,
accept_all_tooltip=None, reject_all_tooltip=None, **kwargs):
QDialog.__init__(self, parent)
self.l = l = QVBoxLayout()
self.setLayout(l)
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.window_title = window_title or _('Compare metadata')
self.compare_widget = CompareSingle(field_metadata, parent=parent, **kwargs)
self.sa = sa = QScrollArea()
l.addWidget(sa)
sa.setWidget(self.compare_widget)
sa.setWidgetResizable(True)
self.bb = bb = QDialogButtonBox(QDialogButtonBox.Cancel)
bb.rejected.connect(self.reject)
self.aarb = b = bb.addButton(_('&Accept all remaining'), bb.YesRole)
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'), bb.NoRole)
if reject_all_tooltip:
b.setToolTip(reject_all_tooltip)
b.clicked.connect(self.reject_all_remaining)
self.sb = b = bb.addButton(_('&Reject'), bb.ActionRole)
b.clicked.connect(partial(self.next_item, False))
if skip_button_tooltip:
b.setToolTip(skip_button_tooltip)
self.nb = b = bb.addButton(_('&Next'), bb.ActionRole)
b.setIcon(QIcon(I('forward.png')))
b.clicked.connect(partial(self.next_item, True))
b.setDefault(True)
l.addWidget(bb)
self.next_item(True)
desktop = QApplication.instance().desktop()
geom = desktop.availableGeometry(parent or self)
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:
self.restoreGeometry(geom)
def accept(self):
gprefs.set('diff_dialog_geom', bytearray(self.saveGeometry()))
super(CompareMany, self).accept()
def reject(self):
gprefs.set('diff_dialog_geom', bytearray(self.saveGeometry()))
super(CompareMany, self).reject()
@property
def current_mi(self):
return self.compare_widget.current_mi
def next_item(self, accept):
if not self.ids:
return self.accept()
if self.current_mi is not None:
changed = self.compare_widget.apply_changes()
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])
old_id = self.ids.pop(0)
if self.current_mi is not None:
self.accepted[old_id] = (changed, self.current_mi) if accept else (False, None)
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):
self.next_item(False)
for id_ in self.ids:
oldmi, newmi = self.get_metadata(id_)
self.accepted[id_] = (False, None)
self.ids = []
self.accept()
if __name__ == '__main__':
app = QApplication([])
from calibre.library import db
db = db()
ids = sorted(db.all_ids(), reverse=True)
ids = [(x, ids[i+1]) for i, x in enumerate(ids[0::2])]
gm = partial(db.get_metadata, index_is_id=True, get_cover=True, cover_as_data=True)
get_metadata = lambda x:map(gm, ids[x])
d = CompareMany(list(xrange(len(ids))), get_metadata, db.field_metadata)
if d.exec_() == d.Accepted:
for changed, mi in d.accepted.itervalues():
if changed and mi is not None:
print (mi)

View File

@ -252,7 +252,7 @@ class FieldMetadata(dict):
'datatype':'int',
'is_multiple':{},
'kind':'field',
'name':None,
'name':_('Cover'),
'search_terms':['cover'],
'is_custom':False,
'is_category':False,