search.py 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581
  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, FailedResponseHtml
  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("<h2>{0}</h2>".format(_("Categories")), 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("<h2>{0}</h2>".format(_("Engines")), 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. confirmDialog.setText(
  285. _("Are you sure you want to delete the category `{0}`?")
  286. .format(self._userCategoriesModel[key].name)
  287. )
  288. confirmDialog.setStandardButtons(
  289. QMessageBox.Yes | QMessageBox.No
  290. )
  291. confirmDialog.button(QMessageBox.Yes).setText(_("Yes"))
  292. confirmDialog.button(QMessageBox.No).setText(_("No"))
  293. if confirmDialog.exec() != QMessageBox.Yes:
  294. return
  295. self._userCategoriesModel.removeCategory(key)
  296. self.__reloadUserCategories()
  297. self.__selectLast()
  298. self.__currentUserCategoryChanged(
  299. self._categoryListWidget.count() - 1
  300. )
  301. def __enginesCategoryFilterChanged(self, index):
  302. if not index: # All
  303. self._enginesTableModel.setCatFilter()
  304. else:
  305. key = list(self._categoriesModel.keys())[index-1]
  306. self._enginesTableModel.setCatFilter(key)
  307. class AddUserCategoryDialog(QDialog):
  308. def __init__(self, existingNames=[], text="", parent=None):
  309. QDialog.__init__(self, parent=parent)
  310. self._existingNames = existingNames
  311. layout = QFormLayout(self)
  312. label = QLabel(_("Name") + ":")
  313. self._nameEdit = QLineEdit(self)
  314. if text:
  315. self._nameEdit.setText(text)
  316. self._nameEdit.setPlaceholderText(text)
  317. else:
  318. self._nameEdit.setPlaceholderText(_("My category"))
  319. self._cancelButton = Button(_("Cancel"), self)
  320. self._addButton = Button(_("Add"), self)
  321. self._addButton.setEnabled(False)
  322. # Add stuff to layout
  323. layout.addRow(label, self._nameEdit)
  324. layout.addRow(self._cancelButton, self._addButton)
  325. # Connections
  326. self._nameEdit.textChanged.connect(self.__inputChanged)
  327. self._addButton.clicked.connect(self.accept)
  328. self._cancelButton.clicked.connect(self.reject)
  329. def __inputChanged(self, text):
  330. if self.isValid():
  331. self._addButton.setEnabled(True)
  332. else:
  333. self._addButton.setEnabled(False)
  334. def isValid(self):
  335. name = self._nameEdit.text().lower()
  336. if not name:
  337. return False
  338. for existingName in self._existingNames:
  339. if name == existingName.lower():
  340. return False
  341. return True
  342. @property
  343. def name(self):
  344. return self._nameEdit.text()
  345. class SearchCategories(CheckboxOptionsButton):
  346. def __init__(self, categoriesModel, instanceCategoriesModel, enginesModel,
  347. userCategoriesModel, parent=None):
  348. """
  349. @param categoriesModel: Predefined categories (only avaiable when at
  350. least one instance has the category).
  351. @type categoriesModel: searxqt.models.search.CategoriesModel
  352. @param instanceCategoriesModel: Some instances define custom search
  353. categories.
  354. @type instanceCategoriesModel: searxqt.models.search.CategoriesModel
  355. @param enginesModel:
  356. @type enginesModel: searxqt.models.instances.EnginesModel
  357. @param userCategoriesModel:
  358. @type userCategoriesModel: searxqt.models.search.UserCategoriesModel
  359. @param parent:
  360. @type parent: QObject or None
  361. """
  362. self._categoriesModel = categoriesModel
  363. self._instanceCategoriesModel = instanceCategoriesModel
  364. self._enginesModel = enginesModel
  365. self._userCategoriesModel = userCategoriesModel
  366. CheckboxOptionsButton.__init__(
  367. self,
  368. labelName=_("Categories"),
  369. parent=parent
  370. )
  371. self._categoriesModel.dataChanged.connect(self.__categoryDataChanged)
  372. def __categoryDataChanged(self):
  373. # This happends after CategoriesModel.setData
  374. self.reGenerate()
  375. def __openUserCategoryEditor(self):
  376. window = CategoryEditor(
  377. self._enginesModel,
  378. self._categoriesModel,
  379. self._userCategoriesModel, self)
  380. window.exec()
  381. def __userCategoryToggled(self, key, state):
  382. if state:
  383. self._userCategoriesModel[key].check()
  384. else:
  385. self._userCategoriesModel[key].uncheck()
  386. self.reGenerate()
  387. def __instanceCategoryToggled(self, key, state):
  388. if state:
  389. self._instanceCategoriesModel[key].check()
  390. else:
  391. self._instanceCategoriesModel[key].uncheck()
  392. self.reGenerate()
  393. """ Below are re-implementations.
  394. """
  395. def addCustomWidgetsTop(self, menu):
  396. # User specified categories
  397. menu.addSection(_("Custom"))
  398. action = QWidgetAction(menu)
  399. manageCustomButton = Button(_("Manage"), menu)
  400. action.setDefaultWidget(manageCustomButton)
  401. menu.addAction(action)
  402. for catKey, cat in self._userCategoriesModel.items():
  403. action = QWidgetAction(menu)
  404. widget = QCheckBox(cat.name, menu)
  405. widget.setTristate(False)
  406. widget.setChecked(
  407. cat.isChecked()
  408. )
  409. action.setDefaultWidget(widget)
  410. widget.stateChanged.connect(
  411. lambda state, key=catKey:
  412. self.__userCategoryToggled(key, state)
  413. )
  414. menu.addAction(action)
  415. # Custom instance specified categories
  416. menu.addSection(_("Instances"))
  417. for catKey, cat in self._instanceCategoriesModel.items():
  418. action = QWidgetAction(menu)
  419. widget = QCheckBox(cat.name, menu)
  420. widget.setTristate(False)
  421. widget.setChecked(cat.isChecked())
  422. action.setDefaultWidget(widget)
  423. widget.stateChanged.connect(
  424. lambda state, key=catKey:
  425. self.__instanceCategoryToggled(key, state)
  426. )
  427. menu.addAction(action)
  428. # Predefined Searx categories
  429. menu.addSection(_("Default"))
  430. manageCustomButton.clicked.connect(self.__openUserCategoryEditor)
  431. def hasEnabledCheckedKeys(self):
  432. """ Same as CheckboxOptionsButton.hasEnabledCheckedKeys(self) but with
  433. User Categories. Categories don't get enabled/disabled so we can skip
  434. that check.
  435. @rtype: bool
  436. """
  437. if self._userCategoriesModel.checkedCategories():
  438. return True
  439. elif self._instanceCategoriesModel.checkedCategories():
  440. return True
  441. return CheckboxOptionsButton.hasEnabledCheckedKeys(self)
  442. def uncheckAllEnabledKeys(self):
  443. """ Unchecks all checked keys that are enabled.
  444. """
  445. for catKey in self._userCategoriesModel.checkedCategories():
  446. self._userCategoriesModel[catKey].uncheck()
  447. for catKey in self._instanceCategoriesModel.checkedCategories():
  448. self._instanceCategoriesModel[catKey].uncheck()
  449. CheckboxOptionsButton.uncheckAllEnabledKeys(self)
  450. def getCheckedOptionNames(self):
  451. """ Should return a list with checked option names. This will
  452. be used to generate the label.
  453. @return: should return a list with strings.
  454. @rtype: list
  455. """
  456. return(
  457. [
  458. self._categoriesModel[catKey].name
  459. for catKey in self._categoriesModel.checkedCategories()
  460. ] +
  461. [
  462. self._instanceCategoriesModel[catKey].name
  463. for catKey in self._instanceCategoriesModel.checkedCategories()
  464. ] +
  465. [
  466. self._userCategoriesModel[catKey].name
  467. for catKey in self._userCategoriesModel.checkedCategories()
  468. ]
  469. )
  470. def getOptions(self):
  471. """ Should return a list with options tuple(key, name, state)
  472. This will be used to generate the options.
  473. """
  474. list_ = []
  475. for catKey in self._categoriesModel:
  476. list_.append(
  477. (
  478. catKey,
  479. self._categoriesModel[catKey].name,
  480. self._categoriesModel[catKey].isChecked()
  481. )
  482. )
  483. return list_
  484. def optionToggled(self, key, state):
  485. if state:
  486. self._categoriesModel[key].check()
  487. else:
  488. self._categoriesModel[key].uncheck()
  489. class SearchPeriod(QComboBox):
  490. def __init__(self, model, parent=None):
  491. QComboBox.__init__(self, parent=parent)
  492. self._model = model
  493. self.setMinimumContentsLength(2)
  494. for period in model.Periods:
  495. self.addItem(model.Periods[period], QVariant(period))
  496. self.currentIndexChanged.connect(self.__indexChanged)
  497. def __indexChanged(self, index):
  498. self._model.timeRange = self.currentData()
  499. class SearchLanguage(QComboBox):
  500. def __init__(self, model, parent=None):
  501. QComboBox.__init__(self, parent=parent)
  502. self._model = model
  503. self._favorites = []
  504. self.setMinimumContentsLength(2)
  505. self.__itemModel = QStandardItemModel(self)
  506. self.setModel(self.__itemModel)
  507. self.currentIndexChanged.connect(self.__indexChanged)
  508. self.__itemModel.itemChanged.connect(self.__favCheckChanged)
  509. def __indexChanged(self, index):
  510. self._model.lang = self.currentData()
  511. def __favCheckChanged(self, item):
  512. lang = item.data(Qt.UserRole)
  513. index = item.row()
  514. newIndex = 0
  515. if item.checkState():
  516. # Language added to favorites.
  517. self._favorites.append(lang)
  518. else:
  519. # Remove language from favorites.
  520. langList = list(self._model.Languages.keys())
  521. newIndex = langList.index(lang)
  522. self._favorites.remove(lang)
  523. # Index offset
  524. for favLang in self._favorites:
  525. if langList.index(favLang) < newIndex:
  526. newIndex -= 1
  527. newIndex += len(self._favorites)
  528. self.__itemModel.takeRow(index)
  529. self.__itemModel.insertRow(newIndex, item)
  530. def populate(self):
  531. self.__itemModel.clear()
  532. for lang in self._model.Languages:
  533. newItem = QStandardItem(self._model.Languages[lang])
  534. newItem.setCheckable(True)
  535. newItem.setFlags(
  536. Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsEnabled
  537. )
  538. newItem.setData(QVariant(lang), Qt.UserRole)
  539. if lang in self._favorites:
  540. newItem.setData(Qt.Checked, Qt.CheckStateRole)
  541. self.__itemModel.insertRow(0, newItem)
  542. continue
  543. newItem.setData(Qt.Unchecked, Qt.CheckStateRole)
  544. self.__itemModel.appendRow(newItem)
  545. def loadSettings(self, data):
  546. for lang in data.get('favs', []):
  547. self._favorites.append(lang)
  548. self.populate()
  549. # Find and set the index that matches lang
  550. self.setCurrentIndex(
  551. self.findData(QVariant(data.get('lang', '')), Qt.UserRole)
  552. )
  553. def saveSettings(self):
  554. return {
  555. 'lang': str(self.currentData()),
  556. 'favs': self._favorites
  557. }
  558. class SearchOptionsContainer(QFrame):
  559. """ Custom QFrame to be able to show or hide certain widgets.
  560. """
  561. def __init__(self, searchModel, instancesModel, enginesModel, parent=None):
  562. """
  563. @param searchModel:
  564. @type searchModel: searxqt.models.search.SearchModel
  565. @param instancesModel:
  566. @type instancesModel: searxqt.models.instances.InstanceModelFilter
  567. @param enginesModel:
  568. @type enginesModel: searxqt.models.instances.EnginesModel
  569. @param parent:
  570. @type parent: QObject or None
  571. """
  572. QFrame.__init__(self, parent=parent)
  573. self._enginesModel = enginesModel
  574. self._searchModel = searchModel
  575. self._categoriesModel = CategoriesModel()
  576. self._instanceCategoriesModel = CategoriesModel()
  577. self._userCategoriesModel = UserCategoriesModel()
  578. # Backup user checked engines.
  579. self.__userCheckedBackup = []
  580. # Keep track of disabled engines (these engines are disabled
  581. # because they are part of one or more checked categories).
  582. #
  583. # @key: engine name (str)
  584. # @value : list with category keys (str)
  585. self.__disabledByCat = {}
  586. layout = QHBoxLayout(self)
  587. self._widgets = {
  588. 'categories': SearchCategories(
  589. self._categoriesModel,
  590. self._instanceCategoriesModel,
  591. enginesModel,
  592. self._userCategoriesModel,
  593. self
  594. ),
  595. 'engines': SearchEngines(searchModel, instancesModel, self),
  596. 'period': SearchPeriod(searchModel, self),
  597. 'lang': SearchLanguage(searchModel, self)
  598. }
  599. for widget in self._widgets.values():
  600. layout.addWidget(widget, 0, Qt.AlignTop)
  601. # Keep widgets left aligned.
  602. spacer = QSpacerItem(
  603. 40, 20, QSizePolicy.MinimumExpanding, QSizePolicy.Minimum
  604. )
  605. layout.addItem(spacer)
  606. # Connections
  607. self._categoriesModel.stateChanged.connect(
  608. self.__categoriesStateChanged
  609. )
  610. self._instanceCategoriesModel.stateChanged.connect(
  611. self.__categoriesStateChanged
  612. )
  613. self._userCategoriesModel.stateChanged.connect(
  614. self.__userCategoriesStateChanged
  615. )
  616. self._userCategoriesModel.changed.connect(self.__userCategoriesChanged)
  617. self._userCategoriesModel.removed.connect(self.__userCategoryRemoved)
  618. self._enginesModel.changed.connect(self.__enginesModelChanged)
  619. def __enginesModelChanged(self):
  620. """Settings loaded or data updated
  621. """
  622. # Remove deleted engines from __disabledByCat
  623. for engine in list(self.__disabledByCat.keys()):
  624. if engine not in self._enginesModel:
  625. del self.__disabledByCat[engine]
  626. self._widgets['engines'].setKeyEnabled(engine)
  627. # Add new categories
  628. for catKey in self._enginesModel.categories():
  629. if catKey not in self._categoriesModel:
  630. name = ""
  631. if catKey in self._searchModel.categories.types:
  632. # Default pre-defined categories are translatable
  633. name = self._searchModel.categories.types[catKey][0]
  634. self._categoriesModel.addCategory(catKey, name)
  635. else:
  636. name = catKey.capitalize()
  637. log.debug(
  638. "Found non default category `{0}`".format(name),
  639. self
  640. )
  641. self._instanceCategoriesModel.addCategory(catKey, name)
  642. # Remove old categories
  643. for catKey in self._categoriesModel.copy():
  644. if catKey not in self._enginesModel.categories():
  645. self._categoriesModel.removeCategory(catKey)
  646. # Release potentialy checked engines
  647. self.__processCategoriesStateChange(
  648. [engine.name for engine in
  649. self._enginesModel.getByCategory(catKey)],
  650. catKey,
  651. False
  652. )
  653. # Remove old instance specific categories
  654. for catKey in self._instanceCategoriesModel.copy():
  655. if (catKey not in self._enginesModel.categories() and
  656. catKey not in self._categoriesModel):
  657. self._instanceCategoriesModel.removeCategory(catKey)
  658. # Release potentialy checked engines
  659. self.__processCategoriesStateChange(
  660. [engine.name for engine in
  661. self._enginesModel.getByCategory(catKey)],
  662. catKey,
  663. False
  664. )
  665. self._widgets['categories'].reGenerate()
  666. self.__finalizeCategoriesStateChange()
  667. def __userCategoryRemoved(self, catKey):
  668. for engineKey, catList in self.__disabledByCat.copy().items():
  669. if catKey in catList:
  670. self.__uncheckEngineByCat(catKey, engineKey)
  671. self._widgets['categories'].reGenerate()
  672. self.__finalizeCategoriesStateChange()
  673. def __userCategoriesChanged(self, catKey):
  674. """ When the user edited a existing user-category this should
  675. check freshly added engines to this category and uncheck engines
  676. that have been removed from this category.
  677. """
  678. if self._userCategoriesModel[catKey].isChecked():
  679. engines = self._userCategoriesModel[catKey].engines
  680. # Uncheck removed engines
  681. for engineKey, categories in self.__disabledByCat.copy().items():
  682. if catKey in categories:
  683. if engineKey not in engines:
  684. self.__uncheckEngineByCat(catKey, engineKey)
  685. # Check newly added engines
  686. for engineKey in engines:
  687. if engineKey not in self.__disabledByCat:
  688. self.__checkEngineByCat(catKey, engineKey)
  689. self.__finalizeCategoriesStateChange()
  690. def __checkEngineByCat(self, catKey, engineKey):
  691. """ This method handles checking of a engine by a category.
  692. @param catKey: Category key
  693. @type catKey: str
  694. @param engineKey: Engine key
  695. @type engineKey: str
  696. """
  697. if engineKey not in self._searchModel.engines:
  698. # User did not check this engine so we are going to.
  699. self._searchModel.engines.append(
  700. engineKey
  701. )
  702. elif(engineKey not in self.__userCheckedBackup and
  703. not self._widgets['engines'].keyDisabled(engineKey)):
  704. # User did check this engine, so we backup that.
  705. self.__userCheckedBackup.append(engineKey)
  706. if not self._widgets['engines'].keyDisabled(engineKey):
  707. # Disable the engine from being toggled by the user.
  708. self._widgets['engines'].setKeyDisabled(engineKey)
  709. if engineKey not in self.__disabledByCat:
  710. self.__disabledByCat.update({engineKey: []})
  711. # Backup that this category is blocking this engine from
  712. # being toggled by the user.
  713. self.__disabledByCat[engineKey].append(catKey)
  714. def __uncheckEngineByCat(self, catKey, engineKey):
  715. """ This method handles the unchecking of a engine by a category.
  716. @param catKey: Category key
  717. @type catKey: str
  718. @param engineKey: Engine key
  719. @type engineKey: str
  720. """
  721. if engineKey in self.__disabledByCat:
  722. if catKey in self.__disabledByCat[engineKey]:
  723. # This category no longer blocks this engine from
  724. # being edited by the user.
  725. self.__disabledByCat[engineKey].remove(catKey)
  726. if not self.__disabledByCat[engineKey]:
  727. # No category left that blocks this engine from
  728. # user-toggleing.
  729. self._widgets['engines'].setKeyEnabled(engineKey)
  730. self.__disabledByCat.pop(engineKey)
  731. if engineKey not in self.__userCheckedBackup:
  732. # User didn't check this engine, so we can
  733. # uncheck it.
  734. self._searchModel.engines.remove(
  735. engineKey
  736. )
  737. else:
  738. # User did check this engine before checking
  739. # this category so we won't uncheck it.
  740. self.__userCheckedBackup.remove(engineKey)
  741. def __userCategoriesStateChanged(self, catKey, state):
  742. """ The user checked or unchecked a user-category.
  743. @param catKey: Category key
  744. @type catKey: str
  745. @param state:Category enabled or disabled (checked or unchecked)
  746. @type state: bool
  747. """
  748. self.__processCategoriesStateChange(
  749. self._userCategoriesModel[catKey].engines,
  750. catKey,
  751. state
  752. )
  753. self._widgets['categories'].reGenerate()
  754. self.__finalizeCategoriesStateChange()
  755. def __categoriesStateChanged(self, catKey, state):
  756. """ The user checked or unchecked a default-category.
  757. @param catKey: Category key
  758. @type catKey: str
  759. @param state: Category enabled or disabled (checked or unchecked)
  760. @type state: bool
  761. """
  762. self.__processCategoriesStateChange(
  763. [engine.name for engine in
  764. self._enginesModel.getByCategory(catKey)],
  765. catKey,
  766. state
  767. )
  768. self._widgets['categories'].reGenerate()
  769. self.__finalizeCategoriesStateChange()
  770. def __processCategoriesStateChange(self, engines, catKey, state):
  771. """ The user checked or unchecked a category, depending on the
  772. `state` variable.
  773. When a category gets checked all the engines in that category
  774. will be checked and disabled so that the user can't toggle the
  775. engine.
  776. On uncheck of a category all engines in that category should be
  777. re-enabled. And those engines should be unchecked if they weren't
  778. checked by the user before checking this category.
  779. @param engines: A list with engineKeys (str) that are part of the
  780. category (catKey).
  781. @type engines: list
  782. @param catKey: Category key
  783. @type catKey: str
  784. @param state: Category enabled or disabled (checked or unchecked)
  785. @type state: bool
  786. """
  787. if state:
  788. # Category checked.
  789. for engine in engines:
  790. self.__checkEngineByCat(catKey, engine)
  791. else:
  792. # Category unchecked.
  793. for engine in engines:
  794. self.__uncheckEngineByCat(catKey, engine)
  795. def __finalizeCategoriesStateChange(self):
  796. # Re-generate the engines label
  797. self._widgets['engines'].reGenerate()
  798. # Update the instances filter.
  799. self._widgets['engines'].updateFilter()
  800. def saveSettings(self):
  801. data = {}
  802. # Store widgets visible state.
  803. for key, widget in self._widgets.items():
  804. data.update({
  805. '{0}Visible'.format(key): not widget.isHidden()
  806. })
  807. # Store category states and CheckboxOptionsButton states (label
  808. # expanded or collapsed)
  809. data.update({
  810. 'userCatModel': self._userCategoriesModel.data(),
  811. 'defaultCatModel': self._categoriesModel.data(),
  812. 'categoriesButton': self._widgets['categories'].saveSettings(),
  813. 'enginesButton': self._widgets['engines'].saveSettings(),
  814. 'language': self._widgets['lang'].saveSettings()
  815. })
  816. return data
  817. def loadSettings(self, data):
  818. # Set widgets visible or hidden depending on their state.
  819. for key, widget in self._widgets.items():
  820. if data.get('{0}Visible'.format(key), True):
  821. widget.show()
  822. else:
  823. widget.hide()
  824. # Load category states
  825. self._userCategoriesModel.setData(data.get('userCatModel', {}))
  826. self._categoriesModel.setData(data.get('defaultCatModel', {}))
  827. # Load CheckboxOptionsButton states (categories and engines label
  828. # states, expanded or collapsed.)
  829. self._widgets['categories'].loadSettings(
  830. data.get('categoriesButton', {})
  831. )
  832. self._widgets['engines'].loadSettings(data.get('enginesButton', {}))
  833. # Load search language.
  834. self._widgets['lang'].loadSettings(data.get('language', {}))
  835. def __checkBoxStateChanged(self, key, state):
  836. if state:
  837. self._widgets[key].show()
  838. else:
  839. self._widgets[key].hide()
  840. """ QFrame re-implementations
  841. """
  842. def contextMenuEvent(self, event):
  843. menu = QMenu(self)
  844. menu.addSection(_("Show / Hide"))
  845. for key, widget in self._widgets.items():
  846. action = QWidgetAction(menu)
  847. checkbox = QCheckBox(key, menu)
  848. checkbox.setTristate(False)
  849. checkbox.setChecked(not widget.isHidden())
  850. action.setDefaultWidget(checkbox)
  851. checkbox.stateChanged.connect(
  852. lambda state, key=key:
  853. self.__checkBoxStateChanged(key, state)
  854. )
  855. menu.addAction(action)
  856. menu.exec_(self.mapToGlobal(event.pos()))
  857. """ Find text in the search results
  858. Shortcuts:
  859. - 'Return' find text.
  860. - 'Shift+Return' find previous text.
  861. """
  862. class ResultSearcher(QWidget):
  863. closeRequest = pyqtSignal()
  864. def __init__(self, resultsContainer, parent):
  865. QWidget.__init__(self, parent)
  866. self.__resultsContainer = resultsContainer
  867. layout = QHBoxLayout(self)
  868. self.__inputEdit = QLineEdit(self)
  869. self.__inputEdit.setPlaceholderText(_("Find .."))
  870. self.__inputEdit.installEventFilter(self)
  871. self.__caseCheckbox = QCheckBox(_("Case sensitive"), self)
  872. self.__wholeCheckbox = QCheckBox(_("Whole words"), self)
  873. nextButton = Button("►", self)
  874. prevButton = Button("◄", self)
  875. closeButton = Button("X", self)
  876. layout.addWidget(self.__inputEdit)
  877. layout.addWidget(prevButton)
  878. layout.addWidget(nextButton)
  879. layout.addWidget(self.__caseCheckbox)
  880. layout.addWidget(self.__wholeCheckbox)
  881. layout.addWidget(closeButton)
  882. closeButton.clicked.connect(self.closeRequest)
  883. nextButton.clicked.connect(self.__search)
  884. prevButton.clicked.connect(self.__searchPrev)
  885. def eventFilter(self, source, event):
  886. if event.type() == QEvent.KeyPress and source is self.__inputEdit:
  887. if event.key() == Qt.Key_Return and event.modifiers() == Qt.ShiftModifier:
  888. self.__search(reverse=True)
  889. elif event.key() == Qt.Key_Return:
  890. self.__search()
  891. elif event.key() == Qt.Key_Escape:
  892. self.closeRequest.emit()
  893. return QLineEdit.eventFilter(self, source, event)
  894. def focusInput(self):
  895. self.__inputEdit.setFocus()
  896. def __searchPrev(self):
  897. self.__search(reverse=True)
  898. def __search(self, reverse=False):
  899. text = self.__inputEdit.text()
  900. flags = QTextDocument.FindFlag(0)
  901. if reverse:
  902. flags |= QTextDocument.FindBackward
  903. if self.__caseCheckbox.isChecked():
  904. flags |= QTextDocument.FindCaseSensitively
  905. if self.__wholeCheckbox.isChecked():
  906. flags |= QTextDocument.FindWholeWords
  907. self.__resultsContainer.find(text, options=flags)
  908. class SearchContainer(QWidget):
  909. def __init__(self, searchModel, instancesModel,
  910. instanceSelecter, enginesModel, guard, parent=None):
  911. """
  912. @type searchModel: models.search.SearchModel
  913. @type instancesModel: models.instances.InstancesModelFilter
  914. @type instanceSelecter: models.instances.InstanceSelecterModel
  915. @type enginesModel: models.instances.EnginesModel
  916. @type guard: core.guard.Guard
  917. """
  918. QWidget.__init__(self, parent=parent)
  919. layout = QVBoxLayout(self)
  920. self._model = searchModel
  921. self._instancesModel = instancesModel
  922. self._instanceSelecter = instanceSelecter
  923. self._guard = guard
  924. self._searchThread = None
  925. # Maximum other instances to try on fail.
  926. self._maxSearchFailCount = 10
  927. # Set `_useFallback` to True to try another instance when the
  928. # search failed somehow or set to False to try same instance or
  929. # for pagination which also should use the same instance.
  930. self._useFallback = True
  931. # `_fallbackActive` should be False when a fresh list of fallback
  932. # instances should be picked on failed search and should be True
  933. # when search(es) fail and `_fallbackInstancesQueue` is beeing
  934. # used.
  935. self._fallbackActive = False
  936. # Every first request that has `_useFallback` set to True will
  937. # use this list as a resource for next instance(s) to try until
  938. # it is out of instance url's. Also on the first request this
  939. # list will be cleared and filled again with `_maxSearchFailCount`
  940. # of random instances.
  941. self._fallbackInstancesQueue = []
  942. # Set to True to break out of the fallback loop.
  943. # This is used for the Stop action.
  944. self._breakFallback = False
  945. searchLayout = QHBoxLayout()
  946. layout.addLayout(searchLayout)
  947. # -- Start search bar
  948. self.queryEdit = QLineEdit(self)
  949. self.queryEdit.setPlaceholderText(_("Search for .."))
  950. searchLayout.addWidget(self.queryEdit)
  951. self.searchButton = Button(_("Searx"), self)
  952. self.searchButton.setToolTip(_("Preform search."))
  953. searchLayout.addWidget(self.searchButton)
  954. self.reloadButton = Button("♻", self)
  955. self.reloadButton.setToolTip(_("Reload"))
  956. searchLayout.addWidget(self.reloadButton)
  957. self.randomButton = Button("⤳", self)
  958. self.randomButton.setToolTip(_(
  959. "Search with random instance.\n"
  960. "(Obsolete when 'Random Every is checked')"
  961. ))
  962. searchLayout.addWidget(self.randomButton)
  963. rightLayout = QVBoxLayout()
  964. rightLayout.setSpacing(0)
  965. searchLayout.addLayout(rightLayout)
  966. self._fallbackCheck = QCheckBox(_("Fallback"), self)
  967. self._fallbackCheck.setToolTip(_("Try random other instance on fail."))
  968. rightLayout.addWidget(self._fallbackCheck)
  969. self._randomCheckEvery = QCheckBox(_("Random every"), self)
  970. self._randomCheckEvery.setToolTip(_("Pick a random instance for "
  971. "every request."))
  972. rightLayout.addWidget(self._randomCheckEvery)
  973. # -- End search bar
  974. # -- Start splitter
  975. self.splitter = QSplitter(self)
  976. self.splitter.setOrientation(Qt.Vertical)
  977. layout.addWidget(self.splitter)
  978. # ---- Start search options toolbar
  979. self._optionsContainer = SearchOptionsContainer(
  980. searchModel, instancesModel, enginesModel, self.splitter
  981. )
  982. # ---- End search options toolbar
  983. # --- Start search results container
  984. searchBottomWidget = QWidget(self.splitter)
  985. self.searchBottomWidgetLayout = QVBoxLayout(searchBottomWidget)
  986. self.resultsContainer = QTextBrowser(self)
  987. self.resultsContainer.setOpenLinks(False)
  988. self.resultsContainer.setOpenExternalLinks(False)
  989. self.resultsContainer.setLineWrapMode(1)
  990. self.searchBottomWidgetLayout.addWidget(self.resultsContainer)
  991. self.navBar = SearchNavigation(self)
  992. self.navBar.setEnabled(False)
  993. self.searchBottomWidgetLayout.addWidget(self.navBar)
  994. # --- End search results container
  995. # -- End splitter
  996. # Find text
  997. self.__findShortcut = QShortcut('Ctrl+F', self);
  998. self.__findShortcut.activated.connect(self.__openFind);
  999. self.__resultsSearcher = None
  1000. self.queryEdit.textChanged.connect(self.__queryChanged)
  1001. self._model.statusChanged.connect(self.__searchStatusChanged)
  1002. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1003. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  1004. self._randomCheckEvery.stateChanged.connect(
  1005. self.__randomEveryRequestChanged)
  1006. self._instanceSelecter.instanceChanged.connect(
  1007. self.__instanceChanged)
  1008. self.queryEdit.returnPressed.connect(self.__searchButtonClicked)
  1009. self.searchButton.clicked.connect(self.__searchButtonClicked)
  1010. self.reloadButton.clicked.connect(self.__reloadButtonClicked)
  1011. self.randomButton.clicked.connect(self.__randomSearchButtonClicked)
  1012. self.navBar.requestPage.connect(self.__navBarRequest)
  1013. self.resultsContainer.anchorClicked.connect(self.__handleAnchorClicked)
  1014. self.__queryChanged("")
  1015. def isBusy(self):
  1016. return bool(self._searchThread is not None)
  1017. def cancelAll(self):
  1018. self._breakFallback = True
  1019. self._searchThread.wait()
  1020. def __openFind(self):
  1021. if self.__resultsSearcher is None:
  1022. self.__resultsSearcher = ResultSearcher(self.resultsContainer, self)
  1023. self.searchBottomWidgetLayout.insertWidget(1, self.__resultsSearcher)
  1024. self.__resultsSearcher.closeRequest.connect(self.__closeFind)
  1025. self.__resultsSearcher.focusInput()
  1026. def __closeFind(self):
  1027. self.searchBottomWidgetLayout.removeWidget(self.__resultsSearcher)
  1028. self.__resultsSearcher.deleteLater()
  1029. self.__resultsSearcher = None
  1030. def __handleAnchorClicked(self, url):
  1031. scheme = url.scheme()
  1032. if scheme in ['http', 'https', 'magnet', 'ftp']:
  1033. QDesktopServices.openUrl(url)
  1034. return
  1035. if scheme == 'search':
  1036. # Internal from sugestions/corrections
  1037. self.queryEdit.setText(url.path())
  1038. self._newSearch(self._instanceSelecter.currentUrl)
  1039. def __searchButtonClicked(self, checked=0):
  1040. # Set to use fallback
  1041. self._useFallback = self._model.useFallback
  1042. if (self._model.randomEvery or
  1043. (self._useFallback and
  1044. not self._instanceSelecter.currentUrl)):
  1045. self._instanceSelecter.randomInstance()
  1046. self._resetPagination()
  1047. self._newSearch(self._instanceSelecter.currentUrl)
  1048. def __stopButtonClicked(self):
  1049. self._breakFallback = True
  1050. self.searchButton.setEnabled(False)
  1051. def __reloadButtonClicked(self):
  1052. self._useFallback = False
  1053. self._newSearch(self._instanceSelecter.currentUrl)
  1054. def __randomSearchButtonClicked(self):
  1055. self._useFallback = self._model.useFallback
  1056. self._instanceSelecter.randomInstance()
  1057. self._newSearch(self._instanceSelecter.currentUrl)
  1058. def __navBarRequest(self, pageNo):
  1059. self._useFallback = False
  1060. self._model.pageno = pageNo
  1061. self._newSearch(self._instanceSelecter.currentUrl)
  1062. def _resetPagination(self):
  1063. self.navBar.reset()
  1064. self._model.pageno = 1
  1065. def __instanceChanged(self):
  1066. self._resetPagination()
  1067. def __searchOptionsChanged(self):
  1068. """ From the model (on load settings)
  1069. """
  1070. self._randomCheckEvery.stateChanged.disconnect(
  1071. self.__randomEveryRequestChanged)
  1072. self._fallbackCheck.stateChanged.disconnect(
  1073. self.__useFallbackChanged)
  1074. self._randomCheckEvery.setChecked(self._model.randomEvery)
  1075. self._fallbackCheck.setChecked(self._model.useFallback)
  1076. self.__handleRandomButtonState()
  1077. self._randomCheckEvery.stateChanged.connect(
  1078. self.__randomEveryRequestChanged)
  1079. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  1080. def __handleRandomButtonState(self):
  1081. """ Hides or shows the 'Random search button'.
  1082. We don't need the button when the model it's randomEvery is True.
  1083. """
  1084. if self._model.randomEvery:
  1085. self.randomButton.hide()
  1086. else:
  1087. self.randomButton.show()
  1088. def __randomEveryRequestChanged(self, state):
  1089. """ From the checkbox
  1090. """
  1091. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  1092. self._model.randomEvery = bool(state)
  1093. self.__handleRandomButtonState()
  1094. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1095. def __useFallbackChanged(self, state):
  1096. """ From the checkbox
  1097. """
  1098. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  1099. self._model.useFallback = bool(state)
  1100. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1101. def __queryChanged(self, q):
  1102. if self._model.status() == SearchStatus.Busy:
  1103. return
  1104. if q:
  1105. self.searchButton.setEnabled(True)
  1106. self.reloadButton.setEnabled(True)
  1107. self.randomButton.setEnabled(True)
  1108. else:
  1109. self.searchButton.setEnabled(False)
  1110. self.reloadButton.setEnabled(False)
  1111. self.randomButton.setEnabled(False)
  1112. def _setOptionsState(self, state=True):
  1113. if self._useFallback:
  1114. if state:
  1115. # Search stopped
  1116. self.searchButton.setText(_("Search"))
  1117. self.searchButton.clicked.disconnect(
  1118. self.__stopButtonClicked
  1119. )
  1120. self.searchButton.clicked.connect(self.__searchButtonClicked)
  1121. self.searchButton.setEnabled(True)
  1122. else:
  1123. # Searching
  1124. self.searchButton.setText(_("Stop"))
  1125. self.searchButton.clicked.disconnect(
  1126. self.__searchButtonClicked
  1127. )
  1128. self.searchButton.clicked.connect(self.__stopButtonClicked)
  1129. else:
  1130. self.searchButton.setEnabled(state)
  1131. self.reloadButton.setEnabled(state)
  1132. self.randomButton.setEnabled(state)
  1133. self._randomCheckEvery.setEnabled(state)
  1134. self._fallbackCheck.setEnabled(state)
  1135. self.queryEdit.setEnabled(state)
  1136. self._optionsContainer.setEnabled(state)
  1137. def __searchStatusChanged(self, status):
  1138. if status == SearchStatus.Busy:
  1139. self._setOptionsState(False)
  1140. elif status == SearchStatus.Done:
  1141. self._setOptionsState()
  1142. @property
  1143. def query(self): return self.queryEdit.text()
  1144. def _newSearch(self, url, query=''):
  1145. self.resultsContainer.clear()
  1146. self._search(url, query)
  1147. def _search(self, url, query=''):
  1148. if self._searchThread:
  1149. return
  1150. if not query:
  1151. query = self.query
  1152. if not query:
  1153. self.resultsContainer.setHtml(_("Please enter a search query."))
  1154. return
  1155. if not url:
  1156. self.resultsContainer.setHtml(_("Please select a instance first."))
  1157. return
  1158. self.navBar.setEnabled(False)
  1159. self._model.url = url
  1160. self._model.query = query
  1161. self._searchThread = Thread(
  1162. self._model.search,
  1163. args=[self._model],
  1164. parent=self
  1165. )
  1166. self._searchThread.finished.connect(self._searchFinished)
  1167. self._searchThread.start()
  1168. def _searchFailed(self, result):
  1169. currentUrl = self._instanceSelecter.currentUrl # backup
  1170. # Don't go further on proxy errors.
  1171. # - Guard should not handle proxy errors.
  1172. # - Fallback should be disabled for proxy errors.
  1173. if result.errorType() == ErrorType.ProxyError:
  1174. self._breakFallback = False
  1175. self.resultsContainer.setHtml(
  1176. FailedResponseHtml.create(result, Themes.htmlCssFail)
  1177. )
  1178. return
  1179. if self._guard.isEnabled():
  1180. # See if the Guard has any consequence for this instance.
  1181. consequence = self._guard.getConsequence(currentUrl)
  1182. if consequence:
  1183. # Apply the consequence.
  1184. if consequence.type == ConsequenceType.Blacklist:
  1185. # Blacklist the instance.
  1186. self._instancesModel.putInstanceOnBlacklist(
  1187. currentUrl,
  1188. reason=result.error()
  1189. )
  1190. else:
  1191. # Put the instance on a timeout.
  1192. self._instancesModel.putInstanceOnTimeout(
  1193. currentUrl,
  1194. duration=consequence.duration,
  1195. reason=result.error()
  1196. )
  1197. self._instancesModel.apply() # Apply the changed filter.
  1198. if self._useFallback: # Re-try another instance
  1199. if self._breakFallback: # Stop button pressed
  1200. self._breakFallback = False
  1201. self.resultsContainer.setHtml(
  1202. FailedResponseHtml.create(result, Themes.htmlCssFail)
  1203. )
  1204. return
  1205. if not self._fallbackActive:
  1206. # Get new list with instances to try same request.
  1207. self._fallbackActive = True
  1208. self._fallbackInstancesQueue.clear()
  1209. self._fallbackInstancesQueue = (
  1210. self._instanceSelecter.getRandomInstances(
  1211. amount=self._maxSearchFailCount))
  1212. if not self._fallbackInstancesQueue:
  1213. self.resultsContainer.setHtml(
  1214. "{0} ({1})".format(
  1215. _("Max fail count reached!"),
  1216. self._maxSearchFailCount))
  1217. self._fallbackActive = False
  1218. return
  1219. # Set next instance url to try.
  1220. self._instanceSelecter.currentUrl = (
  1221. self._fallbackInstancesQueue.pop(0))
  1222. self._search(self._instanceSelecter.currentUrl)
  1223. return
  1224. if self._model.pageno > 1:
  1225. self.navBar.setEnabled(True)
  1226. self.navBar.setNextEnabled(False)
  1227. self.resultsContainer.setHtml(
  1228. FailedResponseHtml.create(result, Themes.htmlCssFail)
  1229. )
  1230. def _searchFinished(self):
  1231. result = self._searchThread.result()
  1232. self._clearSearchThread()
  1233. # Guard
  1234. if self._guard.isEnabled():
  1235. currentUrl = self._instanceSelecter.currentUrl
  1236. # Report the search result to Guard.
  1237. self._guard.reportSearchResult(currentUrl, result)
  1238. if not bool(result): # Failed
  1239. self._searchFailed(result)
  1240. return
  1241. self._fallbackActive = False
  1242. self.resultsContainer.setHtml(
  1243. ResultsHtml.create(result.json(), Themes.htmlCssResults)
  1244. )
  1245. self.navBar.setEnabled(True)
  1246. self.navBar.setNextEnabled(True)
  1247. def _clearSearchThread(self):
  1248. self._searchThread.finished.disconnect(self._searchFinished)
  1249. # Wait before deleting because the `finished` signal is emited
  1250. # from the thread itself, so this method could be called before the
  1251. # thread is actually finished and result in a crash.
  1252. self._searchThread.wait()
  1253. self._searchThread.deleteLater()
  1254. self._searchThread = None
  1255. def saveSettings(self):
  1256. return {
  1257. 'searchOptions': self._optionsContainer.saveSettings(),
  1258. 'splitterState': self.splitter.saveState()
  1259. }
  1260. def loadSettings(self, data):
  1261. self.queryEdit.setText("")
  1262. self._optionsContainer.loadSettings(
  1263. data.get('searchOptions', {})
  1264. )
  1265. self.splitter.restoreState(
  1266. data.get('splitterState', QByteArray())
  1267. )