diff --git a/src/calibre/gui2/tweak_book/function_replace.py b/src/calibre/gui2/tweak_book/function_replace.py index 5ca71c505c..a94b5ebb79 100644 --- a/src/calibre/gui2/tweak_book/function_replace.py +++ b/src/calibre/gui2/tweak_book/function_replace.py @@ -6,13 +6,18 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2014, Kovid Goyal ' -import re, io +import re, io, weakref -from PyQt5.Qt import pyqtSignal +from PyQt5.Qt import ( + pyqtSignal, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QLabel, QFontMetrics, + QSize) from calibre.ebooks.oeb.polish.utils import apply_func_to_match_groups +from calibre.gui2 import error_dialog from calibre.gui2.complete2 import EditWithComplete from calibre.gui2.tweak_book import dictionaries +from calibre.gui2.tweak_book.widgets import Dialog +from calibre.gui2.widgets import PythonHighlighter from calibre.utils.config import JSONConfig from calibre.utils.icu import capitalize, upper, lower, swapcase from calibre.utils.titlecase import titlecase @@ -24,6 +29,8 @@ def compile_code(src): match = re.search(r'coding[:=]\s*([-\w.]+)', src[:200]) enc = match.group(1) if match else 'utf-8' src = src.decode(enc) + if not src or not src.strip(): + src = EMPTY_FUNC # Python complains if there is a coding declaration in a unicode string src = re.sub(r'^#.*coding\s*[:=]\s*([-\w.]+)', '#', src, flags=re.MULTILINE) # Translate newlines to \n @@ -53,6 +60,7 @@ class Function(object): self.context_name = name or '' self.match_index = 0 self.boss = get_boss() + self.data = {} def __hash__(self): return hash(self.name) @@ -65,7 +73,7 @@ class Function(object): def __call__(self, match): self.match_index += 1 - return self.func(match, self.match_index, self.context_name, self.boss.current_metadata, dictionaries, functions()) + return self.func(match, self.match_index, self.context_name, self.boss.current_metadata, dictionaries, self.data, functions()) @property def source(self): @@ -86,8 +94,6 @@ def functions(refresh=False): ans = _functions = {} for func in builtin_functions(): ans[func.name] = Function(func.name, func=func) - if refresh: - user_functions.refresh() for name, source in user_functions.iteritems(): try: f = Function(name, source=source) @@ -96,6 +102,30 @@ def functions(refresh=False): ans[f.name] = f return _functions +def remove_function(name, gui_parent=None): + funcs = functions() + if not name: + return False + if name not in funcs: + error_dialog(gui_parent, _('No such function'), _( + 'There is no function named %s') % name, show=True) + return False + if name not in user_functions: + error_dialog(gui_parent, _('Cannot remove builtin function'), _( + 'The function %s is a builtin function, it cannot be removed.') % name, show=True) + del user_functions[name] + functions(refresh=True) + refresh_boxes() + return True + +boxes = [] + +def refresh_boxes(): + for ref in boxes: + box = ref() + if box is not None: + box.refresh() + class FunctionBox(EditWithComplete): save_search = pyqtSignal() @@ -107,6 +137,7 @@ class FunctionBox(EditWithComplete): self.show_saved_search_actions = show_saved_search_actions self.refresh() self.setToolTip(_('Choose a function to run on matched text (by name)')) + boxes.append(weakref.ref(self)) def refresh(self): self.update_items_cache(set(functions())) @@ -119,6 +150,76 @@ class FunctionBox(EditWithComplete): menu.addAction(_('Show saved searches'), self.show_saved_searches.emit) menu.exec_(event.globalPos()) +class FunctionEditor(Dialog): + + def __init__(self, func_name='', parent=None): + self._func_name = func_name + Dialog.__init__(self, _('Create/edit a function'), 'edit-sr-func', parent=parent) + + def setup_ui(self): + self.l = l = QVBoxLayout(self) + self.h = h = QHBoxLayout() + l.addLayout(h) + + self.la1 = la = QLabel(_('F&unction name:')) + h.addWidget(la) + self.fb = fb = FunctionBox(self) + la.setBuddy(fb) + h.addWidget(fb, stretch=10) + + self.la3 = la = QLabel(_('&Code:')) + self.source_code = QPlainTextEdit(self) + self.highlighter = PythonHighlighter(self.source_code.document()) + la.setBuddy(self.source_code) + l.addWidget(la), l.addWidget(self.source_code) + + if self._func_name: + self.fb.setText(self._func_name) + func = functions().get(self._func_name) + if func is not None: + self.source_code.setPlainText(func.source or ('\n' + EMPTY_FUNC)) + else: + self.source_code.setPlainText('\n' + EMPTY_FUNC) + + self.la2 = la = QLabel(_( + 'For help with creating functions, see the User Manual') % + 'http://manual.calibre-ebook.com/edit.html#function-mode') + la.setOpenExternalLinks(True) + l.addWidget(la) + + l.addWidget(self.bb) + + def sizeHint(self): + fm = QFontMetrics(self.font()) + return QSize(fm.averageCharWidth() * 120, 600) + + @property + def func_name(self): + return self.fb.text().strip() + + @property + def source(self): + return self.source_code.toPlainText() + + def accept(self): + if not self.func_name: + return error_dialog(self, _('Must specify name'), _( + 'You must specify a name for this function.'), show=True) + source = self.source + try: + mod = compile_code(source) + except Exception as err: + return error_dialog(self, _('Invalid python code'), _( + 'The code you created is not valid python code, with error: %s') % err, show=True) + if not callable(mod.get('replace')): + return error_dialog(self, _('No replace function'), _( + 'You must create a python function named replace in your code'), show=True) + user_functions[self.func_name] = source + functions(refresh=True) + refresh_boxes() + + Dialog.accept(self) + # Builtin functions ########################################################## def builtin(name, *args): @@ -128,37 +229,48 @@ def builtin(name, *args): return func return f +EMPTY_FUNC = '''\ +def replace(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs): + return '' +''' + @builtin('Upper-case text', upper, apply_func_to_match_groups) -def replace_uppercase(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs): +def replace_uppercase(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs): '''Make matched text upper case. If the regular expression contains groups, only the text in the groups will be changed, otherwise the entire text is changed.''' return apply_func_to_match_groups(match, upper) @builtin('Lower-case text', lower, apply_func_to_match_groups) -def replace_lowercase(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs): +def replace_lowercase(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs): '''Make matched text lower case. If the regular expression contains groups, only the text in the groups will be changed, otherwise the entire text is changed.''' return apply_func_to_match_groups(match, lower) @builtin('Capitalize text', capitalize, apply_func_to_match_groups) -def replace_capitalize(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs): +def replace_capitalize(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs): '''Capitalize matched text. If the regular expression contains groups, only the text in the groups will be changed, otherwise the entire text is changed.''' return apply_func_to_match_groups(match, capitalize) @builtin('Title-case text', titlecase, apply_func_to_match_groups) -def replace_titlecase(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs): +def replace_titlecase(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs): '''Title-case matched text. If the regular expression contains groups, only the text in the groups will be changed, otherwise the entire text is changed.''' return apply_func_to_match_groups(match, titlecase) @builtin('Swap the case of text', swapcase, apply_func_to_match_groups) -def replace_swapcase(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs): +def replace_swapcase(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs): '''Swap the case of the matched text. If the regular expression contains groups, only the text in the groups will be changed, otherwise the entire text is changed.''' return apply_func_to_match_groups(match, swapcase) + +if __name__ == '__main__': + from PyQt5.Qt import QApplication + app = QApplication([]) + FunctionEditor().exec_() + del app diff --git a/src/calibre/gui2/tweak_book/search.py b/src/calibre/gui2/tweak_book/search.py index 90ebf2900a..f6e1f3fd3d 100644 --- a/src/calibre/gui2/tweak_book/search.py +++ b/src/calibre/gui2/tweak_book/search.py @@ -23,7 +23,8 @@ from calibre.gui2 import error_dialog, info_dialog, choose_files, choose_save_fi from calibre.gui2.dialogs.message_box import MessageBox from calibre.gui2.widgets2 import HistoryComboBox from calibre.gui2.tweak_book import tprefs, editors, current_container -from calibre.gui2.tweak_book.function_replace import FunctionBox, functions as replace_functions +from calibre.gui2.tweak_book.function_replace import ( + FunctionBox, functions as replace_functions, FunctionEditor, remove_function, Function) from calibre.gui2.tweak_book.widgets import BusyCursor from calibre.utils.icu import primary_contains @@ -235,10 +236,12 @@ class SearchWidget(QWidget): fhl.setContentsMargins(0, 0, 0, 0) fhl.addWidget(fb, stretch=10, alignment=Qt.AlignVCenter) self.ae_func = b = QPushButton(_('Create/&edit'), self) + b.clicked.connect(self.edit_function) b.setToolTip(_('Create a new function, or edit an existing function')) fhl.addWidget(b) self.rm_func = b = QPushButton(_('Remo&ve'), self) b.setToolTip(_('Remove this function')) + b.clicked.connect(self.remove_function) fhl.addWidget(b) self.fsep = f = QFrame(self) f.setFrameShape(f.VLine) @@ -290,6 +293,17 @@ class SearchWidget(QWidget): ol.addStretch(10) + def edit_function(self): + d = FunctionEditor(func_name=self.functions.text().strip(), parent=self) + if d.exec_() == d.Accepted: + self.functions.setText(d.func_name) + + def remove_function(self): + fname = self.functions.text().strip() + if fname: + if remove_function(fname, self): + self.functions.setText('') + def mode_changed(self, idx): self.da.setVisible(idx > 0) function_mode = idx == 2 @@ -557,10 +571,12 @@ class EditSearch(QFrame): # {{{ la.setBuddy(f) self.ae_func = b = QPushButton(_('Create/&edit'), self) b.setToolTip(_('Create a new function, or edit an existing function')) + b.clicked.connect(self.edit_function) g.addWidget(b, 1, 1) g.setColumnStretch(0, 10) self.rm_func = b = QPushButton(_('Remo&ve'), self) b.setToolTip(_('Remove this function')) + b.clicked.connect(self.remove_function) g.addWidget(b, 1, 2) self.case_sensitive = c = QCheckBox(_('Case sensitive')) @@ -587,6 +603,17 @@ class EditSearch(QFrame): # {{{ self.mode_box.currentIndexChanged[int].connect(self.mode_changed) self.mode_changed(self.mode_box.currentIndex()) + def edit_function(self): + d = FunctionEditor(func_name=self.function.text().strip(), parent=self) + if d.exec_() == d.Accepted: + self.function.setText(d.func_name) + + def remove_function(self): + fname = self.function.text().strip() + if fname: + if remove_function(fname, self): + self.function.setText('') + def mode_changed(self, idx): self.dot_all.setVisible(idx > 0) self.functions_container.setVisible(idx == 2) @@ -599,19 +626,25 @@ class EditSearch(QFrame): # {{{ self.original_name = self.search.get('name', None) self.search_index = search_index + self.mode_box.mode = self.search.get('mode', 'regex') self.search_name.setText(self.search.get('name', '')) self.find.setPlainText(self.search.get('find', '')) - self.replace.setPlainText(self.search.get('replace', '')) + if self.mode_box.mode == 'function': + self.function.setText(self.search.get('replace', '')) + else: + self.replace.setPlainText(self.search.get('replace', '')) self.case_sensitive.setChecked(self.search.get('case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive'])) self.dot_all.setChecked(self.search.get('dot_all', SearchWidget.DEFAULT_STATE['dot_all'])) - self.mode_box.mode = self.search.get('mode', 'regex') if state is not None: self.find.setPlainText(state['find']) - self.replace.setPlainText(state['replace']) + self.mode_box.mode = state.get('mode') + if self.mode_box.mode == 'function': + self.function.setText(state['replace']) + else: + self.replace.setPlainText(state['replace']) self.case_sensitive.setChecked(state['case_sensitive']) self.dot_all.setChecked(state['dot_all']) - self.mode_box.mode = state.get('mode') def emit_done(self): self.done.emit(True) @@ -1147,6 +1180,8 @@ def get_search_function(search): try: return replace_functions()[ans] except KeyError: + if not ans: + return Function('empty-function', '') raise NoSuchFunction(ans) return ans