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