diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 8388563533..d9096caeec 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -488,6 +488,8 @@ def create_defs():
defs['book_details_note_link_icon_width'] = 1.0
defs['tag_browser_show_category_icons'] = True
defs['tag_browser_show_value_icons'] = True
+ defs['template_editor_run_as_you_type'] = True
+ defs['template_editor_show_all_selected_books'] = True
def migrate_tweak(tweak_name, pref_name):
# If the tweak has been changed then leave the tweak in the file so
diff --git a/src/calibre/gui2/dialogs/template_dialog.py b/src/calibre/gui2/dialogs/template_dialog.py
index f57320bdbd..7a9ce8d11f 100644
--- a/src/calibre/gui2/dialogs/template_dialog.py
+++ b/src/calibre/gui2/dialogs/template_dialog.py
@@ -212,7 +212,7 @@ class TemplateHighlighter(QSyntaxHighlighter):
KEYWORDS_GPM = ['if', 'then', 'else', 'elif', 'fi', 'for', 'rof',
'separator', 'break', 'continue', 'return', 'in', 'inlist',
- 'inlist_field', 'def', 'fed', 'limit']
+ 'inlist_field', 'def', 'fed', 'limit', 'with', 'htiw']
KEYWORDS_PYTHON = ['and', 'as', 'assert', 'break', 'class', 'continue', 'def',
'del', 'elif', 'else', 'except', 'exec', 'finally', 'for', 'from',
@@ -581,8 +581,11 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
formatter_functions().get_builtins_and_aliases())
# Set up the breakpoint bar
- s = gprefs.get('template_editor_break_on_print', False)
- self.go_button.setEnabled(s)
+ run_as_you_type = gprefs.get('template_editor_run_as_you_type')
+ self.run_as_you_type_box.setChecked(run_as_you_type)
+ self.go_button.setEnabled(not run_as_you_type)
+ self.break_box.setEnabled(not run_as_you_type)
+ s = gprefs.get('template_editor_enable_breakpoints', False)
self.remove_all_button.setEnabled(s)
self.set_all_button.setEnabled(s)
self.toggle_button.setEnabled(s)
@@ -591,6 +594,10 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.break_box.setChecked(s)
self.break_box.stateChanged.connect(self.break_box_changed)
self.go_button.clicked.connect(self.go_button_pressed)
+ self.show_all_selected_books.clicked.connect(self.show_all_selected_books_changed)
+ self.run_as_you_type_box.stateChanged.connect(self.run_as_you_type_box_changed)
+ self.show_all_selected_books.setChecked(gprefs.get('template_editor_show_all_selected_books'))
+ self.show_all_selected_books.clicked.connect(self.show_all_selected_books_changed)
# Set up the display table
self.table_column_widths = None
@@ -725,14 +732,20 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
else:
mi = (get_model_metadata_instance(), )
self.mi = mi
+ self.setup_result_display_table()
+
+ def setup_result_display_table(self):
tv = self.template_value
+ mi = self.mi
+ row_count = len(mi) if gprefs.get('template_editor_show_all_selected_books') else 1
+ tv.clear()
tv.setColumnCount(3)
tv.setHorizontalHeaderLabels((_('Book title'), '', _('Template value')))
tv.horizontalHeader().setStretchLastSection(True)
tv.horizontalHeader().sectionResized.connect(self.table_column_resized)
- tv.setRowCount(len(mi))
+ tv.setRowCount(len(mi) )
# Set the height of the table
- h = tv.rowHeight(0) * min(len(mi), 5)
+ h = tv.rowHeight(0) * min(row_count, 5)
h += 2 * tv.frameWidth() + tv.horizontalHeader().height()
tv.setMinimumHeight(h)
tv.setMaximumHeight(h)
@@ -742,9 +755,9 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
else:
tv.setColumnWidth(0, tv.fontMetrics().averageCharWidth() * 10)
tv.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
- tv.setRowCount(len(mi))
+ tv.setRowCount(row_count)
# Use our own widget to get rid of elision. setTextElideMode() doesn't work
- for r in range(len(mi)):
+ for r in range(row_count):
w = QLineEdit(tv)
w.setReadOnly(True)
w.setText(mi[r].get('title', _('No title provided')))
@@ -786,15 +799,15 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
pmi = None
new_mi.append(pmi)
self.set_mi(new_mi, self.fm)
- if not self.break_box.isChecked():
+ if not self.run_as_you_type_box.isChecked():
self.display_values(str(self.textbox.toPlainText()))
def set_waiting_message(self):
- if self.break_box.isChecked():
- for i in range(len(self.mi)):
+ if not self.run_as_you_type_box.isChecked():
+ for i in range(self.template_value.rowCount()):
self.template_value.cellWidget(i, 2).setText('')
self.template_value.cellWidget(0, 2).setText(
- _("*** Breakpoints are enabled. Waiting for the 'Go' button to be pressed"))
+ _("*** Waiting for the 'Go' button to be pressed"))
def show_code_context_menu(self, point):
m = self.source_code.createStandardContextMenu()
@@ -930,15 +943,40 @@ def evaluate(book, context):
gprefs['gpm_template_editor_font_size'] = toWhat
self.set_editor_font()
+ def run_as_you_type_box_changed(self, new_state):
+ gprefs['template_editor_run_as_you_type'] = new_state != 0
+ self.go_button.setEnabled(new_state == 0)
+ if new_state == 0:
+ self.set_waiting_message()
+ self.break_box.setEnabled(True)
+ enable_break_boxes = self.break_box.isChecked()
+ else:
+ self.break_box.setEnabled(False)
+ enable_break_boxes = False
+ self.display_values(str(self.textbox.toPlainText()))
+
+ self.remove_all_button.setEnabled(enable_break_boxes)
+ self.set_all_button.setEnabled(enable_break_boxes)
+ self.toggle_button.setEnabled(enable_break_boxes)
+ self.breakpoint_line_box.setEnabled(enable_break_boxes)
+ self.breakpoint_line_box_label.setEnabled(enable_break_boxes)
+
def break_box_changed(self, new_state):
- gprefs['template_editor_break_on_print'] = new_state != 0
- self.go_button.setEnabled(new_state != 0)
+ gprefs['template_editor_enable_breakpoints'] = new_state != 0
self.remove_all_button.setEnabled(new_state != 0)
self.set_all_button.setEnabled(new_state != 0)
self.toggle_button.setEnabled(new_state != 0)
self.breakpoint_line_box.setEnabled(new_state != 0)
self.breakpoint_line_box_label.setEnabled(new_state != 0)
- if new_state == 0:
+ if gprefs['template_editor_run_as_you_type']:
+ self.display_values(str(self.textbox.toPlainText()))
+ else:
+ self.set_waiting_message()
+
+ def show_all_selected_books_changed(self, new_state):
+ gprefs['template_editor_show_all_selected_books'] = new_state != 0
+ self.setup_result_display_table()
+ if gprefs['template_editor_run_as_you_type']:
self.display_values(str(self.textbox.toPlainText()))
else:
self.set_waiting_message()
@@ -1042,7 +1080,7 @@ def evaluate(book, context):
self.last_text = cur_text
self.highlighter.regenerate_paren_positions()
self.text_cursor_changed()
- if not self.break_box.isChecked():
+ if self.run_as_you_type_box.isChecked():
self.display_values(cur_text)
else:
self.set_waiting_message()
@@ -1095,6 +1133,8 @@ def evaluate(book, context):
w.setCursorPosition(0)
finally:
sys.settrace(None)
+ if not gprefs.get('template_editor_show_all_selected_books', True):
+ break
def text_cursor_changed(self):
cursor = self.textbox.textCursor()
diff --git a/src/calibre/gui2/dialogs/template_dialog.ui b/src/calibre/gui2/dialogs/template_dialog.ui
index 7c42f73629..b3b346cb55 100644
--- a/src/calibre/gui2/dialogs/template_dialog.ui
+++ b/src/calibre/gui2/dialogs/template_dialog.ui
@@ -206,14 +206,13 @@
-
-
+
- Enable &breakpoints
+ R&un as you type
- <p>If checked, the template evaluator will stop when it
-evaluates an expression on a double-clicked line number, opening a dialog showing
-you the value as well as all the local variables</p>
+ <p>If checked then the template will be run (tested) after every
+keystroke. If unchecked then the template will be run when the "Go" button is pushed.</p>
@@ -237,7 +236,7 @@ you the value as well as all the local variables</p>
Qt::ToolButtonTextBesideIcon
- If 'Enable breakpoints' is checked then click this button to run your template
+ If 'Run as you type' is not checked then click this button to run your template
@@ -248,6 +247,18 @@ you the value as well as all the local variables</p>
+ -
+
+
+ Enable &breakpoints
+
+
+ <p>If checked, the template evaluator will stop when it
+evaluates an expression on a double-clicked line number, opening a dialog showing
+you the value as well as all the local variables</p>
+
+
+
-
@@ -378,17 +389,34 @@ you the value as well as all the local variables</p>
-
-
-
- Template value:
-
-
- template_value
-
-
- The value of the template using the current book in the library view
-
-
+
+
-
+
+
+ Template value:
+
+
+ template_value
+
+
+ The value of the template using the currently selected book(s)
+in the library view
+
+
+
+ -
+
+
+ Show template result for all selected books
+
+
+ <p>If checked then the template will be evaluated for all selected
+books. If not checked then the template will be evaluated for the first selected book. Unchecking this
+box is useful if the template uses the selected books to generate a report.</p>
+
+
+
+
-
diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py
index 8d9874f17b..ee7e1f572b 100644
--- a/src/calibre/utils/formatter.py
+++ b/src/calibre/utils/formatter.py
@@ -60,6 +60,7 @@ class Node:
NODE_SWITCH = 31
NODE_SWITCH_IF = 32
NODE_LIST_COUNT_FIELD = 33
+ NODE_WITH = 34
def __init__(self, line_number, name):
self.my_line_number = line_number
@@ -74,6 +75,14 @@ class Node:
return self.my_line_number
+class WithNode(Node):
+ def __init__(self, line_number, book_id, block):
+ Node.__init__(self, line_number, 'if ...')
+ self.node_type = self.NODE_WITH
+ self.book_id = book_id
+ self.block = block
+
+
class IfNode(Node):
def __init__(self, line_number, condition, then_part, else_part):
Node.__init__(self, line_number, 'if ...')
@@ -600,6 +609,20 @@ class _Parser:
def local_call_expression(self, name, arguments):
return LocalFunctionCallNode(self.line_number, name, arguments)
+ def with_expression(self):
+ self.consume()
+ line_number = self.line_number
+ book_id = self.top_expr()
+ if not self.token_op_is(':'):
+ self.error(_("{0} statement: expected '{1}', "
+ "found '{2}'").format('with', ':', self.token_text()))
+ self.consume()
+ block = self.expression_list()
+ if not self.token_is('htiw'):
+ self.error(_("'{0}' statement: missing the closing '{1}'").format('def', 'fed'))
+ self.consume()
+ return WithNode(line_number, book_id, block)
+
def call_expression(self, name, arguments):
compiled_func = self.funcs[name].cached_compiled_text
if compiled_func is None:
@@ -692,6 +715,7 @@ class _Parser:
'continue': (lambda self: self.consume(), lambda self: ContinueNode(self.line_number)),
'return': (lambda self: self.consume(), lambda self: ReturnNode(self.line_number, self.top_expr())),
'def': (lambda self: None, define_function_expression),
+ 'with': (lambda self: None, with_expression)
}
# {inlined_function_name: tuple(constraint on number of length, node builder) }
@@ -1017,6 +1041,27 @@ class _Interpreter:
raise e
return val
+ def do_node_with(self, prog):
+ line_number = prog.line_number
+ parent_book = self.parent_book
+ v = None
+ try:
+ book_id = int(self.expr(prog.book_id))
+ if self.break_reporter:
+ self.break_reporter("'with': book id ", str(book_id), line_number)
+ self.parent_book = self.parent.book = get_database(
+ self.parent_book, 'with statement').new_api.get_proxy_metadata(book_id)
+ v = self.expression_list(prog.block)
+ if self.break_reporter:
+ self.break_reporter("'with': block value", v, line_number)
+ return v
+ except (StopException, ValueError, ReturnExecuted) as e:
+ raise e
+ except Exception as e:
+ self.error(_("Unhandled exception '{0}'").format(e), line_number)
+ finally:
+ self.parent_book = self.parent.book = parent_book
+
def do_node_if(self, prog):
line_number = prog.line_number
test_part = self.expr(prog.condition)
@@ -1608,7 +1653,8 @@ class _Interpreter:
Node.NODE_LOCAL_FUNCTION_DEFINE: do_node_local_function_define,
Node.NODE_LOCAL_FUNCTION_CALL: do_node_local_function_call,
Node.NODE_LIST_COUNT_FIELD: do_node_list_count_field,
- }
+ Node.NODE_WITH: do_node_with,
+ }
def expr(self, prog):
try:
@@ -1707,6 +1753,7 @@ class TemplateFormatter(string.Formatter):
(r'(def|fed|continue)\b', lambda x,t: (_Parser.LEX_KEYWORD, t)),
(r'(return|inlist|break)\b', lambda x,t: (_Parser.LEX_KEYWORD, t)),
(r'(inlist_field)\b', lambda x,t: (_Parser.LEX_KEYWORD, t)),
+ (r'(with|htiw)\b', lambda x,t: (_Parser.LEX_KEYWORD, t)),
(r'(\|\||&&|!|{|})', lambda x,t: (_Parser.LEX_OP, t)),
(r'[(),=;:\+\-*/&]', lambda x,t: (_Parser.LEX_OP, t)),
(r'-?[\d\.]+', lambda x,t: (_Parser.LEX_CONST, t)),
diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py
index 25914aba4b..f6c2ce5259 100644
--- a/src/calibre/utils/formatter_functions.py
+++ b/src/calibre/utils/formatter_functions.py
@@ -55,6 +55,7 @@ CASE_CHANGES = _('Case changes')
DATE_FUNCTIONS = _('Date functions')
DB_FUNCS = _('Database functions')
URL_FUNCTIONS = _('URL functions')
+GUI_FUNCTIONS = __('GUI functions')
# Class and method to save an untranslated copy of translated strings
@@ -1249,7 +1250,7 @@ string.
class BuiltinApproximateFormats(BuiltinFormatterFunction):
name = 'approximate_formats'
arg_count = 0
- category = GET_FROM_METADATA
+ category = DB_FUNCS
def __doc__getter__(self): return translate_ffml(
r'''
``approximate_formats()`` -- return a comma-separated list of formats associated
@@ -1278,7 +1279,7 @@ column's value in your save/send templates.
class BuiltinFormatsModtimes(BuiltinFormatterFunction):
name = 'formats_modtimes'
arg_count = 1
- category = GET_FROM_METADATA
+ category = DB_FUNCS
def __doc__getter__(self): return translate_ffml(
r'''
``formats_modtimes(date_format_string)`` -- return a comma-separated list of
@@ -1302,7 +1303,7 @@ that format names are always uppercase, as in EPUB.
class BuiltinFormatsSizes(BuiltinFormatterFunction):
name = 'formats_sizes'
arg_count = 0
- category = GET_FROM_METADATA
+ category = DB_FUNCS
def __doc__getter__(self): return translate_ffml(
r'''
@@ -1323,7 +1324,7 @@ format names are always uppercase, as in EPUB.
class BuiltinFormatsPaths(BuiltinFormatterFunction):
name = 'formats_paths'
arg_count = -1
- category = GET_FROM_METADATA
+ category = DB_FUNCS
def __doc__getter__(self): return translate_ffml(
r'''
``formats_paths([separator])`` -- return a ``separator``-separated list of
@@ -1345,7 +1346,7 @@ format names are always uppercase, as in EPUB.
class BuiltinFormatsPathSegments(BuiltinFormatterFunction):
name = 'formats_path_segments'
arg_count = 5
- category = GET_FROM_METADATA
+ category = DB_FUNCS
def __doc__getter__(self): return translate_ffml(
r'''
``formats_path_segments(with_author, with_title, with_format, with_ext, sep)``
@@ -1775,7 +1776,7 @@ template, and use that column\'s value in your save/send templates.
class BuiltinAnnotationCount(BuiltinFormatterFunction):
name = 'annotation_count'
arg_count = 0
- category = GET_FROM_METADATA
+ category = DB_FUNCS
def __doc__getter__(self): return translate_ffml(
r'''
``annotation_count()`` -- return the total number of annotations of all types
@@ -3649,6 +3650,116 @@ This can be useful to truncate a value.
return pat.sub(repl, template)
+class BuiltinSelectedBooks(BuiltinFormatterFunction):
+ name = 'selected_books'
+ arg_count = 0
+ category = GUI_FUNCTIONS
+ def __doc__getter__(self): return translate_ffml(
+r'''
+``selected_books([sorted_by, ascending])`` -- returns a list of book ids in
+selection order for the currently selected books.
+
+This function can be used only in the GUI.
+''')
+
+ def evaluate(self, formatter, kwargs, mi, locals, *args):
+ from calibre.gui2.ui import get_gui
+ g = get_gui()
+ book_ids = g.current_view().get_selected_ids()
+ return ', '.join([str(book_id) for book_id in book_ids])
+
+
+class BuiltinSortBookIds(BuiltinFormatterFunction):
+ name = 'sort_book_ids'
+ arg_count = -1
+ category = GUI_FUNCTIONS
+ def __doc__getter__(self): return translate_ffml(
+r'''
+``sort_book_ids(book_ids, sorted_by, ascending [, sorted_by, ascending]*)`` --
+returns the list of book ids sorted by the column specified by the lookup name
+in ``sorted_by`` in the order specified by ``ascending``. If ``ascending`` is
+``'1'`` then the books are sorted by the value in the 'sorted_by' column in
+ascending order, otherwise in descending order. You can have multiple pairs of
+``sorted_by, ascending``. The first pair specifies the major order.
+
+This function can be used only in the GUI.
+''')
+
+ def evaluate(self, formatter, kwargs, mi, locals, book_ids, *args):
+ from calibre.gui2.ui import get_gui
+ g = get_gui()
+ bids = [int(b.strip()) for b in book_ids.split(',')]
+ if len(args) < 2:
+ raise ValueError(_('The sort_book_ids function requires at least 3 arguments'))
+ if len(args) % 2 != 0:
+ raise ValueError(_('The id and direction arguments must be in pairs'))
+ sort_spec = []
+ for i in range(0, len(args), 2):
+ sort_by = args[i]
+ asc = True if args[i+1] == '1' else False
+ sort_spec.append((sort_by, asc))
+ bids = g.current_db.new_api.multisort(sort_spec, bids)
+ return ', '.join([str(b) for b in bids])
+
+
+class BuiltinSelectedColumn(BuiltinFormatterFunction):
+ name = 'selected_column'
+ arg_count = 0
+ category = GUI_FUNCTIONS
+ def __doc__getter__(self): return translate_ffml(
+r'''
+``selected_column()`` -- returns the lookup name of the column containing the currently
+selected cell. It returns ``''`` if no cell is selected.
+
+This function can be used only in the GUI.
+''')
+
+ def evaluate(self, formatter, kwargs, mi, locals):
+ from calibre.gui2.ui import get_gui
+ v = get_gui().current_view()
+ idx = v.currentIndex()
+ if idx.isValid():
+ key = v.column_map[idx.column()]
+ return key
+ return ''
+
+
+class BuiltinShowDialog(BuiltinFormatterFunction):
+ name = 'show_dialog'
+ arg_count = 1
+ category = GUI_FUNCTIONS
+ def __doc__getter__(self): return translate_ffml(
+r'''
+``show_dialog(html_or_text)`` -- show a dialog containing the html or text. The
+function returns ``'1'`` if the user presses OK, ``''`` if Cancel.
+
+This function can be used only in the GUI.
+''')
+
+ def evaluate(self, formatter, kwargs, mi, locals, html):
+ from calibre.gui2.widgets2 import Dialog, HTMLDisplay
+ from qt.core import QDialog, QVBoxLayout
+
+ class HTMLDialog(Dialog):
+
+ def __init__(self, title, prefs):
+ super().__init__(title, 'formatter_html_dialog', prefs=prefs)
+
+ def setup_ui(self):
+ l = QVBoxLayout(self)
+ d = self.display = HTMLDisplay()
+ l.addWidget(d)
+ l.addWidget(self.bb)
+
+ def set_html(self, tt_text):
+ self.display.setHtml(tt_text)
+
+ db = get_database(mi, 'show_dialog')
+ d = HTMLDialog(_('Template output'), db.new_api.backend.prefs)
+ d.set_html(html)
+ return '1' if d.exec() == QDialog.DialogCode.Accepted else ''
+
+
_formatter_builtins = [
BuiltinAdd(), BuiltinAnd(), BuiltinApproximateFormats(), BuiltinArguments(),
BuiltinAssign(),
@@ -3677,8 +3788,10 @@ _formatter_builtins = [
BuiltinMultiply(), BuiltinNot(), BuiltinOndevice(),
BuiltinOr(), BuiltinPrint(), BuiltinQueryString(), BuiltinRatingToStars(),
BuiltinRange(), BuiltinRawField(), BuiltinRawList(),
- BuiltinRe(), BuiltinReGroup(), BuiltinRound(), BuiltinSelect(), BuiltinSeriesSort(),
- BuiltinSetGlobals(), BuiltinShorten(), BuiltinStrcat(), BuiltinStrcatMax(),
+ BuiltinRe(), BuiltinReGroup(), BuiltinRound(), BuiltinSelect(),
+ BuiltinSelectedBooks(), BuiltinSelectedColumn(), BuiltinSeriesSort(),
+ BuiltinSetGlobals(), BuiltinShorten(), BuiltinShowDialog(), BuiltinSortBookIds(),
+ BuiltinStrcat(), BuiltinStrcatMax(),
BuiltinStrcmp(), BuiltinStrcmpcase(), BuiltinStrInList(), BuiltinStrlen(), BuiltinSubitems(),
BuiltinSublist(),BuiltinSubstr(), BuiltinSubtract(), BuiltinSwapAroundArticles(),
BuiltinSwapAroundComma(), BuiltinSwitch(), BuiltinSwitchIf(),