Edit Book: A text search tool for conveniently searching for text even if it crosses multiple HTML tags

Currently only the UI for the tool has been created, the actual smart
search logic has still to be implemented.
This commit is contained in:
Kovid Goyal 2016-06-02 11:50:19 +05:30
parent 1ae8d1766c
commit 7a9d29261d
6 changed files with 260 additions and 1 deletions

View File

@ -114,6 +114,7 @@ class Boss(QObject):
self.gui.central.current_editor_changed.connect(self.apply_current_editor_state)
self.gui.central.close_requested.connect(self.editor_close_requested)
self.gui.central.search_panel.search_triggered.connect(self.search)
self.gui.text_search.find_text.connect(self.find_text)
self.gui.preview.sync_requested.connect(self.sync_editor_to_preview)
self.gui.preview.split_start_requested.connect(self.split_start_requested)
self.gui.preview.split_requested.connect(self.split_requested)
@ -866,6 +867,10 @@ class Boss(QObject):
if text and text.strip():
self.gui.central.pre_fill_search(text)
def show_text_search(self):
self.gui.text_search_dock.show()
self.gui.text_search.find.setFocus(Qt.OtherFocusReason)
def search_action_triggered(self, action, overrides=None):
ss = self.gui.saved_searches.isVisible()
trigger_saved_search = ss and (not self.gui.central.search_panel.isVisible() or self.gui.saved_searches.has_focus())
@ -909,6 +914,18 @@ class Boss(QObject):
else:
self.gui.saved_searches.setFocus(Qt.OtherFocusReason)
def find_text(self, state):
from calibre.gui2.tweak_book.text_search import run_text_search
searchable_names = self.gui.file_list.searchable_names
ed = self.gui.central.current_editor
name = editor_name(ed)
if not validate_search_request(name, searchable_names, getattr(ed, 'has_marked_text', False), state, self.gui):
return
ret = run_text_search(state, ed, name, searchable_names, self.gui, self.show_editor, self.edit_file)
ed = ret is True and self.gui.central.current_editor
if getattr(ed, 'has_line_numbers', False):
ed.editor.setFocus(Qt.OtherFocusReason)
def find_word(self, word, locations):
# Go to a word from the spell check dialog
ed = self.gui.central.current_editor

View File

