search.py 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601
  1. ########################################################################
  2. # Searx-Qt - Lightweight desktop application for Searx.
  3. # Copyright (C) 2020-2022 CYBERDEViL
  4. #
  5. # This file is part of Searx-Qt.
  6. #
  7. # Searx-Qt is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # Searx-Qt is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. #
  20. ########################################################################
  21. from PyQt5.QtWidgets import (
  22. QWidget,
  23. QVBoxLayout,
  24. QHBoxLayout,
  25. QLineEdit,
  26. QTextBrowser,
  27. QCheckBox,
  28. QLabel,
  29. QComboBox,
  30. QFrame,
  31. QMenu,
  32. QWidgetAction,
  33. QShortcut,
  34. QSpacerItem,
  35. QSizePolicy,
  36. QDialog,
  37. QListWidget,
  38. QTableView,
  39. QSplitter,
  40. QAbstractItemView,
  41. QHeaderView,
  42. QFormLayout,
  43. QMessageBox
  44. )
  45. from PyQt5.QtCore import pyqtSignal, Qt, QVariant, QByteArray, QEvent
  46. from PyQt5.QtGui import (
  47. QDesktopServices,
  48. QStandardItem,
  49. QStandardItemModel,
  50. QTextDocument
  51. )
  52. from searxqt.core.htmlGen import ResultsHtml, FailedResponsesHtml
  53. from searxqt.core.guard import ConsequenceType
  54. from searxqt.core.requests import ErrorType
  55. from searxqt.models.search import (
  56. SearchStatus,
  57. UserCategoriesModel,
  58. CategoriesModel
  59. )
  60. from searxqt.models.instances import EnginesTableModel
  61. from searxqt.widgets.buttons import Button, CheckboxOptionsButton
  62. from searxqt.thread import Thread
  63. from searxqt.translations import _
  64. from searxqt.themes import Themes
  65. from searxqt.core import log
  66. class SearchNavigation(QWidget):
  67. requestPage = pyqtSignal(int) # pageno
  68. def __init__(self, parent=None):
  69. QWidget.__init__(self, parent=parent)
  70. layout = QHBoxLayout(self)
  71. self.prevPageButton = Button("◄", self)
  72. self.pageNoLabel = QLabel("1", self)
  73. self.nextPageButton = Button("►", self)
  74. layout.addWidget(self.prevPageButton, 0, Qt.AlignLeft)
  75. layout.addWidget(self.pageNoLabel, 0, Qt.AlignCenter)
  76. layout.addWidget(self.nextPageButton, 0, Qt.AlignRight)
  77. self.prevPageButton.setEnabled(False)
  78. self.nextPageButton.clicked.connect(self._nextPage)
  79. self.prevPageButton.clicked.connect(self._prevPage)
  80. self.reset()
  81. def _updateLabel(self):
  82. self.pageNoLabel.setText(str(self._pageno))
  83. def _nextPage(self):
  84. self._pageno += 1
  85. if self._pageno > 1 and not self.prevPageButton.isEnabled():
  86. self.prevPageButton.setEnabled(True)
  87. self._updateLabel()
  88. self.requestPage.emit(self._pageno)
  89. def _prevPage(self):
  90. self._pageno -= 1
  91. if self._pageno == 1:
  92. self.prevPageButton.setEnabled(False)
  93. self.setNextEnabled(True)
  94. self._updateLabel()
  95. self.requestPage.emit(self._pageno)
  96. def reset(self):
  97. self._pageno = 1
  98. self.prevPageButton.setEnabled(False)
  99. self._updateLabel()
  100. def setNextEnabled(self, state):
  101. self.nextPageButton.setEnabled(state)
  102. class SearchEngines(CheckboxOptionsButton):
  103. def __init__(self, searchModel, instancesModel, parent=None):
  104. """
  105. @param searchModel: needed for getting and setting current
  106. enabled/disabled engines.
  107. @type searchModel: SearchModel
  108. @param instancesModel: needed for listing current available
  109. engines and update it's current filter
  110. to filter out instances without atleast
  111. one of the required engine(s).
  112. @type instancesModel: InstanceModelFilter
  113. """
  114. self._instancesModel = instancesModel
  115. self._searchModel = searchModel
  116. CheckboxOptionsButton.__init__(
  117. self,
  118. labelName=_("Engines"),
  119. parent=parent
  120. )
  121. instancesModel.parentModel().changed.connect(self.reGenerate)
  122. def updateFilter(self):
  123. """ Filter out instances that don't support atleast one of the
  124. enabled engines.
  125. """
  126. self._instancesModel.updateKwargs(
  127. {'engines': self.getCheckedOptionNames()}
  128. )
  129. """ Below are re-implementations.
  130. """
  131. def getCheckedOptionNames(self):
  132. """ Should return a list with checked option names. This will
  133. be used to generate the label.
  134. @return: should return a list with strings.
  135. @rtype: list
  136. """
  137. return self._searchModel.engines
  138. def getOptions(self):
  139. """ Should return a list with options tuple(key, name, state)
  140. This will be used to generate the options.
  141. """
  142. list_ = []
  143. tmp = []
  144. for url, instance in self._instancesModel.items():
  145. for engine in instance.engines:
  146. if engine.name not in tmp:
  147. state = bool(engine.name in self._searchModel.engines)
  148. list_.append((engine.name, engine.name, state))
  149. tmp.append(engine.name)
  150. return sorted(list_)
  151. def optionToggled(self, key, state):
  152. if state:
  153. self._searchModel.engines.append(key)
  154. else:
  155. self._searchModel.engines.remove(key)
  156. self.updateFilter()
  157. class CategoryEditor(QDialog):
  158. def __init__(self, enginesModel, categoriesModel,
  159. userCategoriesModel, parent=None):
  160. QDialog.__init__(self, parent=parent)
  161. self._categoriesModel = categoriesModel
  162. self._userCategoriesModel = userCategoriesModel
  163. self.setWindowTitle(_("Category manager"))
  164. layout = QHBoxLayout(self)
  165. # Splitter to horizontal split the categories widget and the engines
  166. # widget so their width becomes adjustable.
  167. self.splitter = QSplitter(self)
  168. self.splitter.setOrientation(Qt.Horizontal)
  169. layout.addWidget(self.splitter)
  170. # Categories
  171. catWidget = QWidget(self.splitter)
  172. catLayout = QVBoxLayout(catWidget)
  173. label = QLabel(f"<h2>{_('Categories')}</h2>", self)
  174. # Categories toolbuttons
  175. catToolLayout = QHBoxLayout()
  176. catAddButton = Button("+", self)
  177. self._catDelButton = Button("-", self)
  178. catToolLayout.addWidget(catAddButton, 0, Qt.AlignLeft)
  179. catToolLayout.addWidget(self._catDelButton, 1, Qt.AlignLeft)
  180. self._categoryListWidget = QListWidget(self)
  181. catLayout.addWidget(label)
  182. catLayout.addLayout(catToolLayout)
  183. catLayout.addWidget(self._categoryListWidget)
  184. # Engines
  185. engWidget = QWidget(self.splitter)
  186. engLayout = QVBoxLayout(engWidget)
  187. label = QLabel(f"<h2>{_('Engines')}</h2>", self)
  188. # Engines filter
  189. filterLayout = QHBoxLayout()
  190. self._enginesCategoryFilterBox = QComboBox(self)
  191. self._enginesCategoryFilterBox.addItem(_("All"))
  192. for key, cat in categoriesModel.items():
  193. self._enginesCategoryFilterBox.addItem(cat.name)
  194. filterLayout.addWidget(
  195. QLabel(_("Category") + ":", self), 1, Qt.AlignRight
  196. )
  197. filterLayout.addWidget(
  198. self._enginesCategoryFilterBox, 0, Qt.AlignRight
  199. )
  200. # Engines table
  201. self._enginesTableView = QTableView(self)
  202. self._enginesTableView.setAlternatingRowColors(True)
  203. self._enginesTableView.setSelectionBehavior(
  204. QAbstractItemView.SelectRows
  205. )
  206. self._enginesTableView.setEditTriggers(
  207. QAbstractItemView.NoEditTriggers
  208. )
  209. self._enginesTableView.setSortingEnabled(True)
  210. self._enginesTableView.setHorizontalScrollMode(
  211. QAbstractItemView.ScrollPerPixel
  212. )
  213. header = self._enginesTableView.horizontalHeader()
  214. header.setSectionResizeMode(QHeaderView.ResizeToContents)
  215. header.setSectionsMovable(True)
  216. self._enginesTableModel = EnginesTableModel(enginesModel, self)
  217. self._enginesTableView.setModel(self._enginesTableModel)
  218. engLayout.addWidget(label)
  219. engLayout.addLayout(filterLayout)
  220. engLayout.addWidget(self._enginesTableView)
  221. # Connections
  222. catAddButton.clicked.connect(self.__addCategoryClicked)
  223. self._catDelButton.clicked.connect(self.__delCategoryClicked)
  224. self._categoryListWidget.currentRowChanged.connect(
  225. self.__currentUserCategoryChanged
  226. )
  227. self._enginesCategoryFilterBox.currentIndexChanged.connect(
  228. self.__enginesCategoryFilterChanged
  229. )
  230. self._enginesTableView.setEnabled(False)
  231. self._catDelButton.setEnabled(False)
  232. self.__addUserCategories()
  233. self.__selectFirst()
  234. def __currentUserCategoryChanged(self, index):
  235. if index < 0:
  236. self._enginesTableView.setEnabled(False)
  237. self._catDelButton.setEnabled(False)
  238. else:
  239. self._enginesTableView.setEnabled(True)
  240. self._catDelButton.setEnabled(True)
  241. if self._userCategoriesModel:
  242. key = list(self._userCategoriesModel.keys())[index]
  243. self._enginesTableModel.setUserModel(
  244. self._userCategoriesModel[key]
  245. )
  246. def __addUserCategories(self):
  247. for catKey, cat in self._userCategoriesModel.items():
  248. self._categoryListWidget.addItem(cat.name)
  249. def __selectFirst(self):
  250. if self._categoryListWidget.count():
  251. self._categoryListWidget.setCurrentRow(0)
  252. def __selectLast(self):
  253. self._categoryListWidget.setCurrentRow(
  254. self._categoryListWidget.count() - 1
  255. )
  256. def __reloadUserCategories(self):
  257. self._categoryListWidget.currentRowChanged.disconnect(
  258. self.__currentUserCategoryChanged
  259. )
  260. self._enginesTableModel.setUserModel(None)
  261. self._categoryListWidget.clear()
  262. self.__addUserCategories()
  263. self._categoryListWidget.currentRowChanged.connect(
  264. self.__currentUserCategoryChanged
  265. )
  266. def __addCategoryClicked(self, state):
  267. dialog = AddUserCategoryDialog(
  268. self._userCategoriesModel.keys()
  269. )
  270. if dialog.exec():
  271. self._userCategoriesModel.addCategory(
  272. dialog.name.lower(),
  273. dialog.name
  274. )
  275. self.__reloadUserCategories()
  276. self.__selectLast()
  277. def __delCategoryClicked(self, state):
  278. index = self._categoryListWidget.currentRow()
  279. key = list(self._userCategoriesModel.keys())[index]
  280. confirmDialog = QMessageBox()
  281. confirmDialog.setWindowTitle(
  282. _("Delete category")
  283. )
  284. categoryName = self._userCategoriesModel[key].name
  285. confirmDialog.setText(_("Are you sure you want to delete the " \
  286. f"category `{categoryName}`?"))
  287. confirmDialog.setStandardButtons(
  288. QMessageBox.Yes | QMessageBox.No
  289. )
  290. confirmDialog.button(QMessageBox.Yes).setText(_("Yes"))
  291. confirmDialog.button(QMessageBox.No).setText(_("No"))
  292. if confirmDialog.exec() != QMessageBox.Yes:
  293. return
  294. self._userCategoriesModel.removeCategory(key)
  295. self.__reloadUserCategories()
  296. self.__selectLast()
  297. self.__currentUserCategoryChanged(
  298. self._categoryListWidget.count() - 1
  299. )
  300. def __enginesCategoryFilterChanged(self, index):
  301. if not index: # All
  302. self._enginesTableModel.setCatFilter()
  303. else:
  304. key = list(self._categoriesModel.keys())[index-1]
  305. self._enginesTableModel.setCatFilter(key)
  306. class AddUserCategoryDialog(QDialog):
  307. def __init__(self, existingNames=[], text="", parent=None):
  308. QDialog.__init__(self, parent=parent)
  309. self._existingNames = existingNames
  310. layout = QFormLayout(self)
  311. label = QLabel(_("Name") + ":")
  312. self._nameEdit = QLineEdit(self)
  313. if text:
  314. self._nameEdit.setText(text)
  315. self._nameEdit.setPlaceholderText(text)
  316. else:
  317. self._nameEdit.setPlaceholderText(_("My category"))
  318. self._cancelButton = Button(_("Cancel"), self)
  319. self._addButton = Button(_("Add"), self)
  320. self._addButton.setEnabled(False)
  321. # Add stuff to layout
  322. layout.addRow(label, self._nameEdit)
  323. layout.addRow(self._cancelButton, self._addButton)
  324. # Connections
  325. self._nameEdit.textChanged.connect(self.__inputChanged)
  326. self._addButton.clicked.connect(self.accept)
  327. self._cancelButton.clicked.connect(self.reject)
  328. def __inputChanged(self, text):
  329. if self.isValid():
  330. self._addButton.setEnabled(True)
  331. else:
  332. self._addButton.setEnabled(False)
  333. def isValid(self):
  334. name = self._nameEdit.text().lower()
  335. if not name:
  336. return False
  337. for existingName in self._existingNames:
  338. if name == existingName.lower():
  339. return False
  340. return True
  341. @property
  342. def name(self):
  343. return self._nameEdit.text()
  344. class SearchCategories(CheckboxOptionsButton):
  345. def __init__(self, categoriesModel, instanceCategoriesModel, enginesModel,
  346. userCategoriesModel, parent=None):
  347. """
  348. @param categoriesModel: Predefined categories (only avaiable when at
  349. least one instance has the category).
  350. @type categoriesModel: searxqt.models.search.CategoriesModel
  351. @param instanceCategoriesModel: Some instances define custom search
  352. categories.
  353. @type instanceCategoriesModel: searxqt.models.search.CategoriesModel
  354. @param enginesModel:
  355. @type enginesModel: searxqt.models.instances.EnginesModel
  356. @param userCategoriesModel:
  357. @type userCategoriesModel: searxqt.models.search.UserCategoriesModel
  358. @param parent:
  359. @type parent: QObject or None
  360. """
  361. self._categoriesModel = categoriesModel
  362. self._instanceCategoriesModel = instanceCategoriesModel
  363. self._enginesModel = enginesModel
  364. self._userCategoriesModel = userCategoriesModel
  365. CheckboxOptionsButton.__init__(
  366. self,
  367. labelName=_("Categories"),
  368. parent=parent
  369. )
  370. self._categoriesModel.dataChanged.connect(self.__categoryDataChanged)
  371. def __categoryDataChanged(self):
  372. # This happends after CategoriesModel.setData
  373. self.reGenerate()
  374. def __openUserCategoryEditor(self):
  375. window = CategoryEditor(
  376. self._enginesModel,
  377. self._categoriesModel,
  378. self._userCategoriesModel, self)
  379. window.exec()
  380. def __userCategoryToggled(self, key, state):
  381. if state:
  382. self._userCategoriesModel[key].check()
  383. else:
  384. self._userCategoriesModel[key].uncheck()
  385. self.reGenerate()
  386. def __instanceCategoryToggled(self, key, state):
  387. if state:
  388. self._instanceCategoriesModel[key].check()
  389. else:
  390. self._instanceCategoriesModel[key].uncheck()
  391. self.reGenerate()
  392. """ Below are re-implementations.
  393. """
  394. def addCustomWidgetsTop(self, menu):
  395. # User specified categories
  396. menu.addSection(_("Custom"))
  397. action = QWidgetAction(menu)
  398. manageCustomButton = Button(_("Manage"), menu)
  399. action.setDefaultWidget(manageCustomButton)
  400. menu.addAction(action)
  401. for catKey, cat in self._userCategoriesModel.items():
  402. action = QWidgetAction(menu)
  403. widget = QCheckBox(cat.name, menu)
  404. widget.setTristate(False)
  405. widget.setChecked(
  406. cat.isChecked()
  407. )
  408. action.setDefaultWidget(widget)
  409. widget.stateChanged.connect(
  410. lambda state, key=catKey:
  411. self.__userCategoryToggled(key, state)
  412. )
  413. menu.addAction(action)
  414. # Custom instance specified categories
  415. menu.addSection(_("Instances"))
  416. for catKey, cat in self._instanceCategoriesModel.items():
  417. action = QWidgetAction(menu)
  418. widget = QCheckBox(cat.name, menu)
  419. widget.setTristate(False)
  420. widget.setChecked(cat.isChecked())
  421. action.setDefaultWidget(widget)
  422. widget.stateChanged.connect(
  423. lambda state, key=catKey:
  424. self.__instanceCategoryToggled(key, state)
  425. )
  426. menu.addAction(action)
  427. # Predefined Searx categories
  428. menu.addSection(_("Default"))
  429. manageCustomButton.clicked.connect(self.__openUserCategoryEditor)
  430. def hasEnabledCheckedKeys(self):
  431. """ Same as CheckboxOptionsButton.hasEnabledCheckedKeys(self) but with
  432. User Categories. Categories don't get enabled/disabled so we can skip
  433. that check.
  434. @rtype: bool
  435. """
  436. if self._userCategoriesModel.checkedCategories():
  437. return True
  438. elif self._instanceCategoriesModel.checkedCategories():
  439. return True
  440. return CheckboxOptionsButton.hasEnabledCheckedKeys(self)
  441. def uncheckAllEnabledKeys(self):
  442. """ Unchecks all checked keys that are enabled.
  443. """
  444. for catKey in self._userCategoriesModel.checkedCategories():
  445. self._userCategoriesModel[catKey].uncheck()
  446. for catKey in self._instanceCategoriesModel.checkedCategories():
  447. self._instanceCategoriesModel[catKey].uncheck()
  448. CheckboxOptionsButton.uncheckAllEnabledKeys(self)
  449. def getCheckedOptionNames(self):
  450. """ Should return a list with checked option names. This will
  451. be used to generate the label.
  452. @return: should return a list with strings.
  453. @rtype: list
  454. """
  455. return(
  456. [
  457. self._categoriesModel[catKey].name
  458. for catKey in self._categoriesModel.checkedCategories()
  459. ] +
  460. [
  461. self._instanceCategoriesModel[catKey].name
  462. for catKey in self._instanceCategoriesModel.checkedCategories()
  463. ] +
  464. [
  465. self._userCategoriesModel[catKey].name
  466. for catKey in self._userCategoriesModel.checkedCategories()
  467. ]
  468. )
  469. def getOptions(self):
  470. """ Should return a list with options tuple(key, name, state)
  471. This will be used to generate the options.
  472. """
  473. list_ = []
  474. for catKey in self._categoriesModel:
  475. list_.append(
  476. (
  477. catKey,
  478. self._categoriesModel[catKey].name,
  479. self._categoriesModel[catKey].isChecked()
  480. )
  481. )
  482. return list_
  483. def optionToggled(self, key, state):
  484. if state:
  485. self._categoriesModel[key].check()
  486. else:
  487. self._categoriesModel[key].uncheck()
  488. class SearchPeriod(QComboBox):
  489. def __init__(self, model, parent=None):
  490. QComboBox.__init__(self, parent=parent)
  491. self._model = model
  492. self.setMinimumContentsLength(2)
  493. for period in model.Periods:
  494. self.addItem(model.Periods[period], QVariant(period))
  495. self.currentIndexChanged.connect(self.__indexChanged)
  496. def __indexChanged(self, index):
  497. self._model.timeRange = self.currentData()
  498. class SearchLanguage(QComboBox):
  499. def __init__(self, model, parent=None):
  500. QComboBox.__init__(self, parent=parent)
  501. self._model = model
  502. self._favorites = []
  503. self.setMinimumContentsLength(2)
  504. self.__itemModel = QStandardItemModel(self)
  505. self.setModel(self.__itemModel)
  506. self.currentIndexChanged.connect(self.__indexChanged)
  507. self.__itemModel.itemChanged.connect(self.__favCheckChanged)
  508. def __indexChanged(self, index):
  509. self._model.lang = self.currentData()
  510. def __favCheckChanged(self, item):
  511. lang = item.data(Qt.UserRole)
  512. index = item.row()
  513. newIndex = 0
  514. if item.checkState():
  515. # Language added to favorites.
  516. self._favorites.append(lang)
  517. else:
  518. # Remove language from favorites.
  519. langList = list(self._model.Languages.keys())
  520. newIndex = langList.index(lang)
  521. self._favorites.remove(lang)
  522. # Index offset
  523. for favLang in self._favorites:
  524. if langList.index(favLang) < newIndex:
  525. newIndex -= 1
  526. newIndex += len(self._favorites)
  527. self.__itemModel.takeRow(index)
  528. self.__itemModel.insertRow(newIndex, item)
  529. def populate(self):
  530. self.__itemModel.clear()
  531. for lang in self._model.Languages:
  532. newItem = QStandardItem(self._model.Languages[lang])
  533. newItem.setCheckable(True)
  534. newItem.setFlags(
  535. Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsEnabled
  536. )
  537. newItem.setData(QVariant(lang), Qt.UserRole)
  538. if lang in self._favorites:
  539. newItem.setData(Qt.Checked, Qt.CheckStateRole)
  540. self.__itemModel.insertRow(0, newItem)
  541. continue
  542. newItem.setData(Qt.Unchecked, Qt.CheckStateRole)
  543. self.__itemModel.appendRow(newItem)
  544. def loadSettings(self, data):
  545. for lang in data.get('favs', []):
  546. self._favorites.append(lang)
  547. self.populate()
  548. # Find and set the index that matches lang
  549. self.setCurrentIndex(
  550. self.findData(QVariant(data.get('lang', '')), Qt.UserRole)
  551. )
  552. def saveSettings(self):
  553. return {
  554. 'lang': str(self.currentData()),
  555. 'favs': self._favorites
  556. }
  557. class SearchOptionsContainer(QFrame):
  558. """ Custom QFrame to be able to show or hide certain widgets.
  559. """
  560. def __init__(self, searchModel, instancesModel, enginesModel, parent=None):
  561. """
  562. @param searchModel:
  563. @type searchModel: searxqt.models.search.SearchModel
  564. @param instancesModel:
  565. @type instancesModel: searxqt.models.instances.InstanceModelFilter
  566. @param enginesModel:
  567. @type enginesModel: searxqt.models.instances.EnginesModel
  568. @param parent:
  569. @type parent: QObject or None
  570. """
  571. QFrame.__init__(self, parent=parent)
  572. self._enginesModel = enginesModel
  573. self._searchModel = searchModel
  574. self._categoriesModel = CategoriesModel()
  575. self._instanceCategoriesModel = CategoriesModel()
  576. self._userCategoriesModel = UserCategoriesModel()
  577. # Backup user checked engines.
  578. self.__userCheckedBackup = []
  579. # Keep track of disabled engines (these engines are disabled
  580. # because they are part of one or more checked categories).
  581. #
  582. # @key: engine name (str)
  583. # @value : list with category keys (str)
  584. self.__disabledByCat = {}
  585. layout = QHBoxLayout(self)
  586. self._widgets = {
  587. 'categories': SearchCategories(
  588. self._categoriesModel,
  589. self._instanceCategoriesModel,
  590. enginesModel,
  591. self._userCategoriesModel,
  592. self
  593. ),
  594. 'engines': SearchEngines(searchModel, instancesModel, self),
  595. 'period': SearchPeriod(searchModel, self),
  596. 'lang': SearchLanguage(searchModel, self)
  597. }
  598. for widget in self._widgets.values():
  599. layout.addWidget(widget, 0, Qt.AlignTop)
  600. # Keep widgets left aligned.
  601. spacer = QSpacerItem(
  602. 40, 20, QSizePolicy.MinimumExpanding, QSizePolicy.Minimum
  603. )
  604. layout.addItem(spacer)
  605. # Connections
  606. self._categoriesModel.stateChanged.connect(
  607. self.__categoriesStateChanged
  608. )
  609. self._instanceCategoriesModel.stateChanged.connect(
  610. self.__categoriesStateChanged
  611. )
  612. self._userCategoriesModel.stateChanged.connect(
  613. self.__userCategoriesStateChanged
  614. )
  615. self._userCategoriesModel.changed.connect(self.__userCategoriesChanged)
  616. self._userCategoriesModel.removed.connect(self.__userCategoryRemoved)
  617. self._enginesModel.changed.connect(self.__enginesModelChanged)
  618. def __enginesModelChanged(self):
  619. """Settings loaded or data updated
  620. """
  621. # Remove deleted engines from __disabledByCat
  622. for engine in list(self.__disabledByCat.keys()):
  623. if engine not in self._enginesModel:
  624. del self.__disabledByCat[engine]
  625. self._widgets['engines'].setKeyEnabled(engine)
  626. # Add new categories
  627. for catKey in self._enginesModel.categories():
  628. if catKey not in self._categoriesModel:
  629. name = ""
  630. if catKey in self._searchModel.categories.types:
  631. # Default pre-defined categories are translatable
  632. name = self._searchModel.categories.types[catKey][0]
  633. self._categoriesModel.addCategory(catKey, name)
  634. else:
  635. name = catKey.capitalize()
  636. log.debug(f"Found non default category `{name}`", self)
  637. self._instanceCategoriesModel.addCategory(catKey, name)
  638. # Remove old categories
  639. for catKey in self._categoriesModel.copy():
  640. if catKey not in self._enginesModel.categories():
  641. self._categoriesModel.removeCategory(catKey)
  642. # Release potentialy checked engines
  643. self.__processCategoriesStateChange(
  644. [engine.name for engine in
  645. self._enginesModel.getByCategory(catKey)],
  646. catKey,
  647. False
  648. )
  649. # Remove old instance specific categories
  650. for catKey in self._instanceCategoriesModel.copy():
  651. if (catKey not in self._enginesModel.categories() and
  652. catKey not in self._categoriesModel):
  653. self._instanceCategoriesModel.removeCategory(catKey)
  654. # Release potentialy checked engines
  655. self.__processCategoriesStateChange(
  656. [engine.name for engine in
  657. self._enginesModel.getByCategory(catKey)],
  658. catKey,
  659. False
  660. )
  661. self._widgets['categories'].reGenerate()
  662. self.__finalizeCategoriesStateChange()
  663. def __userCategoryRemoved(self, catKey):
  664. for engineKey, catList in self.__disabledByCat.copy().items():
  665. if catKey in catList:
  666. self.__uncheckEngineByCat(catKey, engineKey)
  667. self._widgets['categories'].reGenerate()
  668. self.__finalizeCategoriesStateChange()
  669. def __userCategoriesChanged(self, catKey):
  670. """ When the user edited a existing user-category this should
  671. check freshly added engines to this category and uncheck engines
  672. that have been removed from this category.
  673. """
  674. if self._userCategoriesModel[catKey].isChecked():
  675. engines = self._userCategoriesModel[catKey].engines
  676. # Uncheck removed engines
  677. for engineKey, categories in self.__disabledByCat.copy().items():
  678. if catKey in categories:
  679. if engineKey not in engines:
  680. self.__uncheckEngineByCat(catKey, engineKey)
  681. # Check newly added engines
  682. for engineKey in engines:
  683. if engineKey not in self.__disabledByCat:
  684. self.__checkEngineByCat(catKey, engineKey)
  685. self.__finalizeCategoriesStateChange()
  686. def __checkEngineByCat(self, catKey, engineKey):
  687. """ This method handles checking of a engine by a category.
  688. @param catKey: Category key
  689. @type catKey: str
  690. @param engineKey: Engine key
  691. @type engineKey: str
  692. """
  693. if engineKey not in self._searchModel.engines:
  694. # User did not check this engine so we are going to.
  695. self._searchModel.engines.append(
  696. engineKey
  697. )
  698. elif(engineKey not in self.__userCheckedBackup and
  699. not self._widgets['engines'].keyDisabled(engineKey)):
  700. # User did check this engine, so we backup that.
  701. self.__userCheckedBackup.append(engineKey)
  702. if not self._widgets['engines'].keyDisabled(engineKey):
  703. # Disable the engine from being toggled by the user.
  704. self._widgets['engines'].setKeyDisabled(engineKey)
  705. if engineKey not in self.__disabledByCat:
  706. self.__disabledByCat.update({engineKey: []})
  707. # Backup that this category is blocking this engine from
  708. # being toggled by the user.
  709. self.__disabledByCat[engineKey].append(catKey)
  710. def __uncheckEngineByCat(self, catKey, engineKey):
  711. """ This method handles the unchecking of a engine by a category.
  712. @param catKey: Category key
  713. @type catKey: str
  714. @param engineKey: Engine key
  715. @type engineKey: str
  716. """
  717. if engineKey in self.__disabledByCat:
  718. if catKey in self.__disabledByCat[engineKey]:
  719. # This category no longer blocks this engine from
  720. # being edited by the user.
  721. self.__disabledByCat[engineKey].remove(catKey)
  722. if not self.__disabledByCat[engineKey]:
  723. # No category left that blocks this engine from
  724. # user-toggleing.
  725. self._widgets['engines'].setKeyEnabled(engineKey)
  726. self.__disabledByCat.pop(engineKey)
  727. if engineKey not in self.__userCheckedBackup:
  728. # User didn't check this engine, so we can
  729. # uncheck it.
  730. self._searchModel.engines.remove(
  731. engineKey
  732. )
  733. else:
  734. # User did check this engine before checking
  735. # this category so we won't uncheck it.
  736. self.__userCheckedBackup.remove(engineKey)
  737. def __userCategoriesStateChanged(self, catKey, state):
  738. """ The user checked or unchecked a user-category.
  739. @param catKey: Category key
  740. @type catKey: str
  741. @param state:Category enabled or disabled (checked or unchecked)
  742. @type state: bool
  743. """
  744. self.__processCategoriesStateChange(
  745. self._userCategoriesModel[catKey].engines,
  746. catKey,
  747. state
  748. )
  749. self._widgets['categories'].reGenerate()
  750. self.__finalizeCategoriesStateChange()
  751. def __categoriesStateChanged(self, catKey, state):
  752. """ The user checked or unchecked a default-category.
  753. @param catKey: Category key
  754. @type catKey: str
  755. @param state: Category enabled or disabled (checked or unchecked)
  756. @type state: bool
  757. """
  758. self.__processCategoriesStateChange(
  759. [engine.name for engine in
  760. self._enginesModel.getByCategory(catKey)],
  761. catKey,
  762. state
  763. )
  764. self._widgets['categories'].reGenerate()
  765. self.__finalizeCategoriesStateChange()
  766. def __processCategoriesStateChange(self, engines, catKey, state):
  767. """ The user checked or unchecked a category, depending on the
  768. `state` variable.
  769. When a category gets checked all the engines in that category
  770. will be checked and disabled so that the user can't toggle the
  771. engine.
  772. On uncheck of a category all engines in that category should be
  773. re-enabled. And those engines should be unchecked if they weren't
  774. checked by the user before checking this category.
  775. @param engines: A list with engineKeys (str) that are part of the
  776. category (catKey).
  777. @type engines: list
  778. @param catKey: Category key
  779. @type catKey: str
  780. @param state: Category enabled or disabled (checked or unchecked)
  781. @type state: bool
  782. """
  783. if state:
  784. # Category checked.
  785. for engine in engines:
  786. self.__checkEngineByCat(catKey, engine)
  787. else:
  788. # Category unchecked.
  789. for engine in engines:
  790. self.__uncheckEngineByCat(catKey, engine)
  791. def __finalizeCategoriesStateChange(self):
  792. # Re-generate the engines label
  793. self._widgets['engines'].reGenerate()
  794. # Update the instances filter.
  795. self._widgets['engines'].updateFilter()
  796. def saveSettings(self):
  797. data = {}
  798. # Store widgets visible state.
  799. for key, widget in self._widgets.items():
  800. data.update({
  801. f"{key}Visible": not widget.isHidden()
  802. })
  803. # Store category states and CheckboxOptionsButton states (label
  804. # expanded or collapsed)
  805. data.update({
  806. 'userCatModel': self._userCategoriesModel.data(),
  807. 'defaultCatModel': self._categoriesModel.data(),
  808. 'categoriesButton': self._widgets['categories'].saveSettings(),
  809. 'enginesButton': self._widgets['engines'].saveSettings(),
  810. 'language': self._widgets['lang'].saveSettings()
  811. })
  812. return data
  813. def loadSettings(self, data):
  814. # Set widgets visible or hidden depending on their state.
  815. for key, widget in self._widgets.items():
  816. if data.get(f"{key}Visible", True):
  817. widget.show()
  818. else:
  819. widget.hide()
  820. # Load category states
  821. self._userCategoriesModel.setData(data.get('userCatModel', {}))
  822. self._categoriesModel.setData(data.get('defaultCatModel', {}))
  823. # Load CheckboxOptionsButton states (categories and engines label
  824. # states, expanded or collapsed.)
  825. self._widgets['categories'].loadSettings(
  826. data.get('categoriesButton', {})
  827. )
  828. self._widgets['engines'].loadSettings(data.get('enginesButton', {}))
  829. # Load search language.
  830. self._widgets['lang'].loadSettings(data.get('language', {}))
  831. def __checkBoxStateChanged(self, key, state):
  832. if state:
  833. self._widgets[key].show()
  834. else:
  835. self._widgets[key].hide()
  836. """ QFrame re-implementations
  837. """
  838. def contextMenuEvent(self, event):
  839. menu = QMenu(self)
  840. menu.addSection(_("Show / Hide"))
  841. for key, widget in self._widgets.items():
  842. action = QWidgetAction(menu)
  843. checkbox = QCheckBox(key, menu)
  844. checkbox.setTristate(False)
  845. checkbox.setChecked(not widget.isHidden())
  846. action.setDefaultWidget(checkbox)
  847. checkbox.stateChanged.connect(
  848. lambda state, key=key:
  849. self.__checkBoxStateChanged(key, state)
  850. )
  851. menu.addAction(action)
  852. menu.exec_(self.mapToGlobal(event.pos()))
  853. """ Find text in the search results
  854. Shortcuts:
  855. - 'Return' find text.
  856. - 'Shift+Return' find previous text.
  857. """
  858. class ResultSearcher(QWidget):
  859. closeRequest = pyqtSignal()
  860. def __init__(self, resultsContainer, parent):
  861. QWidget.__init__(self, parent)
  862. self.__resultsContainer = resultsContainer
  863. layout = QHBoxLayout(self)
  864. self.__inputEdit = QLineEdit(self)
  865. self.__inputEdit.setPlaceholderText(_("Find .."))
  866. self.__inputEdit.installEventFilter(self)
  867. self.__caseCheckbox = QCheckBox(_("Case sensitive"), self)
  868. self.__wholeCheckbox = QCheckBox(_("Whole words"), self)
  869. nextButton = Button("►", self)
  870. prevButton = Button("◄", self)
  871. closeButton = Button("X", self)
  872. layout.addWidget(self.__inputEdit)
  873. layout.addWidget(prevButton)
  874. layout.addWidget(nextButton)
  875. layout.addWidget(self.__caseCheckbox)
  876. layout.addWidget(self.__wholeCheckbox)
  877. layout.addWidget(closeButton)
  878. closeButton.clicked.connect(self.closeRequest)
  879. nextButton.clicked.connect(self.__search)
  880. prevButton.clicked.connect(self.__searchPrev)
  881. def eventFilter(self, source, event):
  882. if event.type() == QEvent.KeyPress and source is self.__inputEdit:
  883. if event.key() == Qt.Key_Return and event.modifiers() == Qt.ShiftModifier:
  884. self.__search(reverse=True)
  885. elif event.key() == Qt.Key_Return:
  886. self.__search()
  887. elif event.key() == Qt.Key_Escape:
  888. self.closeRequest.emit()
  889. return QLineEdit.eventFilter(self, source, event)
  890. def focusInput(self):
  891. self.__inputEdit.setFocus()
  892. def __searchPrev(self):
  893. self.__search(reverse=True)
  894. def __search(self, reverse=False):
  895. text = self.__inputEdit.text()
  896. flags = QTextDocument.FindFlag(0)
  897. if reverse:
  898. flags |= QTextDocument.FindBackward
  899. if self.__caseCheckbox.isChecked():
  900. flags |= QTextDocument.FindCaseSensitively
  901. if self.__wholeCheckbox.isChecked():
  902. flags |= QTextDocument.FindWholeWords
  903. self.__resultsContainer.find(text, options=flags)
  904. class SearchContainer(QWidget):
  905. def __init__(self, searchModel, instancesModel,
  906. instanceSelecter, enginesModel, guard, parent=None):
  907. """
  908. @type searchModel: models.search.SearchModel
  909. @type instancesModel: models.instances.InstancesModelFilter
  910. @type instanceSelecter: models.instances.InstanceSelecterModel
  911. @type enginesModel: models.instances.EnginesModel
  912. @type guard: core.guard.Guard
  913. """
  914. QWidget.__init__(self, parent=parent)
  915. layout = QVBoxLayout(self)
  916. self._model = searchModel
  917. self._instancesModel = instancesModel
  918. self._instanceSelecter = instanceSelecter
  919. self._guard = guard
  920. self._searchThread = None
  921. # Maximum other instances to try on fail.
  922. self._maxSearchFailCount = 10
  923. # Set `_useFallback` to True to try another instance when the
  924. # search failed somehow or set to False to try same instance or
  925. # for pagination which also should use the same instance.
  926. self._useFallback = True
  927. # `_fallbackActive` should be False when a fresh list of fallback
  928. # instances should be picked on failed search and should be True
  929. # when search(es) fail and `_fallbackInstancesQueue` is beeing
  930. # used.
  931. self._fallbackActive = False
  932. # Used to store errors when fallback is active so they can be listed
  933. # to the user when even the fallback fails.
  934. self._fallbackErrors = []
  935. # Every first request that has `_useFallback` set to True will
  936. # use this list as a resource for next instance(s) to try until
  937. # it is out of instance url's. Also on the first request this
  938. # list will be cleared and filled again with `_maxSearchFailCount`
  939. # of random instances.
  940. self._fallbackInstancesQueue = []
  941. # Set to True to break out of the fallback loop.
  942. # This is used for the Stop action.
  943. self._breakFallback = False
  944. searchLayout = QHBoxLayout()
  945. layout.addLayout(searchLayout)
  946. # -- Start search bar
  947. self.queryEdit = QLineEdit(self)
  948. self.queryEdit.setPlaceholderText(_("Search for .."))
  949. searchLayout.addWidget(self.queryEdit)
  950. self.searchButton = Button(_("Searx"), self)
  951. self.searchButton.setToolTip(_("Preform search."))
  952. searchLayout.addWidget(self.searchButton)
  953. self.reloadButton = Button("♻", self)
  954. self.reloadButton.setToolTip(_("Reload"))
  955. searchLayout.addWidget(self.reloadButton)
  956. self.randomButton = Button("⤳", self)
  957. self.randomButton.setToolTip(_(
  958. "Search with random instance.\n"
  959. "(Obsolete when 'Random Every is checked')"
  960. ))
  961. searchLayout.addWidget(self.randomButton)
  962. rightLayout = QVBoxLayout()
  963. rightLayout.setSpacing(0)
  964. searchLayout.addLayout(rightLayout)
  965. self._fallbackCheck = QCheckBox(_("Fallback"), self)
  966. self._fallbackCheck.setToolTip(_("Try random other instance on fail."))
  967. rightLayout.addWidget(self._fallbackCheck)
  968. self._randomCheckEvery = QCheckBox(_("Random every"), self)
  969. self._randomCheckEvery.setToolTip(_("Pick a random instance for "
  970. "every request."))
  971. rightLayout.addWidget(self._randomCheckEvery)
  972. # -- End search bar
  973. # -- Start splitter
  974. self.splitter = QSplitter(self)
  975. self.splitter.setOrientation(Qt.Vertical)
  976. layout.addWidget(self.splitter)
  977. # ---- Start search options toolbar
  978. self._optionsContainer = SearchOptionsContainer(
  979. searchModel, instancesModel, enginesModel, self.splitter
  980. )
  981. # ---- End search options toolbar
  982. # --- Start search results container
  983. searchBottomWidget = QWidget(self.splitter)
  984. self.searchBottomWidgetLayout = QVBoxLayout(searchBottomWidget)
  985. self.resultsContainer = QTextBrowser(self)
  986. self.resultsContainer.setOpenLinks(False)
  987. self.resultsContainer.setOpenExternalLinks(False)
  988. self.resultsContainer.setLineWrapMode(1)
  989. self.searchBottomWidgetLayout.addWidget(self.resultsContainer)
  990. self.navBar = SearchNavigation(self)
  991. self.navBar.setEnabled(False)
  992. self.searchBottomWidgetLayout.addWidget(self.navBar)
  993. # --- End search results container
  994. # -- End splitter
  995. # Find text
  996. self.__findShortcut = QShortcut('Ctrl+F', self);
  997. self.__findShortcut.activated.connect(self.__openFind);
  998. self.__resultsSearcher = None
  999. self.queryEdit.textChanged.connect(self.__queryChanged)
  1000. self._model.statusChanged.connect(self.__searchStatusChanged)
  1001. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1002. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  1003. self._randomCheckEvery.stateChanged.connect(
  1004. self.__randomEveryRequestChanged)
  1005. self._instanceSelecter.instanceChanged.connect(
  1006. self.__instanceChanged)
  1007. self.queryEdit.returnPressed.connect(self.__searchButtonClicked)
  1008. self.searchButton.clicked.connect(self.__searchButtonClicked)
  1009. self.reloadButton.clicked.connect(self.__reloadButtonClicked)
  1010. self.randomButton.clicked.connect(self.__randomSearchButtonClicked)
  1011. self.navBar.requestPage.connect(self.__navBarRequest)
  1012. self.resultsContainer.anchorClicked.connect(self.__handleAnchorClicked)
  1013. self.__queryChanged("")
  1014. def isBusy(self):
  1015. return bool(self._searchThread is not None)
  1016. def cancelAll(self):
  1017. self._breakFallback = True
  1018. self._searchThread.wait()
  1019. def reset(self):
  1020. self._model.reset()
  1021. self.resultsContainer.setHtml("")
  1022. self.resultsContainer.clearHistory()
  1023. def __openFind(self):
  1024. if self.__resultsSearcher is None:
  1025. self.__resultsSearcher = ResultSearcher(self.resultsContainer, self)
  1026. self.searchBottomWidgetLayout.insertWidget(1, self.__resultsSearcher)
  1027. self.__resultsSearcher.closeRequest.connect(self.__closeFind)
  1028. self.__resultsSearcher.focusInput()
  1029. def __closeFind(self):
  1030. self.searchBottomWidgetLayout.removeWidget(self.__resultsSearcher)
  1031. self.__resultsSearcher.deleteLater()
  1032. self.__resultsSearcher = None
  1033. def __handleAnchorClicked(self, url):
  1034. scheme = url.scheme()
  1035. if scheme in ['http', 'https', 'magnet', 'ftp']:
  1036. QDesktopServices.openUrl(url)
  1037. return
  1038. if scheme == 'search':
  1039. # Internal from sugestions/corrections
  1040. self.queryEdit.setText(url.path())
  1041. self._newSearch(self._instanceSelecter.currentUrl)
  1042. def __searchButtonClicked(self, checked=0):
  1043. # Set to use fallback
  1044. self._useFallback = self._model.useFallback
  1045. if (self._model.randomEvery or
  1046. (self._useFallback and
  1047. not self._instanceSelecter.currentUrl)):
  1048. self._instanceSelecter.randomInstance()
  1049. self._resetPagination()
  1050. self._newSearch(self._instanceSelecter.currentUrl)
  1051. def __stopButtonClicked(self):
  1052. self._breakFallback = True
  1053. self.searchButton.setEnabled(False)
  1054. def __reloadButtonClicked(self):
  1055. self._useFallback = False
  1056. self._newSearch(self._instanceSelecter.currentUrl)
  1057. def __randomSearchButtonClicked(self):
  1058. self._useFallback = self._model.useFallback
  1059. self._instanceSelecter.randomInstance()
  1060. self._newSearch(self._instanceSelecter.currentUrl)
  1061. def __navBarRequest(self, pageNo):
  1062. self._useFallback = False
  1063. self._model.pageno = pageNo
  1064. self._newSearch(self._instanceSelecter.currentUrl)
  1065. def _resetPagination(self):
  1066. self.navBar.reset()
  1067. self._model.pageno = 1
  1068. def __instanceChanged(self):
  1069. self._resetPagination()
  1070. def __searchOptionsChanged(self):
  1071. """ From the model (on load settings)
  1072. """
  1073. self._randomCheckEvery.stateChanged.disconnect(
  1074. self.__randomEveryRequestChanged)
  1075. self._fallbackCheck.stateChanged.disconnect(
  1076. self.__useFallbackChanged)
  1077. self._randomCheckEvery.setChecked(self._model.randomEvery)
  1078. self._fallbackCheck.setChecked(self._model.useFallback)
  1079. self.__handleRandomButtonState()
  1080. self._randomCheckEvery.stateChanged.connect(
  1081. self.__randomEveryRequestChanged)
  1082. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  1083. def __handleRandomButtonState(self):
  1084. """ Hides or shows the 'Random search button'.
  1085. We don't need the button when the model it's randomEvery is True.
  1086. """
  1087. if self._model.randomEvery:
  1088. self.randomButton.hide()
  1089. else:
  1090. self.randomButton.show()
  1091. def __randomEveryRequestChanged(self, state):
  1092. """ From the checkbox
  1093. """
  1094. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  1095. self._model.randomEvery = bool(state)
  1096. self.__handleRandomButtonState()
  1097. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1098. def __useFallbackChanged(self, state):
  1099. """ From the checkbox
  1100. """
  1101. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  1102. self._model.useFallback = bool(state)
  1103. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1104. def __queryChanged(self, q):
  1105. if self._model.status() == SearchStatus.Busy:
  1106. return
  1107. if q:
  1108. self.searchButton.setEnabled(True)
  1109. self.reloadButton.setEnabled(True)
  1110. self.randomButton.setEnabled(True)
  1111. else:
  1112. self.searchButton.setEnabled(False)
  1113. self.reloadButton.setEnabled(False)
  1114. self.randomButton.setEnabled(False)
  1115. def _setOptionsState(self, state=True):
  1116. if self._useFallback:
  1117. if state:
  1118. # Search stopped
  1119. self.searchButton.setText(_("Search"))
  1120. self.searchButton.clicked.disconnect(
  1121. self.__stopButtonClicked
  1122. )
  1123. self.searchButton.clicked.connect(self.__searchButtonClicked)
  1124. self.searchButton.setEnabled(True)
  1125. else:
  1126. # Searching
  1127. self.searchButton.setText(_("Stop"))
  1128. self.searchButton.clicked.disconnect(
  1129. self.__searchButtonClicked
  1130. )
  1131. self.searchButton.clicked.connect(self.__stopButtonClicked)
  1132. else:
  1133. self.searchButton.setEnabled(state)
  1134. self.reloadButton.setEnabled(state)
  1135. self.randomButton.setEnabled(state)
  1136. self._randomCheckEvery.setEnabled(state)
  1137. self._fallbackCheck.setEnabled(state)
  1138. self.queryEdit.setEnabled(state)
  1139. self._optionsContainer.setEnabled(state)
  1140. def __searchStatusChanged(self, status):
  1141. if status == SearchStatus.Busy:
  1142. self._setOptionsState(False)
  1143. elif status == SearchStatus.Done:
  1144. self._setOptionsState()
  1145. @property
  1146. def query(self): return self.queryEdit.text()
  1147. def _newSearch(self, url, query=''):
  1148. self.resultsContainer.clear()
  1149. self._search(url, query)
  1150. def _search(self, url, query=''):
  1151. if self._searchThread:
  1152. return
  1153. if not query:
  1154. query = self.query
  1155. if not query:
  1156. self.resultsContainer.setHtml(_("Please enter a search query."))
  1157. return
  1158. if not url:
  1159. self.resultsContainer.setHtml(_("Please select a instance first."))
  1160. return
  1161. self.navBar.setEnabled(False)
  1162. self._model.url = url
  1163. self._model.query = query
  1164. self._searchThread = Thread(
  1165. self._model.search,
  1166. args=[self._model],
  1167. parent=self
  1168. )
  1169. self._searchThread.finished.connect(self._searchFinished)
  1170. self._searchThread.start()
  1171. def _searchFailed(self, result):
  1172. currentUrl = self._instanceSelecter.currentUrl # backup
  1173. # Don't go further on proxy errors.
  1174. # - Guard should not handle proxy errors.
  1175. # - Fallback should be disabled for proxy errors.
  1176. if result.errorType() == ErrorType.ProxyError:
  1177. self._breakFallback = False
  1178. self.resultsContainer.setHtml(
  1179. FailedResponsesHtml.create([result], Themes.htmlCssFail)
  1180. )
  1181. return
  1182. if self._guard.isEnabled():
  1183. # See if the Guard has any consequence for this instance.
  1184. consequence = self._guard.getConsequence(currentUrl)
  1185. if consequence:
  1186. # Apply the consequence.
  1187. if consequence.type == ConsequenceType.Blacklist:
  1188. # Blacklist the instance.
  1189. self._instancesModel.putInstanceOnBlacklist(
  1190. currentUrl,
  1191. reason=result.error()
  1192. )
  1193. else:
  1194. # Put the instance on a timeout.
  1195. self._instancesModel.putInstanceOnTimeout(
  1196. currentUrl,
  1197. duration=consequence.duration,
  1198. reason=result.error()
  1199. )
  1200. self._instancesModel.apply() # Apply the changed filter.
  1201. if self._useFallback: # Re-try another instance
  1202. if self._breakFallback: # Stop button pressed
  1203. self._breakFallback = False
  1204. self._fallbackActive = False
  1205. self._fallbackErrors.append(result)
  1206. self.resultsContainer.setHtml(
  1207. FailedResponsesHtml.create(
  1208. self._fallbackErrors,
  1209. f"{_('Max fail count reached!')} " \
  1210. f"({self._maxSearchFailCount})",
  1211. Themes.htmlCssFail)
  1212. )
  1213. self._fallbackErrors.clear()
  1214. return
  1215. if not self._fallbackActive:
  1216. # Get new list with instances to try same request.
  1217. self._fallbackActive = True
  1218. self._fallbackErrors.clear()
  1219. self._fallbackInstancesQueue.clear()
  1220. self._fallbackInstancesQueue = (
  1221. self._instanceSelecter.getRandomInstances(
  1222. amount=self._maxSearchFailCount))
  1223. if not self._fallbackInstancesQueue:
  1224. self.resultsContainer.setHtml(
  1225. FailedResponsesHtml.create(
  1226. self._fallbackErrors,
  1227. f"{_('Max fail count reached!')} " \
  1228. f"({self._maxSearchFailCount})",
  1229. Themes.htmlCssFail)
  1230. )
  1231. self._fallbackActive = False
  1232. self._fallbackErrors.clear()
  1233. return
  1234. # Append current error to error list
  1235. self._fallbackErrors.append(result)
  1236. # Set next instance url to try.
  1237. self._instanceSelecter.currentUrl = (
  1238. self._fallbackInstancesQueue.pop(0))
  1239. self._search(self._instanceSelecter.currentUrl)
  1240. return
  1241. if self._model.pageno > 1:
  1242. self.navBar.setEnabled(True)
  1243. self.navBar.setNextEnabled(False)
  1244. self.resultsContainer.setHtml(
  1245. FailedResponsesHtml.create([result], _("Search request failed."), Themes.htmlCssFail)
  1246. )
  1247. def _searchFinished(self):
  1248. result = self._searchThread.result()
  1249. self._clearSearchThread()
  1250. # Guard
  1251. if self._guard.isEnabled():
  1252. currentUrl = self._instanceSelecter.currentUrl
  1253. # Report the search result to Guard.
  1254. self._guard.reportSearchResult(currentUrl, result)
  1255. if not bool(result): # Failed
  1256. self._searchFailed(result)
  1257. return
  1258. self._fallbackActive = False
  1259. self.resultsContainer.setHtml(
  1260. ResultsHtml.create(result.json(), Themes.htmlCssResults)
  1261. )
  1262. self.navBar.setEnabled(True)
  1263. self.navBar.setNextEnabled(True)
  1264. def _clearSearchThread(self):
  1265. self._searchThread.finished.disconnect(self._searchFinished)
  1266. # Wait before deleting because the `finished` signal is emited
  1267. # from the thread itself, so this method could be called before the
  1268. # thread is actually finished and result in a crash.
  1269. self._searchThread.wait()
  1270. self._searchThread.deleteLater()
  1271. self._searchThread = None
  1272. def saveSettings(self):
  1273. return {
  1274. 'searchOptions': self._optionsContainer.saveSettings(),
  1275. 'splitterState': self.splitter.saveState()
  1276. }
  1277. def loadSettings(self, data):
  1278. self.queryEdit.setText("")
  1279. self._optionsContainer.loadSettings(
  1280. data.get('searchOptions', {})
  1281. )
  1282. self.splitter.restoreState(
  1283. data.get('splitterState', QByteArray())
  1284. )