'
+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 efb24bcc9a..30282e3b2b 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
@@ -256,7 +256,7 @@ class BasicNewsRecipe(Recipe):
#: 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,20 +517,18 @@ class BasicNewsRecipe(Recipe):
'''
raise NotImplementedError
- def extract_author(self, soup):
+ def populate_article_metadata(self, article, soup, first):
'''
- Parse downloaded articles for author, add to OEBBook object.
- :param soup:
+ 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 change the sumamry, remember 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.
'''
- return None
-
- def extract_description(self, soup):
- '''
- Parse downloaded articles for description, add to OEBBook object.
- :param soup:
- '''
- return None
-
+ pass
def postprocess_book(self, oeb, opts, log):
'''
@@ -559,8 +557,8 @@ class BasicNewsRecipe(Recipe):
self.username = options.username
self.password = options.password
self.lrf = options.lrf
- self.output_profile = options.output_profile.name
- self.touchscreen = getattr(options.output_profile,'touchscreen',False)
+ 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:
@@ -655,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):
@@ -879,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):
@@ -927,41 +934,9 @@ class BasicNewsRecipe(Recipe):
#feeds.restore_duplicates()
- # GwR Populate any missing author/description fields in feed
for f, feed in enumerate(feeds):
- feed_dir = os.path.join(self.output_dir, 'feed_%d'%f)
- for article in feed.articles:
- if article.summary == '' or article.author == '':
- file = os.path.join(self.output_dir,feed_dir, article.url)
- if os.path.exists(file):
- with open(file, 'rb') as fi:
- src = fi.read().decode('utf-8')
- soup = BeautifulSoup(src)
- if article.author == '':
- author = self.extract_author(soup)
- if author and not isinstance(author, unicode):
- author = author.decode('utf-8', 'replace')
- article.author = author
-
- if article.summary == '':
- summary = article.summary = self.extract_description(soup)
- if summary and not isinstance(summary, unicode):
- summary = summary.decode('utf-8', 'replace')
- if summary and '<' in summary:
- try:
- s = html.fragment_fromstring(summary, create_parent=True)
- summary = html.tostring(s, method='text', encoding=unicode)
- except:
- print 'Failed to process article summary, deleting:'
- print summary.encode('utf-8')
- traceback.print_exc()
- summary = u''
- article.text_summary = summary
-
-
- for f, feed in enumerate(feeds):
- feed_dir = os.path.join(self.output_dir, 'feed_%d'%f)
html = self.feed2index(feed)
+ feed_dir = os.path.join(self.output_dir, 'feed_%d'%f)
with open(os.path.join(feed_dir, 'index.html'), 'wb') as fi:
fi.write(html)
self.create_opf(feeds)
@@ -1040,47 +1015,13 @@ class BasicNewsRecipe(Recipe):
Create a generic cover for recipes that dont have a cover
'''
try:
- try:
- from PIL import Image, ImageDraw, ImageFont
- Image, ImageDraw, ImageFont
- except ImportError:
- import Image, ImageDraw, ImageFont
- font_path = P('fonts/liberation/LiberationSerif-Bold.ttf')
+ from calibre.utils.magick_draw import create_cover_page, TextLine
title = self.title if isinstance(self.title, unicode) else \
self.title.decode(preferred_encoding, 'replace')
date = strftime(self.timefmt)
- app = '['+__appname__ +' '+__version__+']'
-
- COVER_WIDTH, COVER_HEIGHT = 590, 750
- img = Image.new('RGB', (COVER_WIDTH, COVER_HEIGHT), 'white')
- draw = ImageDraw.Draw(img)
- # Title
- font = ImageFont.truetype(font_path, 44)
- width, height = draw.textsize(title, font=font)
- left = max(int((COVER_WIDTH - width)/2.), 0)
- top = 15
- draw.text((left, top), title, fill=(0,0,0), font=font)
- bottom = top + height
- # Date
- font = ImageFont.truetype(font_path, 32)
- width, height = draw.textsize(date, font=font)
- left = max(int((COVER_WIDTH - width)/2.), 0)
- draw.text((left, bottom+15), date, fill=(0,0,0), font=font)
- # Vanity
- font = ImageFont.truetype(font_path, 28)
- width, height = draw.textsize(app, font=font)
- left = max(int((COVER_WIDTH - width)/2.), 0)
- top = COVER_HEIGHT - height - 15
- draw.text((left, top), app, fill=(0,0,0), font=font)
- # Logo
- logo = Image.open(I('library.png'), 'r')
- width, height = logo.size
- left = max(int((COVER_WIDTH - width)/2.), 0)
- top = max(int((COVER_HEIGHT - height)/2.), 0)
- img.paste(logo, (left, top))
- img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE)
-
- img.convert('RGB').save(cover_file, 'JPEG')
+ lines = [TextLine(title, 44), TextLine(date, 32)]
+ img_data = create_cover_page(lines, I('library.png'), output_format='jpg')
+ cover_file.write(img_data)
cover_file.flush()
except:
self.log.exception('Failed to generate default cover')
@@ -1173,21 +1114,20 @@ class BasicNewsRecipe(Recipe):
pw.DestroyMagickWand(x)
def create_opf(self, feeds, dir=None):
-
if dir is None:
dir = self.output_dir
mi = MetaInformation(self.short_title() + strftime(self.timefmt), [__appname__])
- mi.author_sort = __appname__
- if self.output_profile == 'iPad':
- mi = MetaInformation(self.short_title(), [strftime('%A, %d %B %Y')])
- mi.author_sort = strftime('%Y-%m-%d')
mi.publisher = __appname__
+ mi.author_sort = __appname__
+ if self.output_profile.name == 'iPad':
+ date_as_author = '%s, %s %s, %s' % (strftime('%A'), strftime('%B'), strftime('%d').lstrip('0'), strftime('%Y'))
+ mi.authors = [date_as_author]
+ mi.author_sort = strftime('%Y-%m-%d')
mi.publication_type = 'periodical:'+self.publication_type
mi.timestamp = nowf()
mi.comments = self.description
if not isinstance(mi.comments, unicode):
mi.comments = mi.comments.decode('utf-8', 'replace')
- mi.tags = ['News']
mi.pubdate = nowf()
opf_path = os.path.join(dir, 'index.opf')
ncx_path = os.path.join(dir, 'index.ncx')
@@ -1230,7 +1170,7 @@ class BasicNewsRecipe(Recipe):
entries = ['index.html']
toc = TOC(base_path=dir)
- self.play_order_counter = 1
+ self.play_order_counter = 0
self.play_order_map = {}
def feed_index(num, parent):
@@ -1342,7 +1282,6 @@ class BasicNewsRecipe(Recipe):
Create a list of articles from the list of feeds returned by :meth:`BasicNewsRecipe.get_feeds`.
Return a list of :class:`Feed` objects.
'''
- print "\nweb.feeds.news:parse_feeds()\n"
feeds = self.get_feeds()
parsed_feeds = []
for obj in feeds:
diff --git a/src/calibre/web/feeds/templates.py b/src/calibre/web/feeds/templates.py
index af0c8da6b4..60fd830e67 100644
--- a/src/calibre/web/feeds/templates.py
+++ b/src/calibre/web/feeds/templates.py
@@ -120,6 +120,7 @@ class TouchscreenNavBarTemplate(Template):
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))
@@ -130,6 +131,7 @@ class TouchscreenNavBarTemplate(Template):
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())
@@ -165,8 +167,14 @@ 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")
+ date = '%s, %s %s, %s' % (strftime('%A'), strftime('%B'), strftime('%d').lstrip('0'), strftime('%Y'))
+ masthead_p = etree.Element("p")
+ masthead_p.set("style","text-align:center")
+ masthead_img = etree.Element("img")
+ masthead_img.set("src",masthead)
+ masthead_img.set("alt","masthead")
+ masthead_p.append(masthead_img)
+
head = HEAD(TITLE(title))
if style:
head.append(STYLE(style, type='text/css'))
@@ -178,11 +186,11 @@ class TouchscreenIndexTemplate(Template):
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)))
+ tr.append(TD( CLASS('article_count'),'%3.3s' % len(feed.articles)))
toc.append(tr)
div = DIV(
- PT(masthead_img,style='text-align:center'),
+ masthead_p,
PT(date, style='text-align:center'),
toc,
CLASS('calibre_rescale_100'))
diff --git a/src/calibre/web/fetch/simple.py b/src/calibre/web/fetch/simple.py
index 37858268d4..c1f0d912d6 100644
--- a/src/calibre/web/fetch/simple.py
+++ b/src/calibre/web/fetch/simple.py
@@ -329,7 +329,7 @@ class RecursiveFetcher(object):
try:
data = self.fetch_url(iurl)
if data == 'GIF89a\x01':
- # Skip empty GIF files
+ # Skip empty GIF files as PIL errors on them anyway
continue
except Exception:
self.log.exception('Could not fetch image %s'% iurl)