Edit metadata dialog: Allow undoing the changes to individual fields by right clicking on the field and selecting Undo after a metadata download. Fixes #1223367 [Feature Request: Don't "destroy" undo/ctrl-z and redo/ctrl-y for the fields, after metadata download](https://bugs.launchpad.net/calibre/+bug/1223367)

This commit is contained in:
Kovid Goyal 2014-09-21 15:22:04 +05:30
parent 9f529a74fa
commit 55288fcfdc
4 changed files with 170 additions and 42 deletions

View File

@ -360,6 +360,15 @@ class EditorWidget(QWebView): # {{{
self.set_font_style() self.set_font_style()
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
def set_html(self, val, allow_undo=True):
if not allow_undo or self.readonly:
self.html = val
return
mf = self.page().mainFrame()
mf.evaluateJavaScript('document.execCommand("selectAll", false, null)')
mf.evaluateJavaScript('document.execCommand("insertHTML", false, %s)' % json.dumps(unicode(val)))
self.set_font_style()
def set_font_style(self): def set_font_style(self):
fi = QFontInfo(QApplication.font(self)) fi = QFontInfo(QApplication.font(self))
f = fi.pixelSize() + 1 + int(tweaks['change_book_details_font_size_by']) f = fi.pixelSize() + 1 + int(tweaks['change_book_details_font_size_by'])
@ -622,6 +631,7 @@ class Editor(QWidget): # {{{
t = getattr(self, 'toolbar%d'%i) t = getattr(self, 'toolbar%d'%i)
t.setIconSize(QSize(18, 18)) t.setIconSize(QSize(18, 18))
self.editor = EditorWidget(self) self.editor = EditorWidget(self)
self.set_html = self.editor.set_html
self.tabs = QTabWidget(self) self.tabs = QTabWidget(self)
self.tabs.setTabPosition(self.tabs.South) self.tabs.setTabPosition(self.tabs.South)
self.wyswyg = QWidget(self.tabs) self.wyswyg = QWidget(self.tabs)

View File

@ -58,15 +58,27 @@ class LanguagesEdit(EditWithComplete):
return ans return ans
def fset(self, lang_codes): def fset(self, lang_codes):
ans = [] self.set_lang_codes(lang_codes, allow_undo=False)
for lc in lang_codes:
name = self._lang_map.get(lc, None)
if name is not None:
ans.append(name)
self.setEditText(', '.join(ans))
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
def set_lang_codes(self, lang_codes, allow_undo=True):
ans = []
for lc in lang_codes:
name = self._lang_map.get(lc, None)
if name is not None:
ans.append(name)
ans = ', '.join(ans)
if allow_undo:
orig, self.disable_popup = self.disable_popup, True
try:
self.lineEdit().selectAll(), self.lineEdit().insert(ans)
finally:
self.disable_popup = orig
else:
self.setEditText(ans)
def validate(self): def validate(self):
vals = self.vals vals = self.vals
bad = [] bad = []

View File

