1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPLv3 Copyright: 2013, Kovid Goyal <kovid at kovidgoyal.net>
4
5
6import copy
7import json
8import regex
9import time
10from collections import Counter, OrderedDict
11from functools import partial
12from qt.core import (
13    QAbstractListModel, QAction, QApplication, QCheckBox, QComboBox, QFont, QFrame,
14    QGridLayout, QHBoxLayout, QIcon, QItemSelection, QKeySequence, QLabel, QLineEdit,
15    QListView, QMenu, QMimeData, QModelIndex, QPushButton, QScrollArea, QSize, QItemSelectionModel,
16    QSizePolicy, QStackedLayout, QStyledItemDelegate, Qt, QTimer, QToolBar, QDialog,
17    QToolButton, QVBoxLayout, QWidget, pyqtSignal, QAbstractItemView, QEvent, QDialogButtonBox
18)
19
20from calibre import prepare_string_for_xml
21from calibre.constants import iswindows
22from calibre.ebooks.conversion.search_replace import (
23    REGEX_FLAGS, compile_regular_expression
24)
25from calibre.gui2 import choose_files, choose_save_file, error_dialog, info_dialog
26from calibre.gui2.dialogs.confirm_delete import confirm
27from calibre.gui2.dialogs.message_box import MessageBox
28from calibre.gui2.tweak_book import current_container, editors, tprefs
29from calibre.gui2.tweak_book.editor.snippets import (
30    KEY, MODIFIER, SnippetTextEdit, find_matching_snip, parse_template,
31    string_length
32)
33from calibre.gui2.tweak_book.function_replace import (
34    Function, FunctionBox, FunctionEditor, functions as replace_functions,
35    remove_function
36)
37from calibre.gui2.tweak_book.widgets import BusyCursor
38from calibre.gui2.widgets2 import FlowLayout, HistoryComboBox
39from calibre.utils.icu import primary_contains
40from polyglot.builtins import error_message, iteritems
41
42# The search panel {{{
43
44
45class AnimatablePushButton(QPushButton):
46
47    'A push button that can be animated without actually emitting a clicked signal'
48
49    def __init__(self, *args, **kwargs):
50        QPushButton.__init__(self, *args, **kwargs)
51        self.timer = t = QTimer(self)
52        t.setSingleShot(True), t.timeout.connect(self.animate_done)
53
54    def animate_click(self, msec=100):
55        self.setDown(True)
56        self.update()
57        self.timer.start(msec)
58
59    def animate_done(self):
60        self.setDown(False)
61        self.update()
62
63
64class PushButton(AnimatablePushButton):
65
66    def __init__(self, text, action, parent):
67        AnimatablePushButton.__init__(self, text, parent)
68        connect_lambda(self.clicked, parent, lambda parent: parent.search_triggered.emit(action))
69
70
71def expand_template(line_edit):
72    pos = line_edit.cursorPosition()
73    text = line_edit.text()[:pos]
74    if text:
75        snip, trigger = find_matching_snip(text)
76        if snip is None:
77            error_dialog(line_edit, _('No snippet found'), _(
78                'No matching snippet was found'), show=True)
79            return False
80        text, tab_stops = parse_template(snip['template'])
81        ft = line_edit.text()
82        l = string_length(trigger)
83        line_edit.setText(ft[:pos - l] + text + ft[pos:])
84        line_edit.setCursorPosition(pos - l + string_length(text))
85        return True
86    return False
87
88
89class HistoryBox(HistoryComboBox):
90
91    max_history_items = 100
92    save_search = pyqtSignal()
93    show_saved_searches = pyqtSignal()
94    min_history_entry_length = 1
95
96    def __init__(self, parent, clear_msg):
97        HistoryComboBox.__init__(self, parent, strip_completion_entries=False)
98        self.disable_popup = tprefs['disable_completion_popup_for_search']
99        self.clear_msg = clear_msg
100        self.ignore_snip_expansion = False
101        self.lineEdit().setClearButtonEnabled(True)
102        self.set_uniform_item_sizes(False)
103
104    def event(self, ev):
105        if ev.type() in (QEvent.Type.ShortcutOverride, QEvent.Type.KeyPress) and ev.key() == KEY and ev.modifiers() & MODIFIER:
106            if not self.ignore_snip_expansion:
107                self.ignore_snip_expansion = True
108                expand_template(self.lineEdit())
109                QTimer.singleShot(100, lambda : setattr(self, 'ignore_snip_expansion', False))
110            ev.accept()
111            return True
112        return HistoryComboBox.event(self, ev)
113
114    def contextMenuEvent(self, event):
115        menu = self.lineEdit().createStandardContextMenu()
116        menu.addSeparator()
117        menu.addAction(self.clear_msg, self.clear_history)
118        menu.addAction((_('Enable completion based on search history') if self.disable_popup else _(
119            'Disable completion based on search history')), self.toggle_popups)
120        menu.addSeparator()
121        menu.addAction(_('Save current search'), self.save_search.emit)
122        menu.addAction(_('Show saved searches'), self.show_saved_searches.emit)
123        menu.exec(event.globalPos())
124
125    def toggle_popups(self):
126        self.disable_popup = not bool(self.disable_popup)
127        tprefs['disable_completion_popup_for_search'] = self.disable_popup
128
129
130class WhereBox(QComboBox):
131
132    def __init__(self, parent, emphasize=False):
133        QComboBox.__init__(self)
134        self.addItems([_('Current file'), _('All text files'), _('All style files'), _('Selected files'), _('Open files'), _('Marked text')])
135        self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _(
136            '''
137            Where to search/replace:
138            <dl>
139            <dt><b>Current file</b></dt>
140            <dd>Search only inside the currently opened file</dd>
141            <dt><b>All text files</b></dt>
142            <dd>Search in all text (HTML) files</dd>
143            <dt><b>All style files</b></dt>
144            <dd>Search in all style (CSS) files</dd>
145            <dt><b>Selected files</b></dt>
146            <dd>Search in the files currently selected in the File browser</dd>
147            <dt><b>Open files</b></dt>
148            <dd>Search in the files currently open in the editor</dd>
149            <dt><b>Marked text</b></dt>
150            <dd>Search only within the marked text in the currently opened file. You can mark text using the Search menu.</dd>
151            </dl>'''))
152        self.emphasize = emphasize
153        self.ofont = QFont(self.font())
154        if emphasize:
155            f = self.emph_font = QFont(self.ofont)
156            f.setBold(True), f.setItalic(True)
157            self.setFont(f)
158
159    @property
160    def where(self):
161        wm = {0:'current', 1:'text', 2:'styles', 3:'selected', 4:'open', 5:'selected-text'}
162        return wm[self.currentIndex()]
163
164    @where.setter
165    def where(self, val):
166        wm = {0:'current', 1:'text', 2:'styles', 3:'selected', 4:'open', 5:'selected-text'}
167        self.setCurrentIndex({v:k for k, v in iteritems(wm)}[val])
168
169    def showPopup(self):
170        # We do it like this so that the popup uses a normal font
171        if self.emphasize:
172            self.setFont(self.ofont)
173        QComboBox.showPopup(self)
174
175    def hidePopup(self):
176        if self.emphasize:
177            self.setFont(self.emph_font)
178        QComboBox.hidePopup(self)
179
180
181class DirectionBox(QComboBox):
182
183    def __init__(self, parent):
184        QComboBox.__init__(self, parent)
185        self.addItems([_('Down'), _('Up')])
186        self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _(
187            '''
188            Direction to search:
189            <dl>
190            <dt><b>Down</b></dt>
191            <dd>Search for the next match from your current position</dd>
192            <dt><b>Up</b></dt>
193            <dd>Search for the previous match from your current position</dd>
194            </dl>'''))
195
196    @property
197    def direction(self):
198        return 'down' if self.currentIndex() == 0 else 'up'
199
200    @direction.setter
201    def direction(self, val):
202        self.setCurrentIndex(1 if val == 'up' else 0)
203
204
205class ModeBox(QComboBox):
206
207    def __init__(self, parent):
208        QComboBox.__init__(self, parent)
209        self.addItems([_('Normal'), _('Fuzzy'), _('Regex'), _('Regex-function')])
210        self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _(
211            '''Select how the search expression is interpreted
212            <dl>
213            <dt><b>Normal</b></dt>
214            <dd>The search expression is treated as normal text, calibre will look for the exact text</dd>
215            <dt><b>Fuzzy</b></dt>
216            <dd>The search expression is treated as "fuzzy" which means spaces will match any space character,
217            including tabs and line breaks. Plain quotes will match the typographical equivalents, etc.</dd>
218            <dt><b>Regex</b></dt>
219            <dd>The search expression is interpreted as a regular expression. See the User Manual for more help on using regular expressions.</dd>
220            <dt><b>Regex-function</b></dt>
221            <dd>The search expression is interpreted as a regular expression. The replace expression is an arbitrarily powerful Python function.</dd>
222            </dl>'''))
223
224    @property
225    def mode(self):
226        return ('normal', 'fuzzy', 'regex', 'function')[self.currentIndex()]
227
228    @mode.setter
229    def mode(self, val):
230        self.setCurrentIndex({'fuzzy': 1, 'regex':2, 'function':3}.get(val, 0))
231
232
233class SearchWidget(QWidget):
234
235    DEFAULT_STATE = {
236        'mode': 'normal',
237        'where': 'current',
238        'case_sensitive': False,
239        'direction': 'down',
240        'wrap': True,
241        'dot_all': False,
242    }
243
244    search_triggered = pyqtSignal(object)
245    save_search = pyqtSignal()
246    show_saved_searches = pyqtSignal()
247
248    def __init__(self, parent=None):
249        QWidget.__init__(self, parent)
250        self.l = l = QGridLayout(self)
251        left, top, right, bottom = l.getContentsMargins()
252        l.setContentsMargins(0, 0, right, 0)
253
254        self.fl = fl = QLabel(_('&Find:'))
255        fl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignCenter)
256        self.find_text = ft = HistoryBox(self, _('Clear search &history'))
257        ft.save_search.connect(self.save_search)
258        ft.show_saved_searches.connect(self.show_saved_searches)
259        ft.initialize('tweak_book_find_edit')
260        connect_lambda(ft.lineEdit().returnPressed, self, lambda self: self.search_triggered.emit('find'))
261        fl.setBuddy(ft)
262        l.addWidget(fl, 0, 0)
263        l.addWidget(ft, 0, 1)
264        l.setColumnStretch(1, 10)
265
266        self.rl = rl = QLabel(_('&Replace:'))
267        rl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignCenter)
268        self.replace_text = rt = HistoryBox(self, _('Clear replace &history'))
269        rt.save_search.connect(self.save_search)
270        rt.show_saved_searches.connect(self.show_saved_searches)
271        rt.initialize('tweak_book_replace_edit')
272        rl.setBuddy(rt)
273        self.replace_stack1 = rs1 = QVBoxLayout()
274        self.replace_stack2 = rs2 = QVBoxLayout()
275        rs1.addWidget(rl), rs2.addWidget(rt)
276        l.addLayout(rs1, 1, 0)
277        l.addLayout(rs2, 1, 1)
278
279        self.rl2 = rl2 = QLabel(_('F&unction:'))
280        rl2.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignCenter)
281        self.functions = fb = FunctionBox(self, show_saved_search_actions=True)
282        fb.show_saved_searches.connect(self.show_saved_searches)
283        fb.save_search.connect(self.save_search)
284        rl2.setBuddy(fb)
285        rs1.addWidget(rl2)
286        self.functions_container = w = QWidget(self)
287        rs2.addWidget(w)
288        self.fhl = fhl = QHBoxLayout(w)
289        fhl.setContentsMargins(0, 0, 0, 0)
290        fhl.addWidget(fb, stretch=10, alignment=Qt.AlignmentFlag.AlignVCenter)
291        self.ae_func = b = QPushButton(_('Create/&edit'), self)
292        b.clicked.connect(self.edit_function)
293        b.setToolTip(_('Create a new function, or edit an existing function'))
294        fhl.addWidget(b)
295        self.rm_func = b = QPushButton(_('Remo&ve'), self)
296        b.setToolTip(_('Remove this function'))
297        b.clicked.connect(self.remove_function)
298        fhl.addWidget(b)
299        self.fsep = f = QFrame(self)
300        f.setFrameShape(QFrame.Shape.VLine)
301        fhl.addWidget(f)
302
303        self.fb = fb = PushButton(_('Fin&d'), 'find', self)
304        self.rfb = rfb = PushButton(_('Replace a&nd Find'), 'replace-find', self)
305        self.rb = rb = PushButton(_('Re&place'), 'replace', self)
306        self.rab = rab = PushButton(_('Replace &all'), 'replace-all', self)
307        l.addWidget(fb, 0, 2)
308        l.addWidget(rfb, 0, 3)
309        l.addWidget(rb, 1, 2)
310        l.addWidget(rab, 1, 3)
311
312        self.ml = ml = QLabel(_('&Mode:'))
313        self.ol = ol = FlowLayout()
314        ml.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
315        l.addWidget(ml, 2, 0)
316        l.addLayout(ol, 2, 1, 1, 3)
317        self.mode_box = mb = ModeBox(self)
318        ml.setBuddy(mb)
319        ol.addWidget(mb)
320
321        self.where_box = wb = WhereBox(self)
322        ol.addWidget(wb)
323
324        self.direction_box = db = DirectionBox(self)
325        ol.addWidget(db)
326
327        self.cs = cs = QCheckBox(_('&Case sensitive'))
328        ol.addWidget(cs)
329
330        self.wr = wr = QCheckBox(_('&Wrap'))
331        wr.setToolTip('<p>'+_('When searching reaches the end, wrap around to the beginning and continue the search'))
332        ol.addWidget(wr)
333
334        self.da = da = QCheckBox(_('&Dot all'))
335        da.setToolTip('<p>'+_("Make the '.' special character match any character at all, including a newline"))
336        ol.addWidget(da)
337
338        self.mode_box.currentIndexChanged[int].connect(self.mode_changed)
339        self.mode_changed(self.mode_box.currentIndex())
340
341    def edit_function(self):
342        d = FunctionEditor(func_name=self.functions.text().strip(), parent=self)
343        if d.exec() == QDialog.DialogCode.Accepted:
344            self.functions.setText(d.func_name)
345
346    def remove_function(self):
347        fname = self.functions.text().strip()
348        if fname:
349            if remove_function(fname, self):
350                self.functions.setText('')
351
352    def mode_changed(self, idx):
353        self.da.setVisible(idx > 1)
354        function_mode = idx == 3
355        self.rl.setVisible(not function_mode)
356        self.rl2.setVisible(function_mode)
357        self.replace_text.setVisible(not function_mode)
358        self.functions_container.setVisible(function_mode)
359
360    @property
361    def mode(self):
362        return self.mode_box.mode
363
364    @mode.setter
365    def mode(self, val):
366        self.mode_box.mode = val
367        self.da.setVisible(self.mode in ('regex', 'function'))
368
369    @property
370    def find(self):
371        return str(self.find_text.text())
372
373    @find.setter
374    def find(self, val):
375        self.find_text.setText(val)
376
377    @property
378    def replace(self):
379        if self.mode == 'function':
380            return self.functions.text()
381        return str(self.replace_text.text())
382
383    @replace.setter
384    def replace(self, val):
385        self.replace_text.setText(val)
386
387    @property
388    def where(self):
389        return self.where_box.where
390
391    @where.setter
392    def where(self, val):
393        self.where_box.where = val
394
395    @property
396    def case_sensitive(self):
397        return self.cs.isChecked()
398
399    @case_sensitive.setter
400    def case_sensitive(self, val):
401        self.cs.setChecked(bool(val))
402
403    @property
404    def direction(self):
405        return self.direction_box.direction
406
407    @direction.setter
408    def direction(self, val):
409        self.direction_box.direction = val
410
411    @property
412    def wrap(self):
413        return self.wr.isChecked()
414
415    @wrap.setter
416    def wrap(self, val):
417        self.wr.setChecked(bool(val))
418
419    @property
420    def dot_all(self):
421        return self.da.isChecked()
422
423    @dot_all.setter
424    def dot_all(self, val):
425        self.da.setChecked(bool(val))
426
427    @property
428    def state(self):
429        return {x:getattr(self, x) for x in self.DEFAULT_STATE}
430
431    @state.setter
432    def state(self, val):
433        for x in self.DEFAULT_STATE:
434            if x in val:
435                setattr(self, x, val[x])
436
437    def restore_state(self):
438        self.state = tprefs.get('find-widget-state', self.DEFAULT_STATE)
439        if self.where == 'selected-text':
440            self.where = self.DEFAULT_STATE['where']
441
442    def save_state(self):
443        tprefs.set('find-widget-state', self.state)
444
445    def pre_fill(self, text):
446        if self.mode in ('regex', 'function'):
447            text = regex.escape(text, special_only=True, literal_spaces=True)
448        self.find = text
449        self.find_text.lineEdit().setSelection(0, len(text)+10)
450
451    def paste_saved_search(self, s):
452        self.case_sensitive = s.get('case_sensitive') or False
453        self.dot_all = s.get('dot_all') or False
454        self.wrap = s.get('wrap') or False
455        self.mode = s.get('mode') or 'normal'
456        self.find = s.get('find') or ''
457        self.replace = s.get('replace') or ''
458# }}}
459
460
461class SearchPanel(QWidget):  # {{{
462
463    search_triggered = pyqtSignal(object)
464    save_search = pyqtSignal()
465    show_saved_searches = pyqtSignal()
466
467    def __init__(self, parent=None):
468        QWidget.__init__(self, parent)
469        self.where_before_marked = None
470        self.l = l = QHBoxLayout()
471        self.setLayout(l)
472        l.setContentsMargins(0, 0, 0, 0)
473        self.t = t = QToolBar(self)
474        l.addWidget(t)
475        t.setOrientation(Qt.Orientation.Vertical)
476        t.setIconSize(QSize(12, 12))
477        t.setMovable(False)
478        t.setFloatable(False)
479        t.cl = ac = t.addAction(QIcon(I('window-close.png')), _('Close search panel'))
480        ac.triggered.connect(self.hide_panel)
481        self.widget = SearchWidget(self)
482        l.addWidget(self.widget)
483        self.restore_state, self.save_state = self.widget.restore_state, self.widget.save_state
484        self.widget.search_triggered.connect(self.search_triggered)
485        self.widget.save_search.connect(self.save_search)
486        self.widget.show_saved_searches.connect(self.show_saved_searches)
487        self.pre_fill = self.widget.pre_fill
488
489    def paste_saved_search(self, s):
490        self.widget.paste_saved_search(s)
491
492    def hide_panel(self):
493        self.setVisible(False)
494
495    def show_panel(self):
496        self.setVisible(True)
497        self.widget.find_text.setFocus(Qt.FocusReason.OtherFocusReason)
498        le = self.widget.find_text.lineEdit()
499        le.setSelection(0, le.maxLength())
500
501    @property
502    def state(self):
503        ans = self.widget.state
504        ans['find'] = self.widget.find
505        ans['replace'] = self.widget.replace
506        return ans
507
508    def set_where(self, val):
509        if val == 'selected-text' and self.widget.where != 'selected-text':
510            self.where_before_marked = self.widget.where
511        self.widget.where = val
512
513    def unset_marked(self):
514        if self.widget.where == 'selected-text':
515            self.widget.where = self.where_before_marked or self.widget.DEFAULT_STATE['where']
516            self.where_before_marked = None
517
518    def keyPressEvent(self, ev):
519        if ev.key() == Qt.Key.Key_Escape:
520            self.hide_panel()
521            ev.accept()
522        else:
523            return QWidget.keyPressEvent(self, ev)
524# }}}
525
526
527class SearchDescription(QScrollArea):
528
529    def __init__(self, parent):
530        QScrollArea.__init__(self, parent)
531        self.label = QLabel(' \n \n ')
532        self.setWidget(self.label)
533        self.setWidgetResizable(True)
534        self.label.setTextFormat(Qt.TextFormat.PlainText)
535        self.label.setWordWrap(True)
536        self.set_text = self.label.setText
537
538
539class SearchesModel(QAbstractListModel):
540
541    def __init__(self, parent):
542        QAbstractListModel.__init__(self, parent)
543        self.searches = tprefs['saved_searches']
544        self.filtered_searches = list(range(len(self.searches)))
545
546    def rowCount(self, parent=QModelIndex()):
547        return len(self.filtered_searches)
548
549    def supportedDropActions(self):
550        return Qt.DropAction.MoveAction
551
552    def flags(self, index):
553        ans = QAbstractListModel.flags(self, index)
554        if index.isValid():
555            ans |= Qt.ItemFlag.ItemIsDragEnabled
556        else:
557            ans |= Qt.ItemFlag.ItemIsDropEnabled
558        return ans
559
560    def mimeTypes(self):
561        return ['x-calibre/searches-rows', 'application/vnd.text.list']
562
563    def mimeData(self, indices):
564        ans = QMimeData()
565        names, rows = [], []
566        for i in indices:
567            if i.isValid():
568                names.append(i.data())
569                rows.append(i.row())
570        ans.setData('x-calibre/searches-rows', ','.join(map(str, rows)).encode('ascii'))
571        ans.setData('application/vnd.text.list', '\n'.join(names).encode('utf-8'))
572        return ans
573
574    def dropMimeData(self, data, action, row, column, parent):
575        if parent.isValid() or action != Qt.DropAction.MoveAction or not data.hasFormat('x-calibre/searches-rows') or not self.filtered_searches:
576            return False
577        rows = sorted(map(int, bytes(bytearray(data.data('x-calibre/searches-rows'))).decode('ascii').split(',')))
578        moved_searches = [self.searches[self.filtered_searches[r]] for r in rows]
579        moved_searches_q = {id(s) for s in moved_searches}
580        insert_at = max(0, min(row, len(self.filtered_searches)))
581        while insert_at < len(self.filtered_searches):
582            s = self.searches[self.filtered_searches[insert_at]]
583            if id(s) in moved_searches_q:
584                insert_at += 1
585            else:
586                break
587        insert_before = id(self.searches[self.filtered_searches[insert_at]]) if insert_at < len(self.filtered_searches) else None
588        visible_searches = {id(self.searches[self.filtered_searches[r]]) for r in self.filtered_searches}
589        unmoved_searches = list(filter(lambda s:id(s) not in moved_searches_q, self.searches))
590        if insert_before is None:
591            searches = unmoved_searches + moved_searches
592        else:
593            idx = {id(x):i for i, x in enumerate(unmoved_searches)}[insert_before]
594            searches = unmoved_searches[:idx] + moved_searches + unmoved_searches[idx:]
595        filtered_searches = []
596        for i, s in enumerate(searches):
597            if id(s) in visible_searches:
598                filtered_searches.append(i)
599        self.modelAboutToBeReset.emit()
600        self.searches, self.filtered_searches = searches, filtered_searches
601        self.modelReset.emit()
602        tprefs['saved_searches'] = self.searches
603        return True
604
605    def data(self, index, role):
606        try:
607            if role == Qt.ItemDataRole.DisplayRole:
608                search = self.searches[self.filtered_searches[index.row()]]
609                return search['name']
610            if role == Qt.ItemDataRole.ToolTipRole:
611                search = self.searches[self.filtered_searches[index.row()]]
612                tt = '\n'.join((search['find'], search['replace']))
613                return tt
614            if role == Qt.ItemDataRole.UserRole:
615                search = self.searches[self.filtered_searches[index.row()]]
616                return (self.filtered_searches[index.row()], search)
617        except IndexError:
618            pass
619        return None
620
621    def do_filter(self, text):
622        text = str(text)
623        self.beginResetModel()
624        self.filtered_searches = []
625        for i, search in enumerate(self.searches):
626            if primary_contains(text, search['name']):
627                self.filtered_searches.append(i)
628        self.endResetModel()
629
630    def search_for_index(self, index):
631        try:
632            return self.searches[self.filtered_searches[index.row()]]
633        except IndexError:
634            pass
635
636    def index_for_search(self, search):
637        for row, si in enumerate(self.filtered_searches):
638            if self.searches[si] is search:
639                return self.index(row)
640        return self.index(-1)
641
642    def move_entry(self, row, delta):
643        a, b = row, row + delta
644        if 0 <= b < len(self.filtered_searches):
645            ai, bi = self.filtered_searches[a], self.filtered_searches[b]
646            self.searches[ai], self.searches[bi] = self.searches[bi], self.searches[ai]
647            self.dataChanged.emit(self.index(a), self.index(a))
648            self.dataChanged.emit(self.index(b), self.index(b))
649            tprefs['saved_searches'] = self.searches
650
651    def add_searches(self, count=1):
652        self.beginResetModel()
653        self.searches = tprefs['saved_searches']
654        self.filtered_searches.extend(range(len(self.searches) - count, len(self.searches), 1))
655        self.endResetModel()
656
657    def remove_searches(self, rows):
658        indices = {self.filtered_searches[row] for row in frozenset(rows)}
659        for idx in sorted(indices, reverse=True):
660            del self.searches[idx]
661        tprefs['saved_searches'] = self.searches
662        self.do_filter('')
663
664
665class EditSearch(QFrame):  # {{{
666
667    done = pyqtSignal(object)
668
669    def __init__(self, parent=None):
670        QFrame.__init__(self, parent)
671        self.setFrameShape(QFrame.Shape.StyledPanel)
672        self.search_index = -1
673        self.search = {}
674        self.original_name = None
675
676        self.l = l = QVBoxLayout(self)
677        self.title = la = QLabel('<h2>Edit...')
678        self.ht = h = QHBoxLayout()
679        l.addLayout(h)
680        h.addWidget(la)
681        self.cb = cb = QToolButton(self)
682        cb.setIcon(QIcon(I('window-close.png')))
683        cb.setToolTip(_('Abort editing of search'))
684        h.addWidget(cb)
685        cb.clicked.connect(self.abort_editing)
686        self.search_name = n = QLineEdit('', self)
687        n.setPlaceholderText(_('The name with which to save this search'))
688        self.la1 = la = QLabel(_('&Name:'))
689        la.setBuddy(n)
690        self.h3 = h = QHBoxLayout()
691        h.addWidget(la), h.addWidget(n)
692        l.addLayout(h)
693
694        self.find = f = SnippetTextEdit('', self)
695        self.la2 = la = QLabel(_('&Find:'))
696        la.setBuddy(f)
697        l.addWidget(la), l.addWidget(f)
698
699        self.replace = r = SnippetTextEdit('', self)
700        self.la3 = la = QLabel(_('&Replace:'))
701        la.setBuddy(r)
702        l.addWidget(la), l.addWidget(r)
703
704        self.functions_container = w = QWidget()
705        l.addWidget(w)
706        w.g = g = QGridLayout(w)
707        self.la7 = la = QLabel(_('F&unction:'))
708        self.function = f = FunctionBox(self)
709        g.addWidget(la), g.addWidget(f)
710        g.setContentsMargins(0, 0, 0, 0)
711        la.setBuddy(f)
712        self.ae_func = b = QPushButton(_('Create/&edit'), self)
713        b.setToolTip(_('Create a new function, or edit an existing function'))
714        b.clicked.connect(self.edit_function)
715        g.addWidget(b, 1, 1)
716        g.setColumnStretch(0, 10)
717        self.rm_func = b = QPushButton(_('Remo&ve'), self)
718        b.setToolTip(_('Remove this function'))
719        b.clicked.connect(self.remove_function)
720        g.addWidget(b, 1, 2)
721
722        self.case_sensitive = c = QCheckBox(_('Case sensitive'))
723        self.h = h = QHBoxLayout()
724        l.addLayout(h)
725        h.addWidget(c)
726
727        self.dot_all = d = QCheckBox(_('Dot matches all'))
728        h.addWidget(d), h.addStretch(2)
729
730        self.h2 = h = QHBoxLayout()
731        l.addLayout(h)
732        self.mode_box = m = ModeBox(self)
733        m.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
734        self.la4 = la = QLabel(_('&Mode:'))
735        la.setBuddy(m)
736        h.addWidget(la), h.addWidget(m), h.addStretch(2)
737
738        self.done_button = b = QPushButton(QIcon(I('ok.png')), _('&Done'))
739        b.setToolTip(_('Finish editing of search'))
740        h.addWidget(b)
741        b.clicked.connect(self.emit_done)
742
743        self.mode_box.currentIndexChanged[int].connect(self.mode_changed)
744        self.mode_changed(self.mode_box.currentIndex())
745
746    def edit_function(self):
747        d = FunctionEditor(func_name=self.function.text().strip(), parent=self)
748        if d.exec() == QDialog.DialogCode.Accepted:
749            self.function.setText(d.func_name)
750
751    def remove_function(self):
752        fname = self.function.text().strip()
753        if fname:
754            if remove_function(fname, self):
755                self.function.setText('')
756
757    def mode_changed(self, idx):
758        mode = self.mode_box.mode
759        self.dot_all.setVisible(mode in ('regex', 'function'))
760        function_mode = mode == 'function'
761        self.functions_container.setVisible(function_mode)
762        self.la3.setVisible(not function_mode)
763        self.replace.setVisible(not function_mode)
764
765    def show_search(self, search=None, search_index=-1, state=None):
766        self.title.setText('<h2>' + (_('Add search') if search_index == -1 else _('Edit search')))
767        self.search = search or {}
768        self.original_name = self.search.get('name', None)
769        self.search_index = search_index
770
771        self.mode_box.mode = self.search.get('mode', 'regex')
772        self.search_name.setText(self.search.get('name', ''))
773        self.find.setPlainText(self.search.get('find', ''))
774        if self.mode_box.mode == 'function':
775            self.function.setText(self.search.get('replace', ''))
776        else:
777            self.replace.setPlainText(self.search.get('replace', ''))
778        self.case_sensitive.setChecked(self.search.get('case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive']))
779        self.dot_all.setChecked(self.search.get('dot_all', SearchWidget.DEFAULT_STATE['dot_all']))
780
781        if state is not None:
782            self.find.setPlainText(state['find'])
783            self.mode_box.mode = state.get('mode')
784            if self.mode_box.mode == 'function':
785                self.function.setText(state['replace'])
786            else:
787                self.replace.setPlainText(state['replace'])
788            self.case_sensitive.setChecked(state['case_sensitive'])
789            self.dot_all.setChecked(state['dot_all'])
790
791    def emit_done(self):
792        self.done.emit(True)
793
794    def keyPressEvent(self, ev):
795        if ev.key() == Qt.Key.Key_Escape:
796            self.abort_editing()
797            ev.accept()
798            return
799        return QFrame.keyPressEvent(self, ev)
800
801    def abort_editing(self):
802        self.done.emit(False)
803
804    @property
805    def current_search(self):
806        search = self.search.copy()
807        f = str(self.find.toPlainText())
808        search['find'] = f
809        search['dot_all'] = bool(self.dot_all.isChecked())
810        search['case_sensitive'] = bool(self.case_sensitive.isChecked())
811        search['mode'] = self.mode_box.mode
812        if search['mode'] == 'function':
813            r = self.function.text()
814        else:
815            r = str(self.replace.toPlainText())
816        search['replace'] = r
817        return search
818
819    def save_changes(self):
820        searches = tprefs['saved_searches']
821        all_names = {x['name'] for x in searches} - {self.original_name}
822        n = self.search_name.text().strip()
823        if not n:
824            error_dialog(self, _('Must specify name'), _(
825                'You must specify a search name'), show=True)
826            return False
827        if n in all_names:
828            error_dialog(self, _('Name exists'), _(
829                'Another search with the name %s already exists') % n, show=True)
830            return False
831        search = self.search
832        search['name'] = n
833
834        f = str(self.find.toPlainText())
835        if not f:
836            error_dialog(self, _('Must specify find'), _(
837                'You must specify a find expression'), show=True)
838            return False
839        search['find'] = f
840        search['mode'] = self.mode_box.mode
841
842        if search['mode'] == 'function':
843            r = self.function.text()
844            if not r:
845                error_dialog(self, _('Must specify function'), _(
846                    'You must specify a function name in Function-Regex mode'), show=True)
847                return False
848        else:
849            r = str(self.replace.toPlainText())
850        search['replace'] = r
851
852        search['dot_all'] = bool(self.dot_all.isChecked())
853        search['case_sensitive'] = bool(self.case_sensitive.isChecked())
854
855        if self.search_index == -1:
856            searches.append(search)
857        else:
858            searches[self.search_index] = search
859        tprefs.set('saved_searches', searches)
860        return True
861
862# }}}
863
864
865class SearchDelegate(QStyledItemDelegate):
866
867    def sizeHint(self, *args):
868        ans = QStyledItemDelegate.sizeHint(self, *args)
869        ans.setHeight(ans.height() + 4)
870        return ans
871
872
873class SavedSearches(QWidget):
874
875    run_saved_searches = pyqtSignal(object, object)
876    copy_search_to_search_panel = pyqtSignal(object)
877
878    def __init__(self, parent=None):
879        QWidget.__init__(self, parent)
880        self.setup_ui()
881
882    def setup_ui(self):
883        self.l = l = QVBoxLayout(self)
884        self.setLayout(l)
885
886        self.filter_text = ft = QLineEdit(self)
887        ft.setClearButtonEnabled(True)
888        ft.textChanged.connect(self.do_filter)
889        ft.setPlaceholderText(_('Filter displayed searches'))
890        l.addWidget(ft)
891
892        self.h2 = h = QHBoxLayout()
893        self.searches = searches = QListView(self)
894        self.stack = stack = QStackedLayout()
895        self.main_widget = mw = QWidget(self)
896        stack.addWidget(mw)
897        self.edit_search_widget = es = EditSearch(mw)
898        stack.addWidget(es)
899        es.done.connect(self.search_editing_done)
900        mw.v = QVBoxLayout(mw)
901        mw.v.setContentsMargins(0, 0, 0, 0)
902        mw.v.addWidget(searches)
903        searches.doubleClicked.connect(self.edit_search)
904        self.model = SearchesModel(self.searches)
905        self.model.dataChanged.connect(self.show_details)
906        searches.setModel(self.model)
907        searches.selectionModel().currentChanged.connect(self.show_details)
908        searches.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
909        self.delegate = SearchDelegate(searches)
910        searches.setItemDelegate(self.delegate)
911        searches.setAlternatingRowColors(True)
912        searches.setDragEnabled(True), searches.setAcceptDrops(True), searches.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
913        searches.setDropIndicatorShown(True)
914        h.addLayout(stack, stretch=10)
915        self.v = v = QVBoxLayout()
916        h.addLayout(v)
917        l.addLayout(h)
918        stack.currentChanged.connect(self.stack_current_changed)
919
920        def pb(text, tooltip=None, action=None):
921            b = AnimatablePushButton(text, self)
922            b.setToolTip(tooltip or '')
923            if action:
924                b.setObjectName(action)
925            b.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
926            return b
927
928        mulmsg = '\n\n' + _('The entries are tried in order until the first one matches.')
929        self.action_button_map = {}
930
931        for text, action, tooltip in [
932                (_('&Find'), 'find', _('Run the search using the selected entries.') + mulmsg),
933                (_('&Replace'), 'replace', _('Run replace using the selected entries.') + mulmsg),
934                (_('Replace a&nd Find'), 'replace-find', _('Run replace and then find using the selected entries.') + mulmsg),
935                (_('Replace &all'), 'replace-all', _('Run Replace all for all selected entries in the order selected')),
936                (_('&Count all'), 'count', _('Run Count all for all selected entries')),
937        ]:
938            self.action_button_map[action] = b = pb(text, tooltip, action)
939            v.addWidget(b)
940            connect_lambda(b.clicked, self, lambda self: self.run_search(self.sender().objectName()))
941
942        self.d1 = d = QFrame(self)
943        d.setFrameStyle(QFrame.Shape.HLine)
944        v.addWidget(d)
945
946        self.h3 = h = QHBoxLayout()
947        self.upb = b = QToolButton(self)
948        self.move_up_action = a = QAction(self)
949        a.setShortcut(QKeySequence('Alt+Up'))
950        b.setIcon(QIcon(I('arrow-up.png')))
951        b.setToolTip(_('Move selected entries up') + ' [%s]' % a.shortcut().toString(QKeySequence.SequenceFormat.NativeText))
952        connect_lambda(a.triggered, self, lambda self: self.move_entry(-1))
953        self.searches.addAction(a)
954        connect_lambda(b.clicked, self, lambda self: self.move_entry(-1))
955
956        self.dnb = b = QToolButton(self)
957        self.move_down_action = a = QAction(self)
958        a.setShortcut(QKeySequence('Alt+Down'))
959        b.setIcon(QIcon(I('arrow-down.png')))
960        b.setToolTip(_('Move selected entries down') + ' [%s]' % a.shortcut().toString(QKeySequence.SequenceFormat.NativeText))
961        connect_lambda(a.triggered, self, lambda self: self.move_entry(1))
962        self.searches.addAction(a)
963        connect_lambda(b.clicked, self, lambda self: self.move_entry(1))
964        h.addWidget(self.upb), h.addWidget(self.dnb)
965        v.addLayout(h)
966
967        self.eb = b = pb(_('&Edit search'), _('Edit the currently selected search'))
968        b.clicked.connect(self.edit_search)
969        v.addWidget(b)
970
971        self.rb = b = pb(_('Re&move search'), _('Remove the currently selected searches'))
972        b.clicked.connect(self.remove_search)
973        v.addWidget(b)
974
975        self.ab = b = pb(_('&Add search'), _('Add a new saved search'))
976        b.clicked.connect(self.add_search)
977        v.addWidget(b)
978
979        self.d2 = d = QFrame(self)
980        d.setFrameStyle(QFrame.Shape.HLine)
981        v.addWidget(d)
982
983        self.where_box = wb = WhereBox(self, emphasize=True)
984        self.where = SearchWidget.DEFAULT_STATE['where']
985        v.addWidget(wb)
986        self.direction_box = db = DirectionBox(self)
987        self.direction = SearchWidget.DEFAULT_STATE['direction']
988        v.addWidget(db)
989
990        self.wr = wr = QCheckBox(_('&Wrap'))
991        wr.setToolTip('<p>'+_('When searching reaches the end, wrap around to the beginning and continue the search'))
992        self.wr.setChecked(SearchWidget.DEFAULT_STATE['wrap'])
993        v.addWidget(wr)
994
995        self.d3 = d = QFrame(self)
996        d.setFrameStyle(QFrame.Shape.HLine)
997        v.addWidget(d)
998
999        self.description = d = SearchDescription(self)
1000        mw.v.addWidget(d)
1001        mw.v.setStretch(0, 10)
1002
1003        self.ib = b = pb(_('&Import'), _('Import saved searches'))
1004        b.clicked.connect(self.import_searches)
1005        v.addWidget(b)
1006
1007        self.eb2 = b = pb(_('E&xport'), _('Export saved searches'))
1008        v.addWidget(b)
1009        self.em = m = QMenu(_('Export'))
1010        m.addAction(_('Export all'), lambda : QTimer.singleShot(0, partial(self.export_searches, all=True)))
1011        m.addAction(_('Export selected'), lambda : QTimer.singleShot(0, partial(self.export_searches, all=False)))
1012        m.addAction(_('Copy to search panel'), lambda : QTimer.singleShot(0, self.copy_to_search_panel))
1013        b.setMenu(m)
1014
1015        self.searches.setFocus(Qt.FocusReason.OtherFocusReason)
1016
1017    @property
1018    def state(self):
1019        return {'wrap':self.wrap, 'direction':self.direction, 'where':self.where}
1020
1021    @state.setter
1022    def state(self, val):
1023        self.wrap, self.where, self.direction = val['wrap'], val['where'], val['direction']
1024
1025    def save_state(self):
1026        tprefs['saved_seaches_state'] = self.state
1027
1028    def restore_state(self):
1029        self.state = tprefs.get('saved_seaches_state', SearchWidget.DEFAULT_STATE)
1030
1031    def has_focus(self):
1032        if self.hasFocus():
1033            return True
1034        for child in self.findChildren(QWidget):
1035            if child.hasFocus():
1036                return True
1037        return False
1038
1039    def trigger_action(self, action, overrides=None):
1040        b = self.action_button_map.get(action)
1041        if b is not None:
1042            b.animate_click(300)
1043        self._run_search(action, overrides)
1044
1045    def stack_current_changed(self, index):
1046        visible = index == 0
1047        for x in ('eb', 'ab', 'rb', 'upb', 'dnb', 'd2', 'filter_text', 'd3', 'ib', 'eb2'):
1048            getattr(self, x).setVisible(visible)
1049
1050    @property
1051    def where(self):
1052        return self.where_box.where
1053
1054    @where.setter
1055    def where(self, val):
1056        self.where_box.where = val
1057
1058    @property
1059    def direction(self):
1060        return self.direction_box.direction
1061
1062    @direction.setter
1063    def direction(self, val):
1064        self.direction_box.direction = val
1065
1066    @property
1067    def wrap(self):
1068        return self.wr.isChecked()
1069
1070    @wrap.setter
1071    def wrap(self, val):
1072        self.wr.setChecked(bool(val))
1073
1074    def do_filter(self, text):
1075        self.model.do_filter(text)
1076        self.searches.scrollTo(self.model.index(0))
1077
1078    def run_search(self, action):
1079        return self._run_search(action)
1080
1081    def _run_search(self, action, overrides=None):
1082        searches = []
1083
1084        def fill_in_search(search):
1085            search['wrap'] = self.wrap
1086            search['direction'] = self.direction
1087            search['where'] = self.where
1088            search['mode'] = search.get('mode', 'regex')
1089
1090        if self.editing_search:
1091            search = SearchWidget.DEFAULT_STATE.copy()
1092            del search['mode']
1093            search.update(self.edit_search_widget.current_search)
1094            fill_in_search(search)
1095            searches.append(search)
1096        else:
1097            seen = set()
1098            for index in self.searches.selectionModel().selectedIndexes():
1099                if index.row() in seen:
1100                    continue
1101                seen.add(index.row())
1102                search = SearchWidget.DEFAULT_STATE.copy()
1103                del search['mode']
1104                search_index, s = index.data(Qt.ItemDataRole.UserRole)
1105                search.update(s)
1106                fill_in_search(search)
1107                searches.append(search)
1108        if not searches:
1109            return error_dialog(self, _('Cannot search'), _(
1110                'No saved search is selected'), show=True)
1111        if overrides:
1112            [sc.update(overrides) for sc in searches]
1113        self.run_saved_searches.emit(searches, action)
1114
1115    @property
1116    def editing_search(self):
1117        return self.stack.currentIndex() != 0
1118
1119    def move_entry(self, delta):
1120        if self.editing_search:
1121            return
1122        sm = self.searches.selectionModel()
1123        rows = {index.row() for index in sm.selectedIndexes()} - {-1}
1124        if rows:
1125            searches = [self.model.search_for_index(index) for index in sm.selectedIndexes()]
1126            current_search = self.model.search_for_index(self.searches.currentIndex())
1127            with tprefs:
1128                for row in sorted(rows, reverse=delta > 0):
1129                    self.model.move_entry(row, delta)
1130            sm.clear()
1131            for s in searches:
1132                index = self.model.index_for_search(s)
1133                if index.isValid() and index.row() > -1:
1134                    if s is current_search:
1135                        sm.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.Select)
1136                    else:
1137                        sm.select(index, QItemSelectionModel.SelectionFlag.Select)
1138
1139    def search_editing_done(self, save_changes):
1140        if save_changes and not self.edit_search_widget.save_changes():
1141            return
1142        self.stack.setCurrentIndex(0)
1143        if save_changes:
1144            if self.edit_search_widget.search_index == -1:
1145                self._add_search()
1146            else:
1147                index = self.searches.currentIndex()
1148                if index.isValid():
1149                    self.model.dataChanged.emit(index, index)
1150
1151    def edit_search(self):
1152        index = self.searches.currentIndex()
1153        if not index.isValid():
1154            return error_dialog(self, _('Cannot edit'), _(
1155                'Cannot edit search - no search selected.'), show=True)
1156        if not self.editing_search:
1157            search_index, search = index.data(Qt.ItemDataRole.UserRole)
1158            self.edit_search_widget.show_search(search=search, search_index=search_index)
1159            self.stack.setCurrentIndex(1)
1160            self.edit_search_widget.find.setFocus(Qt.FocusReason.OtherFocusReason)
1161
1162    def remove_search(self):
1163        if self.editing_search:
1164            return
1165        if confirm(_('Are you sure you want to permanently delete the selected saved searches?'),
1166                   'confirm-remove-editor-saved-search', config_set=tprefs):
1167            rows = {index.row() for index in self.searches.selectionModel().selectedIndexes()} - {-1}
1168            self.model.remove_searches(rows)
1169            self.show_details()
1170
1171    def add_search(self):
1172        if self.editing_search:
1173            return
1174        self.edit_search_widget.show_search()
1175        self.stack.setCurrentIndex(1)
1176        self.edit_search_widget.search_name.setFocus(Qt.FocusReason.OtherFocusReason)
1177
1178    def _add_search(self):
1179        self.model.add_searches()
1180        index = self.model.index(self.model.rowCount() - 1)
1181        self.searches.scrollTo(index)
1182        sm = self.searches.selectionModel()
1183        sm.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect)
1184        self.show_details()
1185
1186    def add_predefined_search(self, state):
1187        if self.editing_search:
1188            return
1189        self.edit_search_widget.show_search(state=state)
1190        self.stack.setCurrentIndex(1)
1191        self.edit_search_widget.search_name.setFocus(Qt.FocusReason.OtherFocusReason)
1192
1193    def show_details(self):
1194        self.description.set_text(' \n \n ')
1195        i = self.searches.currentIndex()
1196        if i.isValid():
1197            try:
1198                search_index, search = i.data(Qt.ItemDataRole.UserRole)
1199            except TypeError:
1200                return  # no saved searches
1201            cs = '✓' if search.get('case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive']) else '✗'
1202            da = '✓' if search.get('dot_all', SearchWidget.DEFAULT_STATE['dot_all']) else '✗'
1203            if search.get('mode', SearchWidget.DEFAULT_STATE['mode']) in ('regex', 'function'):
1204                ts = _('(Case sensitive: {0} Dot All: {1})').format(cs, da)
1205            else:
1206                ts = _('(Case sensitive: {0} [Normal search])').format(cs)
1207            self.description.set_text(_('{2} {3}\nFind: {0}\nReplace: {1}').format(
1208                search.get('find', ''), search.get('replace', ''), search.get('name', ''), ts))
1209
1210    def import_searches(self):
1211        path = choose_files(self, 'import_saved_searches', _('Choose file'), filters=[
1212            (_('Saved searches'), ['json'])], all_files=False, select_only_single_file=True)
1213        if path:
1214            with open(path[0], 'rb') as f:
1215                obj = json.loads(f.read())
1216            needed_keys = {'name', 'find', 'replace', 'case_sensitive', 'dot_all', 'mode'}
1217
1218            def err():
1219                error_dialog(self, _('Invalid data'), _(
1220                    'The file %s does not contain valid saved searches') % path, show=True)
1221            if not isinstance(obj, dict) or 'version' not in obj or 'searches' not in obj or obj['version'] not in (1,):
1222                return err()
1223            searches = []
1224            for item in obj['searches']:
1225                if not isinstance(item, dict) or not set(item).issuperset(needed_keys):
1226                    return err
1227                searches.append({k:item[k] for k in needed_keys})
1228
1229            if searches:
1230                tprefs['saved_searches'] = tprefs['saved_searches'] + searches
1231                count = len(searches)
1232                self.model.add_searches(count=count)
1233                sm = self.searches.selectionModel()
1234                top, bottom = self.model.index(self.model.rowCount() - count), self.model.index(self.model.rowCount() - 1)
1235                sm.select(QItemSelection(top, bottom), QItemSelectionModel.SelectionFlag.ClearAndSelect)
1236                self.searches.scrollTo(bottom)
1237
1238    def copy_to_search_panel(self):
1239        ci = self.searches.selectionModel().currentIndex()
1240        if ci and ci.isValid():
1241            search = ci.data(Qt.ItemDataRole.UserRole)[-1]
1242            self.copy_search_to_search_panel.emit(search)
1243
1244    def export_searches(self, all=True):
1245        if all:
1246            searches = copy.deepcopy(tprefs['saved_searches'])
1247            if not searches:
1248                return error_dialog(self, _('No searches'), _(
1249                    'No searches available to be saved'), show=True)
1250        else:
1251            searches = []
1252            for index in self.searches.selectionModel().selectedIndexes():
1253                search = index.data(Qt.ItemDataRole.UserRole)[-1]
1254                searches.append(search.copy())
1255            if not searches:
1256                return error_dialog(self, _('No searches'), _(
1257                    'No searches selected'), show=True)
1258        [s.__setitem__('mode', s.get('mode', 'regex')) for s in searches]
1259        path = choose_save_file(self, 'export-saved-searches', _('Choose file'), filters=[
1260            (_('Saved searches'), ['json'])], all_files=False)
1261        if path:
1262            if not path.lower().endswith('.json'):
1263                path += '.json'
1264            raw = json.dumps({'version':1, 'searches':searches}, ensure_ascii=False, indent=2, sort_keys=True)
1265            with open(path, 'wb') as f:
1266                f.write(raw.encode('utf-8'))
1267
1268
1269def validate_search_request(name, searchable_names, has_marked_text, state, gui_parent):
1270    err = None
1271    where = state['where']
1272    if name is None and where in {'current', 'selected-text'}:
1273        err = _('No file is being edited.')
1274    elif where == 'selected' and not searchable_names['selected']:
1275        err = _('No files are selected in the File browser')
1276    elif where == 'selected-text' and not has_marked_text:
1277        err = _('No text is marked. First select some text, and then use'
1278                ' The "Mark selected text" action in the Search menu to mark it.')
1279    if not err and not state['find']:
1280        err = _('No search query specified')
1281    if err:
1282        error_dialog(gui_parent, _('Cannot search'), err, show=True)
1283        return False
1284    return True
1285
1286
1287class InvalidRegex(regex.error):
1288
1289    def __init__(self, raw, e):
1290        regex.error.__init__(self, error_message(e))
1291        self.regex = raw
1292
1293
1294def get_search_regex(state):
1295    raw = state['find']
1296    is_regex = state['mode'] not in ('normal', 'fuzzy')
1297    if not is_regex:
1298        if state['mode'] == 'fuzzy':
1299            from calibre.gui2.viewer.search import text_to_regex
1300            raw = text_to_regex(raw)
1301        else:
1302            raw = regex.escape(raw, special_only=True)
1303    flags = REGEX_FLAGS
1304    if not state['case_sensitive']:
1305        flags |= regex.IGNORECASE
1306    if is_regex and state['dot_all']:
1307        flags |= regex.DOTALL
1308    if state['direction'] == 'up':
1309        flags |= regex.REVERSE
1310    try:
1311        ans = compile_regular_expression(raw, flags=flags)
1312    except regex.error as e:
1313        raise InvalidRegex(raw, e)
1314
1315    return ans
1316
1317
1318def get_search_function(state):
1319    ans = state['replace']
1320    is_regex = state['mode'] not in ('normal', 'fuzzy')
1321    if not is_regex:
1322        # We dont want backslash escape sequences interpreted in normal mode
1323        return lambda m: ans
1324    if state['mode'] == 'function':
1325        try:
1326            return replace_functions()[ans]
1327        except KeyError:
1328            if not ans:
1329                return Function('empty-function', '')
1330            raise NoSuchFunction(ans)
1331    return ans
1332
1333
1334def get_search_name(state):
1335    return state.get('name', state['find'])
1336
1337
1338def initialize_search_request(state, action, current_editor, current_editor_name, searchable_names):
1339    editor = None
1340    where = state['where']
1341    files = OrderedDict()
1342    do_all = state.get('wrap') or action in {'replace-all', 'count'}
1343    marked = False
1344    if where == 'current':
1345        editor = current_editor
1346    elif where in {'styles', 'text', 'selected', 'open'}:
1347        files = searchable_names[where]
1348        if current_editor_name in files:
1349            # Start searching in the current editor
1350            editor = current_editor
1351            # Re-order the list of other files so that we search in the same
1352            # order every time. Depending on direction, search the files
1353            # that come after the current file, or before the current file,
1354            # first.
1355            lfiles = list(files)
1356            idx = lfiles.index(current_editor_name)
1357            before, after = lfiles[:idx], lfiles[idx+1:]
1358            if state['direction'] == 'up':
1359                lfiles = list(reversed(before))
1360                if do_all:
1361                    lfiles += list(reversed(after)) + [current_editor_name]
1362            else:
1363                lfiles = after
1364                if do_all:
1365                    lfiles += before + [current_editor_name]
1366            files = OrderedDict((m, files[m]) for m in lfiles)
1367    else:
1368        editor = current_editor
1369        marked = True
1370
1371    return editor, where, files, do_all, marked
1372
1373
1374class NoSuchFunction(ValueError):
1375    pass
1376
1377
1378def show_function_debug_output(func):
1379    if isinstance(func, Function):
1380        val = func.debug_buf.getvalue().strip()
1381        func.debug_buf.truncate(0)
1382        if val:
1383            from calibre.gui2.tweak_book.boss import get_boss
1384            get_boss().gui.sr_debug_output.show_log(func.name, val)
1385
1386
1387def reorder_files(names, order):
1388    reverse = order in {'spine-reverse', 'reverse-spine'}
1389    spine_order = {name:i for i, (name, is_linear) in enumerate(current_container().spine_names)}
1390    return sorted(frozenset(names), key=spine_order.get, reverse=reverse)
1391
1392
1393def run_search(
1394    searches, action, current_editor, current_editor_name, searchable_names,
1395    gui_parent, show_editor, edit_file, show_current_diff, add_savepoint, rewind_savepoint, set_modified):
1396
1397    if isinstance(searches, dict):
1398        searches = [searches]
1399
1400    editor, where, files, do_all, marked = initialize_search_request(searches[0], action, current_editor, current_editor_name, searchable_names)
1401    wrap = searches[0]['wrap']
1402
1403    errfind = searches[0]['find']
1404    if len(searches) > 1:
1405        errfind = _('the selected searches')
1406
1407    search_names = [get_search_name(search) for search in searches]
1408
1409    try:
1410        searches = [(get_search_regex(search), get_search_function(search)) for search in searches]
1411    except InvalidRegex as e:
1412        return error_dialog(gui_parent, _('Invalid regex'), '<p>' + _(
1413            'The regular expression you entered is invalid: <pre>{0}</pre>With error: {1}').format(
1414                prepare_string_for_xml(e.regex), error_message(e)), show=True)
1415    except NoSuchFunction as e:
1416        return error_dialog(gui_parent, _('No such function'), '<p>' + _(
1417            'No replace function with the name: %s exists') % prepare_string_for_xml(error_message(e)), show=True)
1418
1419    def no_match():
1420        QApplication.restoreOverrideCursor()
1421        msg = '<p>' + _('No matches were found for %s') % ('<pre style="font-style:italic">' + prepare_string_for_xml(errfind) + '</pre>')
1422        if not wrap:
1423            msg += '<p>' + _('You have turned off search wrapping, so all text might not have been searched.'
1424                ' Try the search again, with wrapping enabled. Wrapping is enabled via the'
1425                ' "Wrap" checkbox at the bottom of the search panel.')
1426        return error_dialog(
1427            gui_parent, _('Not found'), msg, show=True)
1428
1429    def do_find():
1430        for p, __ in searches:
1431            if editor is not None:
1432                if editor.find(p, marked=marked, save_match='gui'):
1433                    return True
1434                if wrap and not files and editor.find(p, wrap=True, marked=marked, save_match='gui'):
1435                    return True
1436            for fname, syntax in iteritems(files):
1437                ed = editors.get(fname, None)
1438                if ed is not None:
1439                    if not wrap and ed is editor:
1440                        continue
1441                    if ed.find(p, complete=True, save_match='gui'):
1442                        show_editor(fname)
1443                        return True
1444                else:
1445                    raw = current_container().raw_data(fname)
1446                    if p.search(raw) is not None:
1447                        edit_file(fname, syntax)
1448                        if editors[fname].find(p, complete=True, save_match='gui'):
1449                            return True
1450        return no_match()
1451
1452    def no_replace(prefix=''):
1453        QApplication.restoreOverrideCursor()
1454        if prefix:
1455            prefix += ' '
1456        error_dialog(
1457            gui_parent, _('Cannot replace'), prefix + _(
1458            'You must first click "Find", before trying to replace'), show=True)
1459        return False
1460
1461    def do_replace():
1462        if editor is None:
1463            return no_replace()
1464        for p, repl in searches:
1465            repl_is_func = isinstance(repl, Function)
1466            if repl_is_func:
1467                repl.init_env(current_editor_name)
1468            if editor.replace(p, repl, saved_match='gui'):
1469                if repl_is_func:
1470                    repl.end()
1471                    show_function_debug_output(repl)
1472                return True
1473        return no_replace(_(
1474                'Currently selected text does not match the search query.'))
1475
1476    def count_message(replaced, count, show_diff=False, show_dialog=True, count_map=None):
1477        if show_dialog:
1478            if replaced:
1479                msg = _('Performed the replacement at {num} occurrences of {query}')
1480            else:
1481                msg = _('Found {num} occurrences of {query}')
1482            msg = msg.format(num=count, query=prepare_string_for_xml(errfind))
1483            det_msg = ''
1484            if count_map is not None and count > 0 and len(count_map) > 1:
1485                for k in sorted(count_map):
1486                    det_msg += _('{0}: {1} occurrences').format(k, count_map[k]) + '\n'
1487            if show_diff and count > 0:
1488                d = MessageBox(MessageBox.INFO, _('Searching done'), '<p>'+msg, parent=gui_parent, show_copy_button=False, det_msg=det_msg)
1489                d.diffb = b = d.bb.addButton(_('See what &changed'), QDialogButtonBox.ButtonRole.AcceptRole)
1490                d.show_changes = False
1491                b.setIcon(QIcon(I('diff.png'))), b.clicked.connect(d.accept)
1492                connect_lambda(b.clicked, d, lambda d: setattr(d, 'show_changes', True))
1493                d.exec()
1494                if d.show_changes:
1495                    show_current_diff(allow_revert=True)
1496            else:
1497                info_dialog(gui_parent, _('Searching done'), prepare_string_for_xml(msg), show=True, det_msg=det_msg)
1498
1499    def do_all(replace=True):
1500        count = 0
1501        count_map = Counter()
1502        if not files and editor is None:
1503            return 0
1504        lfiles = files or {current_editor_name:editor.syntax}
1505        updates = set()
1506        raw_data = {}
1507        for n in lfiles:
1508            if n in editors:
1509                raw = editors[n].get_raw_data()
1510            else:
1511                raw = current_container().raw_data(n)
1512            raw_data[n] = raw
1513
1514        for search_name, (p, repl) in zip(search_names, searches):
1515            repl_is_func = isinstance(repl, Function)
1516            file_iterator = lfiles
1517            if repl_is_func:
1518                repl.init_env()
1519                if repl.file_order is not None and len(lfiles) > 1:
1520                    file_iterator = reorder_files(file_iterator, repl.file_order)
1521            for n in file_iterator:
1522                raw = raw_data[n]
1523                if replace:
1524                    if repl_is_func:
1525                        repl.context_name = n
1526                    raw, num = p.subn(repl, raw)
1527                    if num > 0:
1528                        updates.add(n)
1529                        raw_data[n] = raw
1530                else:
1531                    num = len(p.findall(raw))
1532                count += num
1533                count_map[search_name] += num
1534            if repl_is_func:
1535                repl.end()
1536                show_function_debug_output(repl)
1537
1538        for n in updates:
1539            raw = raw_data[n]
1540            if n in editors:
1541                editors[n].replace_data(raw)
1542            else:
1543                try:
1544                    with current_container().open(n, 'wb') as f:
1545                        f.write(raw.encode('utf-8'))
1546                except PermissionError:
1547                    if not iswindows:
1548                        raise
1549                    time.sleep(2)
1550                    with current_container().open(n, 'wb') as f:
1551                        f.write(raw.encode('utf-8'))
1552
1553        QApplication.restoreOverrideCursor()
1554        count_message(replace, count, show_diff=replace, count_map=count_map)
1555        return count
1556
1557    with BusyCursor():
1558        if action == 'find':
1559            return do_find()
1560        if action == 'replace':
1561            return do_replace()
1562        if action == 'replace-find' and do_replace():
1563            return do_find()
1564        if action == 'replace-all':
1565            if marked:
1566                show_result_dialog = True
1567                for p, repl in searches:
1568                    if getattr(getattr(repl, 'func', None), 'suppress_result_dialog', False):
1569                        show_result_dialog = False
1570                        break
1571                return count_message(True, sum(editor.all_in_marked(p, repl) for p, repl in searches), show_dialog=show_result_dialog)
1572            add_savepoint(_('Before: Replace all'))
1573            count = do_all()
1574            if count == 0:
1575                rewind_savepoint()
1576            else:
1577                set_modified()
1578            return
1579        if action == 'count':
1580            if marked:
1581                return count_message(False, sum(editor.all_in_marked(p) for p, __ in searches))
1582            return do_all(replace=False)
1583
1584
1585if __name__ == '__main__':
1586    app = QApplication([])
1587    d = SavedSearches()
1588    d.show()
1589    app.exec()
1590