From 7a9d29261dec1520b08a82d57b0b2f2966cd8295 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 2 Jun 2016 11:50:19 +0530 Subject: [PATCH] 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. --- src/calibre/gui2/tweak_book/boss.py | 17 ++ src/calibre/gui2/tweak_book/editor/text.py | 42 +++++ src/calibre/gui2/tweak_book/editor/widget.py | 3 + src/calibre/gui2/tweak_book/search.py | 2 +- src/calibre/gui2/tweak_book/text_search.py | 184 +++++++++++++++++++ src/calibre/gui2/tweak_book/ui.py | 13 ++ 6 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/calibre/gui2/tweak_book/text_search.py diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index d3eaae755f..81fdb2e28c 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -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 diff --git a/src/calibre/gui2/tweak_book/editor/text.py b/src/calibre/gui2/tweak_book/editor/text.py index 3424bbef2f..4c26b131cd 100644 --- a/src/calibre/gui2/tweak_book/editor/text.py +++ b/src/calibre/gui2/tweak_book/editor/text.py @@ -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()) diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py index 519b010384..a1aed66e38 100644 --- a/src/calibre/gui2/tweak_book/editor/widget.py +++ b/src/calibre/gui2/tweak_book/editor/widget.py @@ -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) diff --git a/src/calibre/gui2/tweak_book/search.py b/src/calibre/gui2/tweak_book/search.py index 4a0e9f3a47..4986bc179e 100644 --- a/src/calibre/gui2/tweak_book/search.py +++ b/src/calibre/gui2/tweak_book/search.py @@ -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 diff --git a/src/calibre/gui2/tweak_book/text_search.py b/src/calibre/gui2/tweak_book/text_search.py new file mode 100644 index 0000000000..1c7cc626a7 --- /dev/null +++ b/src/calibre/gui2/tweak_book/text_search.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2016, Kovid Goyal + +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('' + _( + '''Select how the search expression is interpreted +
+
Normal
+
The search expression is treated as normal text, calibre will look for the exact text.
+
Regex
+
The search expression is interpreted as a regular expression. See the User Manual for more help on using regular expressions.
+
''')) + + @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('' + _( + ''' + Where to search/replace: +
+
Current file
+
Search only inside the currently opened file
+
All text files
+
Search in all text (HTML) files
+
Selected files
+
Search in the files currently selected in the Files Browser
+
''')) + 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('

'+_("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'), '

' + _( + 'The regular expression you entered is invalid:

{0}
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 = '

' + _('No matches were found for %s') % ('

' + prepare_string_for_xml(search['find']) + '
') + return error_dialog(gui_parent, _('Not found'), msg, show=True) diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py index ec8b4fab3e..91a69ba467 100644 --- a/src/calibre/gui2/tweak_book/ui.py +++ b/src/calibre/gui2/tweak_book/ui.py @@ -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)