@ -7,13 +7,14 @@ __license__ = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>' __copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
import textwrap, re, os, shutil import textwrap, re, os, shutil, weakref
from PyQt5.Qt import ( from PyQt5.Qt import (
Qt, QDateTimeEdit, pyqtSignal, QMessageBox, QIcon, QToolButton, QWidget, Qt, QDateTimeEdit, pyqtSignal, QMessageBox, QIcon, QToolButton, QWidget,
QLabel, QGridLayout, QApplication, QDoubleSpinBox, QListWidgetItem, QSize, QLabel, QGridLayout, QApplication, QDoubleSpinBox, QListWidgetItem, QSize,
QPixmap, QDialog, QMenu, QSpinBox, QLineEdit, QSizePolicy, QPixmap, QDialog, QMenu, QSpinBox, QLineEdit, QSizePolicy, QKeySequence,
QDialogButtonBox, QAction, QCalendarWidget, QDate, QDateTime) QDialogButtonBox, QAction, QCalendarWidget, QDate, QDateTime, QUndoCommand,
QUndoStack)
from calibre.gui2.widgets import EnLineEdit, FormatList as _FormatList, ImageView from calibre.gui2.widgets import EnLineEdit, FormatList as _FormatList, ImageView
from calibre.utils.icu import sort_key from calibre.utils.icu import sort_key
@ -76,10 +77,113 @@ class BasicMetadataWidget(object):
class ToMetadataMixin(object): class ToMetadataMixin(object):
FIELD_NAME = None FIELD_NAME = None
allow_undo = False
def apply_to_metadata(self, mi): def apply_to_metadata(self, mi):
mi.set(self.FIELD_NAME, self.current_val) mi.set(self.FIELD_NAME, self.current_val)
def set_value(self, val, allow_undo=True):
self.allow_undo = allow_undo
try:
self.current_val = val
finally:
self.allow_undo = False
def set_text(self, text):
if self.allow_undo:
self.selectAll(), self.insert(text)
else:
self.setText(text)
def set_edit_text(self, text):
if self.allow_undo:
orig, self.disable_popup = self.disable_popup, True
try:
self.lineEdit().selectAll(), self.lineEdit().insert(text)
finally:
self.disable_popup = orig
else:
self.setEditText(text)
def make_undoable(spinbox):
'Add a proper undo/redo capability to spinbox which must be a sub-class of QAbstractSpinBox'
def access_key(k):
if QKeySequence.keyBindings(k):
return '\t' + QKeySequence(k).toString(QKeySequence.NativeText)
return ''
class UndoCommand(QUndoCommand):
def __init__(self, widget, val):
QUndoCommand.__init__(self)
self.widget = weakref.ref(widget)
if hasattr(widget, 'dateTime'):
self.undo_val = widget.dateTime()
elif hasattr(widget, 'value'):
self.undo_val = widget.value()
self.redo_val = val
def undo(self):
w = self.widget()
if hasattr(w, 'setDateTime'):
w.setDateTime(self.undo_val)
elif hasattr(w, 'setValue'):
w.setValue(self.undo_val)
def redo(self):
w = self.widget()
if hasattr(w, 'setDateTime'):
w.setDateTime(self.redo_val)
elif hasattr(w, 'setValue'):
w.setValue(self.redo_val)
class UndoableSpinbox(spinbox):
def __init__(self, parent=None):
spinbox.__init__(self, parent)
self.undo_stack = QUndoStack(self)
self.undo, self.redo = self.undo_stack.undo, self.undo_stack.redo
def keyPressEvent(self, ev):
if ev == QKeySequence.Undo:
self.undo()
return ev.accept()
if ev == QKeySequence.Redo:
self.redo()
return ev.accept()
return spinbox.keyPressEvent(self, ev)
def contextMenuEvent(self, ev):
m = QMenu(self)
m.addAction(_('&Undo') + access_key(QKeySequence.Undo), self.undo).setEnabled(self.undo_stack.canUndo())
m.addAction(_('&Redo') + access_key(QKeySequence.Redo), self.redo).setEnabled(self.undo_stack.canRedo())
m.addSeparator()
le = self.lineEdit()
m.addAction(_('Cu&t') + access_key(QKeySequence.Cut), le.cut).setEnabled(not le.isReadOnly() and le.hasSelectedText())
m.addAction(_('&Copy') + access_key(QKeySequence.Copy), le.copy).setEnabled(le.hasSelectedText())
m.addAction(_('&Paste') + access_key(QKeySequence.Paste), le.paste).setEnabled(not le.isReadOnly())
m.addAction(_('Delete') + access_key(QKeySequence.Delete), le.del_).setEnabled(not le.isReadOnly() and le.hasSelectedText())
m.addSeparator()
m.addAction(_('Select &All') + access_key(QKeySequence.SelectAll), self.selectAll)
m.addSeparator()
m.addAction(_('&Step up'), self.stepUp)
m.addAction(_('Step &down'), self.stepDown)
m.setAttribute(Qt.WA_DeleteOnClose)
m.popup(ev.globalPos())
def set_spinbox_value(self, val):
if self.allow_undo:
cmd = UndoCommand(self, val)
self.undo_stack.push(cmd)
if hasattr(self, 'setDateTime'):
self.setDateTime(val)
elif hasattr(self, 'setValue'):
self.setValue(val)
return UndoableSpinbox
# Title {{{ # Title {{{
class TitleEdit(EnLineEdit, ToMetadataMixin): class TitleEdit(EnLineEdit, ToMetadataMixin):
@ -126,7 +230,7 @@ class TitleEdit(EnLineEdit, ToMetadataMixin):
val = val.strip() val = val.strip()
if not val: if not val:
val = self.get_default() val = self.get_default()
self.setText(val) self.set_text(val)
self.setCursorPosition(0) self.setCursorPosition(0)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -311,7 +415,7 @@ class AuthorsEdit(EditWithComplete, ToMetadataMixin):
def fset(self, val): def fset(self, val):
if not val: if not val:
val = [self.get_default()] val = [self.get_default()]
self.setEditText(' & '.join([x.strip() for x in val])) self.set_edit_text(' & '.join([x.strip() for x in val]))
self.lineEdit().setCursorPosition(0) self.lineEdit().setCursorPosition(0)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -373,7 +477,7 @@ class AuthorSortEdit(EnLineEdit, ToMetadataMixin):
def fset(self, val): def fset(self, val):
if not val: if not val:
val = '' val = ''
self.setText(val.strip()) self.set_text(val.strip())
self.setCursorPosition(0) self.setCursorPosition(0)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -493,7 +597,7 @@ class SeriesEdit(EditWithComplete, ToMetadataMixin):
def fset(self, val): def fset(self, val):
if not val: if not val:
val = '' val = ''
self.setEditText(val.strip()) self.set_edit_text(val.strip())
self.lineEdit().setCursorPosition(0) self.lineEdit().setCursorPosition(0)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -521,14 +625,14 @@ class SeriesEdit(EditWithComplete, ToMetadataMixin):
self.dialog = None self.dialog = None
class SeriesIndexEdit(QDoubleSpinBox, ToMetadataMixin): class SeriesIndexEdit(make_undoable(QDoubleSpinBox), ToMetadataMixin):
TOOLTIP = '' TOOLTIP = ''
LABEL = _('&Number:') LABEL = _('&Number:')
FIELD_NAME = 'series_index' FIELD_NAME = 'series_index'
def __init__(self, parent, series_edit): def __init__(self, parent, series_edit):
QDoubleSpinBox.__init__(self, parent) super(SeriesIndexEdit, self).__init__(parent)
self.dialog = parent self.dialog = parent
self.db = self.original_series_name = None self.db = self.original_series_name = None
self.setMaximum(10000000) self.setMaximum(10000000)
@ -551,7 +655,7 @@ class SeriesIndexEdit(QDoubleSpinBox, ToMetadataMixin):
if val is None: if val is None:
val = 1.0 val = 1.0
val = float(val) val = float(val)
self.setValue(val) self.set_spinbox_value(val)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -1103,7 +1207,7 @@ class CommentsEdit(Editor, ToMetadataMixin): # {{{
val = '' val = ''
else: else:
val = comments_to_html(val) val = comments_to_html(val)
self.html = val self.set_html(val, self.allow_undo)
self.wyswyg_dirtied() self.wyswyg_dirtied()
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -1117,13 +1221,13 @@ class CommentsEdit(Editor, ToMetadataMixin): # {{{
db.set_comment(id_, self.current_val, notify=False, commit=False) db.set_comment(id_, self.current_val, notify=False, commit=False)
# }}} # }}}
class RatingEdit(QSpinBox, ToMetadataMixin): # {{{ class RatingEdit(make_undoable(QSpinBox), ToMetadataMixin): # {{{
LABEL = _('&Rating:') LABEL = _('&Rating:')
TOOLTIP = _('Rating of this book. 0-5 stars') TOOLTIP = _('Rating of this book. 0-5 stars')
FIELD_NAME = 'rating' FIELD_NAME = 'rating'
def __init__(self, parent): def __init__(self, parent):
QSpinBox.__init__(self, parent) super(RatingEdit, self).__init__(parent)
self.setToolTip(self.TOOLTIP) self.setToolTip(self.TOOLTIP)
self.setWhatsThis(self.TOOLTIP) self.setWhatsThis(self.TOOLTIP)
self.setMaximum(5) self.setMaximum(5)
@ -1141,7 +1245,7 @@ class RatingEdit(QSpinBox, ToMetadataMixin): # {{{
val = 0 val = 0
if val > 5: if val > 5:
val = 5 val = 5
self.setValue(val) self.set_spinbox_value(val)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
def initialize(self, db, id_): def initialize(self, db, id_):
@ -1182,7 +1286,7 @@ class TagsEdit(EditWithComplete, ToMetadataMixin): # {{{
def fset(self, val): def fset(self, val):
if not val: if not val:
val = [] val = []
self.setText(', '.join([x.strip() for x in val])) self.set_edit_text(', '.join([x.strip() for x in val]))
self.setCursorPosition(0) self.setCursorPosition(0)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -1240,7 +1344,7 @@ class LanguagesEdit(LE, ToMetadataMixin): # {{{
def fget(self): def fget(self):
return self.lang_codes return self.lang_codes
def fset(self, val): def fset(self, val):
self.lang_codes = val self.set_lang_codes(val, self.allow_undo)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
def initialize(self, db, id_): def initialize(self, db, id_):
@ -1309,9 +1413,8 @@ class IdentifiersEdit(QLineEdit, ToMetadataMixin): # {{{
val[k] = v val[k] = v
ids = sorted(val.iteritems(), key=keygen) ids = sorted(val.iteritems(), key=keygen)
txt = ', '.join(['%s:%s'%(k.lower(), vl) for k, vl in ids]) txt = ', '.join(['%s:%s'%(k.lower(), vl) for k, vl in ids])
# Use clear + insert instead of setText so that undo works # Use selectAll + insert instead of setText so that undo works
self.clear() self.selectAll(), self.insert(txt.strip())
self.insert(txt.strip())
self.setCursorPosition(0) self.setCursorPosition(0)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -1426,7 +1529,7 @@ class PublisherEdit(EditWithComplete, ToMetadataMixin): # {{{
def fset(self, val): def fset(self, val):
if not val: if not val:
val = '' val = ''
self.setEditText(val.strip()) self.set_edit_text(val.strip())
self.lineEdit().setCursorPosition(0) self.lineEdit().setCursorPosition(0)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
@ -1459,7 +1562,7 @@ class CalendarWidget(QCalendarWidget):
if self.selectedDate().year() == UNDEFINED_DATE.year: if self.selectedDate().year() == UNDEFINED_DATE.year:
self.setSelectedDate(QDate.currentDate()) self.setSelectedDate(QDate.currentDate())
class DateEdit(QDateTimeEdit, ToMetadataMixin): class DateEdit(make_undoable(QDateTimeEdit), ToMetadataMixin):
TOOLTIP = '' TOOLTIP = ''
LABEL = _('&Date:') LABEL = _('&Date:')
@ -1468,7 +1571,7 @@ class DateEdit(QDateTimeEdit, ToMetadataMixin):
TWEAK = 'gui_timestamp_display_format' TWEAK = 'gui_timestamp_display_format'
def __init__(self, parent, create_clear_button=True): def __init__(self, parent, create_clear_button=True):
QDateTimeEdit.__init__(self, parent) super(DateEdit, self).__init__(parent)
self.setToolTip(self.TOOLTIP) self.setToolTip(self.TOOLTIP)
self.setWhatsThis(self.TOOLTIP) self.setWhatsThis(self.TOOLTIP)
fmt = tweaks[self.TWEAK] fmt = tweaks[self.TWEAK]
@ -1499,7 +1602,7 @@ class DateEdit(QDateTimeEdit, ToMetadataMixin):
val = UNDEFINED_DATE val = UNDEFINED_DATE
else: else:
val = as_local_time(val) val = as_local_time(val)
self.setDateTime(val) self.set_spinbox_value(val)
return property(fget=fget, fset=fset) return property(fget=fget, fset=fset)
def initialize(self, db, id_): def initialize(self, db, id_):
@ -1525,7 +1628,7 @@ class DateEdit(QDateTimeEdit, ToMetadataMixin):
ev.accept() ev.accept()
self.setDateTime(QDateTime.currentDateTime()) self.setDateTime(QDateTime.currentDateTime())
else: else:
return QDateTimeEdit.keyPressEvent(self, ev) return super(DateEdit, self).keyPressEvent(ev)
class PubdateEdit(DateEdit): class PubdateEdit(DateEdit):
LABEL = _('Publishe&d:') LABEL = _('Publishe&d:')

