From 740bfd26db315cf471d43940d965159bc0e5dc08 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Wed, 23 Sep 2020 11:37:26 +0100 Subject: [PATCH 1/8] 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): From dd7a28abb29c91ab6713af18ae6236f6fbcb8ea1 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Wed, 23 Sep 2020 11:43:50 +0100 Subject: [PATCH 2/8] Use the template cache to speed up evaluation when searching using templates. --- src/calibre/db/search.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/calibre/db/search.py b/src/calibre/db/search.py index c4610bc126..88ca72ee45 100644 --- a/src/calibre/db/search.py +++ b/src/calibre/db/search.py @@ -643,9 +643,12 @@ class Parser(SearchQueryParser): # {{{ matchkind, query = _matchkind(query, case_sensitive=case_sensitive) matches = set() error_string = '*@*TEMPLATE_ERROR*@*' + template_cache = {} for book_id in candidates: mi = self.dbcache.get_proxy_metadata(book_id) - val = mi.formatter.safe_format(template, {}, error_string, mi) + val = mi.formatter.safe_format(template, {}, error_string, mi, + column_name='search template', + template_cache=template_cache) if val.startswith(error_string): raise ParseException(val[len(error_string):]) if sep == 't': From bfbccea1381397d6bbe13874613469b058bf5fb2 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 23 Sep 2020 17:46:34 +0530 Subject: [PATCH 3/8] Have the sort and resort actions work in all views --- src/calibre/gui2/actions/sort.py | 12 +++--------- src/calibre/gui2/library/views.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/calibre/gui2/actions/sort.py b/src/calibre/gui2/actions/sort.py index 9cfc4c7c8c..99cd2a5c1a 100644 --- a/src/calibre/gui2/actions/sort.py +++ b/src/calibre/gui2/actions/sort.py @@ -44,20 +44,14 @@ class SortByAction(InterfaceAction): self.gui.addAction(ac) return ac - c('reverse_sort_action', _('Reverse current sort'), _('Reverse the current sort order'), self.reverse_sort) + c('reverse_sort_action', _('Reverse current sort'), _('Reverse the current sort order'), self.reverse_sort, 'shift+f5') c('reapply_sort_action', _('Re-apply current sort'), _('Re-apply the current sort'), self.reapply_sort, 'f5') def reverse_sort(self): - lv = self.gui.library_view - m = lv.model() - try: - sort_col, order = m.sorted_on - except TypeError: - sort_col, order = 'date', True - lv.sort_by_named_field(sort_col, not order) + self.gui.current_view().reverse_sort() def reapply_sort(self): - self.gui.library_view.resort() + self.gui.current_view().resort() def location_selected(self, loc): enabled = loc == 'library' diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index 48d345e03c..d60b524862 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -604,6 +604,15 @@ class BooksView(QTableView): # {{{ def resort(self): with self.preserve_state(preserve_vpos=False, require_selected_ids=False): self._model.resort(reset=True) + + def reverse_sort(self): + with self.preserve_state(preserve_vpos=False, require_selected_ids=False): + m = self.model() + try: + sort_col, order = m.sorted_on + except TypeError: + sort_col, order = 'date', True + self.sort_by_named_field(sort_col, not order) # }}} # Ondevice column {{{ @@ -1411,4 +1420,12 @@ class DeviceBooksView(BooksView): # {{{ self._model.set_editable(editable) self.drag_allowed = supports_backloading + def resort(self): + h = self.horizontalHeader() + self.model().sort(h.sortIndicatorSection(), h.sortIndicatorOrder()) + + def reverse_sort(self): + h = self.horizontalHeader() + h.setSortIndicator(h.sortIndicatorSection(), 1 - int(h.sortIndicatorOrder())) + # }}} From 3a9435e537fe5fd99ea6ef2d89f0fb1d61944b2d Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Wed, 23 Sep 2020 16:02:35 +0100 Subject: [PATCH 4/8] Some tests for the search template stuff --- src/calibre/db/search.py | 2 +- src/calibre/db/tests/reading.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/calibre/db/search.py b/src/calibre/db/search.py index 88ca72ee45..6537c9270b 100644 --- a/src/calibre/db/search.py +++ b/src/calibre/db/search.py @@ -639,7 +639,7 @@ class Parser(SearchQueryParser): # {{{ if DEBUG: import traceback traceback.print_exc() - raise ParseException(_('template: missing or invalid separator (#@[tdnb]?#)')) + raise ParseException(_('template: missing or invalid separator (#@[tdnb]#:)')) matchkind, query = _matchkind(query, case_sensitive=case_sensitive) matches = set() error_string = '*@*TEMPLATE_ERROR*@*' diff --git a/src/calibre/db/tests/reading.py b/src/calibre/db/tests/reading.py index 34ef6d28c0..fe0afb129a 100644 --- a/src/calibre/db/tests/reading.py +++ b/src/calibre/db/tests/reading.py @@ -360,6 +360,22 @@ class ReadingTest(BaseTest): self.assertEqual(cache.search('#rating:>4'), {3}) self.assertEqual(cache.search('#rating:2'), {2}) + # template searches + # Test text search + self.assertEqual(cache.search('template:"{#formats}#@t#:fmt1"'), {1,2}) + self.assertEqual(cache.search('template:"{authors}#@t#:=Author One"'), {2}) + cache.set_field('pubdate', {1:p('2001-02-06'), 2:p('2001-10-06'), 3:p('2001-06-06')}) + cache.set_field('timestamp', {1:p('2002-02-06'), 2:p('2000-10-06'), 3:p('2001-06-06')}) + # Test numeric compare search + self.assertEqual(cache.search("template:\"program: " + "floor(days_between(field(\'pubdate\'), " + "field(\'timestamp\')))#@n#:>0\""), {2,3}) + # Test date search + self.assertEqual(cache.search('template:{pubdate}#@d#:<2001-09-01"'), {1,3}) + # Test boolean search + self.assertEqual(cache.search('template:{series}#@b#:true'), {1,2}) + self.assertEqual(cache.search('template:{series}#@b#:false'), {3}) + # Note that the old db searched uuid for un-prefixed searches, the new # db does not, for performance From c8d35b735b88ad58dc4f1a759386124fe76f1379 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Wed, 23 Sep 2020 21:32:06 +0100 Subject: [PATCH 5/8] Change the separator to make it easier to read and to write: #@#:[tdnb]: --- manual/gui.rst | 8 ++++---- src/calibre/db/search.py | 4 ++-- src/calibre/db/tests/reading.py | 12 ++++++------ src/calibre/gui2/dialogs/search.py | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/manual/gui.rst b/manual/gui.rst index c2f3d42f9a..c53dd51ae8 100644 --- a/manual/gui.rst +++ b/manual/gui.rst @@ -443,15 +443,15 @@ Identifiers (e.g., ISBN, doi, lccn etc) also use an extended syntax. First, note 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) + ``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. + * ``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. diff --git a/src/calibre/db/search.py b/src/calibre/db/search.py index 6537c9270b..dca3e11815 100644 --- a/src/calibre/db/search.py +++ b/src/calibre/db/search.py @@ -630,7 +630,7 @@ class Parser(SearchQueryParser): # {{{ if location == 'template': try: - template, sep, query = regex.split('#@([tdnb]?)#:', query, flags=regex.IGNORECASE) + template, sep, query = regex.split('#@#:([tdnb]):', query, flags=regex.IGNORECASE) if sep: sep = sep.lower() else: @@ -639,7 +639,7 @@ class Parser(SearchQueryParser): # {{{ if DEBUG: import traceback traceback.print_exc() - raise ParseException(_('template: missing or invalid separator (#@[tdnb]#:)')) + raise ParseException(_('template: missing or invalid separator (#@#:[tdnb]:)')) matchkind, query = _matchkind(query, case_sensitive=case_sensitive) matches = set() error_string = '*@*TEMPLATE_ERROR*@*' diff --git a/src/calibre/db/tests/reading.py b/src/calibre/db/tests/reading.py index fe0afb129a..e807b5b719 100644 --- a/src/calibre/db/tests/reading.py +++ b/src/calibre/db/tests/reading.py @@ -362,19 +362,19 @@ class ReadingTest(BaseTest): # template searches # Test text search - self.assertEqual(cache.search('template:"{#formats}#@t#:fmt1"'), {1,2}) - self.assertEqual(cache.search('template:"{authors}#@t#:=Author One"'), {2}) + self.assertEqual(cache.search('template:"{#formats}#@#:t:fmt1"'), {1,2}) + self.assertEqual(cache.search('template:"{authors}#@#:t:=Author One"'), {2}) cache.set_field('pubdate', {1:p('2001-02-06'), 2:p('2001-10-06'), 3:p('2001-06-06')}) cache.set_field('timestamp', {1:p('2002-02-06'), 2:p('2000-10-06'), 3:p('2001-06-06')}) # Test numeric compare search self.assertEqual(cache.search("template:\"program: " "floor(days_between(field(\'pubdate\'), " - "field(\'timestamp\')))#@n#:>0\""), {2,3}) + "field(\'timestamp\')))#@#:n:>0\""), {2,3}) # Test date search - self.assertEqual(cache.search('template:{pubdate}#@d#:<2001-09-01"'), {1,3}) + self.assertEqual(cache.search('template:{pubdate}#@#:d:<2001-09-01"'), {1,3}) # Test boolean search - self.assertEqual(cache.search('template:{series}#@b#:true'), {1,2}) - self.assertEqual(cache.search('template:{series}#@b#:false'), {3}) + self.assertEqual(cache.search('template:{series}#@#:b:true'), {1,2}) + self.assertEqual(cache.search('template:{series}#@#:b:false'), {3}) # Note that the old db searched uuid for un-prefixed searches, the new # db does not, for performance diff --git a/src/calibre/gui2/dialogs/search.py b/src/calibre/gui2/dialogs/search.py index 49e065c6b2..2de97b7a48 100644 --- a/src/calibre/gui2/dialogs/search.py +++ b/src/calibre/gui2/dialogs/search.py @@ -376,7 +376,7 @@ class SearchDialog(QDialog): 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) + l = '{0}#@#:{1}:{2}'.format(template, op, value) return 'template:"' + l + '"' return '' From 35acb2ccbd3422ee35d0fbb0a7004bf551e2a213 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Fri, 25 Sep 2020 09:28:37 +0100 Subject: [PATCH 6/8] Save intermediate changes --- src/calibre/customize/builtins.py | 22 +- .../gui2/actions/show_stored_templates.py | 30 +++ src/calibre/gui2/dialogs/template_dialog.py | 5 + src/calibre/gui2/dialogs/template_dialog.ui | 32 ++- .../gui2/preferences/stored_templates.py | 212 ++++++++++++++++++ .../gui2/preferences/template_functions.py | 34 +-- src/calibre/utils/formatter.py | 65 +++++- src/calibre/utils/formatter_functions.py | 26 ++- 8 files changed, 398 insertions(+), 28 deletions(-) create mode 100644 src/calibre/gui2/actions/show_stored_templates.py create mode 100644 src/calibre/gui2/preferences/stored_templates.py diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index 70d0bb106d..7c10ede34d 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -935,6 +935,12 @@ class ActionTemplateTester(InterfaceActionBase): description = _('Show an editor for testing templates') +class ActionStoredTemplates(InterfaceActionBase): + name = 'Stored Templates' + actual_plugin = 'calibre.gui2.actions.show_stored_templates:ShowStoredTemplatesAction' + description = _('Show a dialog for creating and managing stored templates') + + class ActionSaveToDisk(InterfaceActionBase): name = 'Save To Disk' actual_plugin = 'calibre.gui2.actions.save_to_disk:SaveToDiskAction' @@ -1099,7 +1105,7 @@ plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog, ActionCopyToLibrary, ActionTweakEpub, ActionUnpackBook, ActionNextMatch, ActionStore, ActionPluginUpdater, ActionPickRandom, ActionEditToC, ActionSortBy, ActionMarkBooks, ActionEmbed, ActionTemplateTester, ActionTagMapper, ActionAuthorMapper, - ActionVirtualLibrary, ActionBrowseAnnotations] + ActionVirtualLibrary, ActionBrowseAnnotations, ActionStoredTemplates] # }}} @@ -1272,6 +1278,18 @@ class TemplateFunctions(PreferencesPlugin): description = _('Create your own template functions') +class StoredTemplates(PreferencesPlugin): + name = 'StoredTemplates' + icon = I('template_funcs.png') + gui_name = _('Stored templates') + category = 'Advanced' + gui_category = _('Advanced') + category_order = 5 + name_order = 6 + config_widget = 'calibre.gui2.preferences.stored_templates' + description = _('Create stored calibre templates') + + class Email(PreferencesPlugin): name = 'Email' icon = I('mail.png') @@ -1376,7 +1394,7 @@ class Misc(PreferencesPlugin): plugins += [LookAndFeel, Behavior, Columns, Toolbar, Search, InputOptions, CommonOptions, OutputOptions, Adding, Saving, Sending, Plugboard, Email, Server, Plugins, Tweaks, Misc, TemplateFunctions, - MetadataSources, Keyboard, IgnoredDevices] + StoredTemplates, MetadataSources, Keyboard, IgnoredDevices] # }}} diff --git a/src/calibre/gui2/actions/show_stored_templates.py b/src/calibre/gui2/actions/show_stored_templates.py new file mode 100644 index 0000000000..9164a7d5ba --- /dev/null +++ b/src/calibre/gui2/actions/show_stored_templates.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + + +from calibre.gui2.actions import InterfaceAction +from calibre.gui2.preferences.main import Preferences + + +class ShowStoredTemplatesAction(InterfaceAction): + + name = 'Stored Template' + action_spec = (_('Stored Templates'), 'debug.png', None, ()) + dont_add_to = frozenset(('context-menu-device',)) + action_type = 'current' + + def genesis(self): + self.previous_text = _('Manage stored templates') + self.first_time = True + self.qaction.triggered.connect(self.show_template_editor) + + def show_template_editor(self, *args): + d = Preferences(self.gui, initial_plugin=('Advanced', 'StoredTemplates'), + close_after_initial=True) + d.exec_() + diff --git a/src/calibre/gui2/dialogs/template_dialog.py b/src/calibre/gui2/dialogs/template_dialog.py index 19d720eb05..81706fbb40 100644 --- a/src/calibre/gui2/dialogs/template_dialog.py +++ b/src/calibre/gui2/dialogs/template_dialog.py @@ -429,12 +429,17 @@ class TemplateDialog(QDialog, Ui_TemplateDialog): name = unicode_type(toWhat) self.source_code.clear() self.documentation.clear() + self.func_type.clear() if name in self.funcs: self.documentation.setPlainText(self.funcs[name].doc) if name in self.builtins and name in self.builtin_source_dict: self.source_code.setPlainText(self.builtin_source_dict[name]) else: self.source_code.setPlainText(self.funcs[name].program_text) + if self.funcs[name].is_python: + self.func_type.setText(_('Template function in python')) + else: + self.func_type.setText(_('Stored template')) def accept(self): txt = unicode_type(self.textbox.toPlainText()).rstrip() diff --git a/src/calibre/gui2/dialogs/template_dialog.ui b/src/calibre/gui2/dialogs/template_dialog.ui index 2b3657fb4c..9f6d38399c 100644 --- a/src/calibre/gui2/dialogs/template_dialog.ui +++ b/src/calibre/gui2/dialogs/template_dialog.ui @@ -224,6 +224,26 @@ + + + &Function type: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + func_type + + + + + + + true + + + + &Documentation: @@ -236,10 +256,10 @@ - + - Python &code: + &Code: Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop @@ -249,7 +269,7 @@ - + @@ -259,17 +279,17 @@ - + - + true - + true diff --git a/src/calibre/gui2/preferences/stored_templates.py b/src/calibre/gui2/preferences/stored_templates.py new file mode 100644 index 0000000000..d41c35ee41 --- /dev/null +++ b/src/calibre/gui2/preferences/stored_templates.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import traceback + +from PyQt5.Qt import (Qt, QGridLayout, QLabel, QSpacerItem, QSizePolicy, + QComboBox, QTextEdit, QHBoxLayout, QPushButton) + +from calibre.gui2 import error_dialog +from calibre.gui2.dialogs.template_line_editor import TemplateLineEditor +from calibre.gui2.preferences import ConfigWidgetBase, test_widget +from calibre.utils.formatter_functions import (formatter_functions, + compile_user_function, compile_user_template_functions, + load_user_template_functions, function_pref_is_python, + function_pref_name) +from polyglot.builtins import native_string_type, unicode_type + + +class ConfigWidget(ConfigWidgetBase): + + def genesis(self, gui): + self.gui = gui + self.db = gui.library_view.model().db + + help_text = '

