From 91a993643eadc1d288b79c60bad21e871f2ef5a1 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 20 Mar 2014 17:58:39 +0530 Subject: [PATCH] Refactor the search code in preparation for multi-searches --- src/calibre/gui2/tweak_book/boss.py | 178 ++---------------- src/calibre/gui2/tweak_book/char_select.py | 3 +- src/calibre/gui2/tweak_book/check.py | 3 +- src/calibre/gui2/tweak_book/search.py | 207 +++++++++++++++++++-- src/calibre/gui2/tweak_book/widgets.py | 10 +- 5 files changed, 210 insertions(+), 191 deletions(-) diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index c7ad6d53f7..9c2168a43b 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -7,14 +7,14 @@ __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal ' import tempfile, shutil, sys, os -from collections import OrderedDict from functools import partial, wraps from PyQt4.Qt import ( - QObject, QApplication, QDialog, QGridLayout, QLabel, QSize, Qt, QCursor, - QDialogButtonBox, QIcon, QTimer, QPixmap, QTextBrowser, QVBoxLayout, QInputDialog) + QObject, QApplication, QDialog, QGridLayout, QLabel, QSize, Qt, + QDialogButtonBox, QIcon, QTimer, QPixmap, QTextBrowser, QVBoxLayout, + QInputDialog) -from calibre import prints, prepare_string_for_xml, isbytestring +from calibre import prints, isbytestring from calibre.ptempfile import PersistentTemporaryDirectory, TemporaryDirectory from calibre.ebooks.oeb.base import urlnormalize from calibre.ebooks.oeb.polish.main import SUPPORTED, tweak_polish @@ -27,7 +27,6 @@ from calibre.ebooks.oeb.polish.toc import remove_names_from_toc, find_existing_t from calibre.ebooks.oeb.polish.utils import link_stylesheets, setup_cssutils_serialization as scs from calibre.gui2 import error_dialog, choose_files, question_dialog, info_dialog, choose_save_file from calibre.gui2.dialogs.confirm_delete import confirm -from calibre.gui2.dialogs.message_box import MessageBox from calibre.gui2.tweak_book import set_current_container, current_container, tprefs, actions, editors from calibre.gui2.tweak_book.undo import GlobalUndoHistory from calibre.gui2.tweak_book.file_list import NewFileDialog @@ -37,8 +36,10 @@ from calibre.gui2.tweak_book.toc import TOCEditor from calibre.gui2.tweak_book.editor import editor_from_syntax, syntax_from_mime from calibre.gui2.tweak_book.editor.insert_resource import get_resource_data, NewBook from calibre.gui2.tweak_book.preferences import Preferences +from calibre.gui2.tweak_book.search import validate_search_request, run_search from calibre.gui2.tweak_book.widgets import ( - RationalizeFolders, MultiSplit, ImportForeign, QuickOpen, InsertLink, InsertSemantics) + RationalizeFolders, MultiSplit, ImportForeign, QuickOpen, InsertLink, + InsertSemantics, BusyCursor) _diff_dialogs = [] @@ -58,14 +59,6 @@ def get_container(*args, **kwargs): def setup_cssutils_serialization(): scs(tprefs['editor_tab_stop_width']) -class BusyCursor(object): - - def __enter__(self): - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - - def __exit__(self, *args): - QApplication.restoreOverrideCursor() - def in_thread_job(func): @wraps(func) def ans(*args, **kwargs): @@ -675,7 +668,7 @@ class Boss(QObject): # Ensure the search panel is visible sp.setVisible(True) ed = self.gui.central.current_editor - name = editor = None + name = None for n, x in editors.iteritems(): if x is ed: name = n @@ -684,158 +677,11 @@ class Boss(QObject): if overrides: state.update(overrides) searchable_names = self.gui.file_list.searchable_names - where = state['where'] - err = None - if name is None and where in {'current', 'selected-text'}: - err = _('No file is being edited.') - elif where == 'selected' and not searchable_names['selected']: - err = _('No files are selected in the Files Browser') - elif where == 'selected-text' and not ed.has_marked_text: - err = _('No text is marked. First select some text, and then use' - ' The "Mark selected text" action in the Search menu to mark it.') - if not err and not state['find']: - err = _('No search query specified') - if err: - return error_dialog(self.gui, _('Cannot search'), err, show=True) - del err + if not validate_search_request(name, searchable_names, getattr(ed, 'has_marked_text', False), state, self.gui): + return - files = OrderedDict() - do_all = state['wrap'] or action in {'replace-all', 'count'} - marked = False - if where == 'current': - editor = ed - elif where in {'styles', 'text', 'selected'}: - files = searchable_names[where] - if name in files: - # Start searching in the current editor - editor = ed - # Re-order the list of other files so that we search in the same - # order every time. Depending on direction, search the files - # that come after the current file, or before the current file, - # first. - lfiles = list(files) - idx = lfiles.index(name) - before, after = lfiles[:idx], lfiles[idx+1:] - if state['direction'] == 'up': - lfiles = list(reversed(before)) - if do_all: - lfiles += list(reversed(after)) + [name] - else: - lfiles = after - if do_all: - lfiles += before + [name] - files = OrderedDict((m, files[m]) for m in lfiles) - else: - editor = ed - marked = True - - def no_match(): - QApplication.restoreOverrideCursor() - msg = '

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

