From 001eca4196575080472fa7c97317ba878db5d779 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 2 Apr 2010 17:45:58 +0530 Subject: [PATCH] Support for saved searches --- resources/images/search_add_saved.svg | 3544 +++++++++++++++++++++ resources/images/search_copy_saved.svg | 3547 ++++++++++++++++++++++ resources/images/search_delete_saved.svg | 3544 +++++++++++++++++++++ src/calibre/gui2/main.ui | 63 + src/calibre/gui2/search_box.py | 130 + src/calibre/gui2/tag_view.py | 28 +- src/calibre/gui2/ui.py | 17 + src/calibre/library/database2.py | 5 +- src/calibre/utils/config.py | 3 + src/calibre/utils/search_query_parser.py | 67 +- 10 files changed, 10938 insertions(+), 10 deletions(-) create mode 100644 resources/images/search_add_saved.svg create mode 100644 resources/images/search_copy_saved.svg create mode 100644 resources/images/search_delete_saved.svg diff --git a/resources/images/search_add_saved.svg b/resources/images/search_add_saved.svg new file mode 100644 index 0000000000..a5eb13e1e4 --- /dev/null +++ b/resources/images/search_add_saved.svg @@ -0,0 +1,3544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/search_copy_saved.svg b/resources/images/search_copy_saved.svg new file mode 100644 index 0000000000..fc0b1f54b9 --- /dev/null +++ b/resources/images/search_copy_saved.svg @@ -0,0 +1,3547 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/search_delete_saved.svg b/resources/images/search_delete_saved.svg new file mode 100644 index 0000000000..af79e8f9f4 --- /dev/null +++ b/resources/images/search_delete_saved.svg @@ -0,0 +1,3544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui index 5cf8f9d35e..02907175d0 100644 --- a/src/calibre/gui2/main.ui +++ b/src/calibre/gui2/main.ui @@ -220,6 +220,64 @@ + + + + Choose saved search or enter name for new saved search + + + 15 + + + + 150 + 16777215 + + + + + + + + Copy current search text (instead of search name) + + + ... + + + + :/images/search_copy_saved.svg:/images/search_copy_saved.svg + + + + + + + Save current search under the name shown in the box + + + ... + + + + :/images/search_add_saved.svg:/images/search_add_saved.svg + + + + + + + Delete current search and clear search box + + + ... + + + + :/images/search_delete_saved.svg:/images/search_delete_saved.svg + + + @@ -686,6 +744,11 @@ QComboBox
calibre.gui2.search_box
+ + SavedSearchBox + QComboBox +
calibre.gui2.search_box
+
diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py index baee2a2bc2..4a23de8918 100644 --- a/src/calibre/gui2/search_box.py +++ b/src/calibre/gui2/search_box.py @@ -7,6 +7,7 @@ __copyright__ = '2009, Kovid Goyal ' __docformat__ = 'restructuredtext en' from PyQt4.Qt import QComboBox, SIGNAL, Qt, QLineEdit, QStringList, pyqtSlot +from PyQt4.QtGui import QCompleter from calibre.gui2 import config @@ -20,6 +21,10 @@ class SearchLineEdit(QLineEdit): self.emit(SIGNAL('mouse_released(PyQt_PyObject)'), event) QLineEdit.mouseReleaseEvent(self, event) + def focusOutEvent(self, event): + self.emit(SIGNAL('focus_out(PyQt_PyObject)'), event) + QLineEdit.focusOutEvent(self, event) + def dropEvent(self, ev): if self.parent().help_state: self.parent().normalize_state() @@ -176,3 +181,128 @@ class SearchBox2(QComboBox): def search_as_you_type(self, enabled): self.as_you_type = enabled + +class SavedSearchBox(QComboBox): + + ''' + To use this class: + * Call initialize() + * Connect to the changed() signal from this widget + if you care about changes to the list of saved searches. + ''' + + def __init__(self, parent=None): + QComboBox.__init__(self, parent) + self.normal_background = 'rgb(255, 255, 255, 0%)' + + self.line_edit = SearchLineEdit(self) + self.setLineEdit(self.line_edit) + self.connect(self.line_edit, SIGNAL('key_pressed(PyQt_PyObject)'), + self.key_pressed, Qt.DirectConnection) + self.connect(self.line_edit, SIGNAL('mouse_released(PyQt_PyObject)'), + self.mouse_released, Qt.DirectConnection) + self.connect(self.line_edit, SIGNAL('focus_out(PyQt_PyObject)'), + self.focus_out, Qt.DirectConnection) + self.connect(self, SIGNAL('activated(const QString&)'), + self.saved_search_selected) + + completer = QCompleter(self) # turn off auto-completion + self.setCompleter(completer) + self.setEditable(True) + self.help_state = True + self.prev_search = '' + self.setInsertPolicy(self.NoInsert) + + def initialize(self, _saved_searches, _search_box, colorize=False, help_text=_('Search')): + self.tool_tip_text = self.toolTip() + self.saved_searches = _saved_searches + self.search_box = _search_box + self.help_text = help_text + self.colorize = colorize + self.clear_to_help() + + def normalize_state(self): + #print 'in normalize_state' + self.setEditText('') + self.line_edit.setStyleSheet( + 'QLineEdit { color: black; background-color: %s; }' % + self.normal_background) + self.help_state = False + + def clear_to_help(self): + #print 'in clear_to_help' + self.setToolTip(self.tool_tip_text) + self.initialize_saved_search_names() + self.setEditText(self.help_text) + self.line_edit.home(False) + self.help_state = True + self.line_edit.setStyleSheet( + 'QLineEdit { color: gray; background-color: %s; }' % + self.normal_background) + + def focus_out(self, event): + #print 'in focus_out' + if self.currentText() == '': + self.clear_to_help() + + def key_pressed(self, event): + #print 'in key_pressed' + if self.help_state: + self.normalize_state() + + def mouse_released(self, event): + if self.help_state: + self.normalize_state() + + def saved_search_selected (self, qname): + #print 'in saved_search_selected' + if qname is None or qname == '': + return + self.normalize_state() + self.search_box.set_search_string ('search:"'+unicode(qname)+'"') + self.setEditText(qname) + self.setToolTip(self.saved_searches.lookup(qname)) + + def initialize_saved_search_names(self): + #print 'in initialize_saved_search_names' + self.clear() + qnames = self.saved_searches.names() + self.addItems(qnames) + self.setCurrentIndex(-1) + + # SIGNALed from the main UI + def delete_search_button_clicked(self): + #print 'in delete_search_button_clicked' + idx = self.currentIndex + if idx < 0: + return + self.saved_searches.delete (unicode(self.currentText())) + self.clear_to_help() + self.search_box.set_search_string ('') + self.emit(SIGNAL('changed()')) + + # SIGNALed from the main UI + def save_search_button_clicked(self): + #print 'in save_search_button_clicked' + name = self.currentText() + if self.help_state or name == '': + name = self.search_box.text() + self.saved_searches.add(name, self.search_box.text()) + # now go through an initialization cycle to ensure that the combobox has + # the new search in it, that it is selected, and that the search box + # references the new search instead of the text in the search. + self.clear_to_help() + self.normalize_state() + self.setCurrentIndex(self.findText(name)) + self.saved_search_selected (name) + self.emit(SIGNAL('changed()')) + + # 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(self.currentText())) + + diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 6d4e0d8655..e901ea6335 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -13,6 +13,8 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, \ QFont, SIGNAL, QSize, QIcon, QPoint, \ QAbstractItemModel, QVariant, QModelIndex from calibre.gui2 import config, NONE +from calibre.utils.search_query_parser import saved_searches +from calibre.library.database2 import Tag class TagsView(QTreeView): @@ -31,6 +33,7 @@ class TagsView(QTreeView): 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, SIGNAL('need_refresh()'), self.recount, Qt.QueuedConnection) @property def match_all(self): @@ -119,9 +122,14 @@ class TagTreeItem(object): def tag_data(self, role): if role == Qt.DisplayRole: - return QVariant('[%d] %s'%(self.tag.count, self.tag.name)) + if self.tag.count == 0: + return QVariant('%s'%(self.tag.name)) + else: + return QVariant('[%d] %s'%(self.tag.count, self.tag.name)) if role == Qt.DecorationRole: return self.icon_map[self.tag.state] + if role == Qt.ToolTipRole and self.tag.tooltip: + return self.tag.tooltip return NONE def toggle(self): @@ -129,36 +137,44 @@ class TagTreeItem(object): self.tag.state = (self.tag.state + 1)%3 class TagsModel(QAbstractItemModel): - categories = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('Tags')] - row_map = ['author', 'series', 'format', 'publisher', 'news', 'tag'] + categories = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('Tags'), _('Searches')] + row_map = ['author', 'series', 'format', 'publisher', 'news', 'tag', 'search'] def __init__(self, db, parent=None): QAbstractItemModel.__init__(self, parent) self.cmap = tuple(map(QIcon, [I('user_profile.svg'), I('series.svg'), I('book.svg'), I('publisher.png'), - I('news.svg'), I('tags.svg')])) + I('news.svg'), I('tags.svg'), I('search.svg')])) self.icon_map = [QIcon(), QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db self.ignore_next_search = 0 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]) for tag in data[r]: - t = TagTreeItem(parent=c, data=tag, icon_map=self.icon_map) - t + TagTreeItem(parent=c, data=tag, icon_map=self.icon_map) self.db.add_listener(self.database_changed) self.connect(self, SIGNAL('need_refresh()'), self.refresh, Qt.QueuedConnection) + def get_search_nodes(self): + l = [] + for i in saved_searches.names(): + l.append(Tag(i, tooltip=saved_searches.lookup(i))) + return l + def database_changed(self, event, ids): self.emit(SIGNAL('need_refresh()')) def refresh(self): data = self.db.get_categories(config['sort_by_popularity']) + data['search'] = self.get_search_nodes() for i, r in enumerate(self.row_map): category = self.root_item.children[i] names = [t.tag.name for t in category.children] diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index c83cba48ec..5d314a730a 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -29,6 +29,7 @@ from calibre.utils.filenames import ascii_filename from calibre.ptempfile import PersistentTemporaryFile from calibre.utils.config import prefs, dynamic from calibre.utils.ipc.server import Server +from calibre.utils.search_query_parser import saved_searches from calibre.gui2 import warning_dialog, choose_files, error_dialog, \ question_dialog,\ pixmap_to_data, choose_dir, \ @@ -140,9 +141,21 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): Ui_MainWindow.__init__(self) self.setupUi(self) self.setWindowTitle(__appname__) + 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) + self.connect(self.clear_button, SIGNAL('clicked()'), self.saved_search.clear_to_help) + + self.saved_search.initialize(saved_searches, self.search, colorize=True, + help_text=_('Saved Searches')) + self.connect(self.save_search_button, SIGNAL('clicked()'), + self.saved_search.save_search_button_clicked) + self.connect(self.delete_search_button, SIGNAL('clicked()'), + self.saved_search.delete_search_button_clicked) + self.connect(self.copy_search_button, SIGNAL('clicked()'), + self.saved_search.copy_search_button_clicked) + self.progress_indicator = ProgressIndicator(self) self.verbose = opts.verbose self.get_metadata = GetMetadata() @@ -511,6 +524,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.connect(self.tags_view, SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), self.search.search_from_tags) + self.connect(self.tags_view, + SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), + self.saved_search.clear_to_help) self.connect(self.status_bar.tag_view_button, SIGNAL('toggled(bool)'), self.toggle_tags_view) self.connect(self.search, @@ -521,6 +537,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.connect(self.library_view.model(), SIGNAL('count_changed(int)'), self.tags_view.recount) self.connect(self.search, SIGNAL('cleared()'), self.tags_view.clear) + self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.recount) if not gprefs.get('quick_start_guide_added', False): from calibre.ebooks.metadata import MetaInformation mi = MetaInformation(_('Calibre Quick Start Guide'), ['John Schember']) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index c48806f09a..6c886f0e5d 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -409,14 +409,15 @@ class ResultCache(SearchQueryParser): class Tag(object): - def __init__(self, name, id=None, count=0, state=0): + def __init__(self, name, id=None, count=0, state=0, tooltip=None): self.name = name self.id = id self.count = count self.state = state + self.tooltip = tooltip def __unicode__(self): - return u'%s:%s:%s:%s'%(self.name, self.count, self.id, self.state) + return u'%s:%s:%s:%s:%s'%(self.name, self.count, self.id, self.state, self.tooltip) def __str__(self): return unicode(self).encode('utf-8') diff --git a/src/calibre/utils/config.py b/src/calibre/utils/config.py index 316fc1de64..20dddd8061 100644 --- a/src/calibre/utils/config.py +++ b/src/calibre/utils/config.py @@ -673,6 +673,9 @@ def _prefs(): c.add_opt('add_formats_to_existing', default=False, help=_('Add new formats to existing book records')) + # this is here instead of the gui preferences because calibredb can execute searches + c.add_opt('saved_searches', default={}, help=_('List of named saved searches')) + c.add_opt('migrated', default=False, help='For Internal use. Don\'t modify.') return c diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py index a92d195bff..787afec7bd 100644 --- a/src/calibre/utils/search_query_parser.py +++ b/src/calibre/utils/search_query_parser.py @@ -19,7 +19,49 @@ If this module is run, it will perform a series of unit tests. import sys, string, operator from calibre.utils.pyparsing import Keyword, Group, Forward, CharsNotIn, Suppress, \ - OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch + OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch, ParseException +from calibre.constants import preferred_encoding +from calibre.utils.config import prefs + + +''' +This class manages access to the preference holding the saved search queries. +It exists to ensure that unicode is used throughout, and also to permit +adding other fields, such as whether the search is a 'favorite' +''' +class SavedSearchQueries(object): + queries = {} + opt_name = '' + + def __init__(self, _opt_name): + self.opt_name = _opt_name; + self.queries = prefs[self.opt_name] + + def force_unicode(self, x): + if not isinstance(x, unicode): + x = x.decode(preferred_encoding, 'replace') + return x + + def add(self, name, value): + self.queries[self.force_unicode(name)] = self.force_unicode(value).strip() + prefs[self.opt_name] = self.queries + + def lookup(self, name): + return self.queries.get(self.force_unicode(name), None) + + def delete(self, name): + self.queries.pop(self.force_unicode(name), False) + prefs[self.opt_name] = self.queries + + def names(self): + return sorted(self.queries.keys(), + cmp=lambda x,y: cmp(x.lower(), y.lower())) + +''' +Create a global instance of the saved searches. It is global so that the searches +are common across all instances of the parser (devices, library, etc). +''' +saved_searches = SavedSearchQueries('saved_searches') class SearchQueryParser(object): @@ -55,6 +97,7 @@ class SearchQueryParser(object): 'comments', 'format', 'isbn', + 'search', 'all', ] @@ -130,6 +173,13 @@ class SearchQueryParser(object): def parse(self, query): + # empty the list of searches used for recursion testing + self.searches_seen = set([]) + return self._parse(query) + + # this parse is used internally because it doesn't clear the + # recursive search test list + def _parse(self, query): res = self._parser.parseString(query)[0] return self.evaluate(res) @@ -152,7 +202,20 @@ class SearchQueryParser(object): return self.evaluate(argument[0]) def evaluate_token(self, argument): - return self.get_matches(argument[0], argument[1]) + 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) + 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) + return self.get_matches(location, query) def get_matches(self, location, query): '''