Enhancement: allow using templares in search strings

This commit is contained in:
Charles Haley 2020-09-23 11:37:26 +01:00 committed by Kovid Goyal
parent 13e4f17a50
commit ae189e0eee
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
4 changed files with 124 additions and 6 deletions

View File

@ -441,6 +441,20 @@ Identifiers (e.g., ISBN, doi, lccn etc) also use an extended syntax. First, note
:guilabel:`Advanced search dialog` :guilabel:`Advanced search dialog`
You can search using a template in the :ref:`templatelangcalibre` instead of a metadata field. To do so you enter a template, a search type, and the value to search for. The syntax is::
``template:``(the template)``#@``(search type)``#:``(the value)
The ``template`` is any valid calibre template language template. The ``search type`` must be one of ``t`` (text search), ``d`` (date search), ``n`` (numeric search), or ``b`` (set/not set (boolean)). The ``value`` is whatever you want. It can use the special operators described above for the various search types. You must quote the entire search string if there are spaces anywhere in it.
Examples:
* ``template:"program: connected_device_name('main')#@t#:kindle"`` -- is true of the ``kindle`` device is connected
* ``template:"program: select(formats_sizes(), 'EPUB')#@n#:>1000000"`` -- finds epubs larger than 1 MB
* ``template:"program: select(formats_modtimes('iso'), 'EPUB')#@d#:>10daysago"`` -- finds epubs newer than 10 days ago.
You can build template search queries easily using the :guilabel:`Advanced search dialog` accessed by clicking the button |sbi|. You can test templates on specific books using the calibre ``template tester``. Easiest is to add the tester to the book list context menu or to assign the tester a keyboard shortcut.
.. _saved_searches: .. _saved_searches:
Saving searches Saving searches

View File

@ -11,14 +11,14 @@ from functools import partial
from datetime import timedelta from datetime import timedelta
from collections import deque, OrderedDict from collections import deque, OrderedDict
from calibre.constants import preferred_encoding from calibre.constants import preferred_encoding, DEBUG
from calibre.db.utils import force_to_bool from calibre.db.utils import force_to_bool
from calibre.utils.config_base import prefs from calibre.utils.config_base import prefs
from calibre.utils.date import parse_date, UNDEFINED_DATE, now, dt_as_local from calibre.utils.date import parse_date, UNDEFINED_DATE, now, dt_as_local
from calibre.utils.icu import primary_contains, sort_key from calibre.utils.icu import primary_contains, sort_key
from calibre.utils.localization import lang_map, canonicalize_lang from calibre.utils.localization import lang_map, canonicalize_lang
from calibre.utils.search_query_parser import SearchQueryParser, ParseException from calibre.utils.search_query_parser import SearchQueryParser, ParseException
from polyglot.builtins import iteritems, unicode_type, string_or_bytes from polyglot.builtins import iteritems, unicode_type, string_or_bytes, error_message
CONTAINS_MATCH = 0 CONTAINS_MATCH = 0
EQUALS_MATCH = 1 EQUALS_MATCH = 1
@ -627,6 +627,44 @@ class Parser(SearchQueryParser): # {{{
# Everything else (and 'all' matches) # Everything else (and 'all' matches)
case_sensitive = prefs['case_sensitive'] case_sensitive = prefs['case_sensitive']
if location == 'template':
try:
template, sep, query = regex.split('#@([tdnb]?)#:', query, flags=regex.IGNORECASE)
if sep:
sep = sep.lower()
else:
sep = 't'
except:
if DEBUG:
import traceback
traceback.print_exc()
raise ParseException(_('template: missing or invalid separator (#@[tdnb]?#)'))
matchkind, query = _matchkind(query, case_sensitive=case_sensitive)
matches = set()
error_string = '*@*TEMPLATE_ERROR*@*'
for book_id in candidates:
mi = self.dbcache.get_proxy_metadata(book_id)
val = mi.formatter.safe_format(template, {}, error_string, mi)
if val.startswith(error_string):
raise ParseException(val[len(error_string):])
if sep == 't':
if _match(query, [val,], matchkind, use_primary_find_in_search=upf,
case_sensitive=case_sensitive):
matches.add(book_id)
elif sep == 'n' and val:
matches.update(self.num_search(
icu_lower(query), {val:{book_id,}}.items, '', '',
{book_id,}, is_many=False))
elif sep == 'd' and val:
matches.update(self.date_search(
icu_lower(query), {val:{book_id,}}.items))
elif sep == 'b':
matches.update(self.bool_search(icu_lower(query),
{'True' if val else 'False':{book_id,}}.items, False))
return matches
matchkind, query = _matchkind(query, case_sensitive=case_sensitive) matchkind, query = _matchkind(query, case_sensitive=case_sensitive)
all_locs = set() all_locs = set()
text_fields = set() text_fields = set()

View File

