'+_('The current tag category will be ' + 'permanently deleted. Are you sure?') + +'
', 'tag_category_delete', self): + return + del self.categories[self.current_cat_name] + self.current_cat_name = None + self.category_box.removeItem(self.category_box.currentIndex()) + + def select_category(self, idx): + self.save_category() + s = self.category_box.itemText(idx) + if s: + self.current_cat_name = unicode(s) + else: + self.current_cat_name = None + if self.current_cat_name: + self.applied_items = [cat[2] for cat in self.categories.get(self.current_cat_name, [])] + else: + self.applied_items = [] + self.display_filtered_categories(None) + + def accept(self): + self.save_category() + config['user_categories'] = self.categories + QDialog.accept(self) + + def save_category(self): + if self.current_cat_name is not None: + l = [] + for index in self.applied_items: + item = self.all_items[index] + l.append([item.name, item.label, item.index]) + self.categories[self.current_cat_name] = l + + def populate_category_list(self): + for n in sorted(self.categories.keys(), cmp=lambda x,y: cmp(x.lower(), y.lower())): + self.category_box.addItem(n) \ No newline at end of file diff --git a/src/calibre/gui2/dialogs/tag_categories.ui b/src/calibre/gui2/dialogs/tag_categories.ui new file mode 100644 index 0000000000..2904b2464e --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_categories.ui @@ -0,0 +1,385 @@ + +'+_('The selected search will be ' + 'permanently deleted. Are you sure?') + +'
', 'saved_search_delete', self): + return idx = self.currentIndex if idx < 0: return @@ -288,7 +286,6 @@ class SavedSearchBox(QComboBox): # SIGNALed from the main UI def save_search_button_clicked(self): - #print 'in save_search_button_clicked' name = unicode(self.currentText()) if self.help_state or not name.strip(): name = unicode(self.search_box.text()).replace('"', '') @@ -305,10 +302,7 @@ class SavedSearchBox(QComboBox): # SIGNALed from the main UI def copy_search_button_clicked (self): - #print 'in copy_search_button_clicked' idx = self.currentIndex(); if idx < 0: return - self.search_box.set_search_string(self.saved_searches.lookup(unicode(self.currentText()))) - - + self.search_box.set_search_string(self.saved_searches.lookup(unicode(self.currentText()))) \ No newline at end of file diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index d9225b2503..7d79fedb72 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -8,11 +8,13 @@ Browsing book collection by tags. ''' from itertools import izip +from copy import copy from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QFont, SIGNAL, QSize, QIcon, QPoint, \ QAbstractItemModel, QVariant, QModelIndex from calibre.gui2 import config, NONE +from calibre.utils.config import prefs from calibre.utils.search_query_parser import saved_searches from calibre.library.database2 import Tag @@ -27,16 +29,24 @@ class TagsView(QTreeView): self.setIconSize(QSize(30, 30)) self.tag_match = None - def set_database(self, db, tag_match, popularity): + def set_database(self, db, tag_match, popularity, restriction): self._model = TagsModel(db, parent=self) self.popularity = popularity + self.restriction = restriction self.tag_match = tag_match + self.db = db self.setModel(self._model) self.connect(self, SIGNAL('clicked(QModelIndex)'), self.toggle) self.popularity.setChecked(config['sort_by_popularity']) self.connect(self.popularity, SIGNAL('stateChanged(int)'), self.sort_changed) + self.connect(self.restriction, SIGNAL('activated(const QString&)'), self.search_restriction_set) self.need_refresh.connect(self.recount, type=Qt.QueuedConnection) 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() @@ -48,6 +58,19 @@ class TagsView(QTreeView): def sort_changed(self, state): config.set('sort_by_popularity', state == Qt.Checked) self.model().refresh() + # self.search_restriction_set() + + def search_restriction_set(self, s): + self.clear() + if len(s) == 0: + self.search_restriction = '' + else: + self.search_restriction = unicode(s) + self.model().set_search_restriction(self.search_restriction) + self.recount() + self.emit(SIGNAL('restriction_set(PyQt_PyObject)'), self.search_restriction) + self.emit(SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), + self._model.tokens(), self.match_all) def toggle(self, index): modifiers = int(QApplication.keyboardModifiers()) @@ -59,6 +82,20 @@ class TagsView(QTreeView): def clear(self): self.model().clear_state() + def saved_searches_changed(self, recount=True): + p = prefs['saved_searches'].keys() + p.sort() + t = self.restriction.currentText() + self.restriction.clear() # rebuild the restrictions combobox using current saved searches + self.restriction.addItem('') + for s in p: + self.restriction.addItem(s) + if t in p: # redo the current restriction, if there was one + self.restriction.setCurrentIndex(self.restriction.findText(t)) + self.search_restriction_set(t) + if recount: + self.recount() + def recount(self, *args): ci = self.currentIndex() if not ci.isValid(): @@ -74,13 +111,22 @@ 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. + ''' + def set_new_model(self): + self._model = TagsModel(self.db, parent=self) + self.setModel(self._model) + class TagTreeItem(object): CATEGORY = 0 TAG = 1 ROOT = 2 - def __init__(self, data=None, tag=None, category_icon=None, icon_map=None, parent=None): + def __init__(self, data=None, category_icon=None, icon_map=None, parent=None): self.parent = parent self.children = [] if self.parent is not None: @@ -96,13 +142,14 @@ class TagTreeItem(object): self.bold_font.setBold(True) self.bold_font = QVariant(self.bold_font) elif self.type == self.TAG: - self.tag, self.icon_map = data, list(map(QVariant, icon_map)) + icon_map[0] = data.icon + self.tag, self.icon_state_map = data, list(map(QVariant, icon_map)) def __str__(self): if self.type == self.ROOT: return 'ROOT' if self.type == self.CATEGORY: - return 'CATEGORY:'+self.name+':%d'%len(self.children) + return 'CATEGORY:'+str(QVariant.toString(self.name))+':%d'%len(self.children) return 'TAG:'+self.tag.name def row(self): @@ -137,7 +184,7 @@ class TagTreeItem(object): else: return QVariant('[%d] %s'%(self.tag.count, self.tag.name)) if role == Qt.DecorationRole: - return self.icon_map[self.tag.state] + return self.icon_state_map[self.tag.state] if role == Qt.ToolTipRole and self.tag.tooltip: return QVariant(self.tag.tooltip) return NONE @@ -148,38 +195,100 @@ class TagTreeItem(object): class TagsModel(QAbstractItemModel): - categories = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('Tags'), _('Searches')] - row_map = ['author', 'series', 'format', 'publisher', 'news', 'tag', 'search'] + categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('All tags')] + row_map_orig = ['author', 'series', 'format', 'publisher', 'news', 'tag'] + tags_categories_start= 5 + search_keys=['search', _('Searches')] def __init__(self, db, parent=None): QAbstractItemModel.__init__(self, parent) - self.cmap = tuple(map(QIcon, [I('user_profile.svg'), + self.cat_icon_map_orig = list(map(QIcon, [I('user_profile.svg'), I('series.svg'), I('book.svg'), I('publisher.png'), - I('news.svg'), I('tags.svg'), I('search.svg')])) - self.icon_map = [QIcon(), QIcon(I('plus.svg')), - QIcon(I('minus.svg'))] + I('news.svg'), I('tags.svg')])) + self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] + self.custcol_icon = QIcon(I('column.svg')) + self.search_icon = QIcon(I('search.svg')) + self.usercat_icon = QIcon(I('drawer.svg')) + self.label_to_icon_map = dict(map(None, self.row_map_orig, self.cat_icon_map_orig)) + self.label_to_icon_map['*custom'] = self.custcol_icon self.db = db + self.search_restriction = '' + self.user_categories = {} self.ignore_next_search = 0 + data = self.get_node_tree(config['sort_by_popularity']) self.root_item = TagTreeItem() - data = self.db.get_categories(config['sort_by_popularity']) - data['search'] = self.get_search_nodes() - for i, r in enumerate(self.row_map): c = TagTreeItem(parent=self.root_item, - data=self.categories[i], category_icon=self.cmap[i]) + data=self.categories[i], category_icon=self.cat_icon_map[i]) for tag in data[r]: - TagTreeItem(parent=c, data=tag, icon_map=self.icon_map) + TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map) + def set_search_restriction(self, s): + self.search_restriction = s - def get_search_nodes(self): + def get_node_tree(self, sort): + self.row_map = [] + self.categories = [] + # strip the icons after the 'standard' categories. We will put them back later + self.cat_icon_map = self.cat_icon_map_orig[:self.tags_categories_start-len(self.row_map_orig)] + self.user_categories = dict.copy(config['user_categories']) + column_map = config['column_map'] + + for i in range(0, self.tags_categories_start): # First the standard categories + self.row_map.append(self.row_map_orig[i]) + self.categories.append(self.categories_orig[i]) + if len(self.search_restriction): + data = self.db.get_categories(sort_on_count=sort, icon_map=self.label_to_icon_map, + ids=self.db.search(self.search_restriction, return_matches=True)) + else: + data = self.db.get_categories(sort_on_count=sort, icon_map=self.label_to_icon_map) + + for c in data: # now the custom columns + if c not in self.row_map_orig and c in column_map: + self.row_map.append(c) + self.categories.append(self.db.custom_column_label_map[c]['name']) + self.cat_icon_map.append(self.custcol_icon) + + # Now do the user-defined categories. There is a time/space tradeoff here. + # By converting the tags into a map, we can do the verification in the category + # loop much faster, at the cost of duplicating the categories lists. + taglist = {} + for c in self.row_map_orig: + taglist[c] = dict(map(lambda t:(t.name if c != 'author' else t.name.replace('|', ','), t), data[c])) + + for c in self.user_categories: + l = [] + for (name,label,ign) in self.user_categories[c]: + if name in taglist[label]: # use same node as the complete category + l.append(taglist[label][name]) + # else: do nothing, to eliminate nodes that have zero counts + if config['sort_by_popularity']: + data[c+'*'] = sorted(l, cmp=(lambda x, y: cmp(x.count, y.count))) + else: + data[c+'*'] = sorted(l, cmp=(lambda x, y: cmp(x.name.lower(), y.name.lower()))) + self.row_map.append(c+'*') + self.categories.append(c) + self.cat_icon_map.append(self.usercat_icon) + + # Now the rest of the normal tag categories + for i in range(self.tags_categories_start, len(self.row_map_orig)): + self.row_map.append(self.row_map_orig[i]) + self.categories.append(self.categories_orig[i]) + self.cat_icon_map.append(self.cat_icon_map_orig[i]) + data['search'] = self.get_search_nodes(self.search_icon) # Add the search category + self.row_map.append(self.search_keys[0]) + self.categories.append(self.search_keys[1]) + self.cat_icon_map.append(self.search_icon) + return data + + def get_search_nodes(self, icon): l = [] for i in saved_searches.names(): - l.append(Tag(i, tooltip=saved_searches.lookup(i))) + l.append(Tag(i, tooltip=saved_searches.lookup(i), icon=icon)) return l def refresh(self): - data = self.db.get_categories(config['sort_by_popularity']) - data['search'] = self.get_search_nodes() + data = self.get_node_tree(config['sort_by_popularity']) # get category data for i, r in enumerate(self.row_map): category = self.root_item.children[i] names = [t.tag.name for t in category.children] @@ -194,10 +303,8 @@ class TagsModel(QAbstractItemModel): if len(data[r]) > 0: self.beginInsertRows(category_index, 0, len(data[r])-1) for tag in data[r]: - if r == 'author': - tag.name = tag.name.replace('|', ',') tag.state = state_map.get(tag.name, 0) - t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_map) + t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_state_map) self.endInsertRows() def columnCount(self, parent): @@ -273,16 +380,20 @@ class TagsModel(QAbstractItemModel): return len(parent_item.children) def reset_all_states(self, except_=None): + update_list = [] for i in xrange(self.rowCount(QModelIndex())): category_index = self.index(i, 0, QModelIndex()) for j in xrange(self.rowCount(category_index)): tag_index = self.index(j, 0, category_index) tag_item = tag_index.internalPointer() - if tag_item is except_: - continue tag = tag_item.tag - if tag.state != 0: + if tag is except_: + self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), + tag_index, tag_index) + continue + if tag.state != 0 or tag in update_list: tag.state = 0 + update_list.append(tag) self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), tag_index, tag_index) @@ -299,9 +410,9 @@ class TagsModel(QAbstractItemModel): if not index.isValid(): return False item = index.internalPointer() if item.type == TagTreeItem.TAG: - if exclusive: - self.reset_all_states(except_=item) item.toggle() + if exclusive: + self.reset_all_states(except_=item.tag) self.ignore_next_search = 2 self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), index, index) return True @@ -309,14 +420,19 @@ class TagsModel(QAbstractItemModel): def tokens(self): ans = [] + tags_seen = [] for i, key in enumerate(self.row_map): + 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] for tag_item in category_item.children: tag = tag_item.tag - category = key if key != 'news' else 'tag' if tag.state > 0: prefix = ' not ' if tag.state == 2 else '' + category = key if key != 'news' else 'tag' + if category == 'tag': + if tag.name in tags_seen: + continue + tags_seen.append(tag.name) ans.append('%s%s:"=%s"'%(prefix, category, tag.name)) - return ans - - + return ans \ No newline at end of file diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 64004c70f8..754bc6d7da 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -60,6 +60,9 @@ from calibre.ebooks.BeautifulSoup import BeautifulSoup, Tag, NavigableString 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 datetime import datetime class SaveMenu(QMenu): @@ -126,8 +129,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): pixmap_to_data(pixmap)) def __init__(self, listener, opts, actions, parent=None): + self.last_time = datetime.now() self.preferences_action, self.quit_action = actions 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 @@ -143,6 +148,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.setupUi(self) self.setWindowTitle(__appname__) + self.restriction_count_of_books_in_view = 0 + self.restriction_count_of_books_in_library = 0 + self.restriction_in_effect = False self.search.initialize('main_search_history', colorize=True, help_text=_('Search (For Advanced Search click the button to the left)')) self.connect(self.clear_button, SIGNAL('clicked()'), self.search_clear) @@ -501,6 +509,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.search_done)), ('connect_to_book_display', (self.status_bar.book_info.show_data,)), + ('connect_to_restriction_set', + (self.tags_view,)), ]: for view in (self.library_view, self.memory_view, self.card_a_view, self.card_b_view): getattr(view, func)(*args) @@ -540,8 +550,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): db = LibraryDatabase2(self.library_path) self.library_view.set_database(db) prefs['library_path'] = self.library_path - self.library_view.sortByColumn(*dynamic.get('sort_column', - ('timestamp', Qt.DescendingOrder))) + self.library_view.restore_sort_at_startup(dynamic.get('sort_history', [('timestamp', Qt.DescendingOrder)])) if not self.library_view.restore_column_widths(): self.library_view.resizeColumnsToContents() self.search.setFocus(Qt.OtherFocusReason) @@ -551,10 +560,20 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.tags_view.setVisible(False) self.tag_match.setVisible(False) self.popularity.setVisible(False) - self.tags_view.set_database(db, self.tag_match, self.popularity) + self.restriction_label.setVisible(False) + self.edit_categories.setVisible(False) + self.search_restriction.setVisible(False) + self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_edit_categories) + self.tags_view.set_database(db, self.tag_match, self.popularity, self.search_restriction) self.connect(self.tags_view, SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), self.search.search_from_tags) + self.connect(self.tags_view, + SIGNAL('restriction_set(PyQt_PyObject)'), + self.saved_search.clear_to_help) + self.connect(self.tags_view, + SIGNAL('restriction_set(PyQt_PyObject)'), + self.mark_restriction_set) self.connect(self.tags_view, SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), self.saved_search.clear_to_help) @@ -567,8 +586,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): SIGNAL('count_changed(int)'), self.location_view.count_changed) self.connect(self.library_view.model(), SIGNAL('count_changed(int)'), self.tags_view.recount, Qt.QueuedConnection) - self.connect(self.search, SIGNAL('cleared()'), self.tags_view_clear) - self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.recount, Qt.QueuedConnection) + self.connect(self.library_view.model(), SIGNAL('count_changed(int)'), + self.restriction_count_changed, Qt.QueuedConnection) + 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) if not gprefs.get('quick_start_guide_added', False): from calibre.ebooks.metadata import MetaInformation mi = MetaInformation(_('Calibre Quick Start Guide'), ['John Schember']) @@ -618,7 +639,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.resize(self.width(), self._calculated_available_height) self.search.setMaximumWidth(self.width()-150) - if config['autolaunch_server']: from calibre.library.server import start_threaded_server from calibre.library import server_config @@ -658,6 +678,12 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): height = v.rowHeight(0) self.library_view.verticalHeader().setDefaultSectionSize(height) + def do_edit_categories(self): + d = TagCategories(self, self.library_view.model().db) + d.exec_() + if d.result() == d.Accepted: + self.tags_view.set_new_model() + self.tags_view.recount() def resizeEvent(self, ev): MainWindow.resizeEvent(self, ev) @@ -809,23 +835,68 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.tags_view.setVisible(True) self.tag_match.setVisible(True) self.popularity.setVisible(True) + self.restriction_label.setVisible(True) + self.edit_categories.setVisible(True) + self.search_restriction.setVisible(True) self.tags_view.setFocus(Qt.OtherFocusReason) else: self.tags_view.setVisible(False) self.tag_match.setVisible(False) self.popularity.setVisible(False) + self.restriction_label.setVisible(False) + self.edit_categories.setVisible(False) + self.search_restriction.setVisible(False) - def tags_view_clear(self): - self.search_count.setText(_("(all books)")) + ''' + Handling of the count of books in a restricted view requires that + we capture the count after the initial restriction search. To so this, + we require that the restriction_set signal be issued before the search signal, + so that when the search_done happens and the count is displayed, + we can grab the count. This works because the search box is cleared + when a restriction is set, so that first search will find all books. + + Adding and deleting books creates another complexity. When added, they are + displayed regardless of whether they match the restriction. However, if they + do not, they are removed at the next search. The counts must take this + behavior into effect. + ''' + + def restriction_count_changed(self, c): + self.restriction_count_of_books_in_view += c - self.restriction_count_of_books_in_library + self.restriction_count_of_books_in_library = c + if self.restriction_in_effect: + self.set_number_of_books_shown(all='not used', compute_count=False) + + def mark_restriction_set(self, r): + self.restriction_in_effect = False if r is None or not r else True + + def set_number_of_books_shown(self, all, compute_count): + if self.restriction_in_effect: + if compute_count: + self.restriction_count_of_books_in_view = self.current_view().row_count() + t = _("({0} of {1})").format(self.current_view().row_count(), + self.restriction_count_of_books_in_view) + self.search_count.setStyleSheet('QLabel { background-color: yellow; }') + else: # No restriction + if all == 'yes': + t = _("(all books)") + else: + t = _("({0} of all)").format(self.current_view().row_count()) + self.search_count.setStyleSheet('QLabel { background-color: white; }') + self.search_count.setText(t) + + def search_box_cleared(self): + self.set_number_of_books_shown(all='yes', compute_count=True) self.tags_view.clear() + self.saved_search.clear_to_help() def search_clear(self): - self.search_count.setText(_("(all books)")) + self.set_number_of_books_shown(all='yes', compute_count=True) self.search.clear() def search_done(self, view, ok): if view is self.current_view(): - self.search_count.setText(_("(%d found)") % self.current_view().row_count()) + self.set_number_of_books_shown(all='no', compute_count=False) self.search.search_done(ok) def sync_cf_to_listview(self, current, previous): @@ -2174,7 +2245,12 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): _('Cannot configure while there are running jobs.')) d.exec_() return - d = ConfigDialog(self, self.library_view.model().db, + if self.must_restart_before_config: + d = error_dialog(self, _('Cannot configure'), + _('Cannot configure before calibre is restarted.')) + d.exec_() + return + d = ConfigDialog(self, self.library_view.model(), server=self.content_server) d.exec_() self.content_server = d.server @@ -2189,15 +2265,17 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): _('Save only %s format to disk')% prefs['output_format'].upper()) self.library_view.model().read_config() + self.library_view.model().refresh() + self.library_view.model().research() + self.tags_view.set_new_model() # in case columns changed + self.tags_view.recount() self.create_device_menu() - if not patheq(self.library_path, d.database_location): newloc = d.database_location move_library(self.library_path, newloc, self, self.library_moved) - def library_moved(self, newloc): if newloc is None: return db = LibraryDatabase2(newloc) @@ -2374,7 +2452,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): def write_settings(self): config.set('main_window_geometry', self.saveGeometry()) - dynamic.set('sort_column', self.library_view.model().sorted_on) + dynamic.set('sort_history', self.library_view.model().sort_history) dynamic.set('tag_view_visible', self.tags_view.isVisible()) dynamic.set('cover_flow_visible', self.cover_flow.isVisible()) self.library_view.write_settings() diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index b18ada991e..eb456241ce 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -8,12 +8,15 @@ __docformat__ = 'restructuredtext en' import collections, glob, os, re, itertools, functools from itertools import repeat +from datetime import timedelta from PyQt4.QtCore import QThread, QReadWriteLock from PyQt4.QtGui import QImage +from calibre.utils.config import tweaks, prefs +from calibre.utils.date import parse_date, now from calibre.utils.search_query_parser import SearchQueryParser -from calibre.utils.date import parse_date +from calibre.utils.pyparsing import ParseException class CoverCache(QThread): @@ -146,6 +149,14 @@ class ResultCache(SearchQueryParser): ''' Stores sorted and filtered metadata in memory. ''' + def __init__(self, FIELD_MAP, cc_label_map): + self.FIELD_MAP = FIELD_MAP + self.custom_column_label_map = cc_label_map + self._map = self._map_filtered = self._data = [] + self.first_sort = True + self.search_restriction = '' + SearchQueryParser.__init__(self, [c for c in cc_label_map]) + self.build_relop_dict() def build_relop_dict(self): ''' @@ -194,13 +205,6 @@ class ResultCache(SearchQueryParser): self.search_relops = {'=':[1, relop_eq], '>':[1, relop_gt], '<':[1, relop_lt], \ '!=':[2, relop_ne], '>=':[2, relop_ge], '<=':[2, relop_le]} - def __init__(self, FIELD_MAP): - self.FIELD_MAP = FIELD_MAP - self._map = self._map_filtered = self._data = [] - self.first_sort = True - SearchQueryParser.__init__(self) - self.build_relop_dict() - def __getitem__(self, row): return self._data[self._map_filtered[row]] @@ -214,30 +218,63 @@ class ResultCache(SearchQueryParser): def universal_set(self): return set([i[0] for i in self._data if i is not None]) + def get_matches_dates(self, location, query): + matches = set([]) + if len(query) < 2: + return matches + relop = None + for k in self.search_relops.keys(): + if query.startswith(k): + (p, relop) = self.search_relops[k] + query = query[p:] + if relop is None: + (p, relop) = self.search_relops['='] + if location in self.custom_column_label_map: + loc = self.FIELD_MAP[self.custom_column_label_map[location]['num']] + else: + loc = self.FIELD_MAP[{'date':'timestamp', 'pubdate':'pubdate'}[location]] + + if query == _('today'): + qd = now() + field_count = 3 + elif query == _('yesterday'): + qd = now() - timedelta(1) + field_count = 3 + elif query == _('thismonth'): + qd = now() + field_count = 2 + elif query.endswith(_('daysago')): + num = query[0:-len(_('daysago'))] + try: + qd = now() - timedelta(int(num)) + except: + raise ParseException(query, len(query), 'Number conversion error', self) + field_count = 3 + else: + try: + qd = parse_date(query) + except: + raise ParseException(query, len(query), 'Date conversion error', self) + if '-' in query: + field_count = query.count('-') + 1 + else: + field_count = query.count('/') + 1 + for item in self._data: + if item is None or item[loc] is None: continue + if relop(item[loc], qd, field_count): + matches.add(item[0]) + return matches + def get_matches(self, location, query): matches = set([]) if query and query.strip(): location = location.lower().strip() ### take care of dates special case - if location in ('pubdate', 'date'): - if len(query) < 2: - return matches - relop = None - for k in self.search_relops.keys(): - if query.startswith(k): - (p, relop) = self.search_relops[k] - query = query[p:] - if relop is None: - return matches - loc = self.FIELD_MAP[{'date':'timestamp', 'pubdate':'pubdate'}[location]] - qd = parse_date(query) - field_count = query.count('-') + 1 - for item in self._data: - if item is None: continue - if relop(item[loc], qd, field_count): - matches.add(item[0]) - return matches + if (location in ('pubdate', 'date')) or \ + ((location in self.custom_column_label_map) and \ + self.custom_column_label_map[location]['datatype'] == 'datetime'): + return self.get_matches_dates(location, query) ### everything else matchkind = CONTAINS_MATCH @@ -257,19 +294,39 @@ class ResultCache(SearchQueryParser): query = query.decode('utf-8') if location in ('tag', 'author', 'format', 'comment'): location += 's' + all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series', 'formats', 'isbn', 'rating', 'cover') MAP = {} - for x in all: + + for x in all: # get the db columns for the standard searchables MAP[x] = self.FIELD_MAP[x] + IS_CUSTOM = [] + for x in range(len(self.FIELD_MAP)): # build a list containing '' the size of FIELD_MAP + IS_CUSTOM.append('') + IS_CUSTOM[self.FIELD_MAP['rating']] = 'rating' # normal and custom ratings columns use the same code + for x in self.custom_column_label_map: # add custom columns to MAP. Put the column's type into IS_CUSTOM + if self.custom_column_label_map[x]['datatype'] != "datetime": + MAP[x] = self.FIELD_MAP[self.custom_column_label_map[x]['num']] + IS_CUSTOM[MAP[x]] = self.custom_column_label_map[x]['datatype'] + EXCLUDE_FIELDS = [MAP['rating'], MAP['cover']] SPLITABLE_FIELDS = [MAP['authors'], MAP['tags'], MAP['formats']] + for x in self.custom_column_label_map: + if self.custom_column_label_map[x]['is_multiple']: + SPLITABLE_FIELDS.append(MAP[x]) + location = [location] if location != 'all' else list(MAP.keys()) for i, loc in enumerate(location): location[i] = MAP[loc] + try: rating_query = int(query) * 2 except: rating_query = None + + # get the tweak here so that the string lookup and compare aren't in the loop + bools_are_tristate = tweaks['bool_custom_columns_are_tristate'] == 'yes' + for loc in location: if loc == MAP['authors']: q = query.replace(',', '|'); ### DB stores authors with commas changed to bars, so change query @@ -278,14 +335,34 @@ class ResultCache(SearchQueryParser): for item in self._data: if item is None: continue + + if IS_CUSTOM[loc] == 'bool': # complexity caused by the two-/three-value tweak + v = item[loc] + if not bools_are_tristate: + if v is None or not v: # item is None or set to false + if q in [_('no'), _('unchecked'), 'false']: + matches.add(item[0]) + else: # item is explicitly set to true + if q in [_('yes'), _('checked'), 'true']: + matches.add(item[0]) + else: + if v is None: + if q in [_('empty'), _('blank'), 'false']: + matches.add(item[0]) + elif not v: # is not None and false + if q in [_('no'), _('unchecked'), 'true']: + matches.add(item[0]) + else: # item is not None and true + if q in [_('yes'), _('checked'), 'true']: + matches.add(item[0]) + continue + if not item[loc]: - if query == 'false': - if isinstance(item[loc], basestring): - if item[loc].strip() != '': - continue + if q == 'false': matches.add(item[0]) - continue - continue ### item is empty. No possible matches below + continue # item is empty. No possible matches below + if q == 'false': # Field has something in it, so a false query does not match + continue if q == 'true': if isinstance(item[loc], basestring): @@ -293,12 +370,30 @@ class ResultCache(SearchQueryParser): continue matches.add(item[0]) continue - if rating_query and loc == MAP['rating'] and rating_query == int(item[loc]): - matches.add(item[0]) + + if IS_CUSTOM[loc] == 'rating': + if rating_query and rating_query == int(item[loc]): + matches.add(item[0]) continue + + try: # a conversion below might fail + if IS_CUSTOM[loc] == 'float': + if float(query) == item[loc]: # relationals not supported + matches.add(item[0]) + continue + if IS_CUSTOM[loc] == 'int': + if int(query) == item[loc]: + matches.add(item[0]) + continue + except: + continue ## A conversion threw an exception. Because of the type, no further match possible + if loc not in EXCLUDE_FIELDS: if loc in SPLITABLE_FIELDS: - vals = item[loc].split(',') ### check individual tags/authors/formats, not the long string + if IS_CUSTOM[loc]: + vals = item[loc].split('|') + else: + vals = item[loc].split(',') else: vals = [item[loc]] ### make into list to make _match happy if _match(q, vals, matchkind): @@ -342,8 +437,7 @@ class ResultCache(SearchQueryParser): ''' for id in ids: try: - self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?', - (id,))[0] + self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?', (id,))[0] self._data[id].append(db.has_cover(id, index_is_id=True)) except IndexError: return None @@ -399,6 +493,12 @@ class ResultCache(SearchQueryParser): asstr else cmp(self._data[x][loc], self._data[y][loc]) except AttributeError: # Some entries may be None ans = cmp(self._data[x][loc], self._data[y][loc]) + except TypeError: ## raised when a datetime is None + if self._data[x][loc] is None: + if self._data[y][loc] is None: + return 0 # Both None. Return eq + return 1 # x is None, y not. Return gt + return -1 # x is not None and (therefore) y is. return lt if subsort and ans == 0: return cmp(self._data[x][11].lower(), self._data[y][11].lower()) return ans @@ -410,21 +510,35 @@ class ResultCache(SearchQueryParser): if field == 'date': field = 'timestamp' elif field == 'title': field = 'sort' elif field == 'authors': field = 'author_sort' + as_string = field not in ('size', 'rating', 'timestamp') + if field in self.custom_column_label_map: + as_string = self.custom_column_label_map[field]['datatype'] in ('comments', 'text') + field = self.custom_column_label_map[field]['num'] + if self.first_sort: subsort = True self.first_sort = False fcmp = self.seriescmp if field == 'series' else \ functools.partial(self.cmp, self.FIELD_MAP[field], subsort=subsort, - asstr=field not in ('size', 'rating', 'timestamp')) - + asstr=as_string) self._map.sort(cmp=fcmp, reverse=not ascending) self._map_filtered = [id for id in self._map if id in self._map_filtered] - def search(self, query): + def search(self, query, return_matches = False): if not query or not query.strip(): + q = self.search_restriction + else: + q = '%s (%s)' % (self.search_restriction, query) + if not q: + if return_matches: + return list(self.map) # when return_matches, do not update the maps! self._map_filtered = list(self._map) - return - matches = sorted(self.parse(query)) + return [] + matches = sorted(self.parse(q)) + if return_matches: + return [id for id in self._map if id in matches] self._map_filtered = [id for id in self._map if id in matches] + return [] - + def set_search_restriction(self, s): + self.search_restriction = '' if not s else 'search:"%s"' % (s.strip()) \ No newline at end of file diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py index 9258d5222a..6442db4a73 100644 --- a/src/calibre/library/custom_columns.py +++ b/src/calibre/library/custom_columns.py @@ -190,7 +190,7 @@ class CustomColumns(object): (label, num)) changed = True if is_editable is not None: - self.conn.execute('UPDATE custom_columns SET is_editable=? WHERE id=?', + self.conn.execute('UPDATE custom_columns SET editable=? WHERE id=?', (bool(is_editable), num)) self.custom_column_num_map[num]['is_editable'] = bool(is_editable) changed = True diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 8f42107944..6e1ef9308e 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -56,16 +56,15 @@ def delete_tree(path, permanent=False): 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): + def __init__(self, name, id=None, count=0, state=0, tooltip=None, icon=None): self.name = name self.id = id self.count = count self.state = state self.tooltip = tooltip + self.icon = icon def __unicode__(self): return u'%s:%s:%s:%s:%s'%(self.name, self.count, self.id, self.state, self.tooltip) @@ -186,7 +185,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.conn.executescript(script) self.conn.commit() - self.data = ResultCache(self.FIELD_MAP) + self.data = ResultCache(self.FIELD_MAP, self.custom_column_label_map) self.search = self.data.search self.refresh = functools.partial(self.data.refresh, self) self.sort = self.data.sort @@ -576,35 +575,99 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): def get_recipe(self, id): return self.conn.get('SELECT script FROM feeds WHERE id=?', (id,), all=False) - def get_categories(self, sort_on_count=False): - self.conn.executescript(u''' - CREATE TEMP VIEW IF NOT EXISTS tag_browser_news AS SELECT DISTINCT - id, - name, - (SELECT COUNT(id) FROM books_tags_link WHERE tag=x.id) count - 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 - (SELECT id FROM tags WHERE name="{0}"))); - '''.format(_('News'))) - self.conn.commit() + def get_categories(self, sort_on_count=False, ids=None, icon_map=None): + + orig_category_columns = {'tags': ['tag', 'name'], + 'series': ['series', 'name'], + 'publishers': ['publisher', 'name'], + 'authors': ['author', 'name']} # 'news' is added below + cat_cols = {} + + def create_filtered_views(self, ids): + def create_tag_browser_view(table_name, column_name, view_column_name): + script = (''' + CREATE TEMP VIEW IF NOT EXISTS 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 + FROM {tn}; + '''.format(tn=table_name, cn=column_name, vcn=view_column_name)) + self.conn.executescript(script) + + self.cat_cols = {} + for tn,cn in orig_category_columns.iteritems(): + create_tag_browser_view(tn, cn[0], cn[1]) + cat_cols[tn] = cn + for i,v in self.custom_column_num_map.iteritems(): + if v['datatype'] == 'text': + tn = 'custom_column_{0}'.format(i) + create_tag_browser_view(tn, 'value', 'value') + cat_cols[tn] = [v['label'], 'value'] + cat_cols['news'] = ['news', 'name'] + + self.conn.executescript(u''' + 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 + 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 + (SELECT id FROM tags WHERE name="{0}"))); + '''.format(_('News'))) + self.conn.commit() + + self.conn.executescript(u''' + 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 + 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 + (SELECT id FROM tags WHERE name="{0}"))); + '''.format(_('News'))) + self.conn.commit() + + if ids is not None: + s_ids = set(ids) + else: + s_ids = None + self.conn.create_function('books_list_filter', 1, lambda(id): 1 if id in s_ids else 0) + create_filtered_views(self, ids) categories = {} - for x in ('tags', 'series', 'news', 'publishers', 'authors'): - query = 'SELECT id,name,count FROM tag_browser_'+x + for tn,cn in cat_cols.iteritems(): + if ids is None: + query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn[1], tn) + else: + query = 'SELECT id, {0}, count FROM tag_browser_filtered_{1}'.format(cn[1], tn) if sort_on_count: query += ' ORDER BY count DESC' else: - query += ' ORDER BY name ASC' + query += ' ORDER BY {0} ASC'.format(cn[1]) data = self.conn.get(query) - category = x if x in ('series', 'news') else x[:-1] - categories[category] = [Tag(r[1], count=r[2], id=r[0]) for r in data] - + category = cn[0] + icon = icon_map[category] if category in icon_map else icon_map['*custom'] + if ids is None: # no filtering + categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon) + for r in data] + else: # filter out zero-count tags + categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon) + for r in data if r[2] > 0] categories['format'] = [] for fmt in self.conn.get('SELECT DISTINCT format FROM data'): fmt = fmt[0] - count = self.conn.get('SELECT COUNT(id) FROM data WHERE format="%s"'%fmt, - all=False) + if ids is not None: + count = self.conn.get('''SELECT COUNT(id) + FROM data + WHERE format="%s" and books_list_filter(id)'''%fmt, + all=False) + else: + count = self.conn.get('''SELECT COUNT(id) + FROM data + WHERE format="%s"'''%fmt, + all=False) categories['format'].append(Tag(fmt, count=count)) if sort_on_count: @@ -612,7 +675,6 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): reverse=True) else: categories['format'].sort(cmp=lambda x,y:cmp(x.name, y.name)) - return categories def tags_older_than(self, tag, delta): diff --git a/src/calibre/utils/date.py b/src/calibre/utils/date.py index fb9d3e90b0..e48e10d90f 100644 --- a/src/calibre/utils/date.py +++ b/src/calibre/utils/date.py @@ -24,6 +24,13 @@ class SafeLocalTimeZone(tzlocal): pass return False +def compute_locale_info_for_parse_date(): + dt = datetime.strptime('1/5/2000', "%x") + if dt.month == 5: + return True + return False + +parse_date_day_first = compute_locale_info_for_parse_date() utc_tz = _utc_tz = tzutc() local_tz = _local_tz = SafeLocalTimeZone() @@ -44,7 +51,7 @@ def parse_date(date_string, assume_utc=False, as_utc=True, default=None): func = datetime.utcnow if assume_utc else datetime.now default = func().replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=_utc_tz if assume_utc else _local_tz) - dt = parse(date_string, default=default) + dt = parse(date_string, default=default, dayfirst=parse_date_day_first) if dt.tzinfo is None: dt = dt.replace(tzinfo=_utc_tz if assume_utc else _local_tz) return dt.astimezone(_utc_tz if as_utc else _local_tz) diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py index 2acb4708e9..6768b66063 100644 --- a/src/calibre/utils/search_query_parser.py +++ b/src/calibre/utils/search_query_parser.py @@ -116,13 +116,12 @@ class SearchQueryParser(object): failed.append(test[0]) return failed - def __init__(self, test=False): + def __init__(self, custcols=[], test=False): self._tests_failed = False # Define a token - locations = map(lambda x : CaselessLiteral(x)+Suppress(':'), - self.LOCATIONS) + standard_locations = map(lambda x : CaselessLiteral(x)+Suppress(':'), self.LOCATIONS+custcols) location = NoMatch() - for l in locations: + for l in standard_locations: location |= l location = Optional(location, default='all') word_query = CharsNotIn(string.whitespace + '()') @@ -176,14 +175,20 @@ class SearchQueryParser(object): def parse(self, query): # empty the list of searches used for recursion testing + self.recurse_level = 0 self.searches_seen = set([]) return self._parse(query) # this parse is used internally because it doesn't clear the - # recursive search test list + # recursive search test list. However, we permit seeing the + # same search a few times because the search might appear within + # another search. def _parse(self, query): + self.recurse_level += 1 res = self._parser.parseString(query)[0] - return self.evaluate(res) + t = self.evaluate(res) + self.recurse_level -= 1 + return t def method(self, group_name): return getattr(self, 'evaluate_'+group_name) @@ -207,13 +212,13 @@ class SearchQueryParser(object): location = argument[0] query = argument[1] if location.lower() == 'search': - # print "looking for named search " + query if query.startswith('='): query = query[1:] try: if query in self.searches_seen: raise ParseException(query, len(query), 'undefined saved search', self) - self.searches_seen.add(query) + if self.recurse_level > 5: + self.searches_seen.add(query) return self._parse(saved_searches.lookup(query)) except: # convert all exceptions (e.g., missing key) to a parse error raise ParseException(query, len(query), 'undefined saved search', self)