' + _(''' + Here you can add and remove stored templates used in template processing. + You use a stored template in another template with the 'call' template + function, as in 'call(somename, arguments...). Stored templates must use + General Program Mode -- they must begin with the text 'program:'. + In the stored template you get the arguments using the 'arguments()' + template function, as in arguments(var1, var2, ...). The calling arguments + are copied to the named variables. + ''') + '

' + l = QGridLayout(self) + w = QLabel(help_text) + w.setWordWrap(True) + l.addWidget(w, 0, 0, 1, 2) + + lab = QLabel(_('&Template')) + l.addWidget(lab, 1, 0) + lb = QHBoxLayout() + self.program = w = TemplateLineEditor(self) + w.setPlaceholderText(_('The GPM template.')) + lab.setBuddy(w) + lb.addWidget(w, stretch=1) + self.editor_button = b = QPushButton(_('&Open Editor')) + b.clicked.connect(w.open_editor) + lb.addWidget(b) + l.addLayout(lb, 1, 1) + + lab = QLabel(_('&Name')) + l.addWidget(lab, 2, 0) + self.function_name = w = QComboBox(self) + w.setEditable(True) + lab.setBuddy(w) + w.setToolTip(_('The name of the function, used in a call statement')) + + l.addWidget(w, 2, 1) + + lab = QLabel(_('&Documentation')) + l.addWidget(lab, 3, 0, Qt.AlignTop) + self.documentation = w = QTextEdit(self) + w.setPlaceholderText(_('A description of the template. Whatever you wish ...')) + lab.setBuddy(w) + l.addWidget(w, 3, 1) + + lb = QHBoxLayout() + lb.addStretch(1) + self.clear_button = w = QPushButton(_('C&lear')) + lb.addWidget(w) + self.delete_button = w = QPushButton(_('&Delete')) + lb.addWidget(w) + self.replace_button = w = QPushButton(_('&Replace')) + lb.addWidget(w) + self.create_button = w = QPushButton(_('&Create')) + lb.addWidget(w) + lb.addStretch(1) + l.addLayout(lb, 9, 1) + + l.addItem(QSpacerItem(10, 10, vPolicy=QSizePolicy.Expanding), 10, 0, -1, -1) + self.setLayout(l) + + + def initialize(self): + self.funcs = {} + for v in self.db.prefs.get('user_template_functions', []): + if not function_pref_is_python(v): + self.funcs.update({function_pref_name(v):compile_user_function(*v)}) + + self.build_function_names_box() + self.function_name.currentIndexChanged[native_string_type].connect(self.function_index_changed) + self.function_name.editTextChanged.connect(self.function_name_edited) + self.documentation.textChanged.connect(self.enable_replace_button) + self.program.textChanged.connect(self.enable_replace_button) + self.create_button.clicked.connect(self.create_button_clicked) + self.delete_button.clicked.connect(self.delete_button_clicked) + self.create_button.setEnabled(False) + self.delete_button.setEnabled(False) + self.replace_button.setEnabled(False) + self.clear_button.clicked.connect(self.clear_button_clicked) + self.replace_button.clicked.connect(self.replace_button_clicked) + + def enable_replace_button(self): + self.replace_button.setEnabled(self.delete_button.isEnabled()) + + def clear_button_clicked(self): + self.build_function_names_box() + self.program.clear() + self.documentation.clear() + self.create_button.setEnabled(False) + self.delete_button.setEnabled(False) + + def build_function_names_box(self, scroll_to=''): + self.function_name.blockSignals(True) + func_names = sorted(self.funcs) + self.function_name.clear() + self.function_name.addItem('') + self.function_name.addItems(func_names) + self.function_name.setCurrentIndex(0) + self.function_name.blockSignals(False) + if scroll_to: + idx = self.function_name.findText(scroll_to) + if idx >= 0: + self.function_name.setCurrentIndex(idx) + + def delete_button_clicked(self): + name = unicode_type(self.function_name.currentText()) + if name in self.funcs: + del self.funcs[name] + self.changed_signal.emit() + self.create_button.setEnabled(True) + self.delete_button.setEnabled(False) + self.build_function_names_box() + self.program.setReadOnly(False) + else: + error_dialog(self.gui, _('Stored templates'), + _('Function not defined'), show=True) + + def create_button_clicked(self, use_name=None): + self.changed_signal.emit() + name = use_name if use_name else unicode_type(self.function_name.currentText()) + for k,v in formatter_functions().get_functions().items(): + if k == name and v.is_python: + error_dialog(self.gui, _('Stored templates'), + _('Name %s is already used for template function')%(name,), show=True) + try: + prog = unicode_type(self.program.text()) + if not prog.startswith('program:'): + error_dialog(self.gui, _('Stored templates'), + _('The stored template must begin with "program:"'), show=True) + + cls = compile_user_function(name, unicode_type(self.documentation.toPlainText()), + 0, prog) + self.funcs[name] = cls + self.build_function_names_box(scroll_to=name) + except: + error_dialog(self.gui, _('Stored templates'), + _('Exception while storing template'), show=True, + det_msg=traceback.format_exc()) + + def function_name_edited(self, txt): + self.documentation.setReadOnly(False) + self.create_button.setEnabled(True) + self.replace_button.setEnabled(False) + self.program.setReadOnly(False) + + def function_index_changed(self, txt): + txt = unicode_type(txt) + self.create_button.setEnabled(False) + if not txt: + self.program.clear() + self.documentation.clear() + self.documentation.setReadOnly(False) + return + func = self.funcs[txt] + self.documentation.setText(func.doc) + self.program.setText(func.program_text) + self.delete_button.setEnabled(True) + self.program.setReadOnly(False) + self.replace_button.setEnabled(False) + + def replace_button_clicked(self): + name = unicode_type(self.function_name.currentText()) + self.delete_button_clicked() + self.create_button_clicked(use_name=name) + + def refresh_gui(self, gui): + pass + + def commit(self): + # formatter_functions().reset_to_builtins() + pref_value = [v for v in self.db.prefs.get('user_template_functions', []) + if function_pref_is_python(v)] + for v in self.funcs.values(): + pref_value.append(v.to_pref()) + self.db.new_api.set_pref('user_template_functions', pref_value) + funcs = compile_user_template_functions(pref_value) + self.db.new_api.set_user_template_functions(funcs) + self.gui.library_view.model().refresh() + load_user_template_functions(self.db.library_id, [], funcs) + return False + + +if __name__ == '__main__': + from PyQt5.Qt import QApplication + app = QApplication([]) + test_widget('Advanced', 'StoredTemplates') diff --git a/src/calibre/gui2/preferences/template_functions.py b/src/calibre/gui2/preferences/template_functions.py index f1ade855af..269a3a3c80 100644 --- a/src/calibre/gui2/preferences/template_functions.py +++ b/src/calibre/gui2/preferences/template_functions.py @@ -16,7 +16,8 @@ from calibre.gui2.preferences.template_functions_ui import Ui_Form from calibre.gui2.widgets import PythonHighlighter from calibre.utils.formatter_functions import (formatter_functions, compile_user_function, compile_user_template_functions, - load_user_template_functions) + load_user_template_functions, function_pref_is_python, + function_pref_name) from polyglot.builtins import iteritems, native_string_type, unicode_type @@ -86,7 +87,9 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): traceback.print_exc() self.builtin_source_dict = {} - self.funcs = formatter_functions().get_functions() + self.funcs = dict((k,v) for k,v in formatter_functions().get_functions().items() + if v.is_python) + self.builtins = formatter_functions().get_builtins_and_aliases() self.build_function_names_box() @@ -116,16 +119,13 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.create_button.setEnabled(False) self.delete_button.setEnabled(False) - def build_function_names_box(self, scroll_to='', set_to=''): + def build_function_names_box(self, scroll_to=''): self.function_name.blockSignals(True) func_names = sorted(self.funcs) self.function_name.clear() self.function_name.addItem('') self.function_name.addItems(func_names) self.function_name.setCurrentIndex(0) - if set_to: - self.function_name.setEditText(set_to) - self.create_button.setEnabled(True) self.function_name.blockSignals(False) if scroll_to: idx = self.function_name.findText(scroll_to) @@ -140,23 +140,30 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): error_dialog(self.gui, _('Template functions'), _('You cannot delete a built-in function'), show=True) if name in self.funcs: + print('delete') del self.funcs[name] self.changed_signal.emit() self.create_button.setEnabled(True) self.delete_button.setEnabled(False) - self.build_function_names_box(set_to=name) + self.build_function_names_box() self.program.setReadOnly(False) else: error_dialog(self.gui, _('Template functions'), _('Function not defined'), show=True) - def create_button_clicked(self): + def create_button_clicked(self, use_name=None): self.changed_signal.emit() - name = unicode_type(self.function_name.currentText()) + name = use_name if use_name else unicode_type(self.function_name.currentText()) if name in self.funcs: error_dialog(self.gui, _('Template functions'), _('Name %s already used')%(name,), show=True) return + if name in {function_pref_name(v) for v in + self.db.prefs.get('user_template_functions', []) + if not function_pref_is_python(v)}: + error_dialog(self.gui, _('Template functions'), + _('Name %s is already used for stored template')%(name,), show=True) + return if self.argument_count.value() == 0: box = warning_dialog(self.gui, _('Template functions'), _('Argument count should be -1 or greater than zero. ' @@ -215,18 +222,19 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.replace_button.setEnabled(False) def replace_button_clicked(self): + name = unicode_type(self.function_name.currentText()) self.delete_button_clicked() - self.create_button_clicked() + self.create_button_clicked(use_name=name) def refresh_gui(self, gui): pass def commit(self): - # formatter_functions().reset_to_builtins() - pref_value = [] + pref_value = [v for v in self.db.prefs.get('user_template_functions', []) + if not function_pref_is_python(v)] for name, cls in iteritems(self.funcs): if name not in self.builtins: - pref_value.append((cls.name, cls.doc, cls.arg_count, cls.program_text)) + pref_value.append(cls.to_pref()) self.db.new_api.set_pref('user_template_functions', pref_value) funcs = compile_user_template_functions(pref_value) self.db.new_api.set_user_template_functions(funcs) diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 45b85bb280..966dfdfa76 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -28,6 +28,8 @@ class Node(object): NODE_CONSTANT = 7 NODE_FIELD = 8 NODE_RAW_FIELD = 9 + NODE_CALL = 10 + NODE_ARGUMENTS = 11 class IfNode(Node): @@ -55,6 +57,21 @@ class FunctionNode(Node): self.expression_list = expression_list +class CallNode(Node): + def __init__(self, function, expression_list): + Node.__init__(self) + self.node_type = self.NODE_CALL + self.function = function + self.expression_list = expression_list + + +class ArgumentsNode(Node): + def __init__(self, expression_list): + Node.__init__(self) + self.node_type = self.NODE_ARGUMENTS + self.expression_list = expression_list + + class StringInfixNode(Node): def __init__(self, operator, left, right): Node.__init__(self) @@ -228,9 +245,11 @@ class _Parser(object): except: return True - def program(self, funcs, prog): + def program(self, parent, funcs, prog): self.lex_pos = 0 + self.parent = parent self.funcs = funcs + self.func_names = frozenset(set(self.funcs.keys()) | {'call', 'arguments'}) self.prog = prog[0] self.prog_len = len(self.prog) if prog[1] != '': @@ -293,7 +312,7 @@ class _Parser(object): # Check if it is a known one. We do this here so error reporting is # better, as it can identify the tokens near the problem. id_ = id_.strip() - if id_ not in self.funcs: + if id_ not in self.func_names: self.error(_('Unknown function {0}').format(id_)) # Eat the paren self.consume() @@ -314,6 +333,24 @@ class _Parser(object): return IfNode(arguments[0], (arguments[1],), (arguments[2],)) if (id_ == 'assign' and len(arguments) == 2 and arguments[0].node_type == Node.NODE_RVALUE): return AssignNode(arguments[0].name, arguments[1]) + if id_ == 'call': + if arguments[0].node_type != Node.NODE_RVALUE: + self.error('The function name in a call statement must not be quoted') + name = arguments[0].name + if name not in self.func_names or self.funcs[name].is_python: + self.error(_('{} is not a stored template').format(name)) + text = self.funcs[name].program_text + if not text.startswith('program:'): + self.error((_('A stored template must begin with program:'))) + text = text[len('program:'):] + subprog = _Parser().program(self, self.funcs, + self.parent.lex_scanner.scan(text)) + return CallNode(subprog, arguments[1:]) + if id_ == 'arguments': + for arg in arguments: + if arg.node_type != Node.NODE_RVALUE: + self.error(_("Parameters to 'arguments' must be variables")) + return ArgumentsNode(arguments) cls = self.funcs[id_] if cls.arg_count != -1 and len(arguments) != cls.arg_count: self.error(_('Incorrect number of expression_list for function {0}').format(id_)) @@ -408,6 +445,24 @@ class _Interpreter(object): return cls.eval_(self.parent, self.parent_kwargs, self.parent_book, self.locals, *args) + def do_node_call(self, prog): + args = list() + for arg in prog.expression_list: + # evaluate the expression (recursive call) + args.append(self.expr(arg)) + saved_locals = self.locals + self.locals = {} + for dex, v in enumerate(args): + self.locals['*arg_'+ str(dex)] = v + val = self.expression_list(prog.function) + self.locals = saved_locals + return val + + def do_node_arguments(self, prog): + for dex,arg in enumerate(prog.expression_list): + self.locals[arg.name] = self.locals.get('*arg_'+ str(dex), '') + return '' + def do_node_constant(self, prog): return prog.value @@ -454,6 +509,8 @@ class _Interpreter(object): Node.NODE_RAW_FIELD: do_node_raw_field, Node.NODE_STRING_INFIX: do_node_string_infix, Node.NODE_NUMERIC_INFIX: do_node_numeric_infix, + Node.NODE_ARGUMENTS: do_node_arguments, + Node.NODE_CALL: do_node_call, } def expr(self, prog): @@ -554,10 +611,10 @@ class TemplateFormatter(string.Formatter): if column_name is not None and self.template_cache is not None: tree = self.template_cache.get(column_name, None) if not tree: - tree = self.gpm_parser.program(self.funcs, self.lex_scanner.scan(prog)) + tree = self.gpm_parser.program(self, self.funcs, self.lex_scanner.scan(prog)) self.template_cache[column_name] = tree else: - tree = self.gpm_parser.program(self.funcs, self.lex_scanner.scan(prog)) + tree = self.gpm_parser.program(self, self.funcs, self.lex_scanner.scan(prog)) return self.gpm_interpreter.program(self.funcs, self, tree, val) # ################# Override parent classes methods ##################### diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py index 3628e1938e..392e52328f 100644 --- a/src/calibre/utils/formatter_functions.py +++ b/src/calibre/utils/formatter_functions.py @@ -126,6 +126,7 @@ class FormatterFunction(object): category = 'Unknown' arg_count = 0 aliases = [] + is_python = True def evaluate(self, formatter, kwargs, mi, locals, *args): raise NotImplementedError() @@ -1813,17 +1814,35 @@ _formatter_builtins = [ class FormatterUserFunction(FormatterFunction): - def __init__(self, name, doc, arg_count, program_text): + def __init__(self, name, doc, arg_count, program_text, is_python): + self.is_python = is_python self.name = name self.doc = doc self.arg_count = arg_count self.program_text = program_text + def to_pref(self): + return [self.name, self.doc, self.arg_count, self.program_text] tabs = re.compile(r'^\t*') +def function_pref_is_python(pref): + if isinstance(pref, list): + pref = pref[3] + if pref.startswith('def'): + return True + if pref.startswith('program'): + return False + raise ValueError('Unknown program type in formatter function pref') + +def function_pref_name(pref): + return pref[0] + def compile_user_function(name, doc, arg_count, eval_func): + if not function_pref_is_python(eval_func): + return FormatterUserFunction(name, doc, arg_count, eval_func, False) + def replace_func(mo): return mo.group().replace('\t', ' ') @@ -1838,7 +1857,7 @@ class UserFunction(FormatterUserFunction): if DEBUG and tweaks.get('enable_template_debug_printing', False): print(prog) exec(prog, locals_) - cls = locals_['UserFunction'](name, doc, arg_count, eval_func) + cls = locals_['UserFunction'](name, doc, arg_count, eval_func, True) return cls @@ -1855,6 +1874,7 @@ def compile_user_template_functions(funcs): # then white space differences don't cause them to compare differently cls = compile_user_function(*func) + cls.is_python = function_pref_is_python(func) compiled_funcs[cls.name] = cls except Exception: try: @@ -1862,7 +1882,7 @@ def compile_user_template_functions(funcs): except Exception: func_name = 'Unknown' prints('**** Compilation errors in user template function "%s" ****' % func_name) - traceback.print_exc(limit=0) + traceback.print_exc(limit=10) prints('**** End compilation errors in %s "****"' % func_name) return compiled_funcs From c23eb2ae9d47e8c69c0d6c97d0cb2174e6fcc8c8 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Fri, 25 Sep 2020 19:14:17 +0100 Subject: [PATCH 7/8] Add default values to stored template arguments --- src/calibre/utils/formatter.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 966dfdfa76..d5dfae48b5 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -347,13 +347,18 @@ class _Parser(object): self.parent.lex_scanner.scan(text)) return CallNode(subprog, arguments[1:]) if id_ == 'arguments': + new_args = [] for arg in arguments: - if arg.node_type != Node.NODE_RVALUE: - self.error(_("Parameters to 'arguments' must be variables")) - return ArgumentsNode(arguments) + if arg.node_type not in (Node.NODE_ASSIGN, Node.NODE_RVALUE): + self.error(_("Parameters to 'arguments' must be " + "variables or assignments")) + if arg.node_type == Node.NODE_RVALUE: + arg = AssignNode(arg.name, ConstantNode('')) + new_args.append(arg) + return ArgumentsNode(new_args) cls = self.funcs[id_] if cls.arg_count != -1 and len(arguments) != cls.arg_count: - self.error(_('Incorrect number of expression_list for function {0}').format(id_)) + self.error(_('Incorrect number of arguments for function {0}').format(id_)) return FunctionNode(id_, arguments) elif self.token_is_constant(): # String or number @@ -460,7 +465,7 @@ class _Interpreter(object): def do_node_arguments(self, prog): for dex,arg in enumerate(prog.expression_list): - self.locals[arg.name] = self.locals.get('*arg_'+ str(dex), '') + self.locals[arg.left] = self.locals.get('*arg_'+ str(dex), self.expr(arg.right)) return '' def do_node_constant(self, prog): From 7e1e8146ba1596c6241e849bca4f7aed2bc68508 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Sat, 26 Sep 2020 22:43:39 +0100 Subject: [PATCH 8/8] Move the function name out of the argument list --- src/calibre/utils/formatter.py | 52 ++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index d5dfae48b5..b8cfaf10e4 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -205,6 +205,13 @@ class _Parser(object): except: return False + def token_is_call(self): + try: + token = self.prog[self.lex_pos] + return token[1] == 'call' and token[0] == self.LEX_KEYWORD + except: + return False + def token_is_if(self): try: token = self.prog[self.lex_pos] @@ -249,7 +256,7 @@ class _Parser(object): self.lex_pos = 0 self.parent = parent self.funcs = funcs - self.func_names = frozenset(set(self.funcs.keys()) | {'call', 'arguments'}) + self.func_names = frozenset(set(self.funcs.keys()) | {'arguments',}) self.prog = prog[0] self.prog_len = len(self.prog) if prog[1] != '': @@ -295,9 +302,37 @@ class _Parser(object): return NumericInfixNode(operator, left, self.expr()) return left + def call_expression(self): + self.consume() + if not self.token_is_id(): + self.error(_('"call" requires a stored template name')) + name = self.token() + if name not in self.func_names or self.funcs[name].is_python: + self.error(_('{} is not a stored template').format(name)) + text = self.funcs[name].program_text + if not text.startswith('program:'): + self.error((_('A stored template must begin with program:'))) + text = text[len('program:'):] + if not self.token_op_is_lparen(): + self.error(_('"call" requires arguments surrounded by "(" ")"')) + self.consume() + arguments = list() + while not self.token_op_is_rparen(): + arguments.append(self.infix_expr()) + if not self.token_op_is_comma(): + break + self.consume() + if self.token() != ')': + self.error(_('Missing closing parenthesis')) + subprog = _Parser().program(self, self.funcs, + self.parent.lex_scanner.scan(text)) + return CallNode(subprog, arguments) + def expr(self): if self.token_is_if(): return self.if_expression() + if self.token_is_call(): + return self.call_expression() if self.token_is_id(): # We have an identifier. Determine if it is a function id_ = self.token() @@ -333,19 +368,6 @@ class _Parser(object): return IfNode(arguments[0], (arguments[1],), (arguments[2],)) if (id_ == 'assign' and len(arguments) == 2 and arguments[0].node_type == Node.NODE_RVALUE): return AssignNode(arguments[0].name, arguments[1]) - if id_ == 'call': - if arguments[0].node_type != Node.NODE_RVALUE: - self.error('The function name in a call statement must not be quoted') - name = arguments[0].name - if name not in self.func_names or self.funcs[name].is_python: - self.error(_('{} is not a stored template').format(name)) - text = self.funcs[name].program_text - if not text.startswith('program:'): - self.error((_('A stored template must begin with program:'))) - text = text[len('program:'):] - subprog = _Parser().program(self, self.funcs, - self.parent.lex_scanner.scan(text)) - return CallNode(subprog, arguments[1:]) if id_ == 'arguments': new_args = [] for arg in arguments: @@ -598,7 +620,7 @@ class TemplateFormatter(string.Formatter): lex_scanner = re.Scanner([ (r'(==#|!=#|<=#|<#|>=#|>#)', lambda x,t: (_Parser.LEX_NUMERIC_INFIX, t)), (r'(==|!=|<=|<|>=|>)', lambda x,t: (_Parser.LEX_STRING_INFIX, t)), # noqa - (r'(if|then|else|fi)\b', lambda x,t: (_Parser.LEX_KEYWORD, t)), # noqa + (r'(if|then|else|fi|call)\b',lambda x,t: (_Parser.LEX_KEYWORD, t)), # noqa (r'[(),=;]', lambda x,t: (_Parser.LEX_OP, t)), # noqa (r'-?[\d\.]+', lambda x,t: (_Parser.LEX_CONST, t)), # noqa (r'\$', lambda x,t: (_Parser.LEX_ID, t)), # noqa