diff --git a/Changelog.yaml b/Changelog.yaml
index 881e58457c..0d29ea91a2 100644
--- a/Changelog.yaml
+++ b/Changelog.yaml
@@ -20,6 +20,73 @@
# new recipes:
# - title:
+- version: 1.7.0
+ date: 2013-10-18
+
+ new features:
+ - title: "Cover grid: Allow using images as the background for the cover grid. To choose an image, go to Preferences->Look & Feel->Cover Grid."
+ tickets: [1239194]
+
+ - title: "An option to mark newly added books with a temporary mark. Option is in Preferences->Adding books."
+ tickets: [1238609]
+
+ - title: "Edit metadata dialog: Allow turning off the cover size displayed in the bottom right corner of the cover by right clicking the cover and choosing 'Hide cover size'. It can be restored the same way."
+
+ bug fixes:
+ - title: "Conversion: If both embed font family and the filter css option to remove fonts are set, do not remove the font specified by the embed font family option."
+
+ - title: "Fix a few remaining situations that could cause formats column to show an error message about SHLock"
+
+ - title: "Make deleting books to recycle bin more robust. Ensure that the temporary directory created during the move to recycle bin process is not left behind in case of errors."
+
+ - title: "Windows: Check if the books' files are in use before deleting"
+
+ - title: "Fix custom device driver swap main and card option not working. Also fix swapping not happening for a few devices on linux"
+ tickets: [1240504]
+
+ - title: "Edit metadata dialog: The Edit metadata dialog currently limits its max size based on the geometry of the smallest attached screen. Change that to use the geometry of the screen on which it will be shown."
+ tickets: [1239597]
+
+ - title: "HTMLZ Output: Fix '
- output = [u'
'] + [css] + [u''] + output + [u'']
+ output = [u''] + \
+ [css] + [u''] + output + [u'']
return ''.join(output)
def dump_text(self, elem, stylizer, page):
@@ -350,7 +363,11 @@ class OEB2HTMLClassCSSizer(OEB2HTML):
at += ' %s="%s"' % (k, prepare_string_for_xml(v, attribute=True))
# Write the tag.
- text.append('<%s%s>' % (tag, at))
+ text.append('<%s%s' % (tag, at))
+ if tag in SELF_CLOSING_TAGS:
+ text.append(' />')
+ else:
+ text.append('>')
# Process tags that contain text.
if hasattr(elem, 'text') and elem.text:
@@ -363,7 +380,8 @@ class OEB2HTMLClassCSSizer(OEB2HTML):
# Close all open tags.
tags.reverse()
for t in tags:
- text.append('%s>' % t)
+ if t not in SELF_CLOSING_TAGS:
+ text.append('%s>' % t)
# Add the text that is outside of the tag.
if hasattr(elem, 'tail') and elem.tail:
diff --git a/src/calibre/ebooks/oeb/polish/container.py b/src/calibre/ebooks/oeb/polish/container.py
index 66efb697c7..b8362b73c9 100644
--- a/src/calibre/ebooks/oeb/polish/container.py
+++ b/src/calibre/ebooks/oeb/polish/container.py
@@ -745,6 +745,10 @@ class EpubContainer(Container):
f.write(guess_type('a.epub'))
zip_rebuilder(self.root, outpath)
+ @property
+ def path_to_ebook(self):
+ return self.pathtoepub
+
# }}}
# AZW3 {{{
@@ -839,6 +843,11 @@ class AZW3Container(Container):
oeb = create_oebbook(default_log, opf, plumber.opts)
set_cover(oeb)
outp.convert(oeb, outpath, inp, plumber.opts, default_log)
+
+ @property
+ def path_to_ebook(self):
+ return self.pathtoepub
+
# }}}
def get_container(path, log=None, tdir=None):
diff --git a/src/calibre/ebooks/oeb/transforms/flatcss.py b/src/calibre/ebooks/oeb/transforms/flatcss.py
index 9c8442d391..c3b7f6f625 100644
--- a/src/calibre/ebooks/oeb/transforms/flatcss.py
+++ b/src/calibre/ebooks/oeb/transforms/flatcss.py
@@ -410,7 +410,10 @@ class CSSFlattener(object):
if cssdict:
for x in self.filter_css:
- cssdict.pop(x, None)
+ popval = cssdict.pop(x, None)
+ if (self.body_font_family and popval and x == 'font-family' and
+ popval.partition(',')[0][1:-1] == self.body_font_family.partition(',')[0][1:-1]):
+ cssdict[x] = popval
if cssdict:
if self.lineh and self.fbase and tag != 'body':
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 569a531216..67d4b80750 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -118,6 +118,7 @@ defs['cover_grid_color'] = (80, 80, 80)
defs['cover_grid_cache_size'] = 100
defs['cover_grid_disk_cache_size'] = 2500
defs['cover_grid_show_title'] = False
+defs['cover_grid_texture'] = None
defs['show_vl_tabs'] = False
del defs
# }}}
@@ -753,11 +754,13 @@ class ResizableDialog(QDialog):
def __init__(self, *args, **kwargs):
QDialog.__init__(self, *args)
self.setupUi(self)
- nh, nw = min_available_height()-25, available_width()-10
+ desktop = QCoreApplication.instance().desktop()
+ geom = desktop.availableGeometry(self)
+ nh, nw = geom.height()-25, geom.width()-10
if nh < 0:
- nh = 800
+ nh = max(800, self.height())
if nw < 0:
- nw = 600
+ nw = max(600, self.height())
nh = min(self.height(), nh)
nw = min(self.width(), nw)
self.resize(nw, nh)
diff --git a/src/calibre/gui2/actions/delete.py b/src/calibre/gui2/actions/delete.py
index ab5e52d69c..ddd71ee18f 100644
--- a/src/calibre/gui2/actions/delete.py
+++ b/src/calibre/gui2/actions/delete.py
@@ -5,6 +5,7 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
+import errno
from functools import partial
from collections import Counter
@@ -360,7 +361,17 @@ class DeleteAction(InterfaceAction):
return
next_id = view.next_id
if len(to_delete_ids) < 5:
- view.model().delete_books_by_id(to_delete_ids)
+ try:
+ view.model().delete_books_by_id(to_delete_ids)
+ except IOError as err:
+ if err.errno == errno.EACCES:
+ import traceback
+ fname = getattr(err, 'filename', 'file') or 'file'
+ return error_dialog(self.gui, _('Permission denied'),
+ _('Could not access %s. Is it being used by another'
+ ' program? Click "Show details" for more information.')%fname, det_msg=traceback.format_exc(),
+ show=True)
+
self.library_ids_deleted2(to_delete_ids, next_id=next_id)
else:
self.__md = MultiDeleter(self.gui, to_delete_ids,
diff --git a/src/calibre/gui2/actions/mark_books.py b/src/calibre/gui2/actions/mark_books.py
index 8543df4461..2dfdc2781b 100644
--- a/src/calibre/gui2/actions/mark_books.py
+++ b/src/calibre/gui2/actions/mark_books.py
@@ -20,7 +20,7 @@ class MarkBooksAction(InterfaceAction):
action_type = 'current'
action_add_menu = True
dont_add_to = frozenset([
- 'toolbar-device', 'context-menu-device', 'menubar-device', 'context-menu-cover-browser'])
+ 'context-menu-device', 'menubar-device', 'context-menu-cover-browser'])
action_menu_clone_qaction = _('Toggle mark for selected books')
accepts_drops = True
diff --git a/src/calibre/gui2/convert/toc.ui b/src/calibre/gui2/convert/toc.ui
index 6140816804..0e7b6530ea 100644
--- a/src/calibre/gui2/convert/toc.ui
+++ b/src/calibre/gui2/convert/toc.ui
@@ -15,7 +15,11 @@
-
-
+
+
+ 10000
+
+
-
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 4223a638f2..5cbd318ee9 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -234,9 +234,12 @@ class Comments(Base):
self.widgets = [self._box]
def setter(self, val):
- if val is None:
+ if not val or not val.strip():
val = ''
- self._tb.html = comments_to_html(val)
+ else:
+ val = comments_to_html(val)
+ self._tb.html = val
+ self._tb.wyswyg_dirtied()
def getter(self):
val = unicode(self._tb.html).strip()
diff --git a/src/calibre/gui2/dialogs/comments_dialog.py b/src/calibre/gui2/dialogs/comments_dialog.py
index 2734a1bd21..eee23f4b3e 100644
--- a/src/calibre/gui2/dialogs/comments_dialog.py
+++ b/src/calibre/gui2/dialogs/comments_dialog.py
@@ -18,8 +18,8 @@ class CommentsDialog(QDialog, Ui_CommentsDialog):
self.setWindowFlags(self.windowFlags()&(~Qt.WindowContextHelpButtonHint))
self.setWindowIcon(icon)
- if text is not None:
- self.textbox.html = comments_to_html(text)
+ self.textbox.html = comments_to_html(text) if text else ''
+ self.textbox.wyswyg_dirtied()
# self.textbox.setTabChangesFocus(True)
self.buttonBox.button(QDialogButtonBox.Ok).setText(_('&OK'))
self.buttonBox.button(QDialogButtonBox.Cancel).setText(_('&Cancel'))
diff --git a/src/calibre/gui2/ebook_download.py b/src/calibre/gui2/ebook_download.py
index 1b2f8b38d0..9f678bd506 100644
--- a/src/calibre/gui2/ebook_download.py
+++ b/src/calibre/gui2/ebook_download.py
@@ -15,7 +15,7 @@ from calibre import browser, get_download_filename
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.gui2 import Dispatcher
from calibre.gui2.threaded_jobs import ThreadedJob
-from calibre.ptempfile import PersistentTemporaryFile
+from calibre.ptempfile import PersistentTemporaryDirectory
from calibre.utils.filenames import ascii_filename
class EbookDownload(object):
@@ -56,7 +56,8 @@ class EbookDownload(object):
cj.load(cookie_file)
br.set_cookiejar(cj)
with closing(br.open(url)) as r:
- tf = PersistentTemporaryFile(suffix=filename)
+ temp_path = os.path.join(PersistentTemporaryDirectory(), filename)
+ tf = open(temp_path, 'w+b')
tf.write(r.read())
dfilename = tf.name
diff --git a/src/calibre/gui2/library/alternate_views.py b/src/calibre/gui2/library/alternate_views.py
index a3885bf135..44c361e129 100644
--- a/src/calibre/gui2/library/alternate_views.py
+++ b/src/calibre/gui2/library/alternate_views.py
@@ -8,7 +8,6 @@ __copyright__ = '2013, Kovid Goyal '
import itertools, operator, os
from types import MethodType
-from time import time
from threading import Event, Thread
from Queue import LifoQueue
from functools import wraps, partial
@@ -19,10 +18,11 @@ from PyQt4.Qt import (
QTimer, QPalette, QColor, QItemSelection, QPixmap, QMenu, QApplication,
QMimeData, QUrl, QDrag, QPoint, QPainter, QRect, pyqtProperty, QEvent,
QPropertyAnimation, QEasingCurve, pyqtSlot, QHelpEvent, QAbstractItemView,
- QStyleOptionViewItem, QToolTip, QByteArray, QBuffer)
+ QStyleOptionViewItem, QToolTip, QByteArray, QBuffer, QBrush)
from calibre import fit_image, prints, prepare_string_for_xml
from calibre.ebooks.metadata import fmt_sidx
+from calibre.utils import join_with_timeout
from calibre.gui2 import gprefs, config
from calibre.gui2.library.caches import CoverCache, ThumbnailCache
from calibre.utils.config import prefs, tweaks
@@ -482,17 +482,6 @@ class CoverDelegate(QStyledItemDelegate):
return True
return False
-def join_with_timeout(q, timeout=2):
- q.all_tasks_done.acquire()
- try:
- endtime = time() + timeout
- while q.unfinished_tasks:
- remaining = endtime - time()
- if remaining <= 0.0:
- raise RuntimeError('Waiting for queue to clear timed out')
- q.all_tasks_done.wait(remaining)
- finally:
- q.all_tasks_done.release()
# }}}
# The View {{{
@@ -586,6 +575,12 @@ class GridView(QListView):
pal = QPalette()
col = QColor(r, g, b)
pal.setColor(pal.Base, col)
+ tex = gprefs['cover_grid_texture']
+ if tex:
+ from calibre.gui2.preferences.texture_chooser import texture_path
+ path = texture_path(tex)
+ if path:
+ pal.setBrush(pal.Base, QBrush(QPixmap(path)))
dark = (r + g + b)/3.0 < 128
pal.setColor(pal.Text, QColor(Qt.white if dark else Qt.black))
self.setPalette(pal)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 9fe8606fca..81ea3e4643 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -928,7 +928,10 @@ class BooksModel(QAbstractTableModel): # {{{
if role == Qt.DisplayRole: # orientation is vertical
return QVariant(section+1)
if role == Qt.DecorationRole:
- return self.marked_icon if self.db.data.get_marked(self.db.data.index_to_id(section)) else self.row_decoration
+ try:
+ return self.marked_icon if self.db.data.get_marked(self.db.data.index_to_id(section)) else self.row_decoration
+ except (ValueError, IndexError):
+ pass
return NONE
def flags(self, index):
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index ccf15a2be9..aa20670738 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -66,7 +66,7 @@ class HeaderView(QHeaderView): # {{{
try:
opt.icon = model.headerData(logical_index, opt.orientation, Qt.DecorationRole)
opt.iconAlignment = Qt.AlignVCenter
- except TypeError:
+ except (IndexError, ValueError, TypeError):
pass
if sm.isRowSelected(logical_index, QModelIndex()):
opt.state |= QStyle.State_Sunken
@@ -693,7 +693,13 @@ class BooksView(QTableView): # {{{
self.alternate_views.marked_changed(old_marked, current_marked)
if bool(old_marked) == bool(current_marked):
changed = old_marked | current_marked
- sections = tuple(map(self.model().db.data.id_to_index, changed))
+ i = self.model().db.data.id_to_index
+ def f(x):
+ try:
+ return i(x)
+ except ValueError:
+ pass
+ sections = tuple(x for x in map(f, changed) if x is not None)
self.row_header.headerDataChanged(Qt.Vertical, min(sections), max(sections))
else:
# Marked items have either appeared or all been removed
diff --git a/src/calibre/gui2/preferences/look_feel.py b/src/calibre/gui2/preferences/look_feel.py
index 4fc32b0e77..9e26db4563 100644
--- a/src/calibre/gui2/preferences/look_feel.py
+++ b/src/calibre/gui2/preferences/look_feel.py
@@ -8,13 +8,15 @@ __docformat__ = 'restructuredtext en'
from threading import Thread
from functools import partial
-from PyQt4.Qt import (QApplication, QFont, QFontInfo, QFontDialog, QColorDialog,
- QAbstractListModel, Qt, QIcon, QKeySequence, QPalette, QColor, pyqtSignal)
+from PyQt4.Qt import (
+ QApplication, QFont, QFontInfo, QFontDialog, QColorDialog, QPainter,
+ QAbstractListModel, Qt, QIcon, QKeySequence, QColor, pyqtSignal,
+ QWidget, QSizePolicy, QBrush, QPixmap, QSize, QPushButton)
from calibre import human_readable
from calibre.gui2.preferences import ConfigWidgetBase, test_widget, CommaSeparatedList
from calibre.gui2.preferences.look_feel_ui import Ui_Form
-from calibre.gui2 import config, gprefs, qt_app, NONE, open_local_file
+from calibre.gui2 import config, gprefs, qt_app, NONE, open_local_file, question_dialog
from calibre.utils.localization import (available_translations,
get_language, get_lang)
from calibre.utils.config import prefs
@@ -98,6 +100,33 @@ class DisplayedFields(QAbstractListModel): # {{{
# }}}
+class Background(QWidget): # {{{
+
+ def __init__(self, parent):
+ QWidget.__init__(self, parent)
+ self.bcol = QColor(*gprefs['cover_grid_color'])
+ self.btex = gprefs['cover_grid_texture']
+ self.update_brush()
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+
+ def update_brush(self):
+ self.brush = QBrush(self.bcol)
+ if self.btex:
+ from calibre.gui2.preferences.texture_chooser import texture_path
+ path = texture_path(self.btex)
+ if path:
+ self.brush.setTexture(QPixmap(path))
+ self.update()
+
+ def sizeHint(self):
+ return QSize(200, 120)
+
+ def paintEvent(self, ev):
+ painter = QPainter(self)
+ painter.fillRect(ev.rect(), self.brush)
+ painter.end()
+# }}}
+
class ConfigWidget(ConfigWidgetBase, Ui_Form):
size_calculated = pyqtSignal(object)
@@ -209,10 +238,24 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
keys = [unicode(x.toString(QKeySequence.NativeText)) for x in keys]
self.fs_help_msg.setText(unicode(self.fs_help_msg.text())%(
_(' or ').join(keys)))
- self.cover_grid_color_button.clicked.connect(self.change_cover_grid_color)
- self.cover_grid_default_color_button.clicked.connect(self.restore_cover_grid_color)
self.size_calculated.connect(self.update_cg_cache_size, type=Qt.QueuedConnection)
self.tabWidget.currentChanged.connect(self.tab_changed)
+
+ l = self.cg_background_box.layout()
+ self.cg_bg_widget = w = Background(self)
+ l.addWidget(w, 0, 0, 3, 1)
+ self.cover_grid_color_button = b = QPushButton(_('Change &color'), self)
+ b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ l.addWidget(b, 0, 1)
+ b.clicked.connect(self.change_cover_grid_color)
+ self.cover_grid_texture_button = b = QPushButton(_('Change &background image'), self)
+ b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ l.addWidget(b, 1, 1)
+ b.clicked.connect(self.change_cover_grid_texture)
+ self.cover_grid_default_appearance_button = b = QPushButton(_('Restore &default appearance'), self)
+ b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ l.addWidget(b, 2, 1)
+ b.clicked.connect(self.restore_cover_grid_appearance)
self.cover_grid_empty_cache.clicked.connect(self.empty_cache)
self.cover_grid_open_cache.clicked.connect(self.open_cg_cache)
self.cover_grid_smaller_cover.clicked.connect(partial(self.resize_cover, True))
@@ -270,6 +313,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.edit_rules.initialize(db.field_metadata, db.prefs, mi, 'column_color_rules')
self.icon_rules.initialize(db.field_metadata, db.prefs, mi, 'column_icon_rules')
self.set_cg_color(gprefs['cover_grid_color'])
+ self.set_cg_texture(gprefs['cover_grid_texture'])
self.update_aspect_ratio()
def open_cg_cache(self):
@@ -292,9 +336,12 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.size_calculated.emit(self.gui.grid_view.thumbnail_cache.current_size)
def set_cg_color(self, val):
- pal = QPalette()
- pal.setColor(QPalette.Window, QColor(*val))
- self.cover_grid_color_label.setPalette(pal)
+ self.cg_bg_widget.bcol = QColor(*val)
+ self.cg_bg_widget.update_brush()
+
+ def set_cg_texture(self, val):
+ self.cg_bg_widget.btex = val
+ self.cg_bg_widget.update_brush()
def empty_cache(self):
self.gui.grid_view.thumbnail_cache.empty()
@@ -312,17 +359,32 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.icon_rules.clear()
self.changed_signal.emit()
self.set_cg_color(gprefs.defaults['cover_grid_color'])
+ self.set_cg_texture(gprefs.defaults['cover_grid_texture'])
def change_cover_grid_color(self):
- col = QColorDialog.getColor(self.cover_grid_color_label.palette().color(QPalette.Window),
+ col = QColorDialog.getColor(self.cg_bg_widget.bcol,
self.gui, _('Choose background color for cover grid'))
if col.isValid():
col = tuple(col.getRgb())[:3]
self.set_cg_color(col)
self.changed_signal.emit()
+ if self.cg_bg_widget.btex:
+ if question_dialog(
+ self, _('Remove background image?'),
+ _('There is currently a background image set, so the color'
+ ' you have chosen will not be visible. Remove the background image?')):
+ self.set_cg_texture(None)
- def restore_cover_grid_color(self):
+ def change_cover_grid_texture(self):
+ from calibre.gui2.preferences.texture_chooser import TextureChooser
+ d = TextureChooser(parent=self, initial=self.cg_bg_widget.btex)
+ if d.exec_() == d.Accepted:
+ self.set_cg_texture(d.texture)
+ self.changed_signal.emit()
+
+ def restore_cover_grid_appearance(self):
self.set_cg_color(gprefs.defaults['cover_grid_color'])
+ self.set_cg_texture(gprefs.defaults['cover_grid_texture'])
self.changed_signal.emit()
def build_font_obj(self):
@@ -383,7 +445,8 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.display_model.commit()
self.edit_rules.commit(self.gui.current_db.prefs)
self.icon_rules.commit(self.gui.current_db.prefs)
- gprefs['cover_grid_color'] = tuple(self.cover_grid_color_label.palette().color(QPalette.Window).getRgb())[:3]
+ gprefs['cover_grid_color'] = tuple(self.cg_bg_widget.bcol.getRgb())[:3]
+ gprefs['cover_grid_texture'] = self.cg_bg_widget.btex
return rr
def refresh_gui(self, gui):
diff --git a/src/calibre/gui2/preferences/look_feel.ui b/src/calibre/gui2/preferences/look_feel.ui
index 774c017698..af09d6d50d 100644
--- a/src/calibre/gui2/preferences/look_feel.ui
+++ b/src/calibre/gui2/preferences/look_feel.ui
@@ -312,61 +312,12 @@
-
-
-
-
-
-
- Background color for the cover grid:
-
-
- cover_grid_color_button
-
-
-
- -
-
-
-
- 50
- 50
-
-
-
- true
-
-
-
-
-
-
- -
-
-
- Change &color
-
-
-
- -
-
-
- Restore &default color
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
+
+
+ Background for the cover grid
+
+
+
-
diff --git a/src/calibre/gui2/preferences/texture_chooser.py b/src/calibre/gui2/preferences/texture_chooser.py
new file mode 100644
index 0000000000..e10d7d7233
--- /dev/null
+++ b/src/calibre/gui2/preferences/texture_chooser.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python
+# vim:fileencoding=utf-8
+from __future__ import (unicode_literals, division, absolute_import,
+ print_function)
+
+__license__ = 'GPL v3'
+__copyright__ = '2013, Kovid Goyal '
+
+import glob, os, string, shutil
+from functools import partial
+from PyQt4.Qt import (
+ QDialog, QVBoxLayout, QListWidget, QListWidgetItem, Qt, QIcon,
+ QApplication, QSize, QPixmap, QDialogButtonBox, QTimer, QLabel)
+
+from calibre.constants import config_dir
+from calibre.gui2 import choose_files, error_dialog
+from calibre.utils.icu import sort_key
+
+def texture_dir():
+ ans = os.path.join(config_dir, 'textures')
+ if not os.path.exists(ans):
+ os.makedirs(ans)
+ return ans
+
+def texture_path(fname):
+ if not fname:
+ return
+ if fname.startswith(':'):
+ return I('textures/%s' % fname[1:])
+ return os.path.join(texture_dir(), fname)
+
+class TextureChooser(QDialog):
+
+ def __init__(self, parent=None, initial=None):
+ QDialog.__init__(self, parent)
+ self.setWindowTitle(_('Choose a texture'))
+
+ self.l = l = QVBoxLayout()
+ self.setLayout(l)
+
+ self.tdir = texture_dir()
+
+ self.images = il = QListWidget(self)
+ il.itemDoubleClicked.connect(self.accept, type=Qt.QueuedConnection)
+ il.setIconSize(QSize(256, 256))
+ il.setViewMode(il.IconMode)
+ il.setFlow(il.LeftToRight)
+ il.setSpacing(20)
+ il.setSelectionMode(il.SingleSelection)
+ il.itemSelectionChanged.connect(self.update_remove_state)
+ l.addWidget(il)
+
+ self.ad = ad = QLabel(_('The builtin textures come from subtlepatterns.com.'))
+ ad.setOpenExternalLinks(True)
+ ad.setWordWrap(True)
+ l.addWidget(ad)
+ self.bb = bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel)
+ bb.accepted.connect(self.accept)
+ bb.rejected.connect(self.reject)
+ b = self.add_button = bb.addButton(_('Add texture'), bb.ActionRole)
+ b.setIcon(QIcon(I('plus.png')))
+ b.clicked.connect(self.add_texture)
+ b = self.remove_button = bb.addButton(_('Remove texture'), bb.ActionRole)
+ b.setIcon(QIcon(I('minus.png')))
+ b.clicked.connect(self.remove_texture)
+ l.addWidget(bb)
+
+ images = [{
+ 'fname': ':'+os.path.basename(x),
+ 'path': x,
+ 'name': ' '.join(map(string.capitalize, os.path.splitext(os.path.basename(x))[0].split('_')))
+ } for x in glob.glob(I('textures/*.png'))] + [{
+ 'fname': os.path.basename(x),
+ 'path': x,
+ 'name': os.path.splitext(os.path.basename(x))[0],
+ } for x in glob.glob(os.path.join(self.tdir, '*')) if x.rpartition('.')[-1].lower() in {'jpeg', 'png', 'jpg'}]
+
+ images.sort(key=lambda x:sort_key(x['name']))
+
+ map(self.create_item, images)
+ self.update_remove_state()
+
+ if initial:
+ existing = {unicode(i.data(Qt.UserRole).toString()):i for i in (self.images.item(c) for c in xrange(self.images.count()))}
+ item = existing.get(initial, None)
+ if item is not None:
+ item.setSelected(True)
+ QTimer.singleShot(100, partial(il.scrollToItem, item))
+
+ self.resize(QSize(950, 650))
+
+ def create_item(self, data):
+ x = data
+ i = QListWidgetItem(QIcon(QPixmap(x['path']).scaled(256, 256, transformMode=Qt.SmoothTransformation)), x['name'], self.images)
+ i.setData(Qt.UserRole, x['fname'])
+ i.setData(Qt.UserRole+1, x['path'])
+ return i
+
+ def update_remove_state(self):
+ removeable = bool(self.selected_fname and not self.selected_fname.startswith(':'))
+ self.remove_button.setEnabled(removeable)
+
+ @property
+ def texture(self):
+ return self.selected_fname
+
+ def add_texture(self):
+ path = choose_files(self, 'choose-texture-image', _('Choose Image'),
+ filters=[(_('Images'), ['jpeg', 'jpg', 'png'])], all_files=False, select_only_single_file=True)
+ if not path:
+ return
+ path = path[0]
+ fname = os.path.basename(path)
+ name = fname.rpartition('.')[0]
+ existing = {unicode(i.data(Qt.UserRole).toString()):i for i in (self.images.item(c) for c in xrange(self.images.count()))}
+ dest = os.path.join(self.tdir, fname)
+ with open(path, 'rb') as s, open(dest, 'wb') as f:
+ shutil.copyfileobj(s, f)
+ if fname in existing:
+ self.takeItem(existing[fname])
+ data = {'fname': fname, 'path': dest, 'name': name}
+ i = self.create_item(data)
+ i.setSelected(True)
+ self.images.scrollToItem(i)
+
+ @property
+ def selected_item(self):
+ for x in self.images.selectedItems():
+ return x
+
+ @property
+ def selected_fname(self):
+ try:
+ return unicode(self.selected_item.data(Qt.UserRole).toString())
+ except (AttributeError, TypeError):
+ pass
+
+ def remove_texture(self):
+ if not self.selected_fname:
+ return
+ if self.selected_fname.startswith(':'):
+ return error_dialog(self, _('Cannot remove'),
+ _('Cannot remover builtin textures'), show=True)
+ os.remove(unicode(self.selected_item.data(Qt.UserRole+1).toString()))
+ self.images.takeItem(self.images.row(self.selected_item))
+
+if __name__ == '__main__':
+ app = QApplication([]) # noqa
+ d = TextureChooser()
+ d.exec_()
+ print (d.texture)
diff --git a/src/calibre/gui2/tweak_book/__init__.py b/src/calibre/gui2/tweak_book/__init__.py
index 72e61e5ae1..63519c9bea 100644
--- a/src/calibre/gui2/tweak_book/__init__.py
+++ b/src/calibre/gui2/tweak_book/__init__.py
@@ -6,6 +6,8 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal '
+from calibre.utils.config import JSONConfig
+tprefs = JSONConfig('tweak_book_gui')
_current_container = None
diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py
index 61c11597ed..053cea151e 100644
--- a/src/calibre/gui2/tweak_book/boss.py
+++ b/src/calibre/gui2/tweak_book/boss.py
@@ -8,22 +8,25 @@ __copyright__ = '2013, Kovid Goyal '
import tempfile, shutil
-from PyQt4.Qt import QObject
+from PyQt4.Qt import QObject, QApplication
-from calibre.gui2 import error_dialog, choose_files
+from calibre.gui2 import error_dialog, choose_files, question_dialog, info_dialog
from calibre.ptempfile import PersistentTemporaryDirectory
from calibre.ebooks.oeb.polish.main import SUPPORTED
from calibre.ebooks.oeb.polish.container import get_container, clone_container
-from calibre.gui2.tweak_book import set_current_container, current_container
+from calibre.gui2.tweak_book import set_current_container, current_container, tprefs
from calibre.gui2.tweak_book.undo import GlobalUndoHistory
+from calibre.gui2.tweak_book.save import SaveManager
class Boss(QObject):
- def __init__(self, parent=None):
+ def __init__(self, parent):
QObject.__init__(self, parent)
self.global_undo = GlobalUndoHistory()
self.container_count = 0
self.tdir = None
+ self.save_manager = SaveManager(parent)
+ self.save_manager.report_error.connect(self.report_save_error)
def __call__(self, gui):
self.gui = gui
@@ -40,6 +43,10 @@ class Boss(QObject):
def open_book(self, path=None):
if not self.check_dirtied():
return
+ if self.save_manager.has_tasks:
+ return info_dialog(self.gui, _('Cannot open'),
+ _('The current book is being saved, you cannot open a new book until'
+ ' the saving is completed'), show=True)
if not hasattr(path, 'rpartition'):
path = choose_files(self.gui, 'open-book-for-tweaking', _('Choose book'),
@@ -71,12 +78,41 @@ class Boss(QObject):
self.current_metadata = self.gui.current_metadata = container.mi
self.global_undo.open_book(container)
self.gui.update_window_title()
- self.gui.file_list.build(container)
+ self.gui.file_list.build(container, preserve_state=False)
+ self.gui.action_save.setEnabled(False)
+ self.update_global_history_actions()
+
+ def update_global_history_actions(self):
+ gu = self.global_undo
+ for x, text in (('undo', _('&Revert to before')), ('redo', '&Revert to after')):
+ ac = getattr(self.gui, 'action_global_%s' % x)
+ ac.setEnabled(getattr(gu, 'can_' + x))
+ ac.setText(text + ' ' + (getattr(gu, x + '_msg') or '...'))
def add_savepoint(self, msg):
nc = clone_container(current_container(), self.mkdtemp())
self.global_undo.add_savepoint(nc, msg)
set_current_container(nc)
+ self.update_global_history_actions()
+
+ def apply_container_update_to_gui(self):
+ container = current_container()
+ self.gui.file_list.build(container)
+ self.update_global_history_actions()
+ self.gui.action_save.setEnabled(True)
+ # TODO: Apply to other GUI elements
+
+ def do_global_undo(self):
+ container = self.global_undo.undo()
+ if container is not None:
+ set_current_container(container)
+ self.apply_container_update_to_gui()
+
+ def do_global_redo(self):
+ container = self.global_undo.redo()
+ if container is not None:
+ set_current_container(container)
+ self.apply_container_update_to_gui()
def delete_requested(self, spine_items, other_items):
if not self.check_dirtied():
@@ -86,6 +122,48 @@ class Boss(QObject):
c.remove_from_spine(spine_items)
for name in other_items:
c.remove_item(name)
+ self.gui.action_save.setEnabled(True)
self.gui.file_list.delete_done(spine_items, other_items)
+ # TODO: Update other GUI elements
+ def save_book(self):
+ self.gui.action_save.setEnabled(False)
+ tdir = tempfile.mkdtemp(prefix='save-%05d-' % self.container_count, dir=self.tdir)
+ container = clone_container(current_container(), tdir)
+ self.save_manager.schedule(tdir, container)
+ def report_save_error(self, tb):
+ error_dialog(self.gui, _('Could not save'),
+ _('Saving of the book failed. Click "Show Details"'
+ ' for more information.'), det_msg=tb, show=True)
+
+ def quit(self):
+ if not self.confirm_quit():
+ return
+ self.save_state()
+ QApplication.instance().quit()
+
+ def confirm_quit(self):
+ if self.save_manager.has_tasks:
+ if not question_dialog(
+ self.gui, _('Are you sure?'), _(
+ 'The current book is being saved in the background, quitting will abort'
+ ' the save process, are you sure?'), default_yes=False):
+ return False
+ if self.gui.action_save.isEnabled():
+ if not question_dialog(
+ self.gui, _('Are you sure?'), _(
+ 'The current book has unsaved changes, you will lose them if you quit,'
+ ' are you sure?'), default_yes=False):
+ return False
+
+ return True
+
+ def shutdown(self):
+ self.save_state()
+ self.save_manager.shutdown()
+ self.save_manager.wait(0.1)
+
+ def save_state(self):
+ with tprefs:
+ self.gui.save_state()
diff --git a/src/calibre/gui2/tweak_book/file_list.py b/src/calibre/gui2/tweak_book/file_list.py
index b7f1dce6d9..abe0724843 100644
--- a/src/calibre/gui2/tweak_book/file_list.py
+++ b/src/calibre/gui2/tweak_book/file_list.py
@@ -6,6 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal '
+from binascii import hexlify
from PyQt4.Qt import (
QWidget, QTreeWidget, QGridLayout, QSize, Qt, QTreeWidgetItem, QIcon,
QStyledItemDelegate, QStyle, QPixmap, QPainter, pyqtSignal)
@@ -16,6 +17,7 @@ from calibre.ebooks.oeb.polish.container import guess_type
from calibre.ebooks.oeb.polish.cover import get_cover_page_name, get_raster_cover_name
from calibre.gui2 import error_dialog
from calibre.gui2.tweak_book import current_container
+from calibre.utils.icu import sort_key
TOP_ICON_SIZE = 24
NAME_ROLE = Qt.UserRole
@@ -73,7 +75,6 @@ class FileList(QTreeWidget):
self.setAutoExpandDelay(1000)
self.setAnimated(True)
self.setMouseTracking(True)
- self.in_drop_event = False
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_context_menu)
self.root = self.invisibleRootItem()
@@ -89,7 +90,25 @@ class FileList(QTreeWidget):
'images':'view-image.png',
}.iteritems()}
- def build(self, container):
+ def get_state(self):
+ s = {'pos':self.verticalScrollBar().value()}
+ s['expanded'] = {c for c, item in self.categories.iteritems() if item.isExpanded()}
+ s['selected'] = {unicode(i.data(0, NAME_ROLE).toString()) for i in self.selectedItems()}
+ return s
+
+ def set_state(self, state):
+ for category, item in self.categories.iteritems():
+ item.setExpanded(category in state['expanded'])
+ self.verticalScrollBar().setValue(state['pos'])
+ for parent in self.categories.itervalues():
+ for c in (parent.child(i) for i in xrange(parent.childCount())):
+ name = unicode(c.data(0, NAME_ROLE).toString())
+ if name in state['selected']:
+ c.setSelected(True)
+
+ def build(self, container, preserve_state=True):
+ if preserve_state:
+ state = self.get_state()
self.clear()
self.root = self.invisibleRootItem()
self.root.setFlags(Qt.ItemIsDragEnabled)
@@ -140,6 +159,7 @@ class FileList(QTreeWidget):
# We have an exact duplicate (can happen if there are
# duplicates in the spine)
item.setText(0, processed[name].text(0))
+ item.setText(1, processed[name].text(1))
return
parts = name.split('/')
@@ -148,6 +168,7 @@ class FileList(QTreeWidget):
text = parts.pop() + '/' + text
seen[text] = item
item.setText(0, text)
+ item.setText(1, hexlify(sort_key(text)))
def render_emblems(item, emblems):
emblems = tuple(emblems)
@@ -220,11 +241,16 @@ class FileList(QTreeWidget):
continue
processed[name] = create_item(name)
- for c in self.categories.itervalues():
- self.expandItem(c)
+ for name, c in self.categories.iteritems():
+ c.setExpanded(True)
+ if name != 'text':
+ c.sortChildren(1, Qt.AscendingOrder)
+
+ if preserve_state:
+ self.set_state(state)
def show_context_menu(self, point):
- pass
+ pass # TODO: Implement this
def keyPressEvent(self, ev):
if ev.key() in (Qt.Key_Delete, Qt.Key_Backspace):
@@ -262,6 +288,14 @@ class FileList(QTreeWidget):
for c in removals:
c.parent().removeChild(c)
+ def dropEvent(self, event):
+ text = self.categories['text']
+ pre_drop_order = {text.child(i):i for i in xrange(text.childCount())}
+ super(FileList, self).dropEvent(event)
+ current_order = {text.child(i):i for i in xrange(text.childCount())}
+ if current_order != pre_drop_order:
+ pass # TODO: Implement this
+
class FileListWidget(QWidget):
delete_requested = pyqtSignal(object, object)
@@ -277,7 +311,7 @@ class FileListWidget(QWidget):
for x in ('delete_done',):
setattr(self, x, getattr(self.file_list, x))
- def build(self, container):
- self.file_list.build(container)
+ def build(self, container, preserve_state=True):
+ self.file_list.build(container, preserve_state=preserve_state)
diff --git a/src/calibre/gui2/tweak_book/save.py b/src/calibre/gui2/tweak_book/save.py
new file mode 100644
index 0000000000..6c89a75413
--- /dev/null
+++ b/src/calibre/gui2/tweak_book/save.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python
+# vim:fileencoding=utf-8
+from __future__ import (unicode_literals, division, absolute_import,
+ print_function)
+
+__license__ = 'GPL v3'
+__copyright__ = '2013, Kovid Goyal '
+
+import shutil, os
+from threading import Thread
+from Queue import LifoQueue, Empty
+
+from PyQt4.Qt import (QObject, pyqtSignal, QLabel, QWidget, QHBoxLayout, Qt)
+
+from calibre.constants import iswindows
+from calibre.ptempfile import PersistentTemporaryFile
+from calibre.gui2.progress_indicator import ProgressIndicator
+from calibre.utils import join_with_timeout
+from calibre.utils.filenames import atomic_rename
+
+class SaveWidget(QWidget):
+
+ def __init__(self, parent=None):
+ QWidget.__init__(self, parent)
+ self.l = l = QHBoxLayout(self)
+ self.setLayout(l)
+ self.label = QLabel('')
+ self.pi = ProgressIndicator(self, 24)
+ l.addWidget(self.label)
+ l.addWidget(self.pi)
+ l.setContentsMargins(0, 0, 0, 0)
+ self.pi.setVisible(False)
+ self.stop()
+
+ def start(self):
+ self.pi.setVisible(True)
+ self.pi.startAnimation()
+ self.label.setText(_('Saving...'))
+
+ def stop(self):
+ self.pi.setVisible(False)
+ self.pi.stopAnimation()
+ self.label.setText(_('Saved'))
+
+class SaveManager(QObject):
+
+ start_save = pyqtSignal()
+ report_error = pyqtSignal(object)
+ save_done = pyqtSignal()
+
+ def __init__(self, parent):
+ QObject.__init__(self, parent)
+ self.count = 0
+ self.last_saved = -1
+ self.requests = LifoQueue()
+ t = Thread(name='save-thread', target=self.run)
+ t.daemon = True
+ t.start()
+ self.status_widget = w = SaveWidget(parent)
+ self.start_save.connect(w.start, type=Qt.QueuedConnection)
+ self.save_done.connect(w.stop, type=Qt.QueuedConnection)
+
+ def schedule(self, tdir, container):
+ self.count += 1
+ self.requests.put((self.count, tdir, container))
+
+ def run(self):
+ while True:
+ x = self.requests.get()
+ if x is None:
+ self.requests.task_done()
+ self.__empty_queue()
+ break
+ try:
+ count, tdir, container = x
+ self.process_save(count, tdir, container)
+ except:
+ import traceback
+ traceback.print_exc()
+ finally:
+ self.requests.task_done()
+
+ def __empty_queue(self):
+ ' Only to be used during shutdown '
+ while True:
+ try:
+ self.requests.get_nowait()
+ except Empty:
+ break
+ else:
+ self.requests.task_done()
+
+ def process_save(self, count, tdir, container):
+ if count <= self.last_saved:
+ shutil.rmtree(tdir, ignore_errors=True)
+ return
+ self.last_saved = count
+ self.start_save.emit()
+ try:
+ self.do_save(tdir, container)
+ except:
+ import traceback
+ self.report_error.emit(traceback.format_exc())
+ self.save_done.emit()
+
+ def do_save(self, tdir, container):
+ temp = None
+ try:
+ path = container.path_to_ebook
+ temp = PersistentTemporaryFile(
+ prefix=('_' if iswindows else '.'), suffix=os.path.splitext(path)[1], dir=os.path.dirname(path))
+ temp.close()
+ temp = temp.name
+ container.commit(temp)
+ atomic_rename(temp, path)
+ finally:
+ if temp and os.path.exists(temp):
+ os.remove(temp)
+ shutil.rmtree(tdir, ignore_errors=True)
+
+ @property
+ def has_tasks(self):
+ return bool(self.requests.unfinished_tasks)
+
+ def wait(self, timeout=30):
+ if timeout is None:
+ self.requests.join()
+ else:
+ try:
+ join_with_timeout(self.requests, timeout)
+ except RuntimeError:
+ return False
+ return True
+
+ def shutdown(self):
+ self.requests.put(None)
diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py
index d445429f80..d54f55ed8e 100644
--- a/src/calibre/gui2/tweak_book/ui.py
+++ b/src/calibre/gui2/tweak_book/ui.py
@@ -6,10 +6,11 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal '
-from PyQt4.Qt import QDockWidget, Qt, QLabel, QIcon, QAction
+from PyQt4.Qt import QDockWidget, Qt, QLabel, QIcon, QAction, QApplication
+from calibre.constants import __appname__, get_version
from calibre.gui2.main_window import MainWindow
-from calibre.gui2.tweak_book import current_container
+from calibre.gui2.tweak_book import current_container, tprefs
from calibre.gui2.tweak_book.file_list import FileListWidget
from calibre.gui2.tweak_book.job import BlockingJob
from calibre.gui2.tweak_book.boss import Boss
@@ -18,6 +19,7 @@ from calibre.gui2.keyboard import Manager as KeyboardManager
class Main(MainWindow):
APP_NAME = _('Tweak Book')
+ STATE_VERSION = 0
def __init__(self, opts):
MainWindow.__init__(self, opts, disable_automatic_gc=True)
@@ -38,9 +40,17 @@ class Main(MainWindow):
self.status_bar = self.statusBar()
self.l = QLabel('Placeholder')
+ self.status_bar.addPermanentWidget(self.boss.save_manager.status_widget)
+ self.status_bar.addWidget(QLabel(_('{0} {1} created by {2}').format(__appname__, get_version(), 'Kovid Goyal')))
+ f = self.status_bar.font()
+ f.setBold(True)
+ self.status_bar.setFont(f)
self.setCentralWidget(self.l)
self.boss(self)
+ g = QApplication.instance().desktop().availableGeometry(self)
+ self.resize(g.width()-50, g.height()-50)
+ self.restore_state()
self.keyboard.finalize()
@@ -58,18 +68,37 @@ class Main(MainWindow):
return ac
self.action_open_book = reg('document_open.png', _('Open &book'), self.boss.open_book, 'open-book', 'Ctrl+O', _('Open a new book'))
+ self.action_global_undo = reg('back.png', _('&Revert to before'), self.boss.do_global_undo, 'global-undo', 'Ctrl+Left',
+ _('Revert book to before the last action (Undo)'))
+ self.action_global_redo = reg('forward.png', _('&Revert to after'), self.boss.do_global_redo, 'global-redo', 'Ctrl+Right',
+ _('Revert book state to after the next action (Redo)'))
+ self.action_save = reg('save.png', _('&Save'), self.boss.save_book, 'save-book', 'Ctrl+S', _('Save book'))
+ self.action_save.setEnabled(False)
+ self.action_quit = reg('quit.png', _('&Quit'), self.boss.quit, 'quit', 'Ctrl+Q', _('Quit'))
def create_menubar(self):
b = self.menuBar()
+
f = b.addMenu(_('&File'))
f.addAction(self.action_open_book)
+ f.addAction(self.action_save)
+ f.addAction(self.action_quit)
+
+ e = b.addMenu(_('&Edit'))
+ e.addAction(self.action_global_undo)
+ e.addAction(self.action_global_redo)
def create_toolbar(self):
self.global_bar = b = self.addToolBar(_('Global'))
+ b.setObjectName('global_bar') # Needed for saveState
b.addAction(self.action_open_book)
+ b.addAction(self.action_global_undo)
+ b.addAction(self.action_global_redo)
+ b.addAction(self.action_save)
def create_docks(self):
self.file_list_dock = d = QDockWidget(_('&Files Browser'), self)
+ d.setObjectName('file_list_dock') # Needed for saveState
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
self.file_list = FileListWidget(d)
d.setWidget(self.file_list)
@@ -81,3 +110,26 @@ class Main(MainWindow):
def update_window_title(self):
self.setWindowTitle(self.current_metadata.title + ' [%s] - %s' %(current_container().book_type.upper(), self.APP_NAME))
+
+ def closeEvent(self, e):
+ if not self.boss.confirm_quit():
+ e.ignore()
+ return
+ try:
+ self.boss.shutdown()
+ except:
+ import traceback
+ traceback.print_exc()
+ e.accept()
+
+ def save_state(self):
+ tprefs.set('main_window_geometry', bytearray(self.saveGeometry()))
+ tprefs.set('main_window_state', bytearray(self.saveState(self.STATE_VERSION)))
+
+ def restore_state(self):
+ geom = tprefs.get('main_window_geometry', None)
+ if geom is not None:
+ self.restoreGeometry(geom)
+ state = tprefs.get('main_window_state', None)
+ if state is not None:
+ self.restoreState(state, self.STATE_VERSION)
diff --git a/src/calibre/gui2/tweak_book/undo.py b/src/calibre/gui2/tweak_book/undo.py
index 865ba90ef7..c39b2b5694 100644
--- a/src/calibre/gui2/tweak_book/undo.py
+++ b/src/calibre/gui2/tweak_book/undo.py
@@ -53,4 +53,23 @@ class GlobalUndoHistory(object):
self.pos += 1
return self.current_container
+ @property
+ def can_undo(self):
+ return self.pos > 0
+
+ @property
+ def can_redo(self):
+ return self.pos < len(self.states) - 1
+
+ @property
+ def undo_msg(self):
+ if not self.can_undo:
+ return ''
+ return self.states[self.pos - 1].message or ''
+
+ @property
+ def redo_msg(self):
+ if not self.can_redo:
+ return ''
+ return self.states[self.pos].message or ''
diff --git a/src/calibre/library/server/browse.py b/src/calibre/library/server/browse.py
index 383d53f849..470eb0c1f3 100644
--- a/src/calibre/library/server/browse.py
+++ b/src/calibre/library/server/browse.py
@@ -500,7 +500,7 @@ class BrowseServer(object):
datatype, self.opts.url_prefix)
href = re.search(r'