From ed83d9eeb8b7a6818bef93f95950cb0b39b79dcb Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Sun, 14 Sep 2025 14:26:35 +0100 Subject: [PATCH] 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 = '

Book Size Report

'; total = 0; def table_row(title, series, size): return strcat('', '', '', '', 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, '
', title, '', series, '', if size !=# 0 then human_readable(size) else '0' fi, '
'); show_dialog(res) --- src/calibre/gui2/__init__.py | 2 + src/calibre/gui2/dialogs/template_dialog.py | 70 ++++++++--- src/calibre/gui2/dialogs/template_dialog.ui | 62 +++++++--- src/calibre/utils/formatter.py | 49 +++++++- src/calibre/utils/formatter_functions.py | 129 ++++++++++++++++++-- 5 files changed, 271 insertions(+), 41 deletions(-) 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(),