View File

@ -394,23 +394,24 @@ class MetadataSingleDialogBase(ResizableDialog):
return return
def update_from_mi(self, mi, update_sorts=True, merge_tags=True, merge_comments=False): def update_from_mi(self, mi, update_sorts=True, merge_tags=True, merge_comments=False):
fw = self.focusWidget()
if not mi.is_null('title'): if not mi.is_null('title'):
self.title.current_val = mi.title self.title.set_value(mi.title)
if update_sorts: if update_sorts:
self.title_sort.auto_generate() self.title_sort.auto_generate()
if not mi.is_null('authors'): if not mi.is_null('authors'):
self.authors.current_val = mi.authors self.authors.set_value(mi.authors)
if not mi.is_null('author_sort'): if not mi.is_null('author_sort'):
self.author_sort.current_val = mi.author_sort self.author_sort.set_value(mi.author_sort)
elif update_sorts: elif update_sorts:
self.author_sort.auto_generate() self.author_sort.auto_generate()
if not mi.is_null('rating'): if not mi.is_null('rating'):
try: try:
self.rating.current_val = mi.rating self.rating.set_value(mi.rating)
except: except:
pass pass
if not mi.is_null('publisher'): if not mi.is_null('publisher'):
self.publisher.current_val = mi.publisher self.publisher.set_value(mi.publisher)
if not mi.is_null('tags'): if not mi.is_null('tags'):
old_tags = self.tags.current_val old_tags = self.tags.current_val
tags = mi.tags if mi.tags else [] tags = mi.tags if mi.tags else []
@ -418,30 +419,32 @@ class MetadataSingleDialogBase(ResizableDialog):
ltags, lotags = {t.lower() for t in tags}, {t.lower() for t in ltags, lotags = {t.lower() for t in tags}, {t.lower() for t in
old_tags} old_tags}
tags = [t for t in tags if t.lower() in ltags-lotags] + old_tags tags = [t for t in tags if t.lower() in ltags-lotags] + old_tags
self.tags.current_val = tags self.tags.set_value(tags)
if not mi.is_null('identifiers'): if not mi.is_null('identifiers'):
current = self.identifiers.current_val current = self.identifiers.current_val
current.update(mi.identifiers) current.update(mi.identifiers)
self.identifiers.current_val = current self.identifiers.set_value(current)
if not mi.is_null('pubdate'): if not mi.is_null('pubdate'):
self.pubdate.current_val = mi.pubdate self.pubdate.set_value(mi.pubdate)
if not mi.is_null('series') and mi.series.strip(): if not mi.is_null('series') and mi.series.strip():
self.series.current_val = mi.series self.series.set_value(mi.series)
if mi.series_index is not None: if mi.series_index is not None:
self.series_index.reset_original() self.series_index.reset_original()
self.series_index.current_val = float(mi.series_index) self.series_index.set_value(float(mi.series_index))
if not mi.is_null('languages'): if not mi.is_null('languages'):
langs = [canonicalize_lang(x) for x in mi.languages] langs = [canonicalize_lang(x) for x in mi.languages]
langs = [x for x in langs if x is not None] langs = [x for x in langs if x is not None]
if langs: if langs:
self.languages.current_val = langs self.languages.set_value(langs)
if mi.comments and mi.comments.strip(): if mi.comments and mi.comments.strip():
val = mi.comments val = mi.comments
if val and merge_comments: if val and merge_comments:
cval = self.comments.current_val cval = self.comments.current_val
if cval: if cval:
val = merge_two_comments(cval, val) val = merge_two_comments(cval, val)
self.comments.current_val = val self.comments.set_value(val)
if fw is not None:
fw.setFocus(Qt.OtherFocusReason)
def fetch_metadata(self, *args): def fetch_metadata(self, *args):
d = FullFetch(self.cover.pixmap(), self) d = FullFetch(self.cover.pixmap(), self)