Use a wizard for XPath input

This commit is contained in:
Kovid Goyal 2021-11-10 10:52:34 +05:30
parent 8d4966f14b
commit 790b1a2b37
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 147 additions and 136 deletions

View File

@ -3,7 +3,11 @@
# License: GPLv3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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')),
)}

View File

@ -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()))

View File

@ -1,89 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>430</width>
<height>74</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="msg">
<property name="text">
<string>TextLabel</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="buddy">
<cstring>edit</cstring>
</property>
</widget>
</item>
<item>
<widget class="HistoryLineEdit" name="edit">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>350</width>
<height>0</height>
</size>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
</property>
<property name="minimumContentsLength">
<number>30</number>
</property>
</widget>
</item>
</layout>
</item>
<item row="0" column="1">
<widget class="QToolButton" name="button">
<property name="toolTip">
<string>Use a wizard to help construct the Regular expression</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/wizard.png</normaloff>:/images/wizard.png</iconset>
</property>
<property name="iconSize">
<size>
<width>40</width>
<height>40</height>
</size>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>HistoryLineEdit</class>
<extends>QComboBox</extends>
<header>calibre/gui2/widgets.h</header>
</customwidget>
</customwidgets>
<resources>
<include location="../../../../resources/images.qrc"/>
</resources>
<connections/>
</ui>

View File

@ -6,10 +6,13 @@ __license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__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

View File

@ -102,7 +102,11 @@
</widget>
</item>
<item>
<widget class="QLineEdit" name="attribute"/>
<widget class="QLineEdit" name="attribute">
<property name="clearButtonEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_3">

View File

@ -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(_('<a href="{}">CSS selector help</a>').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'<a href="{url}">{text}</a>')
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