diff --git a/src/calibre/ebooks/html_transform_rules.py b/src/calibre/ebooks/html_transform_rules.py
index eee54c3e6b..779805b5ed 100644
--- a/src/calibre/ebooks/html_transform_rules.py
+++ b/src/calibre/ebooks/html_transform_rules.py
@@ -3,7 +3,11 @@
# License: GPLv3 Copyright: 2016, Kovid Goyal
+from functools import partial
+
from calibre.utils.serialize import json_dumps, json_loads
+from calibre.ebooks.oeb.base import XPath
+from css_selectors.select import get_parsed_selector
class Action:
@@ -43,20 +47,46 @@ ACTION_MAP = {a.name: a for a in (
)}
+def non_empty_validator(label, val):
+ if not val:
+ return _('{} must not be empty').format(label)
+
+
+def always_valid(*a):
+ pass
+
+
+def validate_css_selector(val):
+ try:
+ get_parsed_selector(val)
+ except Exception:
+ return _('{} is not a valid CSS selector').format(val)
+
+
+def validate_xpath_selector(val):
+ try:
+ XPath(val)
+ except Exception:
+ return _('{} is not a valid XPath selector').format(val)
+
+
class Match:
- def __init__(self, name, text, placeholder=''):
+ def __init__(self, name, text, placeholder='', validator=None):
self.name = name
self.text = text
self.placeholder = placeholder
+ if validator is None and placeholder:
+ validator = partial(non_empty_validator, self.placeholder)
+ self.validator = validator or always_valid
MATCH_TYPE_MAP = {m.name: m for m in (
Match('is', _('is'), _('Tag name')),
Match('has_class', _('has class'), _('Class name')),
Match('not_has_class', _('does not have class'), _('Class name')),
- Match('css', _('matches CSS selector'), _('CSS selector')),
- Match('xpath', _('matches XPath selector'), _('XPath selector')),
+ Match('css', _('matches CSS selector'), _('CSS selector'), validate_css_selector),
+ Match('xpath', _('matches XPath selector'), _('XPath selector'), validate_xpath_selector),
Match('*', _('is any tag')),
)}
diff --git a/src/calibre/gui2/convert/regex_builder.py b/src/calibre/gui2/convert/regex_builder.py
index 34bae0dc4f..bae0263302 100644
--- a/src/calibre/gui2/convert/regex_builder.py
+++ b/src/calibre/gui2/convert/regex_builder.py
@@ -6,14 +6,14 @@ import os
from contextlib import suppress
from qt.core import (
QApplication, QBrush, QByteArray, QDialog, QDialogButtonBox, Qt, QTextCursor,
- QTextEdit, QWidget, pyqtSignal
+ QTextEdit, pyqtSignal
)
from calibre.constants import iswindows
from calibre.ebooks.conversion.search_replace import compile_regular_expression
from calibre.gui2 import choose_files, error_dialog, gprefs
from calibre.gui2.convert.regex_builder_ui import Ui_RegexBuilder
-from calibre.gui2.convert.xexp_edit_ui import Ui_Form as Ui_Edit
+from calibre.gui2.convert.xpath_wizard import XPathEdit
from calibre.gui2.dialogs.choose_format import ChooseFormatDialog
from calibre.ptempfile import TemporaryFile
from calibre.utils.icu import utf16_length
@@ -212,20 +212,19 @@ class RegexBuilder(QDialog, Ui_RegexBuilder):
return str(self.preview.toPlainText())
-class RegexEdit(QWidget, Ui_Edit):
+class RegexEdit(XPathEdit):
doc_update = pyqtSignal(str)
def __init__(self, parent=None):
- QWidget.__init__(self, parent)
- self.setupUi(self)
+ super().__init__(parent)
self.edit.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive)
-
self.book_id = None
self.db = None
self.doc_cache = None
- self.button.clicked.connect(self.builder)
+ def wizard(self):
+ return self.builder()
def builder(self):
if self.db is None:
@@ -244,7 +243,7 @@ class RegexEdit(QWidget, Ui_Edit):
return self.doc_cache
def setObjectName(self, *args):
- QWidget.setObjectName(self, *args)
+ super().setObjectName(*args)
if hasattr(self, 'edit'):
self.edit.initialize('regex_edit_'+str(self.objectName()))
diff --git a/src/calibre/gui2/convert/xexp_edit.ui b/src/calibre/gui2/convert/xexp_edit.ui
deleted file mode 100644
index 68c0c8c98e..0000000000
--- a/src/calibre/gui2/convert/xexp_edit.ui
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 430
- 74
-
-
-
- Form
-
-
- -
-
-
-
-
-
- TextLabel
-
-
- true
-
-
- edit
-
-
-
- -
-
-
-
- 100
- 0
-
-
-
-
- 350
- 0
-
-
-
- QComboBox::AdjustToMinimumContentsLengthWithIcon
-
-
- 30
-
-
-
-
-
- -
-
-
- Use a wizard to help construct the Regular expression
-
-
- ...
-
-
-
- :/images/wizard.png:/images/wizard.png
-
-
-
- 40
- 40
-
-
-
-
-
-
-
-
- HistoryLineEdit
- QComboBox
-
-
-
-
-
-
-
-
diff --git a/src/calibre/gui2/convert/xpath_wizard.py b/src/calibre/gui2/convert/xpath_wizard.py
index 3a43ffffc2..a6c88627b7 100644
--- a/src/calibre/gui2/convert/xpath_wizard.py
+++ b/src/calibre/gui2/convert/xpath_wizard.py
@@ -6,10 +6,13 @@ __license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-from qt.core import QDialog, QWidget, Qt, QDialogButtonBox, QVBoxLayout
+from qt.core import (
+ QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QIcon, QLabel, QSize, Qt,
+ QToolButton, QVBoxLayout, QWidget
+)
from calibre.gui2.convert.xpath_wizard_ui import Ui_Form
-from calibre.gui2.convert.xexp_edit_ui import Ui_Form as Ui_Edit
+from calibre.gui2.widgets import HistoryLineEdit
from calibre.utils.localization import localize_user_manual_link
@@ -65,12 +68,37 @@ class Wizard(QDialog):
return self.widget.xpath
-class XPathEdit(QWidget, Ui_Edit):
+class XPathEdit(QWidget):
- def __init__(self, parent=None):
+ def __init__(self, parent=None, object_name='', show_msg=True):
QWidget.__init__(self, parent)
- self.setupUi(self)
- self.button.clicked.connect(self.wizard)
+ self.h = h = QHBoxLayout(self)
+ h.setContentsMargins(0, 0, 0, 0)
+ self.l = l = QVBoxLayout()
+ h.addLayout(l)
+ self.button = b = QToolButton(self)
+ b.setIcon(QIcon(I('wizard.png')))
+ b.setToolTip(_('Use a wizard to generate the XPath expression'))
+ b.clicked.connect(self.wizard)
+ h.addWidget(b)
+ self.edit = e = HistoryLineEdit(self)
+ e.setMinimumWidth(350)
+ e.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
+ e.setMinimumContentsLength(30)
+ self.msg = QLabel('')
+ l.addWidget(self.msg)
+ l.addWidget(self.edit)
+ if object_name:
+ self.setObjectName(object_name)
+ if show_msg:
+ b.setIconSize(QSize(40, 40))
+ self.msg.setBuddy(self.edit)
+ else:
+ self.msg.setVisible(False)
+ l.setContentsMargins(0, 0, 0, 0)
+
+ def setPlaceholderText(self, val):
+ self.edit.setPlaceholderText(val)
def wizard(self):
wiz = Wizard(self)
@@ -89,6 +117,11 @@ class XPathEdit(QWidget, Ui_Edit):
def text(self):
return str(self.edit.text())
+ @text.setter
+ def text(self, val):
+ self.edit.setText(str(val))
+ value = text
+
@property
def xpath(self):
return self.text
diff --git a/src/calibre/gui2/convert/xpath_wizard.ui b/src/calibre/gui2/convert/xpath_wizard.ui
index d7c7f7a89b..615d7039c1 100644
--- a/src/calibre/gui2/convert/xpath_wizard.ui
+++ b/src/calibre/gui2/convert/xpath_wizard.ui
@@ -102,7 +102,11 @@
-
-
+
+
+ true
+
+
-
diff --git a/src/calibre/gui2/html_transform_rules.py b/src/calibre/gui2/html_transform_rules.py
index fcf27a6b16..a1b7540e5a 100644
--- a/src/calibre/gui2/html_transform_rules.py
+++ b/src/calibre/gui2/html_transform_rules.py
@@ -15,13 +15,13 @@ from calibre.ebooks.html_transform_rules import (
validate_rule
)
from calibre.gui2 import choose_files, choose_save_file, elided_text, error_dialog
+from calibre.gui2.convert.xpath_wizard import XPathEdit
from calibre.gui2.tag_mapper import (
RuleEditDialog as RuleEditDialogBase, RuleItem as RuleItemBase,
Rules as RulesBase, RulesDialog as RulesDialogBase, SaveLoadMixin
)
from calibre.gui2.widgets2 import Dialog
from calibre.utils.config import JSONConfig
-from calibre.utils.localization import localize_user_manual_link
class TagAction(QWidget):
@@ -138,6 +138,44 @@ class ActionsContainer(QScrollArea):
self.new_action().as_dict = entry
+class GenericEdit(QLineEdit):
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setClearButtonEnabled(True)
+
+ @property
+ def value(self):
+ return self.text()
+
+ @value.setter
+ def value(self, val):
+ self.setText(str(val))
+
+
+class CSSEdit(QWidget):
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ l = QHBoxLayout(self)
+ l.setContentsMargins(0, 0, 0, 0)
+ self.edit = le = GenericEdit(self)
+ l.addWidget(le)
+ l.addSpacing(5)
+ self.la = la = QLabel(_('CSS selector help').format('https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Selectors'))
+ la.setOpenExternalLinks(True)
+ l.addWidget(la)
+ self.setPlaceholderText = self.edit.setPlaceholderText
+
+ @property
+ def value(self):
+ return self.edit.value
+
+ @value.setter
+ def value(self, val):
+ self.edit.value = val
+
+
class RuleEdit(QWidget): # {{{
MSG = _('Create the rule to transform HTML tags below')
@@ -152,7 +190,7 @@ class RuleEdit(QWidget): # {{{
l.addWidget(la)
l.addLayout(h)
english_sentence = '{preamble} {match_type}'
- sentence = _('{preamble} {match_type} {query}')
+ sentence = _('{preamble} {match_type}')
if set(sentence.split()) != set(english_sentence.split()):
sentence = english_sentence
parts = sentence.split()
@@ -168,15 +206,10 @@ class RuleEdit(QWidget): # {{{
if clause is not parts[-1]:
h.addWidget(QLabel('\xa0'))
h.addStretch(1)
- self.hl = h = QHBoxLayout()
- l.addLayout(h)
- self.query = q = QLineEdit(self)
- q.setClearButtonEnabled(True)
- h.addWidget(q)
- h.addSpacing(20)
- self.query_help_label = la = QLabel(self)
- la.setOpenExternalLinks(True)
- h.addWidget(la)
+ self.generic_query = gq = GenericEdit(self)
+ self.css_query = cq = CSSEdit(self)
+ self.xpath_query = xq = XPathEdit(self, object_name='html_transform_rules_xpath', show_msg=False)
+ l.addWidget(gq), l.addWidget(cq), l.addWidget(xq)
self.thenl = QLabel(_('Then:'))
l.addWidget(self.thenl)
@@ -193,28 +226,29 @@ class RuleEdit(QWidget): # {{{
a.setWidth(a.width() + 125)
return a
+ @property
+ def current_query_widget(self):
+ return {'css': self.css_query, 'xpath': self.xpath_query}.get(self.match_type.currentData(), self.generic_query)
+
def update_state(self):
r = self.rule
mt = r['match_type']
- self.query.setVisible(mt != '*')
- self.query.setPlaceholderText(MATCH_TYPE_MAP[mt].placeholder)
- self.query_help_label.setVisible(mt in ('css', 'xpath'))
- if self.query_help_label.isVisible():
- if mt == 'css':
- url = 'https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Selectors'
- text = _('CSS selector help')
- else:
- url = localize_user_manual_link('https://manual.calibre-ebook.com/xpath.html')
- text = _('XPath selector help')
- self.query_help_label.setText(f'{text}')
+ self.generic_query.setVisible(False), self.css_query.setVisible(False), self.xpath_query.setVisible(False)
+ self.current_query_widget.setVisible(True)
+ self.current_query_widget.setPlaceholderText(MATCH_TYPE_MAP[mt].placeholder)
@property
def rule(self):
- return {
- 'match_type': self.match_type.currentData(),
- 'query': self.query.text().strip(),
- 'actions': self.actions.as_list,
- }
+ try:
+ return {
+ 'match_type': self.match_type.currentData(),
+ 'query': self.current_query_widget.value,
+ 'actions': self.actions.as_list,
+ }
+ except Exception:
+ import traceback
+ traceback.print_exc()
+ raise
@rule.setter
def rule(self, rule):
@@ -222,7 +256,7 @@ class RuleEdit(QWidget): # {{{
c = getattr(self, name)
c.setCurrentIndex(max(0, c.findData(str(rule.get(name, '')))))
sc('match_type')
- self.query.setText(str(rule.get('query', '')).strip())
+ self.current_query_widget.value = str(rule.get('query', '')).strip()
self.actions.as_list = rule.get('actions') or []
self.update_state()
@@ -419,7 +453,7 @@ if __name__ == '__main__':
app = Application([])
d = RulesDialog()
d.rules = [
- {'match_type':'*', 'query':'', 'actions':[{'type': 'remove'}]},
+ {'match_type':'xpath', 'query':'//h:h2', 'actions':[{'type': 'remove'}]},
]
d.exec_()
from pprint import pprint