calibre/src/calibre/gui2/dialogs/template_dialog.py

1366 lines
56 KiB
Python

#!/usr/bin/env python
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__license__ = 'GPL v3'
import json
import os
import re
import sys
import traceback
from functools import partial
from qt.core import (
QAbstractItemView,
QApplication,
QCheckBox,
QColor,
QComboBox,
QCursor,
QDialog,
QDialogButtonBox,
QFont,
QFontDatabase,
QFontInfo,
QFontMetrics,
QHBoxLayout,
QIcon,
QLineEdit,
QPalette,
QSize,
QSyntaxHighlighter,
Qt,
QTableWidget,
QTableWidgetItem,
QTextCharFormat,
QTextOption,
QTimer,
QToolButton,
QVBoxLayout,
pyqtSignal,
)
from calibre import sanitize_file_name
from calibre.constants import config_dir, iswindows
from calibre.ebooks.metadata.book.base import Metadata
from calibre.ebooks.metadata.book.formatter import SafeFormat
from calibre.gui2 import choose_files, choose_save_file, error_dialog, gprefs, info_dialog, pixmap_to_data, question_dialog, safe_open_url
from calibre.gui2.dialogs.template_dialog_ui import Ui_TemplateDialog
from calibre.gui2.dialogs.template_general_info import GeneralInformationDialog
from calibre.gui2.widgets2 import Dialog, HTMLDisplay
from calibre.library.coloring import color_row_key, displayable_columns
from calibre.utils.config_base import tweaks
from calibre.utils.date import DEFAULT_DATE
from calibre.utils.ffml_processor import FFMLProcessor
from calibre.utils.formatter import PythonTemplateContext, StopException
from calibre.utils.formatter_functions import StoredObjectType, formatter_functions
from calibre.utils.icu import lower as icu_lower
from calibre.utils.icu import sort_key
from calibre.utils.localization import localize_user_manual_link, ngettext
from calibre.utils.resources import get_path as P
class DocViewer(Dialog):
def __init__(self, ffml, builtins, function_type_string_method, parent=None):
self.ffml = ffml
self.builtins = builtins
self.function_type_string = function_type_string_method
self.last_operation = None
self.last_function = None
self.back_stack = []
super().__init__(title=_('Template function documentation'), name='template_editor_doc_viewer_dialog',
default_buttons=QDialogButtonBox.StandardButton.Close, parent=parent)
def sizeHint(self):
return QSize(800, 600)
def set_html(self, html):
self.doc_viewer_widget.setHtml(html)
def setup_ui(self):
l = QVBoxLayout(self)
e = self.doc_viewer_widget = HTMLDisplay(self)
if iswindows:
e.setDefaultStyleSheet('pre { font-family: "Segoe UI Mono", "Consolas", monospace; }')
e.anchor_clicked.connect(self.url_clicked)
l.addWidget(e)
bl = QHBoxLayout()
l.addLayout(bl)
self.english_cb = cb = QCheckBox(_('Show documentation as original &English'))
cb.setChecked(gprefs.get('template_editor_docs_in_english', False))
cb.stateChanged.connect(self.english_cb_state_changed)
bl.addWidget(cb)
bl.addWidget(self.bb)
b = self.back_button = self.bb.addButton(_('&Back'), QDialogButtonBox.ButtonRole.ActionRole)
b.clicked.connect(self.back)
b.setToolTip((_('Displays the previously viewed function')))
b.setEnabled(False)
b = self.bb.addButton(_('Show &all functions'), QDialogButtonBox.ButtonRole.ActionRole)
b.clicked.connect(self.show_all_functions_button_clicked)
b.setToolTip((_('Shows a list of all built-in functions in alphabetic order')))
def back(self):
if not self.back_stack:
info_dialog(self, _('Go back'), _('No function to go back to'), show=True)
else:
place = self.back_stack.pop()
self.back_button.setEnabled(bool(self.back_stack))
if isinstance(place, int):
self.show_all_functions()
# For reasons known only to Qt, I can't set the scroll bar position
# until some time has passed.
QTimer.singleShot(10, lambda: self.doc_viewer_widget.verticalScrollBar().setValue(place))
else:
self._show_function(place)
def add_to_back_stack(self):
if self.last_function is not None:
self.back_stack.append(self.last_function)
elif self.last_operation is not None:
self.back_stack.append(self.doc_viewer_widget.verticalScrollBar().value())
self.back_button.setEnabled(bool(self.back_stack))
def url_clicked(self, qurl):
if qurl.scheme().startswith('http'):
safe_open_url(qurl)
else:
self.show_function(qurl.path())
def english_cb_state_changed(self):
if self.last_operation is not None:
self.last_operation()
gprefs['template_editor_docs_in_english'] = self.english_cb.isChecked()
def header_line(self, name):
return f'\n<h3>{name} ({self.function_type_string(name, longform=False)})</h3>\n'
def get_doc(self, func):
doc = func.doc if hasattr(func, 'doc') else ''
return getattr(doc, 'formatted_english', doc) if self.english_cb.isChecked() else doc
def no_doc_string(self):
if self.english_cb.isChecked():
return 'No documentation provided'
return _('No documentation provided')
def show_function(self, fname):
if fname in self.builtins and fname != self.last_function:
self.add_to_back_stack()
self._show_function(fname)
def _show_function(self, fname):
self.last_operation = partial(self._show_function, fname)
bif = self.builtins[fname]
if fname not in self.builtins or not bif.doc:
self.set_html(self.header_line(fname) + self.no_doc_string())
else:
self.last_function = fname
self.set_html(self.header_line(fname) +
self.ffml.document_to_html(self.get_doc(bif), fname))
def show_all_functions_button_clicked(self):
self.add_to_back_stack()
self.show_all_functions()
def show_all_functions(self):
self.last_function = None
self.last_operation = self.show_all_functions
result = []
a = result.append
for name in sorted(self.builtins, key=sort_key):
a(self.header_line(name))
try:
doc = self.get_doc(self.builtins[name])
if not doc:
a(self.no_doc_string())
else:
html = self.ffml.document_to_html(doc.strip(), name)
name_pos = html.find(name + '(')
if name_pos < 0:
rest_of_doc = ' -- ' + html
else:
rest_of_doc = html[name_pos + len(name):]
html = f'<a href="ffdoc:{name}">{name}</a>{rest_of_doc}'
a(html)
except Exception:
print('Exception in', name)
raise
self.doc_viewer_widget.setHtml(''.join(result))
class ParenPosition:
def __init__(self, block, pos, paren):
self.block = block
self.pos = pos
self.paren = paren
self.highlight = False
def set_highlight(self, to_what):
self.highlight = to_what
class TemplateHighlighter(QSyntaxHighlighter):
# Code in this class is liberally borrowed from gui2.widgets.PythonHighlighter
BN_FACTOR = 1000
KEYWORDS_GPM = ['if', 'then', 'else', 'elif', 'fi', 'for', 'rof',
'separator', 'break', 'continue', 'return', 'in', 'inlist',
'inlist_field', 'def', 'fed', 'limit']
KEYWORDS_PYTHON = ["and", "as", "assert", "break", "class", "continue", "def",
"del", "elif", "else", "except", "exec", "finally", "for", "from",
"global", "if", "import", "in", "is", "lambda", "not", "or",
"pass", "print", "raise", "return", "try", "while", "with",
"yield"]
BUILTINS_PYTHON = ["abs", "all", "any", "basestring", "bool", "callable", "chr",
"classmethod", "cmp", "compile", "complex", "delattr", "dict",
"dir", "divmod", "enumerate", "eval", "execfile", "exit", "file",
"filter", "float", "frozenset", "getattr", "globals", "hasattr",
"hex", "id", "int", "isinstance", "issubclass", "iter", "len",
"list", "locals", "long", "map", "max", "min", "object", "oct",
"open", "ord", "pow", "property", "range", "reduce", "repr",
"reversed", "round", "set", "setattr", "slice", "sorted",
"staticmethod", "str", "sum", "super", "tuple", "type", "unichr",
"unicode", "vars", "xrange", "zip"]
CONSTANTS_PYTHON = ["False", "True", "None", "NotImplemented", "Ellipsis"]
def __init__(self, parent=None, builtin_functions=None):
super().__init__(parent)
self.initialize_formats()
self.initialize_rules(builtin_functions, for_python=False)
self.regenerate_paren_positions()
self.highlighted_paren = False
def initialize_rules(self, builtin_functions, for_python=False):
self.for_python = for_python
r = []
def a(a, b):
r.append((re.compile(a), b))
if not for_python:
a(r"\b[a-zA-Z]\w*\b(?!\(|\s+\()"
r"|\$+#?[a-zA-Z]\w*",
"identifier")
a(r"^program:", "keymode")
a("|".join([r"\b%s\b" % keyword for keyword in self.KEYWORDS_GPM]), "keyword")
a("|".join([r"\b%s\b" % builtin for builtin in
(builtin_functions if builtin_functions else
formatter_functions().get_builtins())]),
"builtin")
a(r"""(?<!:)'[^']*'|"[^"]*\"""", "string")
else:
a(r"^python:", "keymode")
a("|".join([r"\b%s\b" % keyword for keyword in self.KEYWORDS_PYTHON]), "keyword")
a("|".join([r"\b%s\b" % builtin for builtin in self.BUILTINS_PYTHON]), "builtin")
a("|".join([r"\b%s\b" % constant for constant in self.CONSTANTS_PYTHON]), "constant")
a(r"\bPyQt6\b|\bqt.core\b|\bQt?[A-Z][a-z]\w+\b", "pyqt")
a(r"@\w+(\.\w+)?\b", "decorator")
stringRe = r'''(["'])(?:(?!\1)[^\\]|\\.)*\1'''
a(stringRe, "string")
self.stringRe = re.compile(stringRe)
self.checkTripleInStringRe = re.compile(r"""((?:"|'){3}).*?\1""")
self.tripleSingleRe = re.compile(r"""'''(?!")""")
self.tripleDoubleRe = re.compile(r'''"""(?!')''')
a(
r"\b[+-]?[0-9]+[lL]?\b"
r"|\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b"
r"|\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b",
"number")
a(r'\(', "lparen")
a(r'\)', "rparen")
self.Rules = tuple(r)
def initialize_formats(self):
font_name = gprefs.get('gpm_template_editor_font', None)
size = gprefs['gpm_template_editor_font_size']
if font_name is None:
font = QFont()
font.setFixedPitch(True)
font.setPointSize(size)
font_name = font.family()
config = self.Config = {}
config["fontfamily"] = font_name
app_palette = QApplication.instance().palette()
is_dark = QApplication.instance().is_dark_theme
all_formats = (
# name, color, bold, italic
("normal", None, False, False),
("keyword", app_palette.color(QPalette.ColorRole.Link).name(), True, False),
("builtin", app_palette.color(QPalette.ColorRole.Link).name(), False, False),
("constant", app_palette.color(QPalette.ColorRole.Link).name(), False, False),
("identifier", None, False, True),
("comment", '#00c700' if is_dark else "#007F00", False, True),
("string", '#b6b600' if is_dark else "#808000", False, False),
("number", '#d96d00' if is_dark else "#924900", False, False),
("decorator", "#FF8000", False, True),
("pyqt", None, False, False),
("lparen", None, True, True),
("rparen", None, True, True))
for name, color, bold, italic in all_formats:
config["%sfontcolor" % name] = color
config["%sfontbold" % name] = bold
config["%sfontitalic" % name] = italic
base_format = QTextCharFormat()
base_format.setFontFamilies([config["fontfamily"]])
config["fontsize"] = size
base_format.setFontPointSize(config["fontsize"])
self.Formats = {}
for name, color, bold, italic in all_formats:
format_ = QTextCharFormat(base_format)
color = config["%sfontcolor" % name]
if color:
format_.setForeground(QColor(color))
if config["%sfontbold" % name]:
format_.setFontWeight(QFont.Weight.Bold)
format_.setFontItalic(config["%sfontitalic" % name])
self.Formats[name] = format_
def find_paren(self, bn, pos):
dex = bn * self.BN_FACTOR + pos
return self.paren_pos_map.get(dex, None)
def replace_strings_with_dash(self, mo):
found = mo.group(0)
return '-' * len(found)
def highlightBlock(self, text):
NORMAL, TRIPLESINGLE, TRIPLEDOUBLE = range(3)
bn = self.currentBlock().blockNumber()
textLength = len(text)
self.setFormat(0, textLength, self.Formats["normal"])
if not text:
pass
elif text[0] == "#":
self.setFormat(0, textLength, self.Formats["comment"])
return
for regex, format_ in self.Rules:
for m in regex.finditer(text):
i, length = m.start(), m.end() - m.start()
if format_ in ['lparen', 'rparen']:
pp = self.find_paren(bn, i)
if pp and pp.highlight:
self.setFormat(i, length, self.Formats[format_])
elif format_ == 'keymode':
if bn > 0 and i == 0:
continue
self.setFormat(i, length, self.Formats['keyword'])
else:
self.setFormat(i, length, self.Formats[format_])
# Deal with comments not at the beginning of the line.
if self.for_python and '#' in text:
# Remove any strings from the text before we check for '#'. This way
# we avoid thinking a # inside a string starts a comment.
t = re.sub(self.stringRe, self.replace_strings_with_dash, text)
sharp_pos = t.find('#')
if sharp_pos >= 0: # Do we still have a #?
self.setFormat(sharp_pos, len(text), self.Formats["comment"])
self.setCurrentBlockState(NORMAL)
if self.for_python and self.checkTripleInStringRe.search(text) is None:
# This is fooled by triple quotes inside single quoted strings
for m, state in (
(self.tripleSingleRe.search(text), TRIPLESINGLE),
(self.tripleDoubleRe.search(text), TRIPLEDOUBLE)
):
i = -1 if m is None else m.start()
if self.previousBlockState() == state:
if i == -1:
i = len(text)
self.setCurrentBlockState(state)
self.setFormat(0, i + 3, self.Formats["string"])
elif i > -1:
self.setCurrentBlockState(state)
self.setFormat(i, len(text), self.Formats["string"])
if self.generate_paren_positions:
t = str(text)
i = 0
found_quote = False
while i < len(t):
c = t[i]
if c == ':':
# Deal with the funky syntax of template program mode.
# This won't work if there are more than one template
# expression in the document.
if not found_quote and i+1 < len(t) and t[i+1] == "'":
i += 2
elif c in ["'", '"']:
found_quote = True
i += 1
j = t[i:].find(c)
if j < 0:
i = len(t)
else:
i = i + j
elif c in ('(', ')'):
pp = ParenPosition(bn, i, c)
self.paren_positions.append(pp)
self.paren_pos_map[bn*self.BN_FACTOR+i] = pp
i += 1
def rehighlight(self):
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
super().rehighlight()
QApplication.restoreOverrideCursor()
def check_cursor_pos(self, chr_, block, pos_in_block):
paren_pos = -1
for i, pp in enumerate(self.paren_positions):
pp.set_highlight(False)
if pp.block == block and pp.pos == pos_in_block:
paren_pos = i
if chr_ not in ('(', ')'):
if self.highlighted_paren:
self.rehighlight()
self.highlighted_paren = False
return
if paren_pos >= 0:
stack = 0
if chr_ == '(':
list_ = self.paren_positions[paren_pos+1:]
else:
list_ = reversed(self.paren_positions[0:paren_pos])
for pp in list_:
if pp.paren == chr_:
stack += 1
elif stack:
stack -= 1
else:
pp.set_highlight(True)
self.paren_positions[paren_pos].set_highlight(True)
break
self.highlighted_paren = True
self.rehighlight()
def regenerate_paren_positions(self):
self.generate_paren_positions = True
self.paren_positions = []
self.paren_pos_map = {}
self.rehighlight()
self.generate_paren_positions = False
translate_table = str.maketrans({
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'\\': '\\\\',
})
class TemplateDialog(QDialog, Ui_TemplateDialog):
tester_closed = pyqtSignal(object, object)
def setWindowTitle(self, title, dialog_number=None):
if dialog_number is None:
title = _('{title} (only one template dialog allowed)').format(title=title)
else:
title = _('{title} dialog number {number} (multiple template dialogs allowed)').format(
title=title, number=dialog_number)
super().setWindowTitle(title)
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, dialog_is_st_editor=False,
global_vars=None, all_functions=None, builtin_functions=None,
python_context_object=None, dialog_number=None):
# If dialog_number isn't None then we want separate non-modal windows
# that don't stay on top of the main dialog. This lets Alt-Tab work to
# switch between them. dialog_number must be set only by the template
# tester, not the rules dialogs etc that depend on modality.
if dialog_number is None:
QDialog.__init__(self, parent, flags=Qt.WindowType.Dialog)
else:
QDialog.__init__(self, None, flags=Qt.WindowType.Window)
self.raise_and_focus() # Not needed on windows but here just in case
Ui_TemplateDialog.__init__(self)
self.setupUi(self)
self.setWindowIcon(self.windowIcon())
self.ffml = FFMLProcessor()
self.dialog_number = dialog_number
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
self.global_vars = global_vars or {}
self.python_context_object = python_context_object or PythonTemplateContext()
cols = []
self.fm = fm
if fm is not None:
for key in sorted(displayable_columns(fm),
key=lambda k: sort_key(fm[k]['name'] if k != color_row_key else 0)):
if key == color_row_key and not self.coloring:
continue
from calibre.gui2.preferences.coloring import all_columns_string
name = all_columns_string if key == color_row_key else fm[key]['name']
if name:
cols.append((name, key))
self.color_layout.setVisible(False)
self.icon_layout.setVisible(False)
if self.coloring:
self.color_layout.setVisible(True)
for n1, k1 in cols:
self.colored_field.addItem(n1 +
(' (' + k1 + ')' if k1 != color_row_key else ''), k1)
self.colored_field.setCurrentIndex(self.colored_field.findData(color_field))
elif self.iconing or self.embleming:
self.icon_layout.setVisible(True)
self.icon_select_layout.setContentsMargins(0, 0, 0, 0)
if self.embleming:
self.icon_kind_label.setVisible(False)
self.icon_kind.setVisible(False)
self.icon_chooser_label.setVisible(False)
self.icon_field.setVisible(False)
for n1, k1 in cols:
self.icon_field.addItem(f'{n1} ({k1})', k1)
self.icon_file_names = []
d = os.path.join(config_dir, 'cc_icons')
if os.path.exists(d):
for icon_file in os.listdir(d):
icon_file = icu_lower(icon_file)
if os.path.exists(os.path.join(d, icon_file)):
if icon_file.endswith('.png'):
self.icon_file_names.append(icon_file)
self.icon_file_names.sort(key=sort_key)
self.update_filename_box()
if self.iconing:
dex = 0
from calibre.gui2.preferences.coloring import icon_rule_kinds
for i,tup in enumerate(icon_rule_kinds):
txt,val = tup
self.icon_kind.addItem(txt, userData=(val))
if val == icon_rule_kind:
dex = i
self.icon_kind.setCurrentIndex(dex)
self.icon_field.setCurrentIndex(self.icon_field.findData(icon_field_key))
self.setup_saved_template_editor(not dialog_is_st_editor, dialog_is_st_editor)
self.all_functions = all_functions if all_functions else formatter_functions().get_functions()
self.builtins = (builtin_functions if builtin_functions else
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)
self.remove_all_button.setEnabled(s)
self.set_all_button.setEnabled(s)
self.toggle_button.setEnabled(s)
self.breakpoint_line_box.setEnabled(s)
self.breakpoint_line_box_label.setEnabled(s)
self.break_box.setChecked(s)
self.break_box.stateChanged.connect(self.break_box_changed)
self.go_button.clicked.connect(self.go_button_pressed)
# Set up the display table
self.table_column_widths = None
try:
self.table_column_widths = gprefs.get(self.geometry_string('template_editor_table_widths'), None)
except:
pass
self.set_mi(mi, fm)
self.last_text = ''
self.highlighting_gpm = True
self.highlighter = TemplateHighlighter(self.textbox.document(), builtin_functions=self.builtins)
self.textbox.cursorPositionChanged.connect(self.text_cursor_changed)
self.textbox.textChanged.connect(self.textbox_changed)
self.set_editor_font()
self.doc_viewer = None
self.current_function_name = None
self.documentation.setReadOnly(True)
self.documentation.setOpenLinks(False)
self.documentation.anchorClicked.connect(self.url_clicked)
self.source_code.setReadOnly(True)
self.doc_button.clicked.connect(self.open_documentation_viewer)
self.general_info_button.clicked.connect(self.open_general_info_dialog)
if text is not None:
if text_is_placeholder:
self.textbox.setPlaceholderText(text)
self.textbox.clear()
text = ''
else:
self.textbox.setPlainText(text)
else:
text = ''
self.original_text = text
self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText(_('&OK'))
self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setText(_('&Cancel'))
self.color_copy_button.clicked.connect(self.color_to_clipboard)
self.filename_button.clicked.connect(self.filename_button_clicked)
self.icon_copy_button.clicked.connect(self.icon_to_clipboard)
try:
with open(P('template-functions.json'), 'rb') as f:
self.builtin_source_dict = json.load(f, encoding='utf-8')
except:
self.builtin_source_dict = {}
self.function_names = func_names = sorted(self.all_functions)
self.function.clear()
self.function.addItem('')
for f in func_names:
self.function.addItem('{} -- {}'.format(f,
self.function_type_string(f, longform=False)), f)
self.function.setCurrentIndex(0)
self.function.currentIndexChanged.connect(self.function_changed)
self.rule = (None, '')
tt = _('Template language tutorial')
self.template_tutorial.setText(
'<a href="{}">{}</a>'.format(
localize_user_manual_link('https://manual.calibre-ebook.com/template_lang.html'), tt))
tt = _('Template function reference')
self.tf_ref.setText(
'<a href="{}">{}</a>'.format(
localize_user_manual_link('https://manual.calibre-ebook.com/generated/en/template_ref.html'), tt))
self.textbox.setFocus()
self.set_up_font_boxes()
self.toggle_button.clicked.connect(self.toggle_button_pressed)
self.remove_all_button.clicked.connect(self.remove_all_button_pressed)
self.set_all_button.clicked.connect(self.set_all_button_pressed)
self.load_button.clicked.connect(self.load_template_from_file)
self.save_button.clicked.connect(self.save_template)
self.set_word_wrap(gprefs.get('gpm_template_editor_word_wrap_mode', True))
self.textbox.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.textbox.customContextMenuRequested.connect(self.show_context_menu)
# Now geometry
self.restore_geometry(gprefs, self.geometry_string('template_editor_dialog_geometry'))
def url_clicked(self, qurl):
if qurl.scheme().startswith('http'):
safe_open_url(qurl)
elif qurl.scheme() == 'ffdoc':
name = qurl.path()
if name in self.function_names:
dex = self.function_names.index(name)
self.function.setCurrentIndex(dex+1)
def open_documentation_viewer(self):
if self.doc_viewer is None:
dv = self.doc_viewer = DocViewer(self.ffml, self.all_functions,
self.function_type_string, parent=self)
dv.finished.connect(self.doc_viewer_finished)
dv.show()
if self.current_function_name is not None:
self.doc_viewer.show_function(self.current_function_name)
else:
self.doc_viewer.show_all_functions()
def doc_viewer_finished(self):
self.doc_viewer = None
def open_general_info_dialog(self):
GeneralInformationDialog(include_general_doc=True, include_ffml_doc=True).exec()
def geometry_string(self, txt):
if self.dialog_number is None or self.dialog_number == 0:
return txt
return txt + '_' + str(self.dialog_number)
def setup_saved_template_editor(self, show_buttonbox, show_doc_and_name):
self.buttonBox.setVisible(show_buttonbox)
self.new_doc_label.setVisible(show_doc_and_name)
self.new_doc.setVisible(show_doc_and_name)
self.template_name_label.setVisible(show_doc_and_name)
self.template_name.setVisible(show_doc_and_name)
def set_mi(self, mi, fm):
'''
This sets the metadata for the test result books table. It doesn't reset
the contents of the field selectors for editing rules.
'''
self.fm = fm
from calibre.gui2.ui import get_gui
if mi:
if not isinstance(mi, (tuple, list)):
mi = (mi, )
else:
mi = Metadata(_('Title'), [_('Author')])
mi.author_sort = _('Author Sort')
mi.series = ngettext('Series', 'Series', 1)
mi.series_index = 3
mi.rating = 4.0
mi.tags = [_('Tag 1'), _('Tag 2')]
mi.languages = ['eng']
mi.id = -1
if self.fm is not None:
mi.set_all_user_metadata(self.fm.custom_field_metadata())
else:
# No field metadata. Grab a copy from the current library so
# that we can validate any custom column names. The values for
# the columns will all be empty, which in some very unusual
# cases might cause formatter errors. We can live with that.
fm = get_gui().current_db.new_api.field_metadata
mi.set_all_user_metadata(fm.custom_field_metadata())
for col in mi.get_all_user_metadata(False):
if fm[col]['datatype'] == 'datetime':
mi.set(col, DEFAULT_DATE)
elif fm[col]['datatype'] in ('int', 'float', 'rating'):
mi.set(col, 2)
elif fm[col]['datatype'] == 'bool':
mi.set(col, False)
elif fm[col]['is_multiple']:
mi.set(col, [col])
else:
mi.set(col, col, 1)
mi = (mi, )
self.mi = mi
tv = self.template_value
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))
# Set the height of the table
h = tv.rowHeight(0) * min(len(mi), 5)
h += 2 * tv.frameWidth() + tv.horizontalHeader().height()
tv.setMinimumHeight(h)
tv.setMaximumHeight(h)
# Set the size of the title column
if self.table_column_widths:
tv.setColumnWidth(0, self.table_column_widths[0])
else:
tv.setColumnWidth(0, tv.fontMetrics().averageCharWidth() * 10)
tv.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
tv.setRowCount(len(mi))
# Use our own widget to get rid of elision. setTextElideMode() doesn't work
for r in range(0, len(mi)):
w = QLineEdit(tv)
w.setReadOnly(True)
w.setText(mi[r].title)
tv.setCellWidget(r, 0, w)
tb = QToolButton()
tb.setContentsMargins(0, 0, 0, 0)
tb.setIcon(QIcon.ic("edit_input.png"))
tb.setToolTip(_('Open Edit metadata on this book'))
tb.clicked.connect(partial(self.metadata_button_clicked, r))
tb.setEnabled(mi[r].get('id', -1) >= 0)
tv.setCellWidget(r, 1, tb)
w = QLineEdit(tv)
w.setReadOnly(True)
tv.setCellWidget(r, 2, w)
tv.resizeColumnToContents(1)
self.set_waiting_message()
def metadata_button_clicked(self, row):
# Get the booklist row number for the book
mi = self.mi[row]
id_ = mi.get('id', -1)
if id_ > 0:
from calibre.gui2.ui import get_gui
db = get_gui().current_db
try:
idx = db.data.id_to_index(id_)
em = get_gui().iactions['Edit Metadata']
from calibre.gui2.library.views import PreserveViewState
with PreserveViewState(get_gui().current_view(), require_selected_ids=False):
with em.different_parent(self):
em.edit_metadata_for([idx], [id_], bulk=False)
except Exception:
pass
new_mi = []
for mi in self.mi:
try:
pmi = db.new_api.get_proxy_metadata(mi.get('id'))
except Exception:
pmi = None
new_mi.append(pmi)
self.set_mi(new_mi, self.fm)
if not self.break_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)):
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"))
def show_context_menu(self, point):
m = self.textbox.createStandardContextMenu()
m.addSeparator()
word_wrapping = gprefs['gpm_template_editor_word_wrap_mode']
if word_wrapping:
ca = m.addAction(_('Disable word wrap'))
ca.setIcon(QIcon.ic('list_remove.png'))
else:
ca = m.addAction(_('Enable word wrap'))
ca.setIcon(QIcon.ic('ok.png'))
ca.triggered.connect(partial(self.set_word_wrap, not word_wrapping))
m.addSeparator()
ca = m.addAction(_('Add Python template definition text'))
ca.triggered.connect(self.add_python_template_header_text)
m.addSeparator()
ca = m.addAction(_('Load template from the Template tester'))
m.addSeparator()
ca.triggered.connect(self.load_last_template_text)
ca = m.addAction(_('Load template from file'))
ca.setIcon(QIcon.ic('document_open.png'))
ca.triggered.connect(self.load_template_from_file)
ca = m.addAction(_('Save template to file'))
ca.setIcon(QIcon.ic('save.png'))
ca.triggered.connect(self.save_template)
m.exec(self.textbox.mapToGlobal(point))
def add_python_template_header_text(self):
self.textbox.setPlainText('''python:
def evaluate(book, context):
\t# book is a calibre metadata object
\t# context is an instance of calibre.utils.formatter.PythonTemplateContext,
\t# which currently contains the following attributes:
\t# db: a calibre legacy database object.
\t# globals: the template global variable dictionary.
\t# arguments: is a list of arguments if the template is called by a GPM template, otherwise None.
\t# funcs: used to call Built-in/User functions and Stored GPM/Python templates.
\t# Example: context.funcs.list_re_group()
\t# your Python code goes here
\treturn 'a string'
''')
def set_word_wrap(self, to_what):
gprefs['gpm_template_editor_word_wrap_mode'] = to_what
self.textbox.setWordWrapMode(QTextOption.WrapMode.WordWrap if to_what else QTextOption.WrapMode.NoWrap)
def load_last_template_text(self):
from calibre.customize.ui import find_plugin
tt = find_plugin('Template Tester')
if tt and tt.actual_plugin_:
self.textbox.setPlainText(tt.actual_plugin_.last_template_text())
else:
# I don't think we can get here, but just in case ...
self.textbox.setPlainText(_('No Template tester text is available'))
def load_template_from_file(self):
filename = choose_files(self, 'template_dialog_save_templates',
_('Load template from file'),
filters=[
(_('Template file'), ['txt'])
], select_only_single_file=True)
if filename:
with open(filename[0]) as f:
self.textbox.setPlainText(f.read())
def save_template(self):
filename = choose_save_file(self, 'template_dialog_save_templates',
_('Save template to file'),
filters=[
(_('Template file'), ['txt'])
])
if filename:
with open(filename, 'w') as f:
f.write(str(self.textbox.toPlainText()))
def get_current_font(self):
font_name = gprefs.get('gpm_template_editor_font', None)
size = gprefs['gpm_template_editor_font_size']
if font_name is None:
font = QFont()
font.setFixedPitch(True)
font.setPointSize(size)
else:
font = QFont(font_name, pointSize=size)
return font
def set_editor_font(self):
font = self.get_current_font()
fm = QFontMetrics(font)
chars = tweaks['template_editor_tab_stop_width']
w = fm.averageCharWidth() * chars
self.textbox.setTabStopDistance(w)
self.source_code.setTabStopDistance(w)
self.textbox.setFont(font)
self.highlighter.initialize_formats()
self.highlighter.rehighlight()
def set_up_font_boxes(self):
font = self.get_current_font()
self.font_box.setWritingSystem(QFontDatabase.WritingSystem.Latin)
self.font_box.setCurrentFont(font)
self.font_box.setEditable(False)
gprefs['gpm_template_editor_font'] = str(font.family())
self.font_size_box.setValue(font.pointSize())
self.font_box.currentFontChanged.connect(self.font_changed)
self.font_size_box.valueChanged.connect(self.font_size_changed)
def font_changed(self, font):
fi = QFontInfo(font)
gprefs['gpm_template_editor_font'] = str(fi.family())
self.set_editor_font()
def font_size_changed(self, toWhat):
gprefs['gpm_template_editor_font_size'] = toWhat
self.set_editor_font()
def break_box_changed(self, new_state):
gprefs['template_editor_break_on_print'] = new_state != 0
self.go_button.setEnabled(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:
self.display_values(str(self.textbox.toPlainText()))
else:
self.set_waiting_message()
def go_button_pressed(self):
self.display_values(str(self.textbox.toPlainText()))
def remove_all_button_pressed(self):
self.textbox.set_clicked_line_numbers(set())
def set_all_button_pressed(self):
self.textbox.set_clicked_line_numbers({i for i in range(1, self.textbox.blockCount()+1)})
def toggle_button_pressed(self):
ln = self.breakpoint_line_box.value()
if ln > self.textbox.blockCount():
return
cln = self.textbox.clicked_line_numbers
if ln:
if ln in self.textbox.clicked_line_numbers:
cln.discard(ln)
else:
cln.add(ln)
self.textbox.set_clicked_line_numbers(cln)
def break_reporter(self, txt, val, locals_={}, line_number=0):
l = self.template_value.selectionModel().selectedRows()
mi_to_use = self.mi[0 if len(l) == 0 else l[0].row()]
if self.break_box.isChecked():
if line_number is None or line_number not in self.textbox.clicked_line_numbers:
return
self.break_reporter_dialog = BreakReporter(self, mi_to_use,
txt, val, locals_, line_number)
if not self.break_reporter_dialog.exec():
raise StopException()
def filename_button_clicked(self):
try:
path = choose_files(self, 'choose_category_icon',
_('Select icon'), filters=[
('Images', ['png', 'gif', 'jpg', 'jpeg'])],
all_files=False, select_only_single_file=True)
if path:
icon_path = path[0]
icon_name = sanitize_file_name(
os.path.splitext(
os.path.basename(icon_path))[0]+'.png')
if icon_name not in self.icon_file_names:
self.icon_file_names.append(icon_name)
self.update_filename_box()
try:
p = QIcon(icon_path).pixmap(QSize(128, 128))
d = os.path.join(config_dir, 'cc_icons')
if not os.path.exists(os.path.join(d, icon_name)):
if not os.path.exists(d):
os.makedirs(d)
with open(os.path.join(d, icon_name), 'wb') as f:
f.write(pixmap_to_data(p, format='PNG'))
except:
traceback.print_exc()
self.icon_files.setCurrentIndex(self.icon_files.findText(icon_name))
self.icon_files.adjustSize()
except:
traceback.print_exc()
return
def update_filename_box(self):
self.icon_files.clear()
self.icon_file_names.sort(key=sort_key)
self.icon_files.addItem('')
self.icon_files.addItems(self.icon_file_names)
for i,filename in enumerate(self.icon_file_names):
icon = QIcon(os.path.join(config_dir, 'cc_icons', filename))
self.icon_files.setItemIcon(i+1, icon)
def color_to_clipboard(self):
app = QApplication.instance()
c = app.clipboard()
c.setText(str(self.color_name.color))
def icon_to_clipboard(self):
app = QApplication.instance()
c = app.clipboard()
c.setText(str(self.icon_files.currentText()))
@property
def is_python(self):
return self.textbox.toPlainText().startswith('python:')
def textbox_changed(self):
cur_text = str(self.textbox.toPlainText())
if self.is_python:
if self.highlighting_gpm is True:
self.highlighter.initialize_rules(self.builtins, True)
self.highlighting_gpm = False
self.break_box.setEnabled(True)
elif not self.highlighting_gpm:
self.highlighter.initialize_rules(self.builtins, False)
self.highlighting_gpm = True
self.break_box.setEnabled(True)
if self.last_text != cur_text:
self.last_text = cur_text
self.highlighter.regenerate_paren_positions()
self.text_cursor_changed()
if not self.break_box.isChecked():
self.display_values(cur_text)
else:
self.set_waiting_message()
def trace_lines(self, frame, event, arg):
if event != 'line':
return
# Only respond to events in the "string" which is the template
if frame.f_code.co_filename != '<string>':
return
# Check that there is a breakpoint at the line
if frame.f_lineno not in self.textbox.clicked_line_numbers:
return
l = self.template_value.selectionModel().selectedRows()
mi_to_use = self.mi[0 if len(l) == 0 else l[0].row()]
self.break_reporter_dialog = PythonBreakReporter(self, mi_to_use, frame)
if not self.break_reporter_dialog.exec():
raise StopException()
def trace_calls(self, frame, event, arg):
if event != 'call':
return
# If this is the "string" file (the template), return the trace_lines function
if frame.f_code.co_filename == '<string>':
return self.trace_lines
return None
def display_values(self, txt):
tv = self.template_value
l = self.template_value.selectionModel().selectedRows()
break_on_mi = 0 if len(l) == 0 else l[0].row()
for r,mi in enumerate(self.mi):
w = tv.cellWidget(r, 0)
w.setText(mi.title)
w.setCursorPosition(0)
if self.break_box.isChecked() and r == break_on_mi and self.is_python:
sys.settrace(self.trace_calls)
else:
sys.settrace(None)
try:
v = SafeFormat().safe_format(txt, mi, _('EXCEPTION:'),
mi, global_vars=self.global_vars,
template_functions=self.all_functions,
break_reporter=self.break_reporter if r == break_on_mi else None,
python_context_object=self.python_context_object)
w = tv.cellWidget(r, 2)
w.setText(v.translate(translate_table))
w.setCursorPosition(0)
finally:
sys.settrace(None)
def text_cursor_changed(self):
cursor = self.textbox.textCursor()
position = cursor.position()
t = str(self.textbox.toPlainText())
if position > 0 and position <= len(t):
block_number = cursor.blockNumber()
pos_in_block = cursor.positionInBlock() - 1
self.highlighter.check_cursor_pos(t[position-1], block_number,
pos_in_block)
def function_type_string(self, name, longform=True):
if self.all_functions[name].object_type is StoredObjectType.PythonFunction:
if name in self.builtins:
return (_('Built-in template function') if longform else
_('Built-in function'))
return (_('User defined Python template function') if longform else
_('User function'))
elif self.all_functions[name].object_type is StoredObjectType.StoredPythonTemplate:
return (_('Stored user defined Python template') if longform else _('Stored template'))
return (_('Stored user defined GPM template') if longform else _('Stored template'))
def function_changed(self, toWhat):
self.current_function_name = name = str(self.function.itemData(toWhat))
self.source_code.clear()
self.documentation.clear()
self.func_type.clear()
if name in self.all_functions:
doc = self.all_functions[name].doc.strip()
self.documentation.setHtml(self.ffml.document_to_html(doc, name))
if self.doc_viewer is not None:
self.doc_viewer.show_function(name)
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.all_functions[name].program_text)
self.func_type.setText(self.function_type_string(name, longform=True))
def table_column_resized(self, col, old, new):
self.table_column_widths = []
for c in range(0, self.template_value.columnCount()):
self.table_column_widths.append(self.template_value.columnWidth(c))
def save_geometry(self):
gprefs[self.geometry_string('template_editor_table_widths')] = self.table_column_widths
super().save_geometry(gprefs, self.geometry_string('template_editor_dialog_geometry'))
def keyPressEvent(self, ev):
if ev.key() == Qt.Key.Key_Escape:
# Check about ESC to avoid killing the dialog by mistake
if self.textbox.toPlainText() != self.original_text:
r = question_dialog(self, _('Discard changes?'),
_('Do you really want to close this dialog, discarding any changes?'))
if not r:
return
QDialog.keyPressEvent(self, ev)
def accept(self):
txt = str(self.textbox.toPlainText()).rstrip()
if (self.coloring or self.iconing or self.embleming) and not txt:
error_dialog(self, _('No template provided'),
_('The template box cannot be empty'), show=True)
return
if self.coloring:
if self.colored_field.currentIndex() == -1:
error_dialog(self, _('No column chosen'),
_('You must specify a column to be colored'), show=True)
return
self.rule = (str(self.colored_field.itemData(
self.colored_field.currentIndex()) or ''), txt)
elif self.iconing:
if self.icon_field.currentIndex() == -1:
error_dialog(self, _('No column chosen'),
_('You must specify the column where the icons are applied'), show=True)
return
rt = str(self.icon_kind.itemData(self.icon_kind.currentIndex()) or '')
self.rule = (rt,
str(self.icon_field.itemData(
self.icon_field.currentIndex()) or ''),
txt)
elif self.embleming:
self.rule = ('icon', 'title', txt)
else:
self.rule = ('', txt)
self.save_geometry()
QDialog.accept(self)
if self.dialog_number is not None:
self.tester_closed.emit(txt, self.dialog_number)
if self.doc_viewer is not None:
self.doc_viewer.close()
def reject(self):
self.save_geometry()
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
if self.dialog_number is not None:
self.tester_closed.emit(None, self.dialog_number)
if self.doc_viewer is not None:
self.doc_viewer.close()
class BreakReporterItem(QTableWidgetItem):
def __init__(self, txt):
super().__init__(txt.translate(translate_table) if txt else txt)
self.setFlags(self.flags() & ~(Qt.ItemFlag.ItemIsEditable))
class BreakReporterBase(QDialog):
def setup_ui(self, mi, line_number, locals_, leading_rows):
self.mi = mi
self.leading_rows = leading_rows
self.setModal(True)
l = QVBoxLayout(self)
t = self.table = QTableWidget(self)
t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
t.setColumnCount(2)
t.setHorizontalHeaderLabels((_('Name'), _('Value')))
t.setRowCount(leading_rows)
l.addWidget(t)
self.table_column_widths = None
try:
self.table_column_widths = \
gprefs.get('template_editor_break_table_widths', None)
t.setColumnWidth(0, self.table_column_widths[0])
except:
t.setColumnWidth(0, t.fontMetrics().averageCharWidth() * 20)
t.horizontalHeader().sectionResized.connect(self.table_column_resized)
t.horizontalHeader().setStretchLastSection(True)
bb = QDialogButtonBox()
b = bb.addButton(_('&Continue'), QDialogButtonBox.ButtonRole.AcceptRole)
b.setIcon(QIcon.ic('sync-right.png'))
b.setToolTip(_('Continue running the template'))
b.setDefault(True)
l.addWidget(bb)
b = bb.addButton(_('&Stop'), QDialogButtonBox.ButtonRole.RejectRole)
b.setIcon(QIcon.ic('list_remove.png'))
b.setToolTip(_('Stop running the template'))
l.addWidget(bb)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
self.setLayout(l)
self.setWindowTitle(_('Break: line {0}, book {1}').format(line_number, self.mi.title))
self.mi_combo = QComboBox()
t.setCellWidget(leading_rows-1, 0, self.mi_combo)
self.mi_combo.addItems(self.get_field_keys())
self.mi_combo.setToolTip('Choose a book metadata field to display')
self.mi_combo.currentTextChanged.connect(self.get_field_value)
self.mi_combo.setCurrentIndex(self.mi_combo.findText('title'))
self.restore_geometry(gprefs, 'template_editor_break_geometry')
self.setup_locals(locals_)
def setup_locals(self, locals_):
raise NotImplementedError
def add_local_line(self, locals, row, key):
itm = BreakReporterItem(key)
itm.setToolTip(_('A variable in the template'))
self.table.setItem(row, 0, itm)
itm = BreakReporterItem(repr(locals[key]))
itm.setToolTip(_('The value of the variable'))
self.table.setItem(row, 1, itm)
def get_field_value(self, field):
val = self.displayable_field_value(self.mi, field)
self.table.setItem(self.leading_rows-1, 1, BreakReporterItem(val))
def displayable_field_value(self, mi, field):
raise NotImplementedError
def table_column_resized(self, col, old, new):
self.table_column_widths = []
for c in range(0, self.table.columnCount()):
self.table_column_widths.append(self.table.columnWidth(c))
def get_field_keys(self):
from calibre.gui2.ui import get_gui
keys = set(get_gui().current_db.new_api.field_metadata.displayable_field_keys())
keys.discard('sort')
keys.discard('timestamp')
keys.add('title_sort')
keys.add('date')
return sorted(keys)
def save_geometry(self):
super().save_geometry(gprefs, 'template_editor_break_geometry')
gprefs['template_editor_break_table_widths'] = self.table_column_widths
def reject(self):
self.save_geometry()
QDialog.reject(self)
def accept(self):
self.save_geometry()
QDialog.accept(self)
class BreakReporter(BreakReporterBase):
def __init__(self, parent, mi, op_label, op_value, locals_, line_number):
super().__init__(parent)
self.setup_ui(mi, line_number, locals_, leading_rows=2)
self.table.setItem(0, 0, BreakReporterItem(op_label))
self.table.item(0,0).setToolTip(_('The name of the template language operation'))
self.table.setItem(0, 1, BreakReporterItem(op_value))
def setup_locals(self, locals):
local_names = sorted(locals.keys())
rows = len(local_names)
self.table.setRowCount(rows+2)
for i,k in enumerate(local_names, start=2):
self.add_local_line(locals, i, k)
def displayable_field_value(self, mi, field):
return self.mi.format_field('timestamp' if field == 'date' else field)[1]
class PythonBreakReporter(BreakReporterBase):
def __init__(self, parent, mi, frame):
super().__init__(parent)
self.frame = frame
line_number = frame.f_lineno
locals = frame.f_locals
self.setup_ui(mi, line_number, locals, leading_rows=1)
def setup_locals(self, locals):
locals = self.frame.f_locals
local_names = sorted(k for k in locals.keys() if k not in ('book', 'context'))
rows = len(local_names)
self.table.setRowCount(rows+1)
for i,k in enumerate(local_names, start=1):
if k in ('book', 'context'):
continue
self.add_local_line(locals, i, k)
def displayable_field_value(self, mi, field):
return repr(self.mi.get('timestamp' if field == 'date' else field))
class EmbeddedTemplateDialog(TemplateDialog):
def __init__(self, parent):
TemplateDialog.__init__(self, parent, _('A General Program Mode Template'), text_is_placeholder=True,
dialog_is_st_editor=True)
self.setParent(parent)
self.setWindowFlags(Qt.WindowType.Widget)
if __name__ == '__main__':
from calibre.gui2 import Application
app = Application([])
from calibre.ebooks.metadata.book.base import field_metadata
d = TemplateDialog(None, '{title}', fm=field_metadata)
d.exec()
del app