@ -368,6 +368,48 @@ class TextEdit(PlainTextEdit):
self.saved_matches[save_match] = (pat, m)
return True
def find_text(self, pat, wrap=False, complete=False):
reverse = pat.flags & regex.REVERSE
c = self.textCursor()
c.clearSelection()
if complete:
# Search the entire text
c.movePosition(c.End if reverse else c.Start)
pos = c.Start if reverse else c.End
if wrap and not complete:
pos = c.End if reverse else c.Start
c.movePosition(pos, c.KeepAnchor)
if hasattr(self.smarts, 'find_text'):
found, start, end = self.smarts.find_text(pat, c)
if not found:
return False
else:
raw = unicode(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')
m = pat.search(raw)
if m is None:
return False
start, end = m.span()
if start == end:
return False
if wrap and not complete:
if reverse:
textpos = c.anchor()
start, end = textpos + end, textpos + start
else:
if reverse:
# Put the cursor at the start of the match
start, end = end, start
else:
textpos = c.anchor()
start, end = textpos + start, textpos + end
c.clearSelection()
c.setPosition(start)
c.setPosition(end, c.KeepAnchor)
self.setTextCursor(c)
# Center search result on screen
self.centerCursor()
return True
def find_spell_word(self, original_words, lang, from_cursor=True, center_on_cursor=True):
c = self.textCursor()
c.setPosition(c.position())

View File

@ -286,6 +286,9 @@ class Editor(QMainWindow):
def find(self, *args, **kwargs):
return self.editor.find(*args, **kwargs)
def find_text(self, *args, **kwargs):
return self.editor.find_text(*args, **kwargs)
def find_spell_word(self, *args, **kwargs):
return self.editor.find_spell_word(*args, **kwargs)

View File

@ -1181,7 +1181,7 @@ def initialize_search_request(state, action, current_editor, current_editor_name
editor = None
where = state['where']
files = OrderedDict()
do_all = state['wrap'] or action in {'replace-all', 'count'}
do_all = state.get('wrap') or action in {'replace-all', 'count'}
marked = False
if where == 'current':
editor = current_editor

View File

@ -0,0 +1,184 @@
#!/usr/bin/env python2
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from __future__ import (unicode_literals, division, absolute_import,
print_function)
from functools import partial
from PyQt5.Qt import (
QWidget, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QPushButton, QIcon,
pyqtSignal, QFont, QCheckBox, QSizePolicy
)
from lxml.etree import tostring
from calibre import prepare_string_for_xml
from calibre.gui2 import error_dialog
from calibre.gui2.tweak_book import tprefs, editors, current_container
from calibre.gui2.tweak_book.search import get_search_regex, InvalidRegex, initialize_search_request
from calibre.gui2.tweak_book.widgets import BusyCursor
from calibre.gui2.widgets2 import HistoryComboBox
# UI {{{
class ModeBox(QComboBox):
def __init__(self, parent):
QComboBox.__init__(self, parent)
self.addItems([_('Normal'), _('Regex')])
self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _(
'''Select how the search expression is interpreted
<dl>
<dt><b>Normal</b></dt>
<dd>The search expression is treated as normal text, calibre will look for the exact text.</dd>
<dt><b>Regex</b></dt>
<dd>The search expression is interpreted as a regular expression. See the User Manual for more help on using regular expressions.</dd>
</dl>'''))
@dynamic_property
def mode(self):
def fget(self):
return ('normal', 'regex')[self.currentIndex()]
def fset(self, val):
self.setCurrentIndex({'regex':1}.get(val, 0))
return property(fget=fget, fset=fset)
class WhereBox(QComboBox):
def __init__(self, parent, emphasize=False):
QComboBox.__init__(self)
self.addItems([_('Current file'), _('All text files'), _('Selected files')])
self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _(
'''
Where to search/replace:
<dl>
<dt><b>Current file</b></dt>
<dd>Search only inside the currently opened file</dd>
<dt><b>All text files</b></dt>
<dd>Search in all text (HTML) files</dd>
<dt><b>Selected files</b></dt>
<dd>Search in the files currently selected in the Files Browser</dd>
</dl>'''))
self.emphasize = emphasize
self.ofont = QFont(self.font())
if emphasize:
f = self.emph_font = QFont(self.ofont)
f.setBold(True), f.setItalic(True)
self.setFont(f)
@dynamic_property
def where(self):
wm = {0:'current', 1:'text', 2:'selected'}
def fget(self):
return wm[self.currentIndex()]
def fset(self, val):
self.setCurrentIndex({v:k for k, v in wm.iteritems()}[val])
return property(fget=fget, fset=fset)
def showPopup(self):
# We do it like this so that the popup uses a normal font
if self.emphasize:
self.setFont(self.ofont)
QComboBox.showPopup(self)
def hidePopup(self):
if self.emphasize:
self.setFont(self.emph_font)
QComboBox.hidePopup(self)
class TextSearch(QWidget):
find_text = pyqtSignal(object)
def __init__(self, ui):
QWidget.__init__(self, ui)
self.l = l = QVBoxLayout(self)
self.la = la = QLabel(_('&Find:'))
self.find = ft = HistoryComboBox(self)
ft.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
ft.initialize('tweak_book_text_search_history')
la.setBuddy(ft)
self.h = h = QHBoxLayout()
h.addWidget(la), h.addWidget(ft), l.addLayout(h)
self.h2 = h = QHBoxLayout()
l.addLayout(h)
self.mode = m = ModeBox(self)
h.addWidget(m)
self.where_box = wb = WhereBox(self)
h.addWidget(wb)
self.cs = cs = QCheckBox(_('&Case sensitive'))
h.addWidget(cs)
self.da = da = QCheckBox(_('&Dot all'))
da.setToolTip('<p>'+_("Make the '.' special character match any character at all, including a newline"))
h.addWidget(da)
self.h3 = h = QHBoxLayout()
l.addLayout(h)
h.addStretch(10)
self.next_button = b = QPushButton(QIcon(I('arrow-down.png')), _('&Next'), self)
b.setToolTip(_('Find next match'))
h.addWidget(b), b.clicked.connect(partial(self.do_search, 'down'))
self.prev_button = b = QPushButton(QIcon(I('arrow-up.png')), _('&Previous'), self)
b.setToolTip(_('Find previous match'))
h.addWidget(b), b.clicked.connect(partial(self.do_search, 'up'))
state = tprefs.get('text_search_widget_state')
self.state = state or {}
@dynamic_property
def state(self):
def fget(self):
return {'mode': self.mode.mode, 'where':self.where_box.where, 'case_sensitive':self.cs.isChecked(), 'dot_all':self.da.isChecked()}
def fset(self, val):
self.mode.mode = val.get('mode', 'normal')
self.where_box.where = val.get('where', 'current')
self.cs.setChecked(bool(val.get('case_sensitive')))
self.da.setChecked(bool(val.get('dot_all', True)))
return property(fget=fget, fset=fset)
def save_state(self):
tprefs['text_search_widget_state'] = self.state
def do_search(self, direction='down'):
state = self.state
state['find'] = self.find.text()
state['direction'] = direction
self.find_text.emit(state)
# }}}
def run_text_search(search, current_editor, current_editor_name, searchable_names, gui_parent, show_editor, edit_file):
try:
pat = get_search_regex(search)
except InvalidRegex as e:
return error_dialog(gui_parent, _('Invalid regex'), '<p>' + _(
'The regular expression you entered is invalid: <pre>{0}</pre>With error: {1}').format(
prepare_string_for_xml(e.regex), e.message), show=True)
editor, where, files, do_all, marked = initialize_search_request(search, 'count', current_editor, current_editor_name, searchable_names)
with BusyCursor():
if editor is not None:
if editor.find_text(pat):
return True
if not files and editor.find_text(pat, wrap=True):
return True
for fname, syntax in files.iteritems():
ed = editors.get(fname, None)
if ed is not None:
if ed.find_text(pat, complete=True, save_match='gui'):
show_editor(fname)
return True
else:
root = current_container().parsed(fname)
if hasattr(root, 'xpath'):
raw = tostring(root, method='text', encoding=unicode, with_tail=True)
else:
raw = current_container().raw_data(fname)
if pat.search(raw) is not None:
edit_file(fname, syntax)
if editors[fname].find_text(pat, complete=True):
return True
msg = '<p>' + _('No matches were found for %s') % ('<pre style="font-style:italic">' + prepare_string_for_xml(search['find']) + '</pre>')
return error_dialog(gui_parent, _('Not found'), msg, show=True)

View File

@ -36,6 +36,7 @@ from calibre.gui2.tweak_book.check import Check
from calibre.gui2.tweak_book.check_links import CheckExternalLinks
from calibre.gui2.tweak_book.spell import SpellCheck
from calibre.gui2.tweak_book.search import SavedSearches
from calibre.gui2.tweak_book.text_search import TextSearch
from calibre.gui2.tweak_book.toc import TOCViewer
from calibre.gui2.tweak_book.char_select import CharSelect
from calibre.gui2.tweak_book.live_css import LiveCSS
@ -248,6 +249,7 @@ class Main(MainWindow):
self.check_book = Check(self)
self.spell_check = SpellCheck(parent=self)
self.toc_view = TOCViewer(self)
self.text_search = TextSearch(self)
self.saved_searches = SavedSearches(self)
self.image_browser = InsertImage(self, for_browsing=True)
self.reports = Reports(self)
@ -450,6 +452,8 @@ class Main(MainWindow):
self.action_go_to_line = reg(None, _('Go to &line'), self.boss.go_to_line_number, 'go-to-line-number', ('Ctrl+.',), _('Go to line number'))
self.action_saved_searches = treg('folder_saved_search.png', _('Sa&ved searches'),
self.boss.saved_searches, 'saved-searches', (), _('Show the saved searches dialog'))
self.action_text_search = treg('view.png', _('Search ignoring HTML markup'),
self.boss.show_text_search, 'text-search', (), _('Show the text search panel'))
# Check Book actions
group = _('Check Book')
@ -591,6 +595,8 @@ class Main(MainWindow):
e.addSeparator()
a(self.action_saved_searches)
e.aboutToShow.connect(self.search_menu_about_to_show)
e.addSeparator()
a(self.action_text_search)
if self.plugin_menu_actions:
e = b.addMenu(_('&Plugins'))
@ -710,6 +716,12 @@ class Main(MainWindow):
self.addDockWidget(Qt.LeftDockWidgetArea, d)
d.close() # Hidden by default
d = create(_('Text Search'), 'text-search')
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
d.setWidget(self.text_search)
self.addDockWidget(Qt.LeftDockWidgetArea, d)
d.close() # Hidden by default
d = create(_('Checkpoints'), 'checkpoints')
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
self.checkpoints = CheckpointView(self.boss.global_undo, parent=d)
@ -743,6 +755,7 @@ class Main(MainWindow):
self.central.save_state()
self.saved_searches.save_state()
self.check_book.save_state()
self.text_search.save_state()
def restore_state(self):
geom = tprefs.get('main_window_geometry', None)