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 -
calibre/gui2/widgets.h
-
-
- - - - -
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