From 740bfd26db315cf471d43940d965159bc0e5dc08 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Wed, 23 Sep 2020 11:37:26 +0100
Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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
From d4798a11e5dbd3ef75652619820b56b80bb496b8 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Mon, 28 Sep 2020 13:59:24 +0100
Subject: [PATCH 09/16] Update the manual
---
manual/template_lang.rst | 30 ++++++++++++++++++++++++++----
1 file changed, 26 insertions(+), 4 deletions(-)
diff --git a/manual/template_lang.rst b/manual/template_lang.rst
index 29c23b0aa0..402c3a9d98 100644
--- a/manual/template_lang.rst
+++ b/manual/template_lang.rst
@@ -187,15 +187,16 @@ The example shows several things:
The language is similar to ``functional`` languages in that it is built almost entirely from functions. An expression is generally a function. Constants and identifiers can be thought of as functions returning the value indicated by the constant or stored in the identifier.
-The syntax of the language is shown by the following grammar. For a discussion of 'compare' and 'if_expression' see :ref:`General Program Mode `:::
+The syntax of the language is shown by the following grammar. For a discussion of 'compare','if_expression', and 'template_call' see :ref:`General Program Mode `:::
program ::= expression_list
expression_list ::= expression [ ';' expression ]*
- expression ::= identifier | constant | function | assignment | compare | if_expression
+ expression ::= identifier | constant | function | assignment | compare | if_expression | template_call
function ::= identifier '(' expression [ ',' expression ]* ')'
compare ::= expression compare_op expression
compare_op ::= '==' | '!=' | '>=' | '>' | '<=' | '<' | '==#' | '!=#' | '>=#' | '>#' | '<=#' | '<#'
if_expression ::= 'if' expression 'then' expression_list ['else' statement] 'fi'
+ template_call ::= 'call' identifier '(' [expression [,]]* ')'
assignment ::= identifier '=' expression
constant ::= " string " | ' string ' | number
identifier ::= sequence of letters or ``_`` characters
@@ -254,7 +255,7 @@ The following functions are available in addition to those described in single-f
returns "yes" if the yes/no field ``"#bool"`` is either undefined (neither True nor False) or True. More than one of ``is_undefined``, ``is_false``, or ``is_true`` can be set to 1. This function is usually used by the ``test()`` or ``is_empty()`` functions.
* ``ceiling(x)`` -- returns the smallest integer greater than or equal to x. Throws an exception if x is not a number.
* ``cmp(x, y, lt, eq, gt)`` -- compares x and y after converting both to numbers. Returns ``lt`` if x < y. Returns ``eq`` if x == y. Otherwise returns ``gt``.
- * ``connected_device_name(storage_location)`` -- if a device is connected then return the device name, otherwise return the empty string. Each storage location on a device can have a different name. The location names are 'main', 'carda' and 'cardb'. This function works only in the GUI.
+ * ``connected_device_name(storage_location)`` -- if a device is connected then return the device name, otherwise return the empty string. Each storage location on a device can have a different name. The location names are 'main', 'carda' and 'cardb'. This function works only in the GUI.
* ``current_library_name()`` -- return the last name on the path to the current calibre library. This function can be called in template program mode using the template ``{:'current_library_name()'}``.
* ``current_library_path()`` -- return the path to the current calibre library. This function can be called in template program mode using the template ``{:'current_library_path()'}``.
* ``days_between(date1, date2)`` -- return the number of days between ``date1`` and ``date2``. The number is positive if ``date1`` is greater than ``date2``, otherwise negative. If either ``date1`` or ``date2`` are not dates, the function returns the empty string.
@@ -403,6 +404,27 @@ Program mode also supports the classic relational (comparison) operators: ``==``
* ``program: if or(field('series') == 'foo', field('series') == '1632') then 'yes' else 'no' fi`` returns 'yes' if series is either 'foo' or '1632', otherwise 'no'.
* ``program: if '11' > '2' then 'yes' else 'no' fi`` returns 'no' because it is doing a lexical comparison. If you want numeric comparison instead of lexical comparison, use the operators ``==#``, ``!=#``, ``<#``, ``<=#``, ``>#``, ``>=#``. In this case the left and right values are set to zero if they are undefined or the empty string. If they are not numbers then an error is raised.
+General Program Mode support saving General Program Mode templates and calling those templates from another template. You save
+templates using :guilabel:`Preferences->Advanced->Stored templates`. More information is provided in that dialog. You call
+a template using the ``call`` keyword, passing positional arguments if desired. An argument can be any expression.
+Examples of ``call``, assuming the stored template is named ``foo``:
+
+ * ``call foo()`` -- call the template passing no arguments.
+ * ``call foo(a, b)`` call the template passing the values of the two variables ``a`` and ``b``.
+ * ``call foo(if field('series') then field('series_index') else 0 fi)`` -- if the book has a ``series`` then pass the ``series_index``, otherwise pass the value ``0``.
+
+In the stored template you retrieve the arguments passed in ``call`` using the ``arguments`` function. It both declares and
+initializes local variables. The variables are positional; they get the value of the value given to ``call`` in the same position.
+If the corresponding parameter is not provided in the ``call`` then ``arguments`` gives that parameter the provided default value. If there is no default value then the argument is set to the empty string. For example, the following ``arguments`` function declares 2 variables, ``key``, ``alternate``::
+
+ ``arguments(key, alternate='series')
+
+Examples, again assuming the stored template is named ``foo``:
+
+ * ``call foo('#myseries')`` -- argument``key`` will have the value ``myseries`` and the argument ``alternate`` will have the value ``series``.
+ * ``call foo('series', '#genre')`` the variable ``key`` is assigned the value ``series`` and the variable ``alternate`` is assigned the value ``#genre``.
+ * ``call foo()`` -- the variable ``key`` is assigned the empty string and the variable ``alternate`` is assigned the value ``#genre``.
+
The following example is a `program:` mode implementation of a recipe on the MobileRead forum: "Put series into the title, using either initials or a shortened form. Strip leading articles from the series name (any)." For example, for the book The Two Towers in the Lord of the Rings series, the recipe gives `LotR [02] The Two Towers`. Using standard templates, the recipe requires three custom columns and a plugboard, as explained in the following:
The solution requires creating three composite columns. The first column is used to remove the leading articles. The second is used to compute the 'shorten' form. The third is to compute the 'initials' form. Once you have these columns, the plugboard selects between them. You can hide any or all of the three columns on the library view::
@@ -487,7 +509,7 @@ It would be possible to do the above with no custom columns by putting the progr
User-defined template functions
-------------------------------
-You can add your own functions to the template processor. Such functions are written in Python, and can be used in any of the three template programming modes. The functions are added by going to Preferences -> Advanced -> Template functions. Instructions are shown in that dialog.
+You can add your own python functions to the template processor. Such functions are written in Python, and can be used in any of the three template programming modes. The functions are added by going to guilabel`Preferences -> Advanced -> Template functions`. Instructions are shown in that dialog.
Special notes for save/send templates
-------------------------------------
From 40a77e05d8b7f4b6d70e96da7435614c91282495 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Mon, 28 Sep 2020 14:19:09 +0100
Subject: [PATCH 10/16] ...
---
manual/template_lang.rst | 6 ++++-
.../gui2/preferences/stored_templates.py | 23 +++++++++----------
2 files changed, 16 insertions(+), 13 deletions(-)
diff --git a/manual/template_lang.rst b/manual/template_lang.rst
index 402c3a9d98..ee5f134711 100644
--- a/manual/template_lang.rst
+++ b/manual/template_lang.rst
@@ -425,6 +425,10 @@ Examples, again assuming the stored template is named ``foo``:
* ``call foo('series', '#genre')`` the variable ``key`` is assigned the value ``series`` and the variable ``alternate`` is assigned the value ``#genre``.
* ``call foo()`` -- the variable ``key`` is assigned the empty string and the variable ``alternate`` is assigned the value ``#genre``.
+An easy way to test stored templates is using the ``Template tester`` dialog. Give it a keyboard shortcut in
+:guilabel:`Preferences->Advanced->Keyboard shortcuts->Template tester`. Giving the ``Stored templates`` dialog a
+shortcut will help switching more rapidly between the tester and editing the stored template's source code.
+
The following example is a `program:` mode implementation of a recipe on the MobileRead forum: "Put series into the title, using either initials or a shortened form. Strip leading articles from the series name (any)." For example, for the book The Two Towers in the Lord of the Rings series, the recipe gives `LotR [02] The Two Towers`. Using standard templates, the recipe requires three custom columns and a plugboard, as explained in the following:
The solution requires creating three composite columns. The first column is used to remove the leading articles. The second is used to compute the 'shorten' form. The third is to compute the 'initials' form. Once you have these columns, the plugboard selects between them. You can hide any or all of the three columns on the library view::
@@ -506,7 +510,7 @@ The following program produces the same results as the original recipe, using on
It would be possible to do the above with no custom columns by putting the program into the template box of the plugboard. However, to do so, all comments must be removed because the plugboard text box does not support multi-line editing. It is debatable whether the gain of not having the custom column is worth the vast increase in difficulty caused by the program being one giant line.
-User-defined template functions
+User-defined python template functions
-------------------------------
You can add your own python functions to the template processor. Such functions are written in Python, and can be used in any of the three template programming modes. The functions are added by going to guilabel`Preferences -> Advanced -> Template functions`. Instructions are shown in that dialog.
diff --git a/src/calibre/gui2/preferences/stored_templates.py b/src/calibre/gui2/preferences/stored_templates.py
index d41c35ee41..e687de142c 100644
--- a/src/calibre/gui2/preferences/stored_templates.py
+++ b/src/calibre/gui2/preferences/stored_templates.py
@@ -41,26 +41,25 @@ class ConfigWidget(ConfigWidgetBase):
w.setWordWrap(True)
l.addWidget(w, 0, 0, 1, 2)
- lab = QLabel(_('&Template'))
+ lab = QLabel(_('&Name'))
l.addWidget(lab, 1, 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, 1, 1)
+
+ lab = QLabel(_('&Template'))
+ l.addWidget(lab, 2, 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'))
+ self.editor_button = b = QPushButton(_('&Open template 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)
+ l.addLayout(lb, 2, 1)
lab = QLabel(_('&Documentation'))
l.addWidget(lab, 3, 0, Qt.AlignTop)
From 78e9858f4d23741c035d410f63f2048ebd93b060 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Mon, 28 Sep 2020 14:29:41 +0100
Subject: [PATCH 11/16] Remove print
---
src/calibre/gui2/preferences/template_functions.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/calibre/gui2/preferences/template_functions.py b/src/calibre/gui2/preferences/template_functions.py
index 269a3a3c80..f3fb62fa59 100644
--- a/src/calibre/gui2/preferences/template_functions.py
+++ b/src/calibre/gui2/preferences/template_functions.py
@@ -140,7 +140,6 @@ 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)
From e08fb5f2312cea68a9b90be935d1e09244d7ab97 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Wed, 30 Sep 2020 22:50:26 +0100
Subject: [PATCH 12/16] Commit for testing after making a tabbed window and
embedding the template tester
---
src/calibre/customize/builtins.py | 24 +-
.../gui2/actions/show_stored_templates.py | 10 +-
src/calibre/gui2/dialogs/template_dialog.py | 32 +-
src/calibre/gui2/dialogs/template_dialog.ui | 97 +++++-
.../gui2/preferences/stored_templates.py | 211 ------------
.../gui2/preferences/template_functions.py | 126 ++++++-
.../gui2/preferences/template_functions.ui | 310 ++++++++++++------
7 files changed, 450 insertions(+), 360 deletions(-)
delete mode 100644 src/calibre/gui2/preferences/stored_templates.py
diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py
index 7c10ede34d..b542593a79 100644
--- a/src/calibre/customize/builtins.py
+++ b/src/calibre/customize/builtins.py
@@ -935,10 +935,10 @@ 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 ActionTemplateFunctions(InterfaceActionBase):
+ name = 'Template Functions'
+ actual_plugin = 'calibre.gui2.actions.show_stored_templates:ShowTemplateFunctionsAction'
+ description = _('Show a dialog for creating and managing template functions and stored templates')
class ActionSaveToDisk(InterfaceActionBase):
@@ -1105,7 +1105,7 @@ plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog,
ActionCopyToLibrary, ActionTweakEpub, ActionUnpackBook, ActionNextMatch, ActionStore,
ActionPluginUpdater, ActionPickRandom, ActionEditToC, ActionSortBy,
ActionMarkBooks, ActionEmbed, ActionTemplateTester, ActionTagMapper, ActionAuthorMapper,
- ActionVirtualLibrary, ActionBrowseAnnotations, ActionStoredTemplates]
+ ActionVirtualLibrary, ActionBrowseAnnotations, ActionTemplateFunctions]
# }}}
@@ -1278,18 +1278,6 @@ 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')
@@ -1394,7 +1382,7 @@ class Misc(PreferencesPlugin):
plugins += [LookAndFeel, Behavior, Columns, Toolbar, Search, InputOptions,
CommonOptions, OutputOptions, Adding, Saving, Sending, Plugboard,
Email, Server, Plugins, Tweaks, Misc, TemplateFunctions,
- StoredTemplates, MetadataSources, Keyboard, IgnoredDevices]
+ MetadataSources, Keyboard, IgnoredDevices]
# }}}
diff --git a/src/calibre/gui2/actions/show_stored_templates.py b/src/calibre/gui2/actions/show_stored_templates.py
index 9164a7d5ba..33fe1f8d38 100644
--- a/src/calibre/gui2/actions/show_stored_templates.py
+++ b/src/calibre/gui2/actions/show_stored_templates.py
@@ -11,20 +11,20 @@ from calibre.gui2.actions import InterfaceAction
from calibre.gui2.preferences.main import Preferences
-class ShowStoredTemplatesAction(InterfaceAction):
+class ShowTemplateFunctionsAction(InterfaceAction):
- name = 'Stored Template'
- action_spec = (_('Stored Templates'), 'debug.png', None, ())
+ name = 'Template Functions'
+ action_spec = (_('Template Functions'), 'debug.png', None, ())
dont_add_to = frozenset(('context-menu-device',))
action_type = 'current'
def genesis(self):
- self.previous_text = _('Manage stored templates')
+ self.previous_text = _('Manage template functions')
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'),
+ d = Preferences(self.gui, initial_plugin=('Advanced', 'TemplateFunctions'),
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 81706fbb40..89d88e3c19 100644
--- a/src/calibre/gui2/dialogs/template_dialog.py
+++ b/src/calibre/gui2/dialogs/template_dialog.py
@@ -213,7 +213,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
def __init__(self, parent, text, mi=None, fm=None, color_field=None,
icon_field_key=None, icon_rule_kind=None, doing_emblem=False,
- text_is_placeholder=False):
+ text_is_placeholder=False, dialog_is_st_editor=False):
QDialog.__init__(self, parent)
Ui_TemplateDialog.__init__(self)
self.setupUi(self)
@@ -221,6 +221,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.coloring = color_field is not None
self.iconing = icon_field_key is not None
self.embleming = doing_emblem
+ self.dialog_is_st_editor = dialog_is_st_editor
cols = []
if fm is not None:
@@ -273,6 +274,14 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.icon_kind.setCurrentIndex(dex)
self.icon_field.setCurrentIndex(self.icon_field.findData(icon_field_key))
+ if dialog_is_st_editor:
+ self.buttonBox.setVisible(False)
+ else:
+ self.new_doc_label.setVisible(False)
+ self.new_doc.setVisible(False)
+ self.template_name_label.setVisible(False)
+ self.template_name.setVisible(False)
+
if mi:
self.mi = mi
else:
@@ -467,6 +476,27 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.rule = ('', txt)
QDialog.accept(self)
+ def reject(self):
+ QDialog.reject(self)
+ if self.dialog_is_st_editor:
+ parent = self.parent()
+ while True:
+ if hasattr(parent, 'reject'):
+ parent.reject()
+ break
+ parent = parent.parent()
+ if parent is None:
+ break
+
+
+class EmbeddedTemplateDialog(TemplateDialog):
+
+ def __init__(self, parent):
+ TemplateDialog.__init__(self, parent, "Foo", text_is_placeholder=True,
+ dialog_is_st_editor=True)
+ self.setParent(parent)
+ self.setWindowFlags(Qt.Widget)
+
if __name__ == '__main__':
app = QApplication([])
diff --git a/src/calibre/gui2/dialogs/template_dialog.ui b/src/calibre/gui2/dialogs/template_dialog.ui
index 9f6d38399c..6c5b45f666 100644
--- a/src/calibre/gui2/dialogs/template_dialog.ui
+++ b/src/calibre/gui2/dialogs/template_dialog.ui
@@ -141,11 +141,68 @@
- -
-
-
-
+
-
+
+
+ Template &name:
+
+
+ template_name
+
+
+
+ -
+
+
+ true
+
+
+ The name of the callable template
+
+
+
+ -
+
+
+ T&emplate:
+
+
+ textbox
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+
+ -
+
+
+ The template program text
+
+
+
+ -
+
+
+ D&ocumentation:
+
+
+ new_doc
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+
+ -
+
+
+ Documentation for the function being defined or edited
+
+
+
-
@@ -173,7 +230,7 @@
- -
+
-
-
@@ -203,14 +260,24 @@
- -
+
-
QFrame::HLine
- -
+
-
+
+
+ Template Function Reference
+
+
+ function
+
+
+
+ -
Function &name:
@@ -220,10 +287,10 @@
- -
+
-
- -
+
-
&Function type:
@@ -236,14 +303,14 @@
- -
+
-
true
- -
+
-
&Documentation:
@@ -256,7 +323,7 @@
- -
+
-
&Code:
@@ -269,7 +336,7 @@
- -
+
-
@@ -279,17 +346,17 @@
- -
+
-
- -
+
-
true
- -
+
-
true
diff --git a/src/calibre/gui2/preferences/stored_templates.py b/src/calibre/gui2/preferences/stored_templates.py
deleted file mode 100644
index e687de142c..0000000000
--- a/src/calibre/gui2/preferences/stored_templates.py
+++ /dev/null
@@ -1,211 +0,0 @@
-#!/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(_('&Name'))
- l.addWidget(lab, 1, 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, 1, 1)
-
- lab = QLabel(_('&Template'))
- l.addWidget(lab, 2, 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 template editor'))
- b.clicked.connect(w.open_editor)
- lb.addWidget(b)
- l.addLayout(lb, 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 f3fb62fa59..2ba88e676e 100644
--- a/src/calibre/gui2/preferences/template_functions.py
+++ b/src/calibre/gui2/preferences/template_functions.py
@@ -8,7 +8,7 @@ __docformat__ = 'restructuredtext en'
import json, traceback
-from PyQt5.Qt import QDialogButtonBox
+from PyQt5.Qt import Qt, QDialogButtonBox, QSizePolicy
from calibre.gui2 import error_dialog, warning_dialog
from calibre.gui2.preferences import ConfigWidgetBase, test_widget
@@ -78,6 +78,17 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
''')
self.textBrowser.setHtml(help_text)
+ 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.
+ ''') + '
'
+ self.st_textBrowser.setHtml(help_text)
+ self.st_textBrowser.adjustSize()
def initialize(self):
try:
@@ -92,6 +103,11 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.builtins = formatter_functions().get_builtins_and_aliases()
+ self.st_funcs = {}
+ for v in self.db.prefs.get('user_template_functions', []):
+ if not function_pref_is_python(v):
+ self.st_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)
@@ -108,6 +124,24 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.program.setTabStopWidth(20)
self.highlighter = PythonHighlighter(self.program.document())
+ self.st_build_function_names_box()
+ self.template_editor.template_name.currentIndexChanged[native_string_type].connect(self.st_function_index_changed)
+ self.template_editor.template_name.editTextChanged.connect(self.st_template_name_edited)
+ self.template_editor.new_doc.textChanged.connect(self.st_enable_replace_button)
+ self.template_editor.textbox.textChanged.connect(self.st_enable_replace_button)
+ self.st_create_button.clicked.connect(self.st_create_button_clicked)
+ self.st_delete_button.clicked.connect(self.st_delete_button_clicked)
+ self.st_create_button.setEnabled(False)
+ self.st_delete_button.setEnabled(False)
+ self.st_replace_button.setEnabled(False)
+ self.st_clear_button.clicked.connect(self.st_clear_button_clicked)
+ self.st_replace_button.clicked.connect(self.st_replace_button_clicked)
+
+ self.st_button_layout.insertSpacing(0, 90)
+ self.template_editor.new_doc.setFixedHeight(50)
+
+ # Python funtion tab
+
def enable_replace_button(self):
self.replace_button.setEnabled(self.delete_button.isEnabled())
@@ -228,12 +262,98 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
def refresh_gui(self, gui):
pass
+ # Stored template tab
+
+ def st_enable_replace_button(self):
+ self.st_replace_button.setEnabled(self.st_delete_button.isEnabled())
+
+ def st_clear_button_clicked(self):
+ self.st_build_function_names_box()
+ self.template_editor.textbox.clear()
+ self.template_editor.new_doc.clear()
+ self.st_create_button.setEnabled(False)
+ self.st_delete_button.setEnabled(False)
+
+ def st_build_function_names_box(self, scroll_to=''):
+ self.template_editor.template_name.blockSignals(True)
+ func_names = sorted(self.st_funcs)
+ self.template_editor.template_name.clear()
+ self.template_editor.template_name.addItem('')
+ self.template_editor.template_name.addItems(func_names)
+ self.template_editor.template_name.setCurrentIndex(0)
+ self.template_editor.template_name.blockSignals(False)
+ if scroll_to:
+ idx = self.template_editor.template_name.findText(scroll_to)
+ if idx >= 0:
+ self.template_editor.template_name.setCurrentIndex(idx)
+
+ def st_delete_button_clicked(self):
+ name = unicode_type(self.template_editor.template_name.currentText())
+ if name in self.st_funcs:
+ del self.st_funcs[name]
+ self.changed_signal.emit()
+ self.st_create_button.setEnabled(True)
+ self.st_delete_button.setEnabled(False)
+ self.st_build_function_names_box()
+ self.template_editor.textbox.setReadOnly(False)
+ else:
+ error_dialog(self.gui, _('Stored templates'),
+ _('Function not defined'), show=True)
+
+ def st_create_button_clicked(self, use_name=None):
+ self.changed_signal.emit()
+ name = use_name if use_name else unicode_type(self.template_editor.template_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.template_editor.textbox.toPlainText())
+ 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.template_editor.new_doc.toPlainText()),
+ 0, prog)
+ self.st_funcs[name] = cls
+ self.st_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 st_template_name_edited(self, txt):
+ self.st_create_button.setEnabled(True)
+ self.st_replace_button.setEnabled(False)
+ self.template_editor.textbox.setReadOnly(False)
+
+ def st_function_index_changed(self, txt):
+ txt = unicode_type(txt)
+ self.st_create_button.setEnabled(False)
+ if not txt:
+ self.template_editor.textbox.clear()
+ self.template_editor.new_doc.clear()
+ return
+ func = self.st_funcs[txt]
+ self.template_editor.new_doc.setPlainText(func.doc)
+ self.template_editor.textbox.setPlainText(func.program_text)
+ self.st_delete_button.setEnabled(True)
+ self.template_editor.textbox.setReadOnly(False)
+ self.st_replace_button.setEnabled(False)
+
+ def st_replace_button_clicked(self):
+ name = unicode_type(self.template_editor.template_name.currentText())
+ self.st_delete_button_clicked()
+ self.st_create_button_clicked(use_name=name)
+
def commit(self):
- pref_value = [v for v in self.db.prefs.get('user_template_functions', [])
- if not function_pref_is_python(v)]
+ pref_value = []
for name, cls in iteritems(self.funcs):
+ print(name)
if name not in self.builtins:
pref_value.append(cls.to_pref())
+ for v in self.st_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)
diff --git a/src/calibre/gui2/preferences/template_functions.ui b/src/calibre/gui2/preferences/template_functions.ui
index 56111e6831..da04514b69 100644
--- a/src/calibre/gui2/preferences/template_functions.ui
+++ b/src/calibre/gui2/preferences/template_functions.ui
@@ -6,155 +6,251 @@
0
0
- 798
- 672
+ 788
+ 663
Form
- -
-
-
- Qt::Horizontal
+
-
+
+
+ 0
-
-
- -
-
-
-
-
-
-
-
-
- &Function:
-
-
- function_name
-
+
+
+ &Stored Templates
+
+
+
-
+
+
+ -
+
- -
-
-
- Enter the name of the function to create.
-
-
- true
-
-
-
- -
-
-
-
-
-
- Argument &count:
-
-
- argument_count
-
-
-
- -
-
-
- Set this to -1 if the function takes a variable number of arguments
-
-
- -1
-
-
-
- -
-
-
- -
-
-
- &Documentation:
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
-
-
- documentation
-
-
-
- -
-
+
-
+
-
-
+
&Clear
-
-
+
- &Delete
+ D&elete
-
-
+
&Replace
-
-
+
C&reate
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 0
+
+
+
+
-
-
- -
-
-
-
-
-
- &Program code (Follow Python indenting rules):
+
-
+
+
+ Qt::Vertical
-
- program
-
-
-
- -
-
-
+
- 400
- 0
+ 20
+ 400
-
-
-
-
- 30
+
+
+
+
+
+
+ &Template Functions
+
+
+ -
+
+
+ Qt::Horizontal
+ -
+
+
-
+
+
-
+
+
+ F&unction:
+
+
+ function_name
+
+
+
+ -
+
+
+ Enter the name of the function to create.
+
+
+ true
+
+
+
+ -
+
+
+
+
+
+ Argument &count:
+
+
+ argument_count
+
+
+
+ -
+
+
+ Set this to -1 if the function takes a variable number of arguments
+
+
+ -1
+
+
+
+ -
+
+
+ -
+
+
+ D&ocumentation:
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+ documentation
+
+
+
+ -
+
+
-
+
+
+ Clear
+
+
+
+ -
+
+
+ Delete
+
+
+
+ -
+
+
+ Replace
+
+
+
+ -
+
+
+ C&reate
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+ P&rogram code (Follow Python indenting rules):
+
+
+ program
+
+
+
+ -
+
+
+
+ 400
+ 0
+
+
+
+
+
+
+ 30
+
+
+
+
+
+
+
+ -
+
+
-
-
-
- -
-
+
+
-
-
-
+
+
+ EmbeddedTemplateDialog
+ TemplateDialog
+ calibre/gui2/dialogs/template_dialog.h
+
+
+ TemplateDialog
+ QDialog
+ calibre/gui2/dialogs/template_dialog.h
+
+
+
\ No newline at end of file
From f839ac96e34e96557645b32dcaf3c257ca497754 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Thu, 1 Oct 2020 09:41:18 +0100
Subject: [PATCH 13/16] Stored templates
---
manual/template_lang.rst | 2 +-
src/calibre/gui2/dialogs/template_dialog.py | 5 +++-
.../gui2/preferences/template_functions.py | 30 ++++++++-----------
3 files changed, 18 insertions(+), 19 deletions(-)
diff --git a/manual/template_lang.rst b/manual/template_lang.rst
index ee5f134711..b2f20dc26a 100644
--- a/manual/template_lang.rst
+++ b/manual/template_lang.rst
@@ -405,7 +405,7 @@ Program mode also supports the classic relational (comparison) operators: ``==``
* ``program: if '11' > '2' then 'yes' else 'no' fi`` returns 'no' because it is doing a lexical comparison. If you want numeric comparison instead of lexical comparison, use the operators ``==#``, ``!=#``, ``<#``, ``<=#``, ``>#``, ``>=#``. In this case the left and right values are set to zero if they are undefined or the empty string. If they are not numbers then an error is raised.
General Program Mode support saving General Program Mode templates and calling those templates from another template. You save
-templates using :guilabel:`Preferences->Advanced->Stored templates`. More information is provided in that dialog. You call
+templates using :guilabel:`Preferences->Advanced->Template functions`. More information is provided in that dialog. You call
a template using the ``call`` keyword, passing positional arguments if desired. An argument can be any expression.
Examples of ``call``, assuming the stored template is named ``foo``:
diff --git a/src/calibre/gui2/dialogs/template_dialog.py b/src/calibre/gui2/dialogs/template_dialog.py
index 89d88e3c19..28e0fb2ad5 100644
--- a/src/calibre/gui2/dialogs/template_dialog.py
+++ b/src/calibre/gui2/dialogs/template_dialog.py
@@ -303,6 +303,8 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
from calibre.gui2.ui import get_gui
self.mi.set_all_user_metadata(
get_gui().current_db.new_api.field_metadata.custom_field_metadata())
+ for col in self.mi.get_all_user_metadata(False):
+ self.mi.set(col, (col,), 0)
# Remove help icon on title bar
icon = self.windowIcon()
@@ -322,6 +324,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
if text is not None:
if text_is_placeholder:
self.textbox.setPlaceholderText(text)
+ self.textbox.clear()
else:
self.textbox.setPlainText(text)
self.buttonBox.button(QDialogButtonBox.Ok).setText(_('&OK'))
@@ -492,7 +495,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
class EmbeddedTemplateDialog(TemplateDialog):
def __init__(self, parent):
- TemplateDialog.__init__(self, parent, "Foo", text_is_placeholder=True,
+ TemplateDialog.__init__(self, parent, _('A General Program Mode Template'), text_is_placeholder=True,
dialog_is_st_editor=True)
self.setParent(parent)
self.setWindowFlags(Qt.Widget)
diff --git a/src/calibre/gui2/preferences/template_functions.py b/src/calibre/gui2/preferences/template_functions.py
index 2ba88e676e..f02b6bf2ac 100644
--- a/src/calibre/gui2/preferences/template_functions.py
+++ b/src/calibre/gui2/preferences/template_functions.py
@@ -80,14 +80,15 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.textBrowser.setHtml(help_text)
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.
+ You use a stored template in another template with the '{0}' template
+ function, as in '{0}(some_name, arguments...). Stored templates must use
+ General Program Mode -- they must begin with the text '{1}'.
+ In the stored template you retrieve the arguments using the '{2}()'
+ template function, as in '{2}(var1, var2, ...)'. The calling arguments
+ are copied to the named variables. See the template language tutorial
+ for more information.
''') + '
'
- self.st_textBrowser.setHtml(help_text)
+ self.st_textBrowser.setHtml(help_text.format('call', 'program:', 'arguments'))
self.st_textBrowser.adjustSize()
def initialize(self):
@@ -127,8 +128,6 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.st_build_function_names_box()
self.template_editor.template_name.currentIndexChanged[native_string_type].connect(self.st_function_index_changed)
self.template_editor.template_name.editTextChanged.connect(self.st_template_name_edited)
- self.template_editor.new_doc.textChanged.connect(self.st_enable_replace_button)
- self.template_editor.textbox.textChanged.connect(self.st_enable_replace_button)
self.st_create_button.clicked.connect(self.st_create_button_clicked)
self.st_delete_button.clicked.connect(self.st_delete_button_clicked)
self.st_create_button.setEnabled(False)
@@ -264,9 +263,6 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
# Stored template tab
- def st_enable_replace_button(self):
- self.st_replace_button.setEnabled(self.st_delete_button.isEnabled())
-
def st_clear_button_clicked(self):
self.st_build_function_names_box()
self.template_editor.textbox.clear()
@@ -323,8 +319,10 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
det_msg=traceback.format_exc())
def st_template_name_edited(self, txt):
- self.st_create_button.setEnabled(True)
- self.st_replace_button.setEnabled(False)
+ b = txt in self.st_funcs
+ self.st_create_button.setEnabled(not b)
+ self.st_replace_button.setEnabled(b)
+ self.st_delete_button.setEnabled(b)
self.template_editor.textbox.setReadOnly(False)
def st_function_index_changed(self, txt):
@@ -337,9 +335,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
func = self.st_funcs[txt]
self.template_editor.new_doc.setPlainText(func.doc)
self.template_editor.textbox.setPlainText(func.program_text)
- self.st_delete_button.setEnabled(True)
- self.template_editor.textbox.setReadOnly(False)
- self.st_replace_button.setEnabled(False)
+ self.st_template_name_edited(txt)
def st_replace_button_clicked(self):
name = unicode_type(self.template_editor.template_name.currentText())
From b208241eba1745d8a4a789873b754272445b7964 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Thu, 1 Oct 2020 11:53:05 +0100
Subject: [PATCH 14/16] Make the tabs individually scrollable
---
src/calibre/gui2/preferences/template_functions.ui | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/calibre/gui2/preferences/template_functions.ui b/src/calibre/gui2/preferences/template_functions.ui
index da04514b69..cf58dc766c 100644
--- a/src/calibre/gui2/preferences/template_functions.ui
+++ b/src/calibre/gui2/preferences/template_functions.ui
@@ -15,7 +15,7 @@
-
-
+
0
@@ -252,5 +252,11 @@
QDialog
calibre/gui2/dialogs/template_dialog.h
+
+ ScrollingTabWidget
+ QTabWidget
+
+ 1
+
\ No newline at end of file
From ac50f0afaf4cc7df43d19e656dde44e72d12daf0 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Fri, 2 Oct 2020 08:02:42 +0100
Subject: [PATCH 15/16] 1) cached 'compiled' stored templates 2) change call
syntax so stored templates are usable in single function mode
---
src/calibre/utils/formatter.py | 92 ++++++++++++------------
src/calibre/utils/formatter_functions.py | 1 +
2 files changed, 47 insertions(+), 46 deletions(-)
diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py
index b8cfaf10e4..f58d0dd30c 100644
--- a/src/calibre/utils/formatter.py
+++ b/src/calibre/utils/formatter.py
@@ -302,37 +302,21 @@ 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))
+ def call_expression(self, name, arguments):
+ subprog = self.funcs[name].cached_parse_tree
+ if subprog is None:
+ 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))
+ self.funcs[name].cached_parse_tree = subprog
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()
@@ -378,6 +362,8 @@ class _Parser(object):
arg = AssignNode(arg.name, ConstantNode(''))
new_args.append(arg)
return ArgumentsNode(new_args)
+ if id_ in self.func_names and not self.funcs[id_].is_python:
+ return self.call_expression(id_, arguments)
cls = self.funcs[id_]
if cls.arg_count != -1 and len(arguments) != cls.arg_count:
self.error(_('Incorrect number of arguments for function {0}').format(id_))
@@ -394,12 +380,14 @@ class _Interpreter(object):
m = 'Interpreter: ' + message
raise ValueError(m)
- def program(self, funcs, parent, prog, val):
+ def program(self, funcs, parent, prog, val, is_call=False, args=None):
self.parent = parent
self.parent_kwargs = parent.kwargs
self.parent_book = parent.book
self.funcs = funcs
self.locals = {'$':val}
+ if is_call:
+ return self.do_node_call(CallNode(prog, None), args=args)
return self.expression_list(prog)
def expression_list(self, prog):
@@ -472,11 +460,12 @@ 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))
+ def do_node_call(self, prog, args=None):
+ if args is None:
+ 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):
@@ -620,7 +609,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|call)\b',lambda x,t: (_Parser.LEX_KEYWORD, t)), # noqa
+ (r'(if|then|else|fi)\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
@@ -632,9 +621,6 @@ class TemplateFormatter(string.Formatter):
], flags=re.DOTALL)
def _eval_program(self, val, prog, column_name):
- # keep a cache of the lex'ed program under the theory that re-lexing
- # is much more expensive than the cache lookup. This is certainly true
- # for more than a few tokens, but it isn't clear for simple programs.
if column_name is not None and self.template_cache is not None:
tree = self.template_cache.get(column_name, None)
if not tree:
@@ -644,6 +630,15 @@ class TemplateFormatter(string.Formatter):
tree = self.gpm_parser.program(self, self.funcs, self.lex_scanner.scan(prog))
return self.gpm_interpreter.program(self.funcs, self, tree, val)
+ def _eval_sfm_call(self, template_name, args):
+ func = self.funcs[template_name]
+ tree = func.cached_parse_tree
+ if tree is None:
+ tree = self.gpm_parser.program(self, self.funcs,
+ self.lex_scanner.scan(func.program_text[len('program:'):]))
+ func.cached_parse_tree = tree
+ return self.gpm_interpreter.program(self.funcs, self, tree, None,
+ is_call=True, args=args)
# ################# Override parent classes methods #####################
def get_value(self, key, args, kwargs):
@@ -697,17 +692,22 @@ class TemplateFormatter(string.Formatter):
else:
args = self.arg_parser.scan(fmt[p+1:])[0]
args = [self.backslash_comma_to_comma.sub(',', a) for a in args]
- if (func.arg_count == 1 and (len(args) != 1 or args[0])) or \
- (func.arg_count > 1 and func.arg_count != len(args)+1):
- raise ValueError('Incorrect number of expression_list for function '+ fmt[0:p])
- if func.arg_count == 1:
- val = func.eval_(self, self.kwargs, self.book, self.locals, val)
- if self.strip_results:
- val = val.strip()
+ if not func.is_python:
+ args.insert(0, val)
+ val = self._eval_sfm_call(fname, args)
else:
- val = func.eval_(self, self.kwargs, self.book, self.locals, val, *args)
- if self.strip_results:
- val = val.strip()
+ if (func.arg_count == 1 and (len(args) != 1 or args[0])) or \
+ (func.arg_count > 1 and func.arg_count != len(args)+1):
+ raise ValueError(
+ _('Incorrect number of arguments for function {0}').format(fmt[0:p]))
+ if func.arg_count == 1:
+ val = func.eval_(self, self.kwargs, self.book, self.locals, val)
+ if self.strip_results:
+ val = val.strip()
+ else:
+ val = func.eval_(self, self.kwargs, self.book, self.locals, val, *args)
+ if self.strip_results:
+ val = val.strip()
else:
return _('%s: unknown function')%fname
if val:
diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py
index 392e52328f..d6992a78d8 100644
--- a/src/calibre/utils/formatter_functions.py
+++ b/src/calibre/utils/formatter_functions.py
@@ -1820,6 +1820,7 @@ class FormatterUserFunction(FormatterFunction):
self.doc = doc
self.arg_count = arg_count
self.program_text = program_text
+ self.cached_parse_tree = None
def to_pref(self):
return [self.name, self.doc, self.arg_count, self.program_text]
From 1c56c41b8019d9c3a0b31181b7f6c10bfd4fcc3a Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Fri, 2 Oct 2020 08:11:51 +0100
Subject: [PATCH 16/16] Fix the manual
---
manual/template_lang.rst | 25 ++++++++++++-------------
1 file changed, 12 insertions(+), 13 deletions(-)
diff --git a/manual/template_lang.rst b/manual/template_lang.rst
index b2f20dc26a..3a940231b0 100644
--- a/manual/template_lang.rst
+++ b/manual/template_lang.rst
@@ -191,12 +191,11 @@ The syntax of the language is shown by the following grammar. For a discussion o
program ::= expression_list
expression_list ::= expression [ ';' expression ]*
- expression ::= identifier | constant | function | assignment | compare | if_expression | template_call
+ expression ::= identifier | constant | function | assignment | compare | if_expression
function ::= identifier '(' expression [ ',' expression ]* ')'
compare ::= expression compare_op expression
compare_op ::= '==' | '!=' | '>=' | '>' | '<=' | '<' | '==#' | '!=#' | '>=#' | '>#' | '<=#' | '<#'
if_expression ::= 'if' expression 'then' expression_list ['else' statement] 'fi'
- template_call ::= 'call' identifier '(' [expression [,]]* ')'
assignment ::= identifier '=' expression
constant ::= " string " | ' string ' | number
identifier ::= sequence of letters or ``_`` characters
@@ -406,24 +405,24 @@ Program mode also supports the classic relational (comparison) operators: ``==``
General Program Mode support saving General Program Mode templates and calling those templates from another template. You save
templates using :guilabel:`Preferences->Advanced->Template functions`. More information is provided in that dialog. You call
-a template using the ``call`` keyword, passing positional arguments if desired. An argument can be any expression.
-Examples of ``call``, assuming the stored template is named ``foo``:
+a template the same way you call a function, passing positional arguments if desired. An argument can be any expression.
+Examples of calling a template, assuming the stored template is named ``foo``:
- * ``call foo()`` -- call the template passing no arguments.
- * ``call foo(a, b)`` call the template passing the values of the two variables ``a`` and ``b``.
- * ``call foo(if field('series') then field('series_index') else 0 fi)`` -- if the book has a ``series`` then pass the ``series_index``, otherwise pass the value ``0``.
+ * ``foo()`` -- call the template passing no arguments.
+ * ``foo(a, b)`` call the template passing the values of the two variables ``a`` and ``b``.
+ * ``foo(if field('series') then field('series_index') else 0 fi)`` -- if the book has a ``series`` then pass the ``series_index``, otherwise pass the value ``0``.
-In the stored template you retrieve the arguments passed in ``call`` using the ``arguments`` function. It both declares and
-initializes local variables. The variables are positional; they get the value of the value given to ``call`` in the same position.
-If the corresponding parameter is not provided in the ``call`` then ``arguments`` gives that parameter the provided default value. If there is no default value then the argument is set to the empty string. For example, the following ``arguments`` function declares 2 variables, ``key``, ``alternate``::
+In the stored template you retrieve the arguments passed in the call using the ``arguments`` function. It both declares and
+initializes local variables. The variables are positional; they get the value of the value given in the call in the same position.
+If the corresponding parameter is not provided in the call then ``arguments`` gives that parameter the provided default value. If there is no default value then the argument is set to the empty string. For example, the following ``arguments`` function declares 2 variables, ``key``, ``alternate``::
``arguments(key, alternate='series')
Examples, again assuming the stored template is named ``foo``:
- * ``call foo('#myseries')`` -- argument``key`` will have the value ``myseries`` and the argument ``alternate`` will have the value ``series``.
- * ``call foo('series', '#genre')`` the variable ``key`` is assigned the value ``series`` and the variable ``alternate`` is assigned the value ``#genre``.
- * ``call foo()`` -- the variable ``key`` is assigned the empty string and the variable ``alternate`` is assigned the value ``#genre``.
+ * ``foo('#myseries')`` -- argument``key`` will have the value ``myseries`` and the argument ``alternate`` will have the value ``series``.
+ * ``foo('series', '#genre')`` the variable ``key`` is assigned the value ``series`` and the variable ``alternate`` is assigned the value ``#genre``.
+ * ``foo()`` -- the variable ``key`` is assigned the empty string and the variable ``alternate`` is assigned the value ``#genre``.
An easy way to test stored templates is using the ``Template tester`` dialog. Give it a keyboard shortcut in
:guilabel:`Preferences->Advanced->Keyboard shortcuts->Template tester`. Giving the ``Stored templates`` dialog a