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'
|
__license__ = 'GPL v3'
|
||||||
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
__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.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.complete2 import EditWithComplete
|
||||||
from calibre.gui2.tweak_book import dictionaries
|
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.config import JSONConfig
|
||||||
from calibre.utils.icu import capitalize, upper, lower, swapcase
|
from calibre.utils.icu import capitalize, upper, lower, swapcase
|
||||||
from calibre.utils.titlecase import titlecase
|
from calibre.utils.titlecase import titlecase
|
||||||
@ -24,6 +29,8 @@ def compile_code(src):
|
|||||||
match = re.search(r'coding[:=]\s*([-\w.]+)', src[:200])
|
match = re.search(r'coding[:=]\s*([-\w.]+)', src[:200])
|
||||||
enc = match.group(1) if match else 'utf-8'
|
enc = match.group(1) if match else 'utf-8'
|
||||||
src = src.decode(enc)
|
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
|
# Python complains if there is a coding declaration in a unicode string
|
||||||
src = re.sub(r'^#.*coding\s*[:=]\s*([-\w.]+)', '#', src, flags=re.MULTILINE)
|
src = re.sub(r'^#.*coding\s*[:=]\s*([-\w.]+)', '#', src, flags=re.MULTILINE)
|
||||||
# Translate newlines to \n
|
# Translate newlines to \n
|
||||||
@ -53,6 +60,7 @@ class Function(object):
|
|||||||
self.context_name = name or ''
|
self.context_name = name or ''
|
||||||
self.match_index = 0
|
self.match_index = 0
|
||||||
self.boss = get_boss()
|
self.boss = get_boss()
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return hash(self.name)
|
return hash(self.name)
|
||||||
@ -65,7 +73,7 @@ class Function(object):
|
|||||||
|
|
||||||
def __call__(self, match):
|
def __call__(self, match):
|
||||||
self.match_index += 1
|
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
|
@property
|
||||||
def source(self):
|
def source(self):
|
||||||
@ -86,8 +94,6 @@ def functions(refresh=False):
|
|||||||
ans = _functions = {}
|
ans = _functions = {}
|
||||||
for func in builtin_functions():
|
for func in builtin_functions():
|
||||||
ans[func.name] = Function(func.name, func=func)
|
ans[func.name] = Function(func.name, func=func)
|
||||||
if refresh:
|
|
||||||
user_functions.refresh()
|
|
||||||
for name, source in user_functions.iteritems():
|
for name, source in user_functions.iteritems():
|
||||||
try:
|
try:
|
||||||
f = Function(name, source=source)
|
f = Function(name, source=source)
|
||||||
@ -96,6 +102,30 @@ def functions(refresh=False):
|
|||||||
ans[f.name] = f
|
ans[f.name] = f
|
||||||
return _functions
|
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):
|
class FunctionBox(EditWithComplete):
|
||||||
|
|
||||||
save_search = pyqtSignal()
|
save_search = pyqtSignal()
|
||||||
@ -107,6 +137,7 @@ class FunctionBox(EditWithComplete):
|
|||||||
self.show_saved_search_actions = show_saved_search_actions
|
self.show_saved_search_actions = show_saved_search_actions
|
||||||
self.refresh()
|
self.refresh()
|
||||||
self.setToolTip(_('Choose a function to run on matched text (by name)'))
|
self.setToolTip(_('Choose a function to run on matched text (by name)'))
|
||||||
|
boxes.append(weakref.ref(self))
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
self.update_items_cache(set(functions()))
|
self.update_items_cache(set(functions()))
|
||||||
@ -119,6 +150,76 @@ class FunctionBox(EditWithComplete):
|
|||||||
menu.addAction(_('Show saved searches'), self.show_saved_searches.emit)
|
menu.addAction(_('Show saved searches'), self.show_saved_searches.emit)
|
||||||
menu.exec_(event.globalPos())
|
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 ##########################################################
|
# Builtin functions ##########################################################
|
||||||
|
|
||||||
def builtin(name, *args):
|
def builtin(name, *args):
|
||||||
@ -128,37 +229,48 @@ def builtin(name, *args):
|
|||||||
return func
|
return func
|
||||||
return f
|
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)
|
@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,
|
'''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
|
only the text in the groups will be changed, otherwise the entire text is
|
||||||
changed.'''
|
changed.'''
|
||||||
return apply_func_to_match_groups(match, upper)
|
return apply_func_to_match_groups(match, upper)
|
||||||
|
|
||||||
@builtin('Lower-case text', lower, apply_func_to_match_groups)
|
@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,
|
'''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
|
only the text in the groups will be changed, otherwise the entire text is
|
||||||
changed.'''
|
changed.'''
|
||||||
return apply_func_to_match_groups(match, lower)
|
return apply_func_to_match_groups(match, lower)
|
||||||
|
|
||||||
@builtin('Capitalize text', capitalize, apply_func_to_match_groups)
|
@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,
|
'''Capitalize matched text. If the regular expression contains groups,
|
||||||
only the text in the groups will be changed, otherwise the entire text is
|
only the text in the groups will be changed, otherwise the entire text is
|
||||||
changed.'''
|
changed.'''
|
||||||
return apply_func_to_match_groups(match, capitalize)
|
return apply_func_to_match_groups(match, capitalize)
|
||||||
|
|
||||||
@builtin('Title-case text', titlecase, apply_func_to_match_groups)
|
@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,
|
'''Title-case matched text. If the regular expression contains groups,
|
||||||
only the text in the groups will be changed, otherwise the entire text is
|
only the text in the groups will be changed, otherwise the entire text is
|
||||||
changed.'''
|
changed.'''
|
||||||
return apply_func_to_match_groups(match, titlecase)
|
return apply_func_to_match_groups(match, titlecase)
|
||||||
|
|
||||||
@builtin('Swap the case of text', swapcase, apply_func_to_match_groups)
|
@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,
|
'''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
|
only the text in the groups will be changed, otherwise the entire text is
|
||||||
changed.'''
|
changed.'''
|
||||||
return apply_func_to_match_groups(match, swapcase)
|
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.dialogs.message_box import MessageBox
|
||||||
from calibre.gui2.widgets2 import HistoryComboBox
|
from calibre.gui2.widgets2 import HistoryComboBox
|
||||||
from calibre.gui2.tweak_book import tprefs, editors, current_container
|
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.gui2.tweak_book.widgets import BusyCursor
|
||||||
|
|
||||||
from calibre.utils.icu import primary_contains
|
from calibre.utils.icu import primary_contains
|
||||||
@ -235,10 +236,12 @@ class SearchWidget(QWidget):
|
|||||||
fhl.setContentsMargins(0, 0, 0, 0)
|
fhl.setContentsMargins(0, 0, 0, 0)
|
||||||
fhl.addWidget(fb, stretch=10, alignment=Qt.AlignVCenter)
|
fhl.addWidget(fb, stretch=10, alignment=Qt.AlignVCenter)
|
||||||
self.ae_func = b = QPushButton(_('Create/&edit'), self)
|
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'))
|
b.setToolTip(_('Create a new function, or edit an existing function'))
|
||||||
fhl.addWidget(b)
|
fhl.addWidget(b)
|
||||||
self.rm_func = b = QPushButton(_('Remo&ve'), self)
|
self.rm_func = b = QPushButton(_('Remo&ve'), self)
|
||||||
b.setToolTip(_('Remove this function'))
|
b.setToolTip(_('Remove this function'))
|
||||||
|
b.clicked.connect(self.remove_function)
|
||||||
fhl.addWidget(b)
|
fhl.addWidget(b)
|
||||||
self.fsep = f = QFrame(self)
|
self.fsep = f = QFrame(self)
|
||||||
f.setFrameShape(f.VLine)
|
f.setFrameShape(f.VLine)
|
||||||
@ -290,6 +293,17 @@ class SearchWidget(QWidget):
|
|||||||
|
|
||||||
ol.addStretch(10)
|
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):
|
def mode_changed(self, idx):
|
||||||
self.da.setVisible(idx > 0)
|
self.da.setVisible(idx > 0)
|
||||||
function_mode = idx == 2
|
function_mode = idx == 2
|
||||||
@ -557,10 +571,12 @@ class EditSearch(QFrame): # {{{
|
|||||||
la.setBuddy(f)
|
la.setBuddy(f)
|
||||||
self.ae_func = b = QPushButton(_('Create/&edit'), self)
|
self.ae_func = b = QPushButton(_('Create/&edit'), self)
|
||||||
b.setToolTip(_('Create a new function, or edit an existing function'))
|
b.setToolTip(_('Create a new function, or edit an existing function'))
|
||||||
|
b.clicked.connect(self.edit_function)
|
||||||
g.addWidget(b, 1, 1)
|
g.addWidget(b, 1, 1)
|
||||||
g.setColumnStretch(0, 10)
|
g.setColumnStretch(0, 10)
|
||||||
self.rm_func = b = QPushButton(_('Remo&ve'), self)
|
self.rm_func = b = QPushButton(_('Remo&ve'), self)
|
||||||
b.setToolTip(_('Remove this function'))
|
b.setToolTip(_('Remove this function'))
|
||||||
|
b.clicked.connect(self.remove_function)
|
||||||
g.addWidget(b, 1, 2)
|
g.addWidget(b, 1, 2)
|
||||||
|
|
||||||
self.case_sensitive = c = QCheckBox(_('Case sensitive'))
|
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_box.currentIndexChanged[int].connect(self.mode_changed)
|
||||||
self.mode_changed(self.mode_box.currentIndex())
|
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):
|
def mode_changed(self, idx):
|
||||||
self.dot_all.setVisible(idx > 0)
|
self.dot_all.setVisible(idx > 0)
|
||||||
self.functions_container.setVisible(idx == 2)
|
self.functions_container.setVisible(idx == 2)
|
||||||
@ -599,19 +626,25 @@ class EditSearch(QFrame): # {{{
|
|||||||
self.original_name = self.search.get('name', None)
|
self.original_name = self.search.get('name', None)
|
||||||
self.search_index = search_index
|
self.search_index = search_index
|
||||||
|
|
||||||
|
self.mode_box.mode = self.search.get('mode', 'regex')
|
||||||
self.search_name.setText(self.search.get('name', ''))
|
self.search_name.setText(self.search.get('name', ''))
|
||||||
self.find.setPlainText(self.search.get('find', ''))
|
self.find.setPlainText(self.search.get('find', ''))
|
||||||
|
if self.mode_box.mode == 'function':
|
||||||
|
self.function.setText(self.search.get('replace', ''))
|
||||||
|
else:
|
||||||
self.replace.setPlainText(self.search.get('replace', ''))
|
self.replace.setPlainText(self.search.get('replace', ''))
|
||||||
self.case_sensitive.setChecked(self.search.get('case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive']))
|
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.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:
|
if state is not None:
|
||||||
self.find.setPlainText(state['find'])
|
self.find.setPlainText(state['find'])
|
||||||
|
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.replace.setPlainText(state['replace'])
|
||||||
self.case_sensitive.setChecked(state['case_sensitive'])
|
self.case_sensitive.setChecked(state['case_sensitive'])
|
||||||
self.dot_all.setChecked(state['dot_all'])
|
self.dot_all.setChecked(state['dot_all'])
|
||||||
self.mode_box.mode = state.get('mode')
|
|
||||||
|
|
||||||
def emit_done(self):
|
def emit_done(self):
|
||||||
self.done.emit(True)
|
self.done.emit(True)
|
||||||
@ -1147,6 +1180,8 @@ def get_search_function(search):
|
|||||||
try:
|
try:
|
||||||
return replace_functions()[ans]
|
return replace_functions()[ans]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
if not ans:
|
||||||
|
return Function('empty-function', '')
|
||||||
raise NoSuchFunction(ans)
|
raise NoSuchFunction(ans)
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user