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