Several related changes:

* Add a "with" statement to the template language that for the duration of the code block changes the "current book" to the one specified by the book id.
* A new formatter function selected_books() that returns the book ids of the currently selected books
* A new formatter function selected_column() that returns the lookup name of the column containing the selected cell.
* A new formatter function sort_book_ids() that sorts the books specified by book_ids.
* A new formatter function show_dialog() that opens a dialog to display plain text or html.
* Add check boxes to the template tester to control "run as you type" and to restrict test runs to the first selected book.

Here is an example using several of the new features:

program:
 ids = sort_book_ids(selected_books(), 'series', 1, 'title', 1);
 res = '<style> th, td {padding: 2px;}</style> <h2>Book Size Report</h2><p><table>';
 total = 0;

 def table_row(title, series, size):
  return strcat('<tr><td>', title, '</td>',
       '<td>', series, '</td>',
       '<td>', if size !=# 0 then human_readable(size) else '0' fi, '</td>',
       '</tr>', character('newline'))
 fed;

 for id in ids:
  with id:
   s = booksize();
   total = total + s;
   res = strcat(res, table_row($title, $series, s))
  htiw
 rof;
 res = strcat(res, table_row('TOTAL', '', total));
 res = strcat(res, '</table>');
 show_dialog(res)
This commit is contained in:
Charles Haley 2025-09-14 14:26:35 +01:00
parent 91216de5f3
commit ed83d9eeb8
5 changed files with 271 additions and 41 deletions

View File

@ -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

View File

@ -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()

View File

@ -206,14 +206,13 @@
</widget>
</item>
<item>
<widget class="QCheckBox" name="break_box">
<widget class="QCheckBox" name="run_as_you_type_box">
<property name="text">
<string>Enable &amp;breakpoints</string>
<string>R&amp;un as you type</string>
</property>
<property name="toolTip">
<string>&lt;p&gt;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&lt;/p&gt;</string>
<string>&lt;p&gt;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.&lt;/p&gt;</string>
</property>
</widget>
</item>
@ -237,7 +236,7 @@ you the value as well as all the local variables&lt;/p&gt;</string>
<set>Qt::ToolButtonTextBesideIcon</set>
</property>
<property name="toolTip">
<string>If 'Enable breakpoints' is checked then click this button to run your template</string>
<string>If 'Run as you type' is not checked then click this button to run your template</string>
</property>
</widget>
</item>
@ -248,6 +247,18 @@ you the value as well as all the local variables&lt;/p&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="break_box">
<property name="text">
<string>Enable &amp;breakpoints</string>
</property>
<property name="toolTip">
<string>&lt;p&gt;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&lt;/p&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="breakpoint_line_box_label">
<property name="text">
@ -378,17 +389,34 @@ you the value as well as all the local variables&lt;/p&gt;</string>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel">
<property name="text">
<string>Template value:</string>
</property>
<property name="buddy">
<cstring>template_value</cstring>
</property>
<property name="toolTip">
<string>The value of the template using the current book in the library view</string>
</property>
</widget>
<layout class="QHBoxLayout">
<item>
<widget class="QLabel">
<property name="text">
<string>Template value:</string>
</property>
<property name="buddy">
<cstring>template_value</cstring>
</property>
<property name="toolTip">
<string>The value of the template using the currently selected book(s)
in the library view</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="show_all_selected_books">
<property name="text">
<string>Show template result for all selected books</string>
</property>
<property name="toolTip">
<string>&lt;p&gt;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.&lt;/p&gt;</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="8" column="0" colspan="3">
<widget class="QTableWidget" name="template_value">

View File

@ -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)),

View File

@ -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(),