diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py index bda839b28f..2075391da4 100644 --- a/resources/default_tweaks.py +++ b/resources/default_tweaks.py @@ -71,3 +71,10 @@ gui_pubdate_display_format = 'MMM yyyy' # order until the title is edited. Double-clicking on a title and hitting return # without changing anything is sufficient to change the sort. title_series_sorting = 'library_order' + +# How to render average rating in the tag browser. +# There are two rendering methods available. The first is to show a partial +# star, and the second is to show a partially filled rectangle. The first is +# better looking, but uses more screen space than the second. +# Values are 'star' or 'rectangle' +render_avg_rating_using='star' diff --git a/src/calibre/ebooks/metadata/__init__.py b/src/calibre/ebooks/metadata/__init__.py index 6b573a0420..c633e5149b 100644 --- a/src/calibre/ebooks/metadata/__init__.py +++ b/src/calibre/ebooks/metadata/__init__.py @@ -30,7 +30,7 @@ def authors_to_string(authors): def author_to_author_sort(author): method = tweaks['author_sort_copy_method'] - if method == 'copy' or (method == 'comma' and author.count(',') > 0): + if method == 'copy' or (method == 'comma' and ',' in author): return author tokens = author.split() tokens = tokens[-1:] + tokens[:-1] diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py index 1f321568f5..3367ab14f6 100644 --- a/src/calibre/ebooks/metadata/opf2.py +++ b/src/calibre/ebooks/metadata/opf2.py @@ -741,7 +741,7 @@ class OPF(object): def fset(self, val): for tag in list(self.tags_path(self.metadata)): - self.metadata.remove(tag) + tag.getparent().remove(tag) for tag in val: elem = self.create_metadata_element('subject') self.set_text(elem, unicode(tag)) diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 418e39c41b..306bbc77e6 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -101,6 +101,8 @@ def _config(): help=_('tag browser categories not to display')) c.add_opt('gui_layout', choices=['wide', 'narrow'], help=_('The layout of the user interface'), default='wide') + c.add_opt('show_avg_rating', default=True, + help=_('Show the average rating per item indication in the tag browser')) return ConfigProxy(c) config = _config() diff --git a/src/calibre/gui2/convert/metadata.py b/src/calibre/gui2/convert/metadata.py index 2026f1cee5..3ddd5674bb 100644 --- a/src/calibre/gui2/convert/metadata.py +++ b/src/calibre/gui2/convert/metadata.py @@ -13,7 +13,7 @@ from PyQt4.Qt import QPixmap, SIGNAL from calibre.gui2 import choose_images, error_dialog from calibre.gui2.convert.metadata_ui import Ui_Form from calibre.ebooks.metadata import authors_to_string, string_to_authors, \ - MetaInformation, authors_to_sort_string + MetaInformation from calibre.ebooks.metadata.opf2 import metadata_to_opf from calibre.ptempfile import PersistentTemporaryFile from calibre.gui2.convert import Widget @@ -57,7 +57,7 @@ class MetadataWidget(Widget, Ui_Form): au = unicode(self.author.currentText()) au = re.sub(r'\s+et al\.$', '', au) authors = string_to_authors(au) - self.author_sort.setText(authors_to_sort_string(authors)) + self.author_sort.setText(self.db.author_sort_from_authors(authors)) def initialize_metadata_options(self): diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index cf54e6c1f3..c8eb4c2403 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -23,7 +23,7 @@ from calibre.devices.scanner import DeviceScanner from calibre.gui2 import config, error_dialog, Dispatcher, dynamic, \ pixmap_to_data, warning_dialog, \ question_dialog, info_dialog, choose_dir -from calibre.ebooks.metadata import authors_to_string, authors_to_sort_string +from calibre.ebooks.metadata import authors_to_string from calibre import preferred_encoding, prints from calibre.utils.filenames import ascii_filename from calibre.devices.errors import FreeSpaceError @@ -1409,7 +1409,7 @@ class DeviceMixin(object): # {{{ # Set author_sort if it isn't already asort = getattr(book, 'author_sort', None) if not asort and book.authors: - book.author_sort = authors_to_sort_string(book.authors) + book.author_sort = self.db.author_sort_from_authors(book.authors) resend_metadata = True if resend_metadata: diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py index aa68c030b5..ad49848b7b 100644 --- a/src/calibre/gui2/dialogs/config/__init__.py +++ b/src/calibre/gui2/dialogs/config/__init__.py @@ -481,6 +481,8 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.opt_enforce_cpu_limit.setChecked(config['enforce_cpu_limit']) self.device_detection_button.clicked.connect(self.debug_device_detection) self.port.editingFinished.connect(self.check_port_value) + self.search_as_you_type.setChecked(config['search_as_you_type']) + self.show_avg_rating.setChecked(config['show_avg_rating']) self.show_splash_screen.setChecked(gprefs.get('show_splash_screen', True)) li = None @@ -862,6 +864,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): config['delete_news_from_library_on_upload'] = self.delete_news.isChecked() config['upload_news_to_device'] = self.sync_news.isChecked() config['search_as_you_type'] = self.search_as_you_type.isChecked() + config['show_avg_rating'] = self.show_avg_rating.isChecked() config['get_social_metadata'] = self.opt_get_social_metadata.isChecked() config['overwrite_author_title_metadata'] = self.opt_overwrite_author_title_metadata.isChecked() config['enforce_cpu_limit'] = bool(self.opt_enforce_cpu_limit.isChecked()) diff --git a/src/calibre/gui2/dialogs/config/config.ui b/src/calibre/gui2/dialogs/config/config.ui index 84a2b7bbcb..ba92c0d301 100644 --- a/src/calibre/gui2/dialogs/config/config.ui +++ b/src/calibre/gui2/dialogs/config/config.ui @@ -371,6 +371,16 @@ + + + Show &average ratings in the tags browser + + + true + + + + Search as you type @@ -380,21 +390,21 @@ - + Automatically send downloaded &news to ebook reader - + &Delete news from library when it is automatically sent to reader - + @@ -411,7 +421,7 @@ - + Toolbar @@ -459,7 +469,7 @@ - + diff --git a/src/calibre/gui2/dialogs/edit_authors_dialog.py b/src/calibre/gui2/dialogs/edit_authors_dialog.py new file mode 100644 index 0000000000..842fd7c943 --- /dev/null +++ b/src/calibre/gui2/dialogs/edit_authors_dialog.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' +__docformat__ = 'restructuredtext en' +__license__ = 'GPL v3' + +from PyQt4.Qt import Qt, QDialog, QTableWidgetItem, QAbstractItemView + +from calibre.ebooks.metadata import author_to_author_sort +from calibre.gui2.dialogs.edit_authors_dialog_ui import Ui_EditAuthorsDialog + +class tableItem(QTableWidgetItem): + def __ge__(self, other): + return unicode(self.text()).lower() >= unicode(other.text()).lower() + + def __lt__(self, other): + return unicode(self.text()).lower() < unicode(other.text()).lower() + +class EditAuthorsDialog(QDialog, Ui_EditAuthorsDialog): + + def __init__(self, parent, db, id_to_select): + QDialog.__init__(self, parent) + Ui_EditAuthorsDialog.__init__(self) + self.setupUi(self) + + self.buttonBox.accepted.connect(self.accepted) + + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setColumnCount(2) + self.table.setHorizontalHeaderLabels([_('Author'), _('Author sort')]) + + self.authors = {} + auts = db.get_authors_with_ids() + self.table.setRowCount(len(auts)) + select_item = None + for row, (id, author, sort) in enumerate(auts): + author = author.replace('|', ',') + self.authors[id] = (author, sort) + aut = tableItem(author) + aut.setData(Qt.UserRole, id) + sort = tableItem(sort) + self.table.setItem(row, 0, aut) + self.table.setItem(row, 1, sort) + if id == id_to_select: + select_item = sort + self.table.resizeColumnsToContents() + + # set up the signal after the table is filled + self.table.cellChanged.connect(self.cell_changed) + + self.table.setSortingEnabled(True) + self.table.sortByColumn(1, Qt.AscendingOrder) + if select_item is not None: + self.table.setCurrentItem(select_item) + self.table.editItem(select_item) + else: + self.table.setCurrentCell(0, 0) + + def accepted(self): + self.result = [] + for row in range(0,self.table.rowCount()): + id = self.table.item(row, 0).data(Qt.UserRole).toInt()[0] + aut = unicode(self.table.item(row, 0).text()).strip() + sort = unicode(self.table.item(row, 1).text()).strip() + orig_aut,orig_sort = self.authors[id] + if orig_aut != aut or orig_sort != sort: + self.result.append((id, orig_aut, aut, sort)) + + def cell_changed(self, row, col): + if col == 0: + item = self.table.item(row, 0) + aut = unicode(item.text()).strip() + c = self.table.item(row, 1) + c.setText(author_to_author_sort(aut)) + item = c + else: + item = self.table.item(row, 1) + self.table.setCurrentItem(item) + # disable and reenable sorting to force the sort now, so we can scroll + # to the item after it moves + self.table.setSortingEnabled(False) + self.table.setSortingEnabled(True) + self.table.scrollToItem(item) diff --git a/src/calibre/gui2/dialogs/edit_authors_dialog.ui b/src/calibre/gui2/dialogs/edit_authors_dialog.ui new file mode 100644 index 0000000000..d124f1498d --- /dev/null +++ b/src/calibre/gui2/dialogs/edit_authors_dialog.ui @@ -0,0 +1,86 @@ + + + EditAuthorsDialog + + + + 0 + 0 + 730 + 342 + + + + + 0 + 0 + + + + Manage authors + + + + + + + 0 + 0 + + + + 0 + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + true + + + + + + + + + buttonBox + accepted() + EditAuthorsDialog + accept() + + + 229 + 211 + + + 157 + 234 + + + + + buttonBox + rejected() + EditAuthorsDialog + reject() + + + 297 + 217 + + + 286 + 234 + + + + + diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py index eca7fe9c15..8b27ff1999 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.py +++ b/src/calibre/gui2/dialogs/metadata_bulk.py @@ -8,7 +8,7 @@ from PyQt4.QtGui import QDialog, QGridLayout from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog from calibre.gui2.dialogs.tag_editor import TagEditor -from calibre.ebooks.metadata import string_to_authors, authors_to_sort_string, \ +from calibre.ebooks.metadata import string_to_authors, \ authors_to_string from calibre.gui2.custom_column_widgets import populate_bulk_metadata_page @@ -110,10 +110,7 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog): au = string_to_authors(au) self.db.set_authors(id, au, notify=False) if self.auto_author_sort.isChecked(): - aut = self.db.authors(id, index_is_id=True) - aut = aut if aut else '' - aut = [a.strip().replace('|', ',') for a in aut.strip().split(',')] - x = authors_to_sort_string(aut) + x = self.db.author_sort_from_book(id, index_is_id=True) if x: self.db.set_author_sort(id, x, notify=False) aus = unicode(self.author_sort.text()) diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py index 0e35f938dd..96323ac596 100644 --- a/src/calibre/gui2/dialogs/metadata_single.py +++ b/src/calibre/gui2/dialogs/metadata_single.py @@ -23,7 +23,7 @@ from calibre.gui2.dialogs.fetch_metadata import FetchMetadata from calibre.gui2.dialogs.tag_editor import TagEditor from calibre.gui2.widgets import ProgressIndicator from calibre.ebooks import BOOK_EXTENSIONS -from calibre.ebooks.metadata import authors_to_sort_string, string_to_authors, \ +from calibre.ebooks.metadata import string_to_authors, \ authors_to_string, check_isbn from calibre.ebooks.metadata.library_thing import cover_from_isbn from calibre import islinux, isfreebsd @@ -460,7 +460,7 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): au = unicode(self.authors.text()) au = re.sub(r'\s+et al\.$', '', au) authors = string_to_authors(au) - self.author_sort.setText(authors_to_sort_string(authors)) + self.author_sort.setText(self.db.author_sort_from_authors(authors)) def swap_title_author(self): title = self.title.text() diff --git a/src/calibre/gui2/dialogs/tag_list_editor.ui b/src/calibre/gui2/dialogs/tag_list_editor.ui index 4f57af745b..39076aa1f6 100644 --- a/src/calibre/gui2/dialogs/tag_list_editor.ui +++ b/src/calibre/gui2/dialogs/tag_list_editor.ui @@ -121,6 +121,9 @@ QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + true + diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index ff4b2b6ee9..8080769377 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -420,8 +420,11 @@ class BooksModel(QAbstractTableModel): # {{{ pt.orig_file_path = os.path.abspath(src.name) pt.seek(0) if set_metadata: - _set_metadata(pt, self.db.get_metadata(id, get_cover=True, index_is_id=True), + try: + _set_metadata(pt, self.db.get_metadata(id, get_cover=True, index_is_id=True), format) + except: + traceback.print_exc() pt.close() def to_uni(x): if isbytestring(x): diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index bc698a3502..9cc90ca83f 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -13,15 +13,76 @@ from functools import partial from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, QCheckBox, \ QFont, QSize, QIcon, QPoint, QVBoxLayout, QComboBox, \ QAbstractItemModel, QVariant, QModelIndex, QMenu, \ - QPushButton, QWidget + QPushButton, QWidget, QItemDelegate, QString, QPen, \ + QColor, QLinearGradient, QBrush from calibre.gui2 import config, NONE -from calibre.utils.config import prefs +from calibre.utils.config import prefs, tweaks from calibre.library.field_metadata import TagsIcons from calibre.utils.search_query_parser import saved_searches from calibre.gui2 import error_dialog from calibre.gui2.dialogs.tag_categories import TagCategories from calibre.gui2.dialogs.tag_list_editor import TagListEditor +from calibre.gui2.dialogs.edit_authors_dialog import EditAuthorsDialog + +class TagDelegate(QItemDelegate): + + def __init__(self, parent): + QItemDelegate.__init__(self, parent) + self._parent = parent + self.icon = QIcon(I('star.png')) + + def paint(self, painter, option, index): + item = index.internalPointer() + if item.type != TagTreeItem.TAG: + QItemDelegate.paint(self, painter, option, index) + return + r = option.rect + # Paint the decoration icon + icon = self._parent.model().data(index, Qt.DecorationRole).toPyObject() + icon.paint(painter, r, Qt.AlignLeft) + + # Paint the rating, if any. The decoration icon is assumed to be square, + # filling the row top to bottom. The three is arbitrary, there to + # provide a little space between the icon and what follows + r.setLeft(r.left()+r.height()+3) + rating = item.tag.avg_rating + if config['show_avg_rating'] and item.tag.avg_rating is not None: + painter.save() + if tweaks['render_avg_rating_using'] == 'star': + painter.setClipRect(r.left(), r.top(), + int(r.height()*(rating/5.0)), r.height()) + self.icon.paint(painter, r, Qt.AlignLeft | Qt.AlignVCenter) + r.setLeft(r.left() + r.height()) + else: + painter.translate(r.left(), r.top()) + # Compute factor so sizes can be expressed in percentages of the + # box defined by the row height + factor = r.height()/100. + width = 20 + height = 80 + left_offset = 5 + top_offset = 10 + if r > 0.0: + color = QColor(100, 100, 255) #medium blue, less glare + pen = QPen(color, 5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + painter.setPen(pen) + painter.scale(factor, factor) + painter.drawRect(left_offset, top_offset, width, height) + fill_height = height*(rating/5.0) + gradient = QLinearGradient(0, 0, 0, 100) + gradient.setColorAt(0.0, color) + gradient.setColorAt(1.0, color) + painter.setBrush(QBrush(gradient)) + painter.drawRect(left_offset, top_offset+(height-fill_height), + width, fill_height) + # The '3' is arbitrary, there because we need a little space + # between the rectangle and the text. + r.setLeft(r.left() + ((width+left_offset*2)*factor) + 3) + painter.restore() + # Paint the text + painter.drawText(r, Qt.AlignLeft|Qt.AlignVCenter, + QString('[%d] %s'%(item.tag.count, item.tag.name))) class TagsView(QTreeView): # {{{ @@ -30,6 +91,7 @@ class TagsView(QTreeView): # {{{ user_category_edit = pyqtSignal(object) tag_list_edit = pyqtSignal(object, object) saved_search_edit = pyqtSignal(object) + author_sort_edit = pyqtSignal(object, object) tag_item_renamed = pyqtSignal() search_item_renamed = pyqtSignal() @@ -43,6 +105,7 @@ class TagsView(QTreeView): # {{{ self.setAlternatingRowColors(True) self.setAnimated(True) self.setHeaderHidden(True) + self.setItemDelegate(TagDelegate(self)) def set_database(self, db, tag_match, popularity): self.hidden_categories = config['tag_browser_hidden_categories'] @@ -112,6 +175,9 @@ class TagsView(QTreeView): # {{{ if action == 'manage_searches': self.saved_search_edit.emit(category) return + if action == 'edit_author_sort': + self.author_sort_edit.emit(self, index) + return if action == 'hide': self.hidden_categories.add(category) elif action == 'show': @@ -132,6 +198,7 @@ class TagsView(QTreeView): # {{{ if item.type == TagTreeItem.TAG: tag_item = item tag_name = item.tag.name + tag_id = item.tag.id item = item.parent if item.type == TagTreeItem.CATEGORY: category = unicode(item.name.toString()) @@ -147,9 +214,13 @@ class TagsView(QTreeView): # {{{ (key in ['authors', 'tags', 'series', 'publisher', 'search'] or \ self.db.field_metadata[key]['is_custom'] and \ self.db.field_metadata[key]['datatype'] != 'rating'): - self.context_menu.addAction(_('Rename') + " '" + tag_name + "'", + self.context_menu.addAction(_('Rename \'%s\'')%tag_name, partial(self.context_menu_handler, action='edit_item', category=tag_item, index=index)) + if key == 'authors': + self.context_menu.addAction(_('Edit sort for \'%s\'')%tag_name, + partial(self.context_menu_handler, + action='edit_author_sort', index=tag_id)) self.context_menu.addSeparator() # Hide/Show/Restore categories self.context_menu.addAction(_('Hide category %s') % category, @@ -166,9 +237,12 @@ class TagsView(QTreeView): # {{{ self.context_menu.addSeparator() if key in ['tags', 'publisher', 'series'] or \ self.db.field_metadata[key]['is_custom']: - self.context_menu.addAction(_('Manage ') + category, + self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='open_editor', category=tag_name, key=key)) + elif key == 'authors': + self.context_menu.addAction(_('Manage %s')%category, + partial(self.context_menu_handler, action='edit_author_sort')) elif key == 'search': self.context_menu.addAction(_('Manage Saved Searches'), partial(self.context_menu_handler, action='manage_searches', @@ -298,7 +372,11 @@ class TagTreeItem(object): # {{{ if self.tag.count == 0: return QVariant('%s'%(self.tag.name)) else: - return QVariant('[%d] %s'%(self.tag.count, self.tag.name)) + if self.tag.avg_rating is None: + return QVariant('[%d] %s'%(self.tag.count, self.tag.name)) + else: + return QVariant('[%d][%3.1f] %s'%(self.tag.count, + self.tag.avg_rating, self.tag.name)) if role == Qt.EditRole: return QVariant(self.tag.name) if role == Qt.DecorationRole: @@ -332,6 +410,7 @@ class TagsModel(QAbstractItemModel): # {{{ ':custom' : QIcon(I('column.svg')), ':user' : QIcon(I('drawer.svg')), 'search' : QIcon(I('search.svg'))}) + self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags'] self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db @@ -354,7 +433,14 @@ class TagsModel(QAbstractItemModel): # {{{ data=self.categories[i], category_icon=self.category_icon_map[r], tooltip=tt, category_key=r) + # This duplicates code in refresh(). Having it here as well + # can save seconds during startup, because we avoid a second + # call to get_node_tree. for tag in data[r]: + if r not in self.categories_with_ratings and \ + not self.db.field_metadata[r]['is_custom'] and \ + not self.db.field_metadata[r]['kind'] == 'user': + tag.avg_rating = None TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map) def set_search_restriction(self, s): @@ -417,6 +503,10 @@ class TagsModel(QAbstractItemModel): # {{{ if len(data[r]) > 0: self.beginInsertRows(category_index, 0, len(data[r])-1) for tag in data[r]: + if r not in self.categories_with_ratings and \ + not self.db.field_metadata[r]['is_custom'] and \ + not self.db.field_metadata[r]['kind'] == 'user': + tag.avg_rating = None tag.state = state_map.get(tag.name, 0) t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_state_map) self.endInsertRows() @@ -607,6 +697,7 @@ class TagBrowserMixin(object): # {{{ 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.tags_view.author_sort_edit.connect(self.do_author_sort_edit) self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed) self.tags_view.search_item_renamed.connect(self.saved_search.clear_to_help) self.edit_categories.clicked.connect(lambda x: @@ -636,6 +727,19 @@ class TagBrowserMixin(object): # {{{ self.saved_search.clear_to_help() self.search.clear_to_help() + def do_author_sort_edit(self, parent, id): + db = self.library_view.model().db + editor = EditAuthorsDialog(parent, db, id) + d = editor.exec_() + if d: + for (id, old_author, new_author, new_sort) in editor.result: + if old_author != new_author: + # The id might change if the new author already exists + id = db.rename_author(id, new_author) + db.set_sort_field_for_author(id, unicode(new_sort)) + self.library_view.model().refresh() + self.tags_view.recount() + # }}} class TagBrowserWidget(QWidget): # {{{ diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py index 23b78f38ae..c0ba91e252 100644 --- a/src/calibre/library/custom_columns.py +++ b/src/calibre/library/custom_columns.py @@ -461,14 +461,27 @@ class CustomColumns(object): CREATE VIEW tag_browser_{table} AS SELECT id, value, - (SELECT COUNT(id) FROM {lt} WHERE value={table}.id) count + (SELECT COUNT(id) FROM {lt} WHERE value={table}.id) count, + (SELECT AVG(r.rating) + FROM {lt}, + books_ratings_link as bl, + ratings as r + WHERE {lt}.value={table}.id and bl.book={lt}.book and + r.id = bl.rating and r.rating <> 0) avg_rating FROM {table}; CREATE VIEW tag_browser_filtered_{table} AS SELECT id, value, (SELECT COUNT({lt}.id) FROM {lt} WHERE value={table}.id AND - books_list_filter(book)) count + books_list_filter(book)) count, + (SELECT AVG(r.rating) + FROM {lt}, + books_ratings_link as bl, + ratings as r + WHERE {lt}.value={table}.id AND bl.book={lt}.book AND + r.id = bl.rating AND r.rating <> 0 AND + books_list_filter(bl.book)) avg_rating FROM {table}; '''.format(lt=lt, table=table), @@ -505,7 +518,6 @@ class CustomColumns(object): END; '''.format(table=table), ] - script = ' \n'.join(lines) self.conn.executescript(script) self.conn.commit() diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 31e9b43f86..2fb22a27f4 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -12,7 +12,7 @@ from math import floor from PyQt4.QtGui import QImage -from calibre.ebooks.metadata import title_sort +from calibre.ebooks.metadata import title_sort, author_to_author_sort from calibre.library.database import LibraryDatabase from calibre.library.field_metadata import FieldMetadata, TagsIcons from calibre.library.schema_upgrades import SchemaUpgrade @@ -20,7 +20,7 @@ from calibre.library.caches import ResultCache from calibre.library.custom_columns import CustomColumns from calibre.library.sqlite import connect, IntegrityError, DBThread from calibre.ebooks.metadata import string_to_authors, authors_to_string, \ - MetaInformation, authors_to_sort_string + MetaInformation from calibre.ebooks.metadata.meta import get_metadata, metadata_from_formats from calibre.constants import preferred_encoding, iswindows, isosx, filesystem_encoding from calibre.ptempfile import PersistentTemporaryFile @@ -56,11 +56,14 @@ copyfile = os.link if hasattr(os, 'link') else shutil.copyfile class Tag(object): - def __init__(self, name, id=None, count=0, state=0, tooltip=None, icon=None): + def __init__(self, name, id=None, count=0, state=0, avg=0, sort=None, + tooltip=None, icon=None): self.name = name self.id = id self.count = count self.state = state + self.avg_rating = avg/2.0 if avg is not None else 0 + self.sort = sort self.tooltip = tooltip self.icon = icon @@ -133,7 +136,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): CREATE TEMP VIEW IF NOT EXISTS tag_browser_news AS SELECT DISTINCT id, name, - (SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE tag=x.id) count + (SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE tag=x.id) count, + (0) as avg_rating, + name as sort FROM tags as x WHERE name!="{0}" AND id IN (SELECT DISTINCT tag FROM books_tags_link WHERE book IN (SELECT DISTINCT book FROM books_tags_link WHERE tag IN @@ -144,7 +149,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): CREATE TEMP VIEW IF NOT EXISTS tag_browser_filtered_news AS SELECT DISTINCT id, name, - (SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE tag=x.id and books_list_filter(book)) count + (SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE tag=x.id and books_list_filter(book)) count, + (0) as avg_rating, + name as sort FROM tags as x WHERE name!="{0}" AND id IN (SELECT DISTINCT tag FROM books_tags_link WHERE book IN (SELECT DISTINCT book FROM books_tags_link WHERE tag IN @@ -422,6 +429,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if aum: aum = [a.strip().replace('|', ',') for a in aum.split(',')] mi = MetaInformation(self.title(idx, index_is_id=index_is_id), aum) mi.author_sort = self.author_sort(idx, index_is_id=index_is_id) + mi.authors_sort_strings = self.authors_sort_strings(idx, index_is_id) mi.comments = self.comments(idx, index_is_id=index_is_id) mi.publisher = self.publisher(idx, index_is_id=index_is_id) mi.timestamp = self.timestamp(idx, index_is_id=index_is_id) @@ -698,13 +706,15 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): continue cn = cat['column'] if ids is None: - query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn, tn) + query = '''SELECT id, {0}, count, avg_rating, sort + FROM tag_browser_{1}'''.format(cn, tn) else: - query = 'SELECT id, {0}, count FROM tag_browser_filtered_{1}'.format(cn, tn) + query = '''SELECT id, {0}, count, avg_rating, sort + FROM tag_browser_filtered_{1}'''.format(cn, tn) if sort_on_count: query += ' ORDER BY count DESC' else: - query += ' ORDER BY {0} ASC'.format(cn) + query += ' ORDER BY sort ASC' data = self.conn.get(query) # icon_map is not None if get_categories is to store an icon and @@ -722,6 +732,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): datatype = cat['datatype'] if datatype == 'rating': + # eliminate the zero ratings line as well as count == 0 item_not_zero_func = (lambda x: x[1] > 0 and x[2] > 0) formatter = (lambda x:u'\u2605'*int(round(x/2.))) elif category == 'authors': @@ -733,15 +744,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): formatter = (lambda x:unicode(x)) categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], - icon=icon, tooltip = tooltip) + avg=r[3], sort=r[4], + icon=icon, tooltip=tooltip) for r in data if item_not_zero_func(r)] - if category == 'series' and not sort_on_count: - if tweaks['title_series_sorting'] == 'library_order': - ts = lambda x: title_sort(x) - else: - ts = lambda x:x - categories[category].sort(cmp=lambda x,y:cmp(ts(x.name).lower(), - ts(y.name).lower())) # We delayed computing the standard formats category because it does not # use a view, but is computed dynamically @@ -909,6 +914,38 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.set_path(id, True) self.notify('metadata', [id]) + # Given a book, return the list of author sort strings for the book's authors + def authors_sort_strings(self, id, index_is_id=False): + id = id if index_is_id else self.id(id) + aut_strings = self.conn.get(''' + SELECT sort + FROM authors, books_authors_link as bl + WHERE bl.book=? and authors.id=bl.author + ORDER BY bl.id''', (id,)) + result = [] + for (sort,) in aut_strings: + result.append(sort) + return result + + # Given a book, return the author_sort string for authors of the book + def author_sort_from_book(self, id, index_is_id=False): + auts = self.authors_sort_strings(id, index_is_id) + return ' & '.join(auts).replace('|', ',') + + # Given a list of authors, return the author_sort string for the authors, + # preferring the author sort associated with the author over the computed + # string + def author_sort_from_authors(self, authors): + result = [] + for aut in authors: + r = self.conn.get('SELECT sort FROM authors WHERE name=?', + (aut.replace(',', '|'),), all=False) + if r is None: + result.append(author_to_author_sort(aut)) + else: + result.append(r) + return ' & '.join(result).replace('|', ',') + def set_authors(self, id, authors, notify=True): ''' `authors`: A list of authors. @@ -935,7 +972,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): (id, aid)) except IntegrityError: # Sometimes books specify the same author twice in their metadata pass - ss = authors_to_sort_string(authors) + self.conn.commit() + ss = self.author_sort_from_book(id, index_is_id=True) self.conn.execute('UPDATE books SET author_sort=? WHERE id=?', (ss, id)) self.conn.commit() @@ -1007,6 +1045,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): return result def rename_tag(self, old_id, new_name): + new_name = new_name.strip() new_id = self.conn.get( '''SELECT id from tags WHERE name=?''', (new_name,), all=False) @@ -1046,6 +1085,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): return result def rename_series(self, old_id, new_name): + new_name = new_name.strip() new_id = self.conn.get( '''SELECT id from series WHERE name=?''', (new_name,), all=False) @@ -1075,7 +1115,6 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): index = index + 1 self.conn.commit() - def delete_series_using_id(self, id): books = self.conn.get('SELECT book from books_series_link WHERE series=?', (id,)) self.conn.execute('DELETE FROM books_series_link WHERE series=?', (id,)) @@ -1091,6 +1130,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): return result def rename_publisher(self, old_id, new_name): + new_name = new_name.strip() new_id = self.conn.get( '''SELECT id from publishers WHERE name=?''', (new_name,), all=False) @@ -1113,12 +1153,25 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.conn.execute('DELETE FROM publishers WHERE id=?', (old_id,)) self.conn.commit() - # There is no editor for author, so we do not need get_authors_with_ids or - # delete_author_using_id. + def get_authors_with_ids(self): + result = self.conn.get('SELECT id,name,sort FROM authors') + if not result: + return [] + return result + + def set_sort_field_for_author(self, old_id, new_sort): + self.conn.execute('UPDATE authors SET sort=? WHERE id=?', \ + (new_sort.strip(), old_id)) + self.conn.commit() + # Now change all the author_sort fields in books by this author + bks = self.conn.get('SELECT book from books_authors_link WHERE author=?', (old_id,)) + for (book_id,) in bks: + ss = self.author_sort_from_book(book_id, index_is_id=True) + self.set_author_sort(book_id, ss) def rename_author(self, old_id, new_name): # Make sure that any commas in new_name are changed to '|'! - new_name = new_name.replace(',', '|') + new_name = new_name.replace(',', '|').strip() # Get the list of books we must fix up, one way or the other # Save the list so we can use it twice @@ -1141,7 +1194,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.conn.execute('UPDATE authors SET name=? WHERE id=?', (new_name, old_id)) self.conn.commit() - return + return new_id # Author exists. To fix this, we must replace all the authors # instead of replacing the one. Reason: db integrity checks can stop # the rename process, which would leave everything half-done. We @@ -1184,24 +1237,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # now fix the filesystem paths self.set_path(book_id, index_is_id=True) # Next fix the author sort. Reset it to the default - authors = self.conn.get(''' - SELECT authors.name - FROM authors, books_authors_link as bl - WHERE bl.book = ? and bl.author = authors.id - ORDER BY bl.id - ''' , (book_id,)) - # unpack the double-list structure - for i,aut in enumerate(authors): - authors[i] = aut[0] - ss = authors_to_sort_string(authors) - # Change the '|'s to ',' - ss = ss.replace('|', ',') - self.conn.execute('''UPDATE books - SET author_sort=? - WHERE id=?''', (ss, book_id)) - self.conn.commit() + ss = self.author_sort_from_book(book_id, index_is_id=True) + self.set_author_sort(book_id, ss) # the caller will do a general refresh, so we don't need to # do one here + return new_id # end convenience methods @@ -1436,7 +1476,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if not add_duplicates and self.has_book(mi): return None series_index = 1.0 if mi.series_index is None else mi.series_index - aus = mi.author_sort if mi.author_sort else ', '.join(mi.authors) + aus = mi.author_sort if mi.author_sort else self.author_sort_from_authors(mi.authors) title = mi.title if isinstance(aus, str): aus = aus.decode(preferred_encoding, 'replace') @@ -1476,7 +1516,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): duplicates.append((path, format, mi)) continue series_index = 1.0 if mi.series_index is None else mi.series_index - aus = mi.author_sort if mi.author_sort else ', '.join(mi.authors) + aus = mi.author_sort if mi.author_sort else self.author_sort_from_authors(mi.authors) title = mi.title if isinstance(aus, str): aus = aus.decode(preferred_encoding, 'replace') @@ -1515,7 +1555,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): mi.title = _('Unknown') if not mi.authors: mi.authors = [_('Unknown')] - aus = mi.author_sort if mi.author_sort else authors_to_sort_string(mi.authors) + aus = mi.author_sort if mi.author_sort else self.author_sort_from_authors(mi.authors) if isinstance(aus, str): aus = aus.decode(preferred_encoding, 'replace') title = mi.title if isinstance(mi.title, unicode) else \ diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py index 82e4edfdf2..8cb5c9bdad 100644 --- a/src/calibre/library/field_metadata.py +++ b/src/calibre/library/field_metadata.py @@ -44,9 +44,12 @@ class FieldMetadata(dict): is_category: is a tag browser category. If true, then: table: name of the db table used to construct item list column: name of the column in the normalized table to join on - link_column: name of the column in the connection table to join on + link_column: name of the column in the connection table to join on. This + key should not be present if there is no link table + category_sort: the field in the normalized table to sort on. This + key must be present if is_category is True If these are None, then the category constructor must know how - to build the item list (e.g., formats). + to build the item list (e.g., formats, news). The order below is the order that the categories will appear in the tags pane. @@ -66,6 +69,7 @@ class FieldMetadata(dict): ('authors', {'table':'authors', 'column':'name', 'link_column':'author', + 'category_sort':'sort', 'datatype':'text', 'is_multiple':',', 'kind':'field', @@ -76,6 +80,7 @@ class FieldMetadata(dict): ('series', {'table':'series', 'column':'name', 'link_column':'series', + 'category_sort':'(title_sort(name))', 'datatype':'text', 'is_multiple':None, 'kind':'field', @@ -95,6 +100,7 @@ class FieldMetadata(dict): ('publisher', {'table':'publishers', 'column':'name', 'link_column':'publisher', + 'category_sort':'name', 'datatype':'text', 'is_multiple':None, 'kind':'field', @@ -105,6 +111,7 @@ class FieldMetadata(dict): ('rating', {'table':'ratings', 'column':'rating', 'link_column':'rating', + 'category_sort':'rating', 'datatype':'rating', 'is_multiple':None, 'kind':'field', @@ -114,6 +121,7 @@ class FieldMetadata(dict): 'is_category':True}), ('news', {'table':'news', 'column':'name', + 'category_sort':'name', 'datatype':None, 'is_multiple':None, 'kind':'category', @@ -124,6 +132,7 @@ class FieldMetadata(dict): ('tags', {'table':'tags', 'column':'name', 'link_column': 'tag', + 'category_sort':'name', 'datatype':'text', 'is_multiple':',', 'kind':'field', @@ -374,7 +383,7 @@ class FieldMetadata(dict): 'search_terms':[key], 'label':label, 'colnum':colnum, 'display':display, 'is_custom':True, 'is_category':is_category, - 'link_column':'value', + 'link_column':'value','category_sort':'value', 'is_editable': is_editable,} self._add_search_terms_to_map(key, [key]) self.custom_label_to_key_map[label] = key diff --git a/src/calibre/library/schema_upgrades.py b/src/calibre/library/schema_upgrades.py index 66cf091016..a8ffd9cde4 100644 --- a/src/calibre/library/schema_upgrades.py +++ b/src/calibre/library/schema_upgrades.py @@ -296,3 +296,117 @@ class SchemaUpgrade(object): ('books_%s_link'%field['table'],), all=False) if table is not None: create_tag_browser_view(field['table'], field['link_column'], field['column']) + + def upgrade_version_11(self): + 'Add average rating to tag browser views' + def create_std_tag_browser_view(table_name, column_name, + view_column_name, sort_column_name): + script = (''' + DROP VIEW IF EXISTS tag_browser_{tn}; + CREATE VIEW tag_browser_{tn} AS SELECT + id, + {vcn}, + (SELECT COUNT(id) FROM books_{tn}_link WHERE {cn}={tn}.id) count, + (SELECT AVG(ratings.rating) + FROM books_{tn}_link AS tl, books_ratings_link AS bl, ratings + WHERE tl.{cn}={tn}.id AND bl.book=tl.book AND + ratings.id = bl.rating AND ratings.rating <> 0) avg_rating, + {scn} AS sort + FROM {tn}; + DROP VIEW IF EXISTS tag_browser_filtered_{tn}; + CREATE VIEW tag_browser_filtered_{tn} AS SELECT + id, + {vcn}, + (SELECT COUNT(books_{tn}_link.id) FROM books_{tn}_link WHERE + {cn}={tn}.id AND books_list_filter(book)) count, + (SELECT AVG(ratings.rating) + FROM books_{tn}_link AS tl, books_ratings_link AS bl, ratings + WHERE tl.{cn}={tn}.id AND bl.book=tl.book AND + ratings.id = bl.rating AND ratings.rating <> 0 AND + books_list_filter(bl.book)) avg_rating, + {scn} AS sort + FROM {tn}; + + '''.format(tn=table_name, cn=column_name, + vcn=view_column_name, scn= sort_column_name)) + self.conn.executescript(script) + + def create_cust_tag_browser_view(table_name, link_table_name): + script = ''' + DROP VIEW IF EXISTS tag_browser_{table}; + CREATE VIEW tag_browser_{table} AS SELECT + id, + value, + (SELECT COUNT(id) FROM {lt} WHERE value={table}.id) count, + (SELECT AVG(r.rating) + FROM {lt}, + books_ratings_link AS bl, + ratings AS r + WHERE {lt}.value={table}.id AND bl.book={lt}.book AND + r.id = bl.rating AND r.rating <> 0) avg_rating, + value AS sort + FROM {table}; + + DROP VIEW IF EXISTS tag_browser_filtered_{table}; + CREATE VIEW tag_browser_filtered_{table} AS SELECT + id, + value, + (SELECT COUNT({lt}.id) FROM {lt} WHERE value={table}.id AND + books_list_filter(book)) count, + (SELECT AVG(r.rating) + FROM {lt}, + books_ratings_link AS bl, + ratings AS r + WHERE {lt}.value={table}.id AND bl.book={lt}.book AND + r.id = bl.rating AND r.rating <> 0 AND + books_list_filter(bl.book)) avg_rating, + value AS sort + FROM {table}; + '''.format(lt=link_table_name, table=table_name) + self.conn.executescript(script) + + for field in self.field_metadata.itervalues(): + if field['is_category'] and not field['is_custom'] and 'link_column' in field: + table = self.conn.get( + 'SELECT name FROM sqlite_master WHERE type="table" AND name=?', + ('books_%s_link'%field['table'],), all=False) + if table is not None: + create_std_tag_browser_view(field['table'], field['link_column'], + field['column'], field['category_sort']) + + db_tables = self.conn.get('''SELECT name FROM sqlite_master + WHERE type='table' + ORDER BY name'''); + tables = [] + for (table,) in db_tables: + tables.append(table) + for table in tables: + link_table = 'books_%s_link'%table + if table.startswith('custom_column_') and link_table in tables: + create_cust_tag_browser_view(table, link_table) + + from calibre.ebooks.metadata import author_to_author_sort + + aut = self.conn.get('SELECT id, name FROM authors'); + records = [] + for (id, author) in aut: + records.append((id, author.replace('|', ','))) + for id,author in records: + self.conn.execute('UPDATE authors SET sort=? WHERE id=?', + (author_to_author_sort(author.replace('|', ',')).strip(), id)) + self.conn.commit() + self.conn.executescript(''' + DROP TRIGGER IF EXISTS author_insert_trg; + CREATE TRIGGER author_insert_trg + AFTER INSERT ON authors + BEGIN + UPDATE authors SET sort=author_to_author_sort(NEW.name) WHERE id=NEW.id; + END; + DROP TRIGGER IF EXISTS author_update_trg; + CREATE TRIGGER author_update_trg + BEFORE UPDATE ON authors + BEGIN + UPDATE authors SET sort=author_to_author_sort(NEW.name) + WHERE id=NEW.id AND name <> NEW.name; + END; + ''') diff --git a/src/calibre/library/sqlite.py b/src/calibre/library/sqlite.py index adf6691671..7e0458fba4 100644 --- a/src/calibre/library/sqlite.py +++ b/src/calibre/library/sqlite.py @@ -14,7 +14,7 @@ from Queue import Queue from threading import RLock from datetime import datetime -from calibre.ebooks.metadata import title_sort +from calibre.ebooks.metadata import title_sort, author_to_author_sort from calibre.utils.config import tweaks from calibre.utils.date import parse_date, isoformat @@ -116,10 +116,12 @@ class DBThread(Thread): self.conn.create_aggregate('concat', 1, Concatenate) self.conn.create_aggregate('sortconcat', 2, SortedConcatenate) self.conn.create_aggregate('sort_concat', 2, SafeSortedConcatenate) - if tweaks['title_series_sorting'] == 'library_order': - self.conn.create_function('title_sort', 1, title_sort) - else: + if tweaks['title_series_sorting'] == 'strictly_alphabetic': self.conn.create_function('title_sort', 1, lambda x:x) + else: + self.conn.create_function('title_sort', 1, title_sort) + self.conn.create_function('author_to_author_sort', 1, + lambda x: author_to_author_sort(x.replace('|', ','))) self.conn.create_function('uuid4', 0, lambda : str(uuid.uuid4())) # Dummy functions for dynamically created filters self.conn.create_function('books_list_filter', 1, lambda x: 1)