@ -235,6 +235,46 @@ def create_date_tab(self, db):
l.addStretch(10) l.addStretch(10)
def create_template_tab(self):
self.simple_tab = w = QWidget(self.tab_widget)
self.tab_widget.addTab(w, _("&Template search"))
w.l = l = QFormLayout(w)
l.setFieldGrowthPolicy(l.AllNonFixedFieldsGrow)
self.template_value_box = le = QLineEdit(w)
le.setObjectName('template_value_box')
le.setPlaceholderText(_('The value to search for'))
le.setToolTip('<p>' +
_("You can use the search test specifications described "
"in the calibre documentation. For example, with Number "
"comparisons you can the relational operators like '>=' etc. "
"With Text comparisons you can use exact, comtains "
"or regular expression matches. With Date you can use "
"today, yesterday, etc. Set/not set takes 'true' for set "
"and 'false' for not set.")
+ '</p>')
l.addRow(_('Template &value:'), le)
self.template_test_type_box = le = QComboBox(w)
le.setObjectName('template_test_type_box')
for op, desc in [
('t', _('Text')),
('d', _('Date')),
('n', _('Number')),
('b', _('Set/Not Set'))]:
le.addItem(desc, op)
le.setToolTip(_('How the template result will be compared to the value'))
l.addRow(_('C&omparison type:'), le)
from calibre.gui2.dialogs.template_line_editor import TemplateLineEditor
self.template_program_box = le = TemplateLineEditor(self.tab_widget)
le.setObjectName('template_program_box')
le.setPlaceholderText(_('The template that generates the value'))
le.setToolTip(_('Right click to open a template editor'))
l.addRow(_('Tem&plate:'), le)
def setup_ui(self, db): def setup_ui(self, db):
self.setWindowTitle(_("Advanced search")) self.setWindowTitle(_("Advanced search"))
self.setWindowIcon(QIcon(I('search.png'))) self.setWindowIcon(QIcon(I('search.png')))
@ -251,6 +291,7 @@ def setup_ui(self, db):
create_adv_tab(self) create_adv_tab(self)
create_simple_tab(self, db) create_simple_tab(self, db)
create_date_tab(self, db) create_date_tab(self, db)
create_template_tab(self)
# }}} # }}}
@ -270,6 +311,13 @@ class SearchDialog(QDialog):
w = getattr(self, focused_field, None) w = getattr(self, focused_field, None)
if w is not None: if w is not None:
w.setFocus(Qt.OtherFocusReason) w.setFocus(Qt.OtherFocusReason)
elif current_tab == 3:
self.template_program_box.setText(
gprefs.get('advanced_search_template_tab_program_field', ''))
self.template_value_box.setText(
gprefs.get('advanced_search_template_tab_value_field', ''))
self.template_test_type_box.setCurrentIndex(
int(gprefs.get('advanced_search_template_tab_test_field', '0')))
self.resize(self.sizeHint()) self.resize(self.sizeHint())
def save_state(self): def save_state(self):
@ -279,6 +327,13 @@ class SearchDialog(QDialog):
fw = self.tab_widget.focusWidget() fw = self.tab_widget.focusWidget()
if fw: if fw:
gprefs.set('advanced_search_simple_tab_focused_field', fw.objectName()) gprefs.set('advanced_search_simple_tab_focused_field', fw.objectName())
elif self.tab_widget.currentIndex() == 3:
gprefs.set('advanced_search_template_tab_program_field',
unicode_type(self.template_program_box.text()))
gprefs.set('advanced_search_template_tab_value_field',
unicode_type(self.template_value_box.text()))
gprefs.set('advanced_search_template_tab_test_field',
unicode_type(self.template_test_type_box.currentIndex()))
def accept(self): def accept(self):
self.save_state() self.save_state()
@ -290,9 +345,9 @@ class SearchDialog(QDialog):
def clear_button_pushed(self): def clear_button_pushed(self):
w = self.tab_widget.currentWidget() w = self.tab_widget.currentWidget()
for c in w.findChildren(QComboBox):
c.setCurrentIndex(0)
if w is self.date_tab: if w is self.date_tab:
for c in w.findChildren(QComboBox):
c.setCurrentIndex(0)
for c in w.findChildren(QSpinBox): for c in w.findChildren(QSpinBox):
c.setValue(c.minimum()) c.setValue(c.minimum())
self.sel_date.setChecked(True) self.sel_date.setChecked(True)
@ -312,7 +367,18 @@ class SearchDialog(QDialog):
def search_string(self): def search_string(self):
i = self.tab_widget.currentIndex() i = self.tab_widget.currentIndex()
return (self.adv_search_string, self.box_search_string, self.date_search_string)[i]() return (self.adv_search_string, self.box_search_string,
self.date_search_string, self.template_search_string)[i]()
def template_search_string(self):
template = unicode_type(self.template_program_box.text())
value = unicode_type(self.template_value_box.text()).replace('"', '\\"')
if template and value:
cb = self.template_test_type_box
op = unicode_type(cb.itemData(cb.currentIndex()))
l = '{0}#@{1}#:{2}'.format(template, op, value)
return 'template:"' + l + '"'
return ''
def date_search_string(self): def date_search_string(self):
field = unicode_type(self.date_field.itemData(self.date_field.currentIndex()) or '') field = unicode_type(self.date_field.itemData(self.date_field.currentIndex()) or '')

View File

@ -384,7 +384,7 @@ class FieldMetadata(object):
'int', 'float', 'bool', 'series', 'composite', 'enumeration']) 'int', 'float', 'bool', 'series', 'composite', 'enumeration'])
# search labels that are not db columns # search labels that are not db columns
search_items = ['all', 'search', 'vl'] search_items = ['all', 'search', 'vl', 'template']
__calibre_serializable__ = True __calibre_serializable__ = True
def __init__(self): def __init__(self):