' + prepare_string_for_xml(state['find']) + '
') - if not state['wrap']: - msg += '

' + _('You have turned off search wrapping, so all text might not have been searched.' - ' Try the search again, with wrapping enabled. Wrapping is enabled via the' - ' "Wrap" checkbox at the bottom of the search panel.') - return error_dialog( - self.gui, _('Not found'), msg, show=True) - - pat = sp.get_regex(state) - - def do_find(): - if editor is not None: - if editor.find(pat, marked=marked, save_match='gui'): - return - if not files: - if not state['wrap']: - return no_match() - return editor.find(pat, wrap=True, marked=marked, save_match='gui') or no_match() - for fname, syntax in files.iteritems(): - if fname in editors: - if not editors[fname].find(pat, complete=True, save_match='gui'): - continue - return self.show_editor(fname) - raw = current_container().raw_data(fname) - if pat.search(raw) is not None: - self.edit_file(fname, syntax) - if editors[fname].find(pat, complete=True, save_match='gui'): - return - return no_match() - - def no_replace(prefix=''): - QApplication.restoreOverrideCursor() - if prefix: - prefix += ' ' - error_dialog( - self.gui, _('Cannot replace'), prefix + _( - 'You must first click Find, before trying to replace'), show=True) - return False - - def do_replace(): - if editor is None: - return no_replace() - if not editor.replace(pat, state['replace'], saved_match='gui'): - return no_replace(_( - 'Currently selected text does not match the search query.')) - return True - - def count_message(action, count, show_diff=False): - msg = _('%(action)s %(num)s occurrences of %(query)s' % dict(num=count, query=state['find'], action=action)) - if show_diff and count > 0: - d = MessageBox(MessageBox.INFO, _('Searching done'), prepare_string_for_xml(msg), parent=self.gui, show_copy_button=False) - d.diffb = b = d.bb.addButton(_('See what &changed'), d.bb.ActionRole) - b.setIcon(QIcon(I('diff.png'))), d.set_details(None), b.clicked.connect(d.accept) - b.clicked.connect(partial(self.show_current_diff, allow_revert=True)) - d.exec_() - else: - info_dialog(self.gui, _('Searching done'), prepare_string_for_xml(msg), show=True) - - def do_all(replace=True): - count = 0 - if not files and editor is None: - return 0 - lfiles = files or {name:editor.syntax} - - for n, syntax in lfiles.iteritems(): - if n in editors: - raw = editors[n].get_raw_data() - else: - raw = current_container().raw_data(n) - if replace: - raw, num = pat.subn(state['replace'], raw) - else: - num = len(pat.findall(raw)) - count += num - if replace and num > 0: - if n in editors: - editors[n].replace_data(raw) - else: - with current_container().open(n, 'wb') as f: - f.write(raw.encode('utf-8')) - QApplication.restoreOverrideCursor() - count_message(_('Replaced') if replace else _('Found'), count, show_diff=replace) - return count - - with BusyCursor(): - if action == 'find': - return do_find() - if action == 'replace': - return do_replace() - if action == 'replace-find' and do_replace(): - return do_find() - if action == 'replace-all': - if marked: - return count_message(_('Replaced'), editor.all_in_marked(pat, state['replace'])) - self.add_savepoint(_('Before: Replace all')) - count = do_all() - if count == 0: - self.rewind_savepoint() - else: - self.set_modified() - return - if action == 'count': - if marked: - return count_message(_('Found'), editor.all_in_marked(pat)) - return do_all(replace=False) + run_search(state, action, ed, name, searchable_names, + self.gui, self.show_editor, self.edit_file, self.show_current_diff, self.add_savepoint, self.rewind_savepoint, self.set_modified) def create_checkpoint(self): text, ok = QInputDialog.getText(self.gui, _('Choose name'), _( diff --git a/src/calibre/gui2/tweak_book/char_select.py b/src/calibre/gui2/tweak_book/char_select.py index 9530087210..46d1f4b254 100644 --- a/src/calibre/gui2/tweak_book/char_select.py +++ b/src/calibre/gui2/tweak_book/char_select.py @@ -21,7 +21,7 @@ from calibre.constants import plugins, cache_dir from calibre.gui2 import NONE from calibre.gui2.widgets2 import HistoryLineEdit2 from calibre.gui2.tweak_book import tprefs -from calibre.gui2.tweak_book.widgets import Dialog +from calibre.gui2.tweak_book.widgets import Dialog, BusyCursor from calibre.utils.icu import safe_chr as chr, icu_unicode_version, character_name_from_code ROOT = QModelIndex() @@ -765,7 +765,6 @@ class CharSelect(Dialog): self.char_view.setFocus(Qt.OtherFocusReason) def do_search(self): - from calibre.gui2.tweak_book.boss import BusyCursor text = unicode(self.search.text()).strip() if not text: return self.clear_search() diff --git a/src/calibre/gui2/tweak_book/check.py b/src/calibre/gui2/tweak_book/check.py index 2c248b4c9c..fba761dbee 100644 --- a/src/calibre/gui2/tweak_book/check.py +++ b/src/calibre/gui2/tweak_book/check.py @@ -15,6 +15,7 @@ from PyQt4.Qt import ( from calibre.ebooks.oeb.polish.check.base import WARN, INFO, DEBUG, ERROR, CRITICAL from calibre.ebooks.oeb.polish.check.main import run_checks, fix_errors from calibre.gui2.tweak_book import tprefs +from calibre.gui2.tweak_book.widgets import BusyCursor def icon_for_level(level): if level > WARN: @@ -160,7 +161,6 @@ class Check(QSplitter): template % (err.HELP, ifix, fix_tt, fix_msg, run_tt, run_msg)) def run_checks(self, container): - from calibre.gui2.tweak_book.boss import BusyCursor with BusyCursor(): self.show_busy() QApplication.processEvents() @@ -179,7 +179,6 @@ class Check(QSplitter): self.clear_help(_('No problems found')) def fix_errors(self, container, errors): - from calibre.gui2.tweak_book.boss import BusyCursor with BusyCursor(): self.show_busy(_('Running fixers, please wait...')) QApplication.processEvents() diff --git a/src/calibre/gui2/tweak_book/search.py b/src/calibre/gui2/tweak_book/search.py index 6f8b1dfba1..80c365ed82 100644 --- a/src/calibre/gui2/tweak_book/search.py +++ b/src/calibre/gui2/tweak_book/search.py @@ -7,6 +7,7 @@ __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal ' from functools import partial +from collections import OrderedDict from PyQt4.Qt import ( QWidget, QToolBar, Qt, QHBoxLayout, QSize, QIcon, QGridLayout, QLabel, @@ -16,10 +17,12 @@ from PyQt4.Qt import ( import regex -from calibre.gui2 import NONE, error_dialog +from calibre import prepare_string_for_xml +from calibre.gui2 import NONE, error_dialog, info_dialog +from calibre.gui2.dialogs.message_box import MessageBox from calibre.gui2.widgets2 import HistoryLineEdit2 -from calibre.gui2.tweak_book import tprefs -from calibre.gui2.tweak_book.widgets import Dialog +from calibre.gui2.tweak_book import tprefs, editors, current_container +from calibre.gui2.tweak_book.widgets import Dialog, BusyCursor from calibre.utils.icu import primary_contains @@ -332,22 +335,6 @@ class SearchPanel(QWidget): # {{{ def set_where(self, val): self.widget.where = val - def get_regex(self, state): - raw = state['find'] - if state['mode'] != 'regex': - raw = regex.escape(raw, special_only=True) - flags = REGEX_FLAGS - if not state['case_sensitive']: - flags |= regex.IGNORECASE - if state['mode'] == 'regex' and state['dot_all']: - flags |= regex.DOTALL - if state['direction'] == 'up': - flags |= regex.REVERSE - ans = regex_cache.get((flags, raw), None) - if ans is None: - ans = regex_cache[(flags, raw)] = regex.compile(raw, flags=flags) - return ans - def keyPressEvent(self, ev): if ev.key() == Qt.Key_Escape: self.hide_panel() @@ -544,7 +531,7 @@ class SavedSearches(Dialog): (_('&Replace'), 'replace', _('Run replace using the selected entries.') + mulmsg), (_('Replace a&nd Find'), 'replace-find', _('Run replace and then find using the selected entries.') + mulmsg), (_('Replace &all'), 'replace-all', _('Run Replace All for all selected entries in the order selected')), - (_('&Count all'), 'count-all', _('Run Count All for all selected entries')), + (_('&Count all'), 'count', _('Run Count All for all selected entries')), ]: b = pb(text, tooltip) v.addWidget(b) @@ -642,6 +629,7 @@ class SavedSearches(Dialog): search['wrap'] = self.wrap search['direction'] = self.direction search['where'] = self.where + search['mode'] = 'regex' searches.append(search) if not searches: return @@ -689,6 +677,185 @@ class SavedSearches(Dialog): self.description.setText(_('{2}\nFind: {0}\nReplace: {1}').format( search.get('find', ''), search.get('replace', ''), search.get('name', ''))) +def validate_search_request(name, searchable_names, has_marked_text, state, gui_parent): + err = None + where = state['where'] + if name is None and where in {'current', 'selected-text'}: + err = _('No file is being edited.') + elif where == 'selected' and not searchable_names['selected']: + err = _('No files are selected in the Files Browser') + elif where == 'selected-text' and not has_marked_text: + err = _('No text is marked. First select some text, and then use' + ' The "Mark selected text" action in the Search menu to mark it.') + if not err and not state['find']: + err = _('No search query specified') + if err: + error_dialog(gui_parent, _('Cannot search'), err, show=True) + return False + return True + +def get_search_regex(state): + raw = state['find'] + if state['mode'] != 'regex': + raw = regex.escape(raw, special_only=True) + flags = REGEX_FLAGS + if not state['case_sensitive']: + flags |= regex.IGNORECASE + if state['mode'] == 'regex' and state['dot_all']: + flags |= regex.DOTALL + if state['direction'] == 'up': + flags |= regex.REVERSE + ans = regex_cache.get((flags, raw), None) + if ans is None: + ans = regex_cache[(flags, raw)] = regex.compile(raw, flags=flags) + return ans + +def initialize_search_request(state, action, current_editor, current_editor_name, searchable_names): + editor = None + where = state['where'] + files = OrderedDict() + do_all = state['wrap'] or action in {'replace-all', 'count'} + marked = False + if where == 'current': + editor = current_editor + elif where in {'styles', 'text', 'selected'}: + files = searchable_names[where] + if current_editor_name in files: + # Start searching in the current editor + editor = current_editor + # Re-order the list of other files so that we search in the same + # order every time. Depending on direction, search the files + # that come after the current file, or before the current file, + # first. + lfiles = list(files) + idx = lfiles.index(current_editor_name) + before, after = lfiles[:idx], lfiles[idx+1:] + if state['direction'] == 'up': + lfiles = list(reversed(before)) + if do_all: + lfiles += list(reversed(after)) + [current_editor_name] + else: + lfiles = after + if do_all: + lfiles += before + [current_editor_name] + files = OrderedDict((m, files[m]) for m in lfiles) + else: + editor = current_editor + marked = True + + return editor, where, files, do_all, marked, get_search_regex(state) + +def run_search( + state, action, current_editor, current_editor_name, searchable_names, + gui_parent, show_editor, edit_file, show_current_diff, add_savepoint, rewind_savepoint, set_modified): + + editor, where, files, do_all, marked, pat = initialize_search_request(state, action, current_editor, current_editor_name, searchable_names) + def no_match(): + QApplication.restoreOverrideCursor() + msg = '

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

' + prepare_string_for_xml(state['find']) + '
') + if not state['wrap']: + msg += '

' + _('You have turned off search wrapping, so all text might not have been searched.' + ' Try the search again, with wrapping enabled. Wrapping is enabled via the' + ' "Wrap" checkbox at the bottom of the search panel.') + return error_dialog( + gui_parent, _('Not found'), msg, show=True) + + def do_find(): + if editor is not None: + if editor.find(pat, marked=marked, save_match='gui'): + return + if not files: + if not state['wrap']: + return no_match() + return editor.find(pat, wrap=True, marked=marked, save_match='gui') or no_match() + for fname, syntax in files.iteritems(): + if fname in editors: + if not editors[fname].find(pat, complete=True, save_match='gui'): + continue + return show_editor(fname) + raw = current_container().raw_data(fname) + if pat.search(raw) is not None: + edit_file(fname, syntax) + if editors[fname].find(pat, complete=True, save_match='gui'): + return + return no_match() + + def no_replace(prefix=''): + QApplication.restoreOverrideCursor() + if prefix: + prefix += ' ' + error_dialog( + gui_parent, _('Cannot replace'), prefix + _( + 'You must first click Find, before trying to replace'), show=True) + return False + + def do_replace(): + if editor is None: + return no_replace() + if not editor.replace(pat, state['replace'], saved_match='gui'): + return no_replace(_( + 'Currently selected text does not match the search query.')) + return True + + def count_message(action, count, show_diff=False): + msg = _('%(action)s %(num)s occurrences of %(query)s' % dict(num=count, query=state['find'], action=action)) + if show_diff and count > 0: + d = MessageBox(MessageBox.INFO, _('Searching done'), prepare_string_for_xml(msg), parent=gui_parent, show_copy_button=False) + d.diffb = b = d.bb.addButton(_('See what &changed'), d.bb.ActionRole) + b.setIcon(QIcon(I('diff.png'))), d.set_details(None), b.clicked.connect(d.accept) + b.clicked.connect(partial(show_current_diff, allow_revert=True)) + d.exec_() + else: + info_dialog(gui_parent, _('Searching done'), prepare_string_for_xml(msg), show=True) + + def do_all(replace=True): + count = 0 + if not files and editor is None: + return 0 + lfiles = files or {current_editor_name:editor.syntax} + + for n, syntax in lfiles.iteritems(): + if n in editors: + raw = editors[n].get_raw_data() + else: + raw = current_container().raw_data(n) + if replace: + raw, num = pat.subn(state['replace'], raw) + else: + num = len(pat.findall(raw)) + count += num + if replace and num > 0: + if n in editors: + editors[n].replace_data(raw) + else: + with current_container().open(n, 'wb') as f: + f.write(raw.encode('utf-8')) + QApplication.restoreOverrideCursor() + count_message(_('Replaced') if replace else _('Found'), count, show_diff=replace) + return count + + with BusyCursor(): + if action == 'find': + return do_find() + if action == 'replace': + return do_replace() + if action == 'replace-find' and do_replace(): + return do_find() + if action == 'replace-all': + if marked: + return count_message(_('Replaced'), editor.all_in_marked(pat, state['replace'])) + add_savepoint(_('Before: Replace all')) + count = do_all() + if count == 0: + rewind_savepoint() + else: + set_modified() + return + if action == 'count': + if marked: + return count_message(_('Found'), editor.all_in_marked(pat)) + return do_all(replace=False) + if __name__ == '__main__': app = QApplication([]) d = SavedSearches() diff --git a/src/calibre/gui2/tweak_book/widgets.py b/src/calibre/gui2/tweak_book/widgets.py index 9a0b9df508..e3ddc0d13b 100644 --- a/src/calibre/gui2/tweak_book/widgets.py +++ b/src/calibre/gui2/tweak_book/widgets.py @@ -15,7 +15,7 @@ from PyQt4.Qt import ( QFormLayout, QHBoxLayout, QToolButton, QIcon, QApplication, Qt, QWidget, QPoint, QSizePolicy, QPainter, QStaticText, pyqtSignal, QTextOption, QAbstractListModel, QModelIndex, QVariant, QStyledItemDelegate, QStyle, - QListView, QTextDocument, QSize, QComboBox, QFrame) + QListView, QTextDocument, QSize, QComboBox, QFrame, QCursor) from calibre import prepare_string_for_xml from calibre.gui2 import error_dialog, choose_files, choose_save_file, NONE, info_dialog @@ -25,6 +25,14 @@ from calibre.utils.matcher import get_char, Matcher ROOT = QModelIndex() +class BusyCursor(object): + + def __enter__(self): + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + + def __exit__(self, *args): + QApplication.restoreOverrideCursor() + class Dialog(QDialog): def __init__(self, title, name, parent=None):