mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-31 14:33:54 -04:00
Support for saved searches
This commit is contained in:
parent
374569abb0
commit
001eca4196
3544
resources/images/search_add_saved.svg
Normal file
3544
resources/images/search_add_saved.svg
Normal file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 134 KiB |
3547
resources/images/search_copy_saved.svg
Normal file
3547
resources/images/search_copy_saved.svg
Normal file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 151 KiB |
3544
resources/images/search_delete_saved.svg
Normal file
3544
resources/images/search_delete_saved.svg
Normal file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 134 KiB |
@ -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"/>
|
||||
|
@ -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()))
|
||||
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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'])
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
'''
|
||||
|
Loading…
x
Reference in New Issue
Block a user