From ae189e0eee8cc5804dd5308e04ae5bafda200f9e Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Wed, 23 Sep 2020 11:37:26 +0100 Subject: [PATCH] Enhancement: allow using templares in search strings --- manual/gui.rst | 14 ++++++ src/calibre/db/search.py | 42 +++++++++++++++- src/calibre/gui2/dialogs/search.py | 72 +++++++++++++++++++++++++-- src/calibre/library/field_metadata.py | 2 +- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/manual/gui.rst b/manual/gui.rst index 5016391fa3..c2f3d42f9a 100644 --- a/manual/gui.rst +++ b/manual/gui.rst @@ -441,6 +441,20 @@ Identifiers (e.g., ISBN, doi, lccn etc) also use an extended syntax. First, note :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: Saving searches diff --git a/src/calibre/db/search.py b/src/calibre/db/search.py index 8854e8657e..c4610bc126 100644 --- a/src/calibre/db/search.py +++ b/src/calibre/db/search.py @@ -11,14 +11,14 @@ from functools import partial from datetime import timedelta 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.utils.config_base import prefs 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.localization import lang_map, canonicalize_lang 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 EQUALS_MATCH = 1 @@ -627,6 +627,44 @@ class Parser(SearchQueryParser): # {{{ # Everything else (and 'all' matches) 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) all_locs = set() text_fields = set() diff --git a/src/calibre/gui2/dialogs/search.py b/src/calibre/gui2/dialogs/search.py index ba1d015e72..49e065c6b2 100644 --- a/src/calibre/gui2/dialogs/search.py +++ b/src/calibre/gui2/dialogs/search.py @@ -235,6 +235,46 @@ def create_date_tab(self, db): 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('

' + + _("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.") + + '

') + 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): self.setWindowTitle(_("Advanced search")) self.setWindowIcon(QIcon(I('search.png'))) @@ -251,6 +291,7 @@ def setup_ui(self, db): create_adv_tab(self) create_simple_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) if w is not None: 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()) def save_state(self): @@ -279,6 +327,13 @@ class SearchDialog(QDialog): fw = self.tab_widget.focusWidget() if fw: 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): self.save_state() @@ -290,9 +345,9 @@ class SearchDialog(QDialog): def clear_button_pushed(self): w = self.tab_widget.currentWidget() + for c in w.findChildren(QComboBox): + c.setCurrentIndex(0) if w is self.date_tab: - for c in w.findChildren(QComboBox): - c.setCurrentIndex(0) for c in w.findChildren(QSpinBox): c.setValue(c.minimum()) self.sel_date.setChecked(True) @@ -312,7 +367,18 @@ class SearchDialog(QDialog): def search_string(self): 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): field = unicode_type(self.date_field.itemData(self.date_field.currentIndex()) or '') diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py index e6df9b48fa..ccbac66e58 100644 --- a/src/calibre/library/field_metadata.py +++ b/src/calibre/library/field_metadata.py @@ -384,7 +384,7 @@ class FieldMetadata(object): 'int', 'float', 'bool', 'series', 'composite', 'enumeration']) # search labels that are not db columns - search_items = ['all', 'search', 'vl'] + search_items = ['all', 'search', 'vl', 'template'] __calibre_serializable__ = True def __init__(self):