mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-11-26 00:05:01 -05:00
1366 lines
56 KiB
Python
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
|