mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
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:
parent
1ae8d1766c
commit
7a9d29261d
@ -114,6 +114,7 @@ class Boss(QObject):
|
|||||||
self.gui.central.current_editor_changed.connect(self.apply_current_editor_state)
|
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.close_requested.connect(self.editor_close_requested)
|
||||||
self.gui.central.search_panel.search_triggered.connect(self.search)
|
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.sync_requested.connect(self.sync_editor_to_preview)
|
||||||
self.gui.preview.split_start_requested.connect(self.split_start_requested)
|
self.gui.preview.split_start_requested.connect(self.split_start_requested)
|
||||||
self.gui.preview.split_requested.connect(self.split_requested)
|
self.gui.preview.split_requested.connect(self.split_requested)
|
||||||
@ -866,6 +867,10 @@ class Boss(QObject):
|
|||||||
if text and text.strip():
|
if text and text.strip():
|
||||||
self.gui.central.pre_fill_search(text)
|
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):
|
def search_action_triggered(self, action, overrides=None):
|
||||||
ss = self.gui.saved_searches.isVisible()
|
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())
|
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:
|
else:
|
||||||
self.gui.saved_searches.setFocus(Qt.OtherFocusReason)
|
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):
|
def find_word(self, word, locations):
|
||||||
# Go to a word from the spell check dialog
|
# Go to a word from the spell check dialog
|
||||||
ed = self.gui.central.current_editor
|
ed = self.gui.central.current_editor
|
||||||
|
@ -368,6 +368,48 @@ class TextEdit(PlainTextEdit):
|
|||||||
self.saved_matches[save_match] = (pat, m)
|
self.saved_matches[save_match] = (pat, m)
|
||||||
return True
|
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):
|
def find_spell_word(self, original_words, lang, from_cursor=True, center_on_cursor=True):
|
||||||
c = self.textCursor()
|
c = self.textCursor()
|
||||||
c.setPosition(c.position())
|
c.setPosition(c.position())
|
||||||
|
@ -286,6 +286,9 @@ class Editor(QMainWindow):
|
|||||||
def find(self, *args, **kwargs):
|
def find(self, *args, **kwargs):
|
||||||
return self.editor.find(*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):
|
def find_spell_word(self, *args, **kwargs):
|
||||||
return self.editor.find_spell_word(*args, **kwargs)
|
return self.editor.find_spell_word(*args, **kwargs)
|
||||||
|
|
||||||
|
@ -1181,7 +1181,7 @@ def initialize_search_request(state, action, current_editor, current_editor_name
|
|||||||
editor = None
|
editor = None
|
||||||
where = state['where']
|
where = state['where']
|
||||||
files = OrderedDict()
|
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
|
marked = False
|
||||||
if where == 'current':
|
if where == 'current':
|
||||||
editor = current_editor
|
editor = current_editor
|
||||||
|
184
src/calibre/gui2/tweak_book/text_search.py
Normal file
184
src/calibre/gui2/tweak_book/text_search.py
Normal 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)
|
@ -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.check_links import CheckExternalLinks
|
||||||
from calibre.gui2.tweak_book.spell import SpellCheck
|
from calibre.gui2.tweak_book.spell import SpellCheck
|
||||||
from calibre.gui2.tweak_book.search import SavedSearches
|
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.toc import TOCViewer
|
||||||
from calibre.gui2.tweak_book.char_select import CharSelect
|
from calibre.gui2.tweak_book.char_select import CharSelect
|
||||||
from calibre.gui2.tweak_book.live_css import LiveCSS
|
from calibre.gui2.tweak_book.live_css import LiveCSS
|
||||||
@ -248,6 +249,7 @@ class Main(MainWindow):
|
|||||||
self.check_book = Check(self)
|
self.check_book = Check(self)
|
||||||
self.spell_check = SpellCheck(parent=self)
|
self.spell_check = SpellCheck(parent=self)
|
||||||
self.toc_view = TOCViewer(self)
|
self.toc_view = TOCViewer(self)
|
||||||
|
self.text_search = TextSearch(self)
|
||||||
self.saved_searches = SavedSearches(self)
|
self.saved_searches = SavedSearches(self)
|
||||||
self.image_browser = InsertImage(self, for_browsing=True)
|
self.image_browser = InsertImage(self, for_browsing=True)
|
||||||
self.reports = Reports(self)
|
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_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.action_saved_searches = treg('folder_saved_search.png', _('Sa&ved searches'),
|
||||||
self.boss.saved_searches, 'saved-searches', (), _('Show the saved searches dialog'))
|
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
|
# Check Book actions
|
||||||
group = _('Check Book')
|
group = _('Check Book')
|
||||||
@ -591,6 +595,8 @@ class Main(MainWindow):
|
|||||||
e.addSeparator()
|
e.addSeparator()
|
||||||
a(self.action_saved_searches)
|
a(self.action_saved_searches)
|
||||||
e.aboutToShow.connect(self.search_menu_about_to_show)
|
e.aboutToShow.connect(self.search_menu_about_to_show)
|
||||||
|
e.addSeparator()
|
||||||
|
a(self.action_text_search)
|
||||||
|
|
||||||
if self.plugin_menu_actions:
|
if self.plugin_menu_actions:
|
||||||
e = b.addMenu(_('&Plugins'))
|
e = b.addMenu(_('&Plugins'))
|
||||||
@ -710,6 +716,12 @@ class Main(MainWindow):
|
|||||||
self.addDockWidget(Qt.LeftDockWidgetArea, d)
|
self.addDockWidget(Qt.LeftDockWidgetArea, d)
|
||||||
d.close() # Hidden by default
|
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 = create(_('Checkpoints'), 'checkpoints')
|
||||||
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
|
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
|
||||||
self.checkpoints = CheckpointView(self.boss.global_undo, parent=d)
|
self.checkpoints = CheckpointView(self.boss.global_undo, parent=d)
|
||||||
@ -743,6 +755,7 @@ class Main(MainWindow):
|
|||||||
self.central.save_state()
|
self.central.save_state()
|
||||||
self.saved_searches.save_state()
|
self.saved_searches.save_state()
|
||||||
self.check_book.save_state()
|
self.check_book.save_state()
|
||||||
|
self.text_search.save_state()
|
||||||
|
|
||||||
def restore_state(self):
|
def restore_state(self):
|
||||||
geom = tprefs.get('main_window_geometry', None)
|
geom = tprefs.get('main_window_geometry', None)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user