'
+from PyQt4.QtCore import SIGNAL, Qt
+from PyQt4.QtGui import QDialog, QListWidgetItem
+
+from calibre.gui2.dialogs.tag_list_editor_ui import Ui_TagListEditor
+from calibre.gui2 import question_dialog, error_dialog
+
+class TagListEditor(QDialog, Ui_TagListEditor):
+
+ def tag_cmp(self, x, y):
+ return cmp(x.lower(), y.lower())
+
+ def __init__(self, window, db, tag_to_match):
+ QDialog.__init__(self, window)
+ Ui_TagListEditor.__init__(self)
+ self.setupUi(self)
+
+ self.to_rename = {}
+ self.to_delete = []
+ self.db = db
+ self.all_tags = {}
+ for k,v in db.get_tags_with_ids():
+ self.all_tags[v] = k
+ for tag in sorted(self.all_tags.keys(), cmp=self.tag_cmp):
+ item = QListWidgetItem(tag)
+ item.setData(Qt.UserRole, self.all_tags[tag])
+ self.available_tags.addItem(item)
+
+ items = self.available_tags.findItems(tag_to_match, Qt.MatchExactly)
+ if len(items) == 1:
+ self.available_tags.setCurrentItem(items[0])
+
+ self.connect(self.delete_button, SIGNAL('clicked()'), self.delete_tags)
+ self.connect(self.rename_button, SIGNAL('clicked()'), self.rename_tag)
+ self.connect(self.available_tags, SIGNAL('itemDoubleClicked(QListWidgetItem *)'), self._rename_tag)
+ self.connect(self.available_tags, SIGNAL('itemChanged(QListWidgetItem *)'), self.finish_editing)
+
+ def finish_editing(self, item):
+ if item.text() != self.item_before_editing.text():
+ if item.text() in self.all_tags.keys() or item.text() in self.to_rename.keys():
+ error_dialog(self, 'Tag already used',
+ 'The tag %s is already used.'%(item.text())).exec_()
+ item.setText(self.item_before_editing.text())
+ return
+ id,ign = self.item_before_editing.data(Qt.UserRole).toInt()
+ self.to_rename[item.text()] = id
+
+ def rename_tag(self):
+ item = self.available_tags.currentItem()
+ self._rename_tag(item)
+
+ def _rename_tag(self, item):
+ if item is None:
+ error_dialog(self, 'No tag selected', 'You must select one tag from the list of Available tags.').exec_()
+ return
+ self.item_before_editing = item.clone()
+ item.setFlags (item.flags() | Qt.ItemIsEditable);
+ self.available_tags.editItem(item)
+
+ def delete_tags(self, item=None):
+ confirms, deletes = [], []
+ items = self.available_tags.selectedItems() if item is None else [item]
+ if not items:
+ error_dialog(self, 'No tags selected', 'You must select at least one tag from the list of Available tags.').exec_()
+ return
+ for item in items:
+ if self.db.is_tag_used(unicode(item.text())):
+ confirms.append(item)
+ else:
+ deletes.append(item)
+ if confirms:
+ ct = ', '.join([unicode(item.text()) for item in confirms])
+ if question_dialog(self, _('Are your sure?'),
+ ''+_('The following tags are used by one or more books. '
+ 'Are you certain you want to delete them?')+'
'+ct):
+ deletes += confirms
+
+ for item in deletes:
+ self.to_delete.append(item)
+ self.available_tags.takeItem(self.available_tags.row(item))
+
+ def accept(self):
+ for text in self.to_rename:
+ self.db.rename_tag(self.to_rename[text], unicode(text))
+ for item in self.to_delete:
+ self.db.delete_tag(unicode(item.text()))
+ QDialog.accept(self)
+
diff --git a/src/calibre/gui2/dialogs/tag_list_editor.ui b/src/calibre/gui2/dialogs/tag_list_editor.ui
new file mode 100644
index 0000000000..383dc875ac
--- /dev/null
+++ b/src/calibre/gui2/dialogs/tag_list_editor.ui
@@ -0,0 +1,163 @@
+
+
+ TagListEditor
+
+
+
+ 0
+ 0
+ 397
+ 335
+
+
+
+ Tag Editor
+
+
+
+ :/images/chapters.svg:/images/chapters.svg
+
+
+ -
+
+
-
+
+
-
+
+
+ Tags in use
+
+
+ available_tags
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
-
+
+
+ Delete tag from database. This will unapply the tag from all books and then remove it from the database.
+
+
+ ...
+
+
+
+ :/images/trash.svg:/images/trash.svg
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+ Rename the tag everywhere it is used.
+
+
+ ...
+
+
+
+ :/images/edit_input.svg:/images/edit_input.svg
+
+
+
+ 32
+ 32
+
+
+
+ Ctrl+S
+
+
+
+
+
+ -
+
+
+ true
+
+
+ QAbstractItemView::ExtendedSelection
+
+
+ QAbstractItemView::SelectRows
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+
+ buttonBox
+ accepted()
+ TagListEditor
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ buttonBox
+ rejected()
+ TagListEditor
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/src/calibre/gui2/main.py b/src/calibre/gui2/main.py
index c47c821913..29ae1875c8 100644
--- a/src/calibre/gui2/main.py
+++ b/src/calibre/gui2/main.py
@@ -5,13 +5,15 @@ import sys, os, time, socket, traceback
from functools import partial
from PyQt4.Qt import QCoreApplication, QIcon, QMessageBox, QObject, QTimer, \
- QThread, pyqtSignal, Qt, QProgressDialog, QString
+ QThread, pyqtSignal, Qt, QProgressDialog, QString, QPixmap, \
+ QSplashScreen, QApplication
from calibre import prints, plugins
-from calibre.constants import iswindows, __appname__, isosx, filesystem_encoding
+from calibre.constants import iswindows, __appname__, isosx, DEBUG, \
+ filesystem_encoding
from calibre.utils.ipc import ADDRESS, RC
from calibre.gui2 import ORG_NAME, APP_UID, initialize_file_icon_provider, \
- Application, choose_dir, error_dialog, question_dialog
+ Application, choose_dir, error_dialog, question_dialog, gprefs
from calibre.gui2.main_window import option_parser as _option_parser
from calibre.utils.config import prefs, dynamic
from calibre.library.database2 import LibraryDatabase2
@@ -113,15 +115,25 @@ class GuiRunner(QObject):
initialization'''
def __init__(self, opts, args, actions, listener, app):
+ self.startup_time = time.time()
self.opts, self.args, self.listener, self.app = opts, args, listener, app
self.actions = actions
self.main = None
QObject.__init__(self)
+ self.splash_screen = None
self.timer = QTimer.singleShot(1, self.initialize)
+ if DEBUG:
+ prints('Starting up...')
def start_gui(self):
from calibre.gui2.ui import Main
- main = Main(self.library_path, self.db, self.listener, self.opts, self.actions)
+ main = Main(self.opts)
+ if self.splash_screen is not None:
+ self.splash_screen.showMessage(_('Initializing user interface...'))
+ self.splash_screen.finish(main)
+ main.initialize(self.library_path, self.db, self.listener, self.actions)
+ if DEBUG:
+ prints('Started up in', time.time() - self.startup_time)
add_filesystem_book = partial(main.add_filesystem_book, allow_device=False)
sys.excepthook = main.unhandled_exception
if len(self.args) > 1:
@@ -142,7 +154,7 @@ class GuiRunner(QObject):
if db is None and tb is not None:
# DB Repair failed
- error_dialog(None, _('Repairing failed'),
+ error_dialog(self.splash_screen, _('Repairing failed'),
_('The database repair failed. Starting with '
'a new empty library.'),
det_msg=tb, show=True)
@@ -159,7 +171,7 @@ class GuiRunner(QObject):
os.makedirs(x)
except:
x = os.path.expanduser('~')
- candidate = choose_dir(None, 'choose calibre library',
+ candidate = choose_dir(self.splash_screen, 'choose calibre library',
_('Choose a location for your new calibre e-book library'),
default_dir=x)
@@ -170,7 +182,7 @@ class GuiRunner(QObject):
self.library_path = candidate
db = LibraryDatabase2(candidate)
except:
- error_dialog(None, _('Bad database location'),
+ error_dialog(self.splash_screen, _('Bad database location'),
_('Bad database location %r. calibre will now quit.'
)%self.library_path,
det_msg=traceback.format_exc(), show=True)
@@ -184,7 +196,7 @@ class GuiRunner(QObject):
try:
db = LibraryDatabase2(self.library_path)
except (sqlite.Error, DatabaseException):
- repair = question_dialog(None, _('Corrupted database'),
+ repair = question_dialog(self.splash_screen, _('Corrupted database'),
_('Your calibre database appears to be corrupted. Do '
'you want calibre to try and repair it automatically? '
'If you say No, a new empty calibre library will be created.'),
@@ -203,14 +215,27 @@ class GuiRunner(QObject):
self.repair.start()
return
except:
- error_dialog(None, _('Bad database location'),
+ error_dialog(self.splash_screen, _('Bad database location'),
_('Bad database location %r. Will start with '
' a new, empty calibre library')%self.library_path,
det_msg=traceback.format_exc(), show=True)
self.initialize_db_stage2(db, None)
+ def show_splash_screen(self):
+ self.splash_pixmap = QPixmap()
+ self.splash_pixmap.load(I('library.png'))
+ self.splash_screen = QSplashScreen(self.splash_pixmap,
+ Qt.SplashScreen|Qt.WindowStaysOnTopHint)
+ self.splash_screen.showMessage(_('Starting %s: Loading books...') %
+ __appname__)
+ self.splash_screen.show()
+ QApplication.instance().processEvents()
+
def initialize(self, *args):
+ if gprefs.get('show_splash_screen', True):
+ self.show_splash_screen()
+
self.library_path = get_library_path()
if self.library_path is None:
self.initialization_failed()
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 15270e14b1..11db157ed4 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -8,10 +8,11 @@ Browsing book collection by tags.
'''
from itertools import izip
+from functools import partial
from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \
QFont, QSize, QIcon, QPoint, \
- QAbstractItemModel, QVariant, QModelIndex
+ QAbstractItemModel, QVariant, QModelIndex, QMenu
from calibre.gui2 import config, NONE
from calibre.utils.config import prefs
from calibre.library.field_metadata import TagsIcons
@@ -19,9 +20,12 @@ from calibre.utils.search_query_parser import saved_searches
class TagsView(QTreeView): # {{{
- need_refresh = pyqtSignal()
- restriction_set = pyqtSignal(object)
- tags_marked = pyqtSignal(object, object)
+ need_refresh = pyqtSignal()
+ restriction_set = pyqtSignal(object)
+ tags_marked = pyqtSignal(object, object)
+ user_category_edit = pyqtSignal(object)
+ tag_list_edit = pyqtSignal(object)
+ saved_search_edit = pyqtSignal(object)
def __init__(self, *args):
QTreeView.__init__(self, *args)
@@ -31,13 +35,16 @@ class TagsView(QTreeView): # {{{
self.tag_match = None
def set_database(self, db, tag_match, popularity, restriction):
- self._model = TagsModel(db, parent=self)
+ self.hidden_categories = config['tag_browser_hidden_categories']
+ self._model = TagsModel(db, parent=self, hidden_categories=self.hidden_categories)
self.popularity = popularity
self.restriction = restriction
self.tag_match = tag_match
self.db = db
self.setModel(self._model)
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
self.clicked.connect(self.toggle)
+ self.customContextMenuRequested.connect(self.show_context_menu)
self.popularity.setChecked(config['sort_by_popularity'])
self.popularity.stateChanged.connect(self.sort_changed)
self.restriction.activated[str].connect(self.search_restriction_set)
@@ -45,10 +52,6 @@ class TagsView(QTreeView): # {{{
db.add_listener(self.database_changed)
self.saved_searches_changed(recount=False)
- def create_tag_category(self, name, tag_list):
- self._model.create_tag_category(name, tag_list)
- self.recount()
-
def database_changed(self, event, ids):
self.need_refresh.emit()
@@ -72,12 +75,87 @@ class TagsView(QTreeView): # {{{
self.recount() # Must happen after the emission of the restriction_set signal
self.tags_marked.emit(self._model.tokens(), self.match_all)
+ def mouseReleaseEvent(self, event):
+ # Swallow everything except leftButton so context menus work correctly
+ if event.button() == Qt.LeftButton:
+ QTreeView.mouseReleaseEvent(self, event)
+
def toggle(self, index):
modifiers = int(QApplication.keyboardModifiers())
exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT)
if self._model.toggle(index, exclusive):
self.tags_marked.emit(self._model.tokens(), self.match_all)
+ def context_menu_handler(self, action=None, category=None):
+ if not action:
+ return
+ try:
+ if action == 'manage_tags':
+ self.tag_list_edit.emit(category)
+ return
+ if action == 'manage_categories':
+ self.user_category_edit.emit(category)
+ return
+ if action == 'manage_searches':
+ self.saved_search_edit.emit(category)
+ return
+ if action == 'hide':
+ self.hidden_categories.add(category)
+ elif action == 'show':
+ self.hidden_categories.discard(category)
+ elif action == 'defaults':
+ self.hidden_categories.clear()
+ config.set('tag_browser_hidden_categories', self.hidden_categories)
+ self.set_new_model()
+ except:
+ return
+
+ def show_context_menu(self, point):
+ index = self.indexAt(point)
+ if not index.isValid():
+ return False
+ item = index.internalPointer()
+ tag_name = ''
+ if item.type == TagTreeItem.TAG:
+ tag_name = item.tag.name
+ item = item.parent
+ if item.type == TagTreeItem.CATEGORY:
+ category = unicode(item.name.toString())
+ self.context_menu = QMenu(self)
+ self.context_menu.addAction(_('Hide %s') % category,
+ partial(self.context_menu_handler, action='hide', category=category))
+
+ if self.hidden_categories:
+ self.context_menu.addSeparator()
+ m = self.context_menu.addMenu(_('Show category'))
+ for col in self.hidden_categories:
+ m.addAction(col,
+ partial(self.context_menu_handler, action='show', category=col))
+ self.context_menu.addSeparator()
+ self.context_menu.addAction(_('Restore defaults'),
+ partial(self.context_menu_handler, action='defaults'))
+
+ self.context_menu.addSeparator()
+ self.context_menu.addAction(_('Manage Tags'),
+ partial(self.context_menu_handler, action='manage_tags',
+ category=tag_name))
+
+ if category in prefs['user_categories'].keys():
+ self.context_menu.addAction(_('Manage User Categories'),
+ partial(self.context_menu_handler, action='manage_categories',
+ category=category))
+ else:
+ self.context_menu.addAction(_('Manage User Categories'),
+ partial(self.context_menu_handler, action='manage_categories',
+ category=None))
+
+ self.context_menu.addAction(_('Manage Saved Searches'),
+ partial(self.context_menu_handler, action='manage_searches',
+ category=tag_name))
+
+ self.context_menu.popup(self.mapToGlobal(point))
+ return True
+
def clear(self):
self.model().clear_state()
@@ -110,13 +188,12 @@ class TagsView(QTreeView): # {{{
self.setCurrentIndex(idx)
self.scrollTo(idx, QTreeView.PositionAtCenter)
- '''
- If the number of user categories changed, or if custom columns have come or gone,
- we must rebuild the model. Reason: it is much easier to do that than to reconstruct
- the browser tree.
- '''
+ # If the number of user categories changed, if custom columns have come or
+ # gone, or if columns have been hidden or restored, we must rebuild the
+ # model. Reason: it is much easier than reconstructing the browser tree.
def set_new_model(self):
- self._model = TagsModel(self.db, parent=self)
+ self._model = TagsModel(self.db, parent=self,
+ hidden_categories=self.hidden_categories)
self.setModel(self._model)
# }}}
@@ -200,7 +277,7 @@ class TagTreeItem(object): # {{{
class TagsModel(QAbstractItemModel): # {{{
- def __init__(self, db, parent=None):
+ def __init__(self, db, parent=None, hidden_categories=None):
QAbstractItemModel.__init__(self, parent)
# must do this here because 'QPixmap: Must construct a QApplication
@@ -220,6 +297,7 @@ class TagsModel(QAbstractItemModel): # {{{
self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))]
self.db = db
+ self.hidden_categories = hidden_categories
self.search_restriction = ''
self.ignore_next_search = 0
@@ -237,6 +315,8 @@ class TagsModel(QAbstractItemModel): # {{{
data = self.get_node_tree(config['sort_by_popularity'])
self.root_item = TagTreeItem()
for i, r in enumerate(self.row_map):
+ if self.hidden_categories and self.categories[i] in self.hidden_categories:
+ continue
if self.db.field_metadata[r]['kind'] != 'user':
tt = _('The lookup/search name is "{0}"').format(r)
else:
@@ -271,12 +351,16 @@ class TagsModel(QAbstractItemModel): # {{{
def refresh(self):
data = self.get_node_tree(config['sort_by_popularity']) # get category data
+ row_index = -1
for i, r in enumerate(self.row_map):
- category = self.root_item.children[i]
+ if self.hidden_categories and self.categories[i] in self.hidden_categories:
+ continue
+ row_index += 1
+ category = self.root_item.children[row_index]
names = [t.tag.name for t in category.children]
states = [t.tag.state for t in category.children]
state_map = dict(izip(names, states))
- category_index = self.index(i, 0, QModelIndex())
+ category_index = self.index(row_index, 0, QModelIndex())
if len(category.children) > 0:
self.beginRemoveRows(category_index, 0,
len(category.children)-1)
@@ -401,16 +485,20 @@ class TagsModel(QAbstractItemModel): # {{{
def tokens(self):
ans = []
tags_seen = set()
+ row_index = -1
for i, key in enumerate(self.row_map):
+ if self.hidden_categories and self.categories[i] in self.hidden_categories:
+ continue
+ row_index += 1
if key.endswith(':'): # User category, so skip it. The tag will be marked in its real category
continue
- category_item = self.root_item.children[i]
+ category_item = self.root_item.children[row_index]
for tag_item in category_item.children:
tag = tag_item.tag
if tag.state > 0:
prefix = ' not ' if tag.state == 2 else ''
category = key if key != 'news' else 'tag'
- if tag.name[0] == u'\u2605': # char is a star. Assume rating
+ if tag.name and tag.name[0] == u'\u2605': # char is a star. Assume rating
ans.append('%s%s:%s'%(prefix, category, len(tag.name)))
else:
if category == 'tags':
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 8bc85e7195..773f44acd2 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -61,6 +61,8 @@ from calibre.library.database2 import LibraryDatabase2
from calibre.library.caches import CoverCache
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.dialogs.tag_categories import TagCategories
+from calibre.gui2.dialogs.tag_list_editor import TagListEditor
+from calibre.gui2.dialogs.saved_search_editor import SavedSearchEditor
class SaveMenu(QMenu):
@@ -127,13 +129,18 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
pixmap_to_data(pixmap))
self.last_time = datetime.datetime.now()
- def __init__(self, library_path, db, listener, opts, actions, parent=None):
+
+ def __init__(self, opts, parent=None):
+ MainWindow.__init__(self, opts, parent)
+ self.opts = opts
+
+ def initialize(self, library_path, db, listener, actions):
+ opts = self.opts
self.last_time = datetime.datetime.now()
self.preferences_action, self.quit_action = actions
self.library_path = library_path
self.spare_servers = []
self.must_restart_before_config = False
- MainWindow.__init__(self, opts, parent)
# Initialize fontconfig in a separate thread as this can be a lengthy
# process if run for the first time on this machine
from calibre.utils.fonts import fontconfig
@@ -537,19 +544,23 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.cover_cache = CoverCache(self.library_path)
self.cover_cache.start()
self.library_view.model().cover_cache = self.cover_cache
- self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_edit_categories)
+ self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_user_categories_edit)
self.tags_view.set_database(db, self.tag_match, self.popularity, self.search_restriction)
self.tags_view.tags_marked.connect(self.search.search_from_tags)
for x in (self.saved_search.clear_to_help, self.mark_restriction_set):
self.tags_view.restriction_set.connect(x)
self.tags_view.tags_marked.connect(self.saved_search.clear_to_help)
+ self.tags_view.tag_list_edit.connect(self.do_tags_list_edit)
+ self.tags_view.user_category_edit.connect(self.do_user_categories_edit)
+ self.tags_view.saved_search_edit.connect(self.do_saved_search_edit)
self.search.search.connect(self.tags_view.model().reinit)
for x in (self.location_view.count_changed, self.tags_view.recount,
self.restriction_count_changed):
self.library_view.model().count_changed_signal.connect(x)
self.connect(self.search, SIGNAL('cleared()'), self.search_box_cleared)
- self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.saved_searches_changed, Qt.QueuedConnection)
+ self.connect(self.saved_search, SIGNAL('changed()'),
+ self.tags_view.saved_searches_changed, Qt.QueuedConnection)
if not gprefs.get('quick_start_guide_added', False):
from calibre.ebooks.metadata import MetaInformation
mi = MetaInformation(_('Calibre Quick Start Guide'), ['John Schember'])
@@ -642,13 +653,28 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self._add_filesystem_book = Dispatcher(self.__add_filesystem_book)
self.keyboard_interrupt.connect(self.quit, type=Qt.QueuedConnection)
- def do_edit_categories(self):
- d = TagCategories(self, self.library_view.model().db)
+ def do_user_categories_edit(self, on_category=None):
+ d = TagCategories(self, self.library_view.model().db, on_category)
d.exec_()
if d.result() == d.Accepted:
self.tags_view.set_new_model()
self.tags_view.recount()
+ def do_tags_list_edit(self, tag):
+ d = TagListEditor(self, self.library_view.model().db, tag)
+ d.exec_()
+ if d.result() == d.Accepted:
+ self.tags_view.set_new_model()
+ self.tags_view.recount()
+ self.library_view.model().refresh()
+
+ def do_saved_search_edit(self, search):
+ d = SavedSearchEditor(self, search)
+ d.exec_()
+ if d.result() == d.Accepted:
+ self.tags_view.saved_searches_changed(recount=True)
+ self.saved_search.clear_to_help()
+
def resizeEvent(self, ev):
MainWindow.resizeEvent(self, ev)
self.search.setMaximumWidth(self.width()-150)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 93891ee92b..83c56c5395 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -17,7 +17,7 @@ from calibre.utils.config import tweaks
from calibre.utils.date import parse_date, now, UNDEFINED_DATE
from calibre.utils.search_query_parser import SearchQueryParser
from calibre.utils.pyparsing import ParseException
-# from calibre.library.field_metadata import FieldMetadata
+from calibre.ebooks.metadata import title_sort
class CoverCache(QThread):
@@ -564,7 +564,8 @@ class ResultCache(SearchQueryParser):
def seriescmp(self, x, y):
sidx = self.FIELD_MAP['series']
try:
- ans = cmp(self._data[x][sidx].lower(), self._data[y][sidx].lower())
+ ans = cmp(title_sort(self._data[x][sidx].lower()),
+ title_sort(self._data[y][sidx].lower()))
except AttributeError: # Some entries may be None
ans = cmp(self._data[x][sidx], self._data[y][sidx])
if ans != 0: return ans
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 4107d327ce..0544293095 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -648,6 +648,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.conn.execute(st%dict(ltable='publishers', table='publishers', ltable_col='publisher'))
self.conn.execute(st%dict(ltable='tags', table='tags', ltable_col='tag'))
self.conn.execute(st%dict(ltable='series', table='series', ltable_col='series'))
+ for id_, tag in self.conn.get('SELECT id, name FROM tags', all=True):
+ if not tag.strip():
+ self.conn.execute('DELETE FROM books_tags_link WHERE tag=?',
+ (id_,))
+ self.conn.execute('DELETE FROM tags WHERE id=?', (id_,))
self.clean_custom()
self.conn.commit()
@@ -725,6 +730,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0],
icon=icon, tooltip = tooltip)
for r in data if item_not_zero_func(r)]
+ if category == 'series':
+ categories[category].sort(cmp=lambda x,y:cmp(title_sort(x.name),
+ title_sort(y.name)))
# We delayed computing the standard formats category because it does not
# use a view, but is computed dynamically
@@ -977,6 +985,20 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if notify:
self.notify('metadata', [id])
+ # Convenience method for tags_list_editor
+ def get_tags_with_ids(self):
+ result = self.conn.get('SELECT * FROM tags')
+ if not result:
+ return {}
+ r = []
+ for k,v in result:
+ r.append((k,v))
+ return r
+
+ def rename_tag(self, id, new):
+ self.conn.execute('UPDATE tags SET name=? WHERE id=?', (new, id))
+ self.conn.commit()
+
def get_tags(self, id):
result = self.conn.get(
'SELECT name FROM tags WHERE id IN (SELECT tag FROM books_tags_link WHERE book=?)',
diff --git a/src/calibre/library/server/content.py b/src/calibre/library/server/content.py
index 8638035c88..12bd786322 100644
--- a/src/calibre/library/server/content.py
+++ b/src/calibre/library/server/content.py
@@ -16,7 +16,7 @@ except ImportError:
from calibre import fit_image, guess_type
from calibre.utils.date import fromtimestamp
-
+from calibre.ebooks.metadata import title_sort
class ContentServer(object):
@@ -67,7 +67,7 @@ class ContentServer(object):
def seriescmp(self, x, y):
si = self.db.FIELD_MAP['series']
try:
- ans = cmp(x[si].lower(), y[si].lower())
+ ans = cmp(title_sort(x[si].lower()), title_sort(y[si].lower()))
except AttributeError: # Some entries may be None
ans = cmp(x[si], y[si])
if ans != 0: return ans
diff --git a/src/calibre/manual/conversion.rst b/src/calibre/manual/conversion.rst
index ee148c79c7..cd8abd0493 100644
--- a/src/calibre/manual/conversion.rst
+++ b/src/calibre/manual/conversion.rst
@@ -453,7 +453,7 @@ as HTML and then convert the resulting HTML file with |app|. When saving as HTML
There is a Word macro package that can automate the conversion of Word documents using |app|. It also makes
generating the Table of Contents much simpler. It is called BookCreator and is available for free
-`here `_.
+at `mobileread `_.
Convert TXT documents
~~~~~~~~~~~~~~~~~~~~~~
@@ -493,7 +493,7 @@ TXT input supports a number of options to differentiate how paragraphs are detec
allows for basic formatting to be added to TXT documents, such as bold, italics, section headings, tables,
lists, a Table of Contents, etc. Marking chapter headings with a leading # and setting the chapter XPath detection
expression to "//h:h1" is the easiest way to have a proper table of contents generated from a TXT document.
- You can learn more about the markdown syntax `here `_.
+ You can learn more about the markdown syntax at `daringfireball `_.
Convert PDF documents
@@ -540,7 +540,7 @@ EPUB advanced formatting demo
Various advanced formatting for EPUB files is demonstrated in this `demo file `_.
The file was created from hand coded HTML using calibre and is meant to be used as a template for your own EPUB creation efforts.
-The source HTML it was created from is available `here `_. The settings used to create the
+The source HTML it was created from is available `demo.zip `_. The settings used to create the
EPUB from the ZIP file are::
ebook-convert demo.zip .epub -vv --authors "Kovid Goyal" --language en --level1-toc '//*[@class="title"]' --disable-font-rescaling --page-breaks-before / --no-default-epub-cover
diff --git a/src/calibre/manual/faq.rst b/src/calibre/manual/faq.rst
index f7329fb54d..e606505194 100644
--- a/src/calibre/manual/faq.rst
+++ b/src/calibre/manual/faq.rst
@@ -133,7 +133,7 @@ Can I use the collections feature of the SONY reader?
turned into a collection on the reader. Note that the PRS-500 does not support collections for books stored on the SD card. The PRS-505 does.
How do I use |app| with my iPad/iPhone/iTouch?
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can access your calibre library on a iPad/iPhone/iTouch over the air using the calibre content server.
diff --git a/src/calibre/manual/news_recipe.rst b/src/calibre/manual/news_recipe.rst
index 8f39cee387..c840cefb53 100644
--- a/src/calibre/manual/news_recipe.rst
+++ b/src/calibre/manual/news_recipe.rst
@@ -115,7 +115,7 @@ Pre/post processing of downloaded HTML
.. automethod:: BasicNewsRecipe.postprocess_html
-
+.. automethod:: BasicNewsRecipe.populate_article_metadata
Convenience methods
diff --git a/src/calibre/utils/magick_draw.py b/src/calibre/utils/magick_draw.py
index 0288107b45..5625da0869 100644
--- a/src/calibre/utils/magick_draw.py
+++ b/src/calibre/utils/magick_draw.py
@@ -51,6 +51,8 @@ class FontMetrics(object):
def get_font_metrics(image, d_wand, text):
+ if isinstance(text, unicode):
+ text = text.encode('utf-8')
ret = p.MagickQueryFontMetrics(image, d_wand, text)
return FontMetrics(ret)
diff --git a/src/calibre/web/feeds/news.py b/src/calibre/web/feeds/news.py
index 26b3ad0593..79cec4a2a7 100644
--- a/src/calibre/web/feeds/news.py
+++ b/src/calibre/web/feeds/news.py
@@ -146,7 +146,7 @@ class BasicNewsRecipe(Recipe):
#: If True empty feeds are removed from the output.
#: This option has no effect if parse_index is overriden in
#: the sub class. It is meant only for recipes that return a list
- #: of feeds using :member:`feeds` or :method:`get_feeds`.
+ #: of feeds using `feeds` or :method:`get_feeds`.
remove_empty_feeds = False
#: List of regular expressions that determines which links to follow
@@ -254,9 +254,9 @@ class BasicNewsRecipe(Recipe):
#: will remove everythong from `` to ``.
preprocess_regexps = []
- #: The CSS that is used to styles the templates, i.e., the navigation bars and
+ #: The CSS that is used to style the templates, i.e., the navigation bars and
#: the Tables of Contents. Rather than overriding this variable, you should
- #: use :member:`extra_css` in your recipe to customize look and feel.
+ #: use `extra_css` in your recipe to customize look and feel.
template_css = u'''
.article_date {
color: gray; font-family: monospace;
@@ -506,7 +506,7 @@ class BasicNewsRecipe(Recipe):
def get_obfuscated_article(self, url):
'''
- If you set :member:`articles_are_obfuscated` this method is called with
+ If you set `articles_are_obfuscated` this method is called with
every article URL. It should return the path to a file on the filesystem
that contains the article HTML. That file is processed by the recursive
HTML fetching engine, so it can contain links to pages/images on the web.
@@ -517,6 +517,19 @@ class BasicNewsRecipe(Recipe):
'''
raise NotImplementedError
+ def populate_article_metadata(self, article, soup, first):
+ '''
+ Called when each HTML page belonging to article is downloaded.
+ Intended to be used to get article metadata like author/summary/etc.
+ from the parsed HTML (soup).
+ :param article: A object of class :class:`calibre.web.feeds.Article`.
+ If you chane the sumamry, remeber to also change the
+ text_summary
+ :param soup: Parsed HTML belonging to this article
+ :param first: True iff the parsed HTML is the first page of the article.
+ '''
+ pass
+
def postprocess_book(self, oeb, opts, log):
'''
Run any needed post processing on the parsed downloaded e-book.
@@ -544,6 +557,8 @@ class BasicNewsRecipe(Recipe):
self.username = options.username
self.password = options.password
self.lrf = options.lrf
+ self.output_profile = options.output_profile
+ self.touchscreen = getattr(self.output_profile, 'touchscreen', False)
self.output_dir = os.path.abspath(self.output_dir)
if options.test:
@@ -597,7 +612,7 @@ class BasicNewsRecipe(Recipe):
if self.delay > 0:
self.simultaneous_downloads = 1
- self.navbar = templates.NavBarTemplate()
+ self.navbar = templates.TouchscreenNavBarTemplate() if self.touchscreen else templates.NavBarTemplate()
self.failed_downloads = []
self.partial_failures = []
@@ -638,7 +653,15 @@ class BasicNewsRecipe(Recipe):
for base in list(soup.findAll(['base', 'iframe'])):
base.extract()
- return self.postprocess_html(soup, first_fetch)
+ ans = self.postprocess_html(soup, first_fetch)
+ try:
+ article = self.feed_objects[f].articles[a]
+ except:
+ self.log.exception('Failed to get article object for postprocessing')
+ pass
+ else:
+ self.populate_article_metadata(article, ans, first_fetch)
+ return ans
def download(self):
@@ -674,7 +697,11 @@ class BasicNewsRecipe(Recipe):
def feeds2index(self, feeds):
templ = templates.IndexTemplate()
css = self.template_css + '\n\n' +(self.extra_css if self.extra_css else '')
- return templ.generate(self.title, self.timefmt, feeds,
+ timefmt = self.timefmt
+ if self.touchscreen:
+ templ = templates.TouchscreenIndexTemplate()
+ timefmt = '%A, %d %b %Y'
+ return templ.generate(self.title, "mastheadImage.jpg", timefmt, feeds,
extra_css=css).render(doctype='xhtml')
@classmethod
@@ -727,6 +754,44 @@ class BasicNewsRecipe(Recipe):
templ = templates.FeedTemplate()
css = self.template_css + '\n\n' +(self.extra_css if self.extra_css else '')
+
+ if self.touchscreen:
+ touchscreen_css = u'''
+ .summary_headline {
+ font-size:large; font-weight:bold; margin-top:0px; margin-bottom:0px;
+ }
+
+ .summary_byline {
+ font-size:small; margin-top:0px; margin-bottom:0px;
+ }
+
+ .summary_text {
+ margin-top:0px; margin-bottom:0px;
+ }
+
+ .feed {
+ font-family:sans-serif; font-weight:bold; font-size:larger;
+ }
+
+ .calibre_navbar {
+ font-family:monospace;
+ }
+ hr {
+ border-color:gray;
+ border-style:solid;
+ border-width:thin;
+ }
+
+ table.toc {
+ font-size:large;
+ }
+ td.article_count {
+ text-align:right;
+ }
+ '''
+
+ templ = templates.TouchscreenFeedTemplate()
+ css = touchscreen_css + '\n\n' + (self.extra_css if self.extra_css else '')
return templ.generate(feed, self.description_limiter,
extra_css=css).render(doctype='xhtml')
@@ -820,6 +885,7 @@ class BasicNewsRecipe(Recipe):
if hasattr(feed, 'reverse'):
feed.reverse()
+ self.feed_objects = feeds
for f, feed in enumerate(feeds):
feed_dir = os.path.join(self.output_dir, 'feed_%d'%f)
if not os.path.isdir(feed_dir):
@@ -1053,6 +1119,9 @@ class BasicNewsRecipe(Recipe):
mi = MetaInformation(self.short_title() + strftime(self.timefmt), [__appname__])
mi.publisher = __appname__
mi.author_sort = __appname__
+ if self.output_profile.name == 'iPad':
+ mi.authors = [strftime('%A, %d %B %Y')]
+ mi.author_sort = strftime('%Y-%m-%d')
mi.publication_type = 'periodical:'+self.publication_type
mi.timestamp = nowf()
mi.comments = self.description
diff --git a/src/calibre/web/feeds/templates.py b/src/calibre/web/feeds/templates.py
index 4de7c42daa..af0c8da6b4 100644
--- a/src/calibre/web/feeds/templates.py
+++ b/src/calibre/web/feeds/templates.py
@@ -5,7 +5,8 @@ __copyright__ = '2008, Kovid Goyal '
from lxml import html, etree
from lxml.html.builder import HTML, HEAD, TITLE, STYLE, DIV, BODY, \
- STRONG, BR, H1, SPAN, A, HR, UL, LI, H2, IMG, P as PT
+ STRONG, BR, H1, SPAN, A, HR, UL, LI, H2, IMG, P as PT, \
+ TABLE, TD, TR
from calibre import preferred_encoding, strftime, isbytestring
@@ -89,12 +90,55 @@ class NavBarTemplate(Template):
self.root = HTML(head, BODY(navbar))
+class TouchscreenNavBarTemplate(Template):
+ def _generate(self, bottom, feed, art, number_of_articles_in_feed,
+ two_levels, url, __appname__, prefix='', center=True,
+ extra_css=None, style=None):
+ head = HEAD(TITLE('navbar'))
+ if style:
+ head.append(STYLE(style, type='text/css'))
+ if extra_css:
+ head.append(STYLE(extra_css, type='text/css'))
+ if prefix and not prefix.endswith('/'):
+ prefix += '/'
+ align = 'center' if center else 'left'
+ navbar = DIV(CLASS('calibre_navbar', 'calibre_rescale_100',
+ style='text-align:'+align))
+ if bottom:
+ navbar.append(HR())
+ text = 'This article was downloaded by '
+ p = PT(text, STRONG(__appname__), A(url, href=url), style='text-align:left')
+ p[0].tail = ' from '
+ navbar.append(BR())
+ navbar.append(BR())
+ else:
+ next = 'feed_%d'%(feed+1) if art == number_of_articles_in_feed - 1 \
+ else 'article_%d'%(art+1)
+ up = '../..' if art == number_of_articles_in_feed - 1 else '..'
+ href = '%s%s/%s/index.html'%(prefix, up, next)
+ navbar.text = '| '
+ navbar.append(A('Next', href=href))
+ href = '%s../index.html#article_%d'%(prefix, art)
+ navbar.iterchildren(reversed=True).next().tail = ' | '
+ navbar.append(A('Section Menu', href=href))
+ href = '%s../../index.html#feed_%d'%(prefix, feed)
+ navbar.iterchildren(reversed=True).next().tail = ' | '
+ navbar.append(A('Main Menu', href=href))
+ if art > 0 and not bottom:
+ href = '%s../article_%d/index.html'%(prefix, art-1)
+ navbar.iterchildren(reversed=True).next().tail = ' | '
+ navbar.append(A('Previous', href=href))
+ navbar.iterchildren(reversed=True).next().tail = ' | '
+ if not bottom:
+ navbar.append(HR())
+
+ self.root = HTML(head, BODY(navbar))
class IndexTemplate(Template):
- def _generate(self, title, datefmt, feeds, extra_css=None, style=None):
+ def _generate(self, title, masthead, datefmt, feeds, extra_css=None, style=None):
if isinstance(datefmt, unicode):
datefmt = datefmt.encode(preferred_encoding)
date = strftime(datefmt)
@@ -110,12 +154,40 @@ class IndexTemplate(Template):
href='feed_%d/index.html'%i)), id='feed_%d'%i)
ul.append(li)
div = DIV(
- H1(title, CLASS('calibre_recipe_title', 'calibre_rescale_180')),
+ PT(IMG(src=masthead,alt="masthead"),style='text-align:center'),
PT(date, style='text-align:right'),
ul,
CLASS('calibre_rescale_100'))
self.root = HTML(head, BODY(div))
+class TouchscreenIndexTemplate(Template):
+
+ def _generate(self, title, masthead, datefmt, feeds, extra_css=None, style=None):
+ if isinstance(datefmt, unicode):
+ datefmt = datefmt.encode(preferred_encoding)
+ date = strftime(datefmt)
+ masthead_img = IMG(src=masthead,alt="masthead")
+ head = HEAD(TITLE(title))
+ if style:
+ head.append(STYLE(style, type='text/css'))
+ if extra_css:
+ head.append(STYLE(extra_css, type='text/css'))
+
+ toc = TABLE(CLASS('toc'),width="100%",border="0",cellpadding="3px")
+ for i, feed in enumerate(feeds):
+ if feed:
+ tr = TR()
+ tr.append(TD( CLASS('toc_item'), A(feed.title, href='feed_%d/index.html'%i)))
+ tr.append(TD( CLASS('article_count'),'%d' % len(feed.articles)))
+ toc.append(tr)
+
+ div = DIV(
+ PT(masthead_img,style='text-align:center'),
+ PT(date, style='text-align:center'),
+ toc,
+ CLASS('calibre_rescale_100'))
+ self.root = HTML(head, BODY(div))
+
class FeedTemplate(Template):
def _generate(self, feed, cutoff, extra_css=None, style=None):
@@ -166,6 +238,56 @@ class FeedTemplate(Template):
self.root = HTML(head, body)
+class TouchscreenFeedTemplate(Template):
+
+ def _generate(self, feed, cutoff, extra_css=None, style=None):
+ head = HEAD(TITLE(feed.title))
+ if style:
+ head.append(STYLE(style, type='text/css'))
+ if extra_css:
+ head.append(STYLE(extra_css, type='text/css'))
+ body = BODY(style='page-break-before:always')
+ div = DIV(
+ H2(feed.title,
+ CLASS('calibre_feed_title', 'calibre_rescale_160')),
+ CLASS('calibre_rescale_100')
+ )
+ body.append(div)
+ if getattr(feed, 'image', None):
+ div.append(DIV(IMG(
+ alt = feed.image_alt if feed.image_alt else '',
+ src = feed.image_url
+ ),
+ CLASS('calibre_feed_image')))
+ if getattr(feed, 'description', None):
+ d = DIV(feed.description, CLASS('calibre_feed_description',
+ 'calibre_rescale_80'))
+ d.append(BR())
+ div.append(d)
+
+ toc = TABLE(CLASS('toc'),width="100%",border="0",cellpadding="3px")
+ for i, article in enumerate(feed.articles):
+ if not getattr(article, 'downloaded', False):
+ continue
+ tr = TR()
+ td = TD(
+ A(article.title, CLASS('article calibre_rescale_100',
+ href=article.url))
+ )
+ if article.summary:
+ td.append(DIV(cutoff(article.text_summary),
+ CLASS('article_description', 'calibre_rescale_80')))
+ tr.append(td)
+ toc.append(tr)
+ div.append(toc)
+
+ navbar = DIV('| ', CLASS('calibre_navbar', 'calibre_rescale_100'),style='text-align:center')
+ link = A('Up one level', href="../index.html")
+ link.tail = ' |'
+ navbar.append(link)
+ div.append(navbar)
+
+ self.root = HTML(head, body)
class EmbeddedContent(Template):
diff --git a/src/calibre/web/fetch/simple.py b/src/calibre/web/fetch/simple.py
index 93fb516f2d..c1f0d912d6 100644
--- a/src/calibre/web/fetch/simple.py
+++ b/src/calibre/web/fetch/simple.py
@@ -328,6 +328,9 @@ class RecursiveFetcher(object):
continue
try:
data = self.fetch_url(iurl)
+ if data == 'GIF89a\x01':
+ # Skip empty GIF files as PIL errors on them anyway
+ continue
except Exception:
self.log.exception('Could not fetch image %s'% iurl)
continue