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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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
+
+ SavedSearchBox
+ QComboBox
+
+
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):
'''