Support for saved searches

This commit is contained in:
Kovid Goyal 2010-04-02 17:45:58 +05:30
parent 374569abb0
commit 001eca4196
10 changed files with 10938 additions and 10 deletions

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 134 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 151 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -220,6 +220,64 @@
</property>
</widget>
</item>
<item>
<widget class="SavedSearchBox" name="saved_search">
<property name="toolTip">
<string>Choose saved search or enter name for new saved search</string>
</property>
<property name="minimumContentsLength">
<number>15</number>
</property>
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="copy_search_button">
<property name="toolTip">
<string>Copy current search text (instead of search name)</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset resource="../../../resources/images.qrc">
<normaloff>:/images/search_copy_saved.svg</normaloff>:/images/search_copy_saved.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="save_search_button">
<property name="toolTip">
<string>Save current search under the name shown in the box</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset resource="../../../resources/images.qrc">
<normaloff>:/images/search_add_saved.svg</normaloff>:/images/search_add_saved.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="delete_search_button">
<property name="toolTip">
<string>Delete current search and clear search box</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset resource="../../../resources/images.qrc">
<normaloff>:/images/search_delete_saved.svg</normaloff>:/images/search_delete_saved.svg</iconset>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
@ -686,6 +744,11 @@
<extends>QComboBox</extends>
<header>calibre.gui2.search_box</header>
</customwidget>
<customwidget>
<class>SavedSearchBox</class>
<extends>QComboBox</extends>
<header>calibre.gui2.search_box</header>
</customwidget>
</customwidgets>
<resources>
<include location="../../../resources/images.qrc"/>

View File

@ -7,6 +7,7 @@ __copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__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()))

View File

@ -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]

View File

@ -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'])

View File

@ -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')

View File

@ -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

View File

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