mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Finish implementation of function mode for the editor
This commit is contained in:
parent
35cd019066
commit
ef3509ebc5
@ -6,13 +6,18 @@ from __future__ import (unicode_literals, division, absolute_import,
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
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 <a href="%s">User Manual</a>') %
|
||||
'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
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user