search.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317
  1. ########################################################################
  2. # Searx-qt - Lightweight desktop application for SearX.
  3. # Copyright (C) 2020 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. QSpacerItem,
  34. QSizePolicy,
  35. QDialog,
  36. QListWidget,
  37. QTableView,
  38. QAbstractItemView,
  39. QHeaderView,
  40. QFormLayout,
  41. QMessageBox
  42. )
  43. from PyQt5.QtCore import pyqtSignal, Qt, QVariant
  44. from searxqt.models.search import (
  45. SearchStatus,
  46. UserCategoriesModel,
  47. CategoriesModel
  48. )
  49. from searxqt.models.instances import EnginesTableModel
  50. from searxqt.widgets.buttons import Button, CheckboxOptionsButton
  51. from searxqt.thread import Thread
  52. from searxqt.translations import _
  53. class SearchNavigation(QWidget):
  54. requestPage = pyqtSignal(int) # pageno
  55. def __init__(self, parent=None):
  56. QWidget.__init__(self, parent=parent)
  57. layout = QHBoxLayout(self)
  58. self.prevPageButton = Button("<<", self)
  59. self.pageNoLabel = QLabel("1", self)
  60. self.nextPageButton = Button(">>", self)
  61. layout.addWidget(self.prevPageButton, 0, Qt.AlignLeft)
  62. layout.addWidget(self.pageNoLabel, 0, Qt.AlignCenter)
  63. layout.addWidget(self.nextPageButton, 0, Qt.AlignRight)
  64. self.prevPageButton.setEnabled(False)
  65. self.nextPageButton.clicked.connect(self._nextPage)
  66. self.prevPageButton.clicked.connect(self._prevPage)
  67. self.reset()
  68. def _updateLabel(self):
  69. self.pageNoLabel.setText(str(self._pageno))
  70. def _nextPage(self):
  71. self._pageno += 1
  72. if self._pageno > 1 and not self.prevPageButton.isEnabled():
  73. self.prevPageButton.setEnabled(True)
  74. self._updateLabel()
  75. self.requestPage.emit(self._pageno)
  76. def _prevPage(self):
  77. self._pageno -= 1
  78. if self._pageno == 1:
  79. self.prevPageButton.setEnabled(False)
  80. self.setNextEnabled(True)
  81. self._updateLabel()
  82. self.requestPage.emit(self._pageno)
  83. def reset(self):
  84. self._pageno = 1
  85. self.prevPageButton.setEnabled(False)
  86. self._updateLabel()
  87. def setNextEnabled(self, state):
  88. self.nextPageButton.setEnabled(state)
  89. class SearchEngines(CheckboxOptionsButton):
  90. def __init__(self, searchModel, instancesModel, parent=None):
  91. """
  92. @param searchModel: needed for getting and setting current
  93. enabled/disabled engines.
  94. @type searchModel: SearchModel
  95. @param instancesModel: needed for listing current available
  96. engines and update it's current filter
  97. to filter out instances without atleast
  98. one of the required engine(s).
  99. @type instancesModel: InstanceModelFilter
  100. """
  101. self._instancesModel = instancesModel
  102. self._searchModel = searchModel
  103. CheckboxOptionsButton.__init__(
  104. self,
  105. labelName=_("Engines"),
  106. parent=parent
  107. )
  108. instancesModel.parentModel().changed.connect(self.reGenerate)
  109. def updateFilter(self):
  110. """ Filter out instances that don't support atleast one of the
  111. enabled engines.
  112. """
  113. self._instancesModel.updateKwargs(
  114. {'engines': self.getCheckedOptionNames()}
  115. )
  116. """ Below are re-implementations.
  117. """
  118. def getCheckedOptionNames(self):
  119. """ Should return a list with checked option names. This will
  120. be used to generate the label.
  121. @return: should return a list with strings.
  122. @rtype: list
  123. """
  124. return self._searchModel.engines
  125. def getOptions(self):
  126. """ Should return a list with options tuple(key, name, state)
  127. This will be used to generate the options.
  128. """
  129. list_ = []
  130. tmp = []
  131. for url, instance in self._instancesModel.items():
  132. for engine in instance.engines:
  133. if engine.name not in tmp:
  134. state = bool(engine.name in self._searchModel.engines)
  135. list_.append((engine.name, engine.name, state))
  136. tmp.append(engine.name)
  137. return sorted(list_)
  138. def optionToggled(self, key, state):
  139. if state:
  140. self._searchModel.engines.append(key)
  141. else:
  142. self._searchModel.engines.remove(key)
  143. self.updateFilter()
  144. class CategoryEditor(QDialog):
  145. def __init__(self, enginesModel, categoriesModel,
  146. userCategoriesModel, parent=None):
  147. QDialog.__init__(self, parent=parent)
  148. self._categoriesModel = categoriesModel
  149. self._userCategoriesModel = userCategoriesModel
  150. self.setWindowTitle(_("Category manager"))
  151. layout = QHBoxLayout(self)
  152. # Categories
  153. catLayout = QVBoxLayout()
  154. label = QLabel("<h2>{0}</h2>".format(_("Categories")), self)
  155. # Categories toolbuttons
  156. catToolLayout = QHBoxLayout()
  157. catAddButton = Button("+", self)
  158. self._catDelButton = Button("-", self)
  159. catToolLayout.addWidget(catAddButton, 0, Qt.AlignLeft)
  160. catToolLayout.addWidget(self._catDelButton, 1, Qt.AlignLeft)
  161. self._categoryListWidget = QListWidget(self)
  162. catLayout.addWidget(label)
  163. catLayout.addLayout(catToolLayout)
  164. catLayout.addWidget(self._categoryListWidget)
  165. # Engines
  166. engLayout = QVBoxLayout()
  167. label = QLabel("<h2>{0}</h2>".format(_("Engines")), self)
  168. # Engines filter
  169. filterLayout = QHBoxLayout()
  170. self._enginesCategoryFilterBox = QComboBox(self)
  171. self._enginesCategoryFilterBox.addItem(_("All"))
  172. for key, cat in categoriesModel.items():
  173. self._enginesCategoryFilterBox.addItem(cat.name)
  174. filterLayout.addWidget(
  175. QLabel(_("Category") + ":", self), 1, Qt.AlignRight
  176. )
  177. filterLayout.addWidget(
  178. self._enginesCategoryFilterBox, 0, Qt.AlignRight
  179. )
  180. # Engines table
  181. self._enginesTableView = QTableView(self)
  182. self._enginesTableView.setAlternatingRowColors(True)
  183. self._enginesTableView.setSelectionBehavior(
  184. QAbstractItemView.SelectRows
  185. )
  186. self._enginesTableView.setEditTriggers(
  187. QAbstractItemView.NoEditTriggers
  188. )
  189. self._enginesTableView.setSortingEnabled(True)
  190. self._enginesTableView.setHorizontalScrollMode(
  191. QAbstractItemView.ScrollPerPixel
  192. )
  193. header = self._enginesTableView.horizontalHeader()
  194. header.setSectionResizeMode(QHeaderView.ResizeToContents)
  195. header.setSectionsMovable(True)
  196. self._enginesTableModel = EnginesTableModel(enginesModel, self)
  197. self._enginesTableView.setModel(self._enginesTableModel)
  198. engLayout.addWidget(label)
  199. engLayout.addLayout(filterLayout)
  200. engLayout.addWidget(self._enginesTableView)
  201. layout.addLayout(catLayout)
  202. layout.addLayout(engLayout)
  203. # Connections
  204. catAddButton.clicked.connect(self.__addCategoryClicked)
  205. self._catDelButton.clicked.connect(self.__delCategoryClicked)
  206. self._categoryListWidget.currentRowChanged.connect(
  207. self.__currentUserCategoryChanged
  208. )
  209. self._enginesCategoryFilterBox.currentIndexChanged.connect(
  210. self.__enginesCategoryFilterChanged
  211. )
  212. self._enginesTableView.setEnabled(False)
  213. self._catDelButton.setEnabled(False)
  214. self.__addUserCategories()
  215. self.__selectFirst()
  216. def __currentUserCategoryChanged(self, index):
  217. if index < 0:
  218. self._enginesTableView.setEnabled(False)
  219. self._catDelButton.setEnabled(False)
  220. else:
  221. self._enginesTableView.setEnabled(True)
  222. self._catDelButton.setEnabled(True)
  223. if self._userCategoriesModel:
  224. key = list(self._userCategoriesModel.keys())[index]
  225. self._enginesTableModel.setUserModel(
  226. self._userCategoriesModel[key]
  227. )
  228. def __addUserCategories(self):
  229. for catKey, cat in self._userCategoriesModel.items():
  230. self._categoryListWidget.addItem(cat.name)
  231. def __selectFirst(self):
  232. if self._categoryListWidget.count():
  233. self._categoryListWidget.setCurrentRow(0)
  234. def __selectLast(self):
  235. self._categoryListWidget.setCurrentRow(
  236. self._categoryListWidget.count() - 1
  237. )
  238. def __reloadUserCategories(self):
  239. self._categoryListWidget.currentRowChanged.disconnect(
  240. self.__currentUserCategoryChanged
  241. )
  242. self._enginesTableModel.setUserModel(None)
  243. self._categoryListWidget.clear()
  244. self.__addUserCategories()
  245. self._categoryListWidget.currentRowChanged.connect(
  246. self.__currentUserCategoryChanged
  247. )
  248. def __addCategoryClicked(self, state):
  249. dialog = AddUserCategoryDialog(
  250. self._userCategoriesModel.keys()
  251. )
  252. if dialog.exec():
  253. self._userCategoriesModel.addCategory(
  254. dialog.name.lower(),
  255. dialog.name
  256. )
  257. self.__reloadUserCategories()
  258. self.__selectLast()
  259. def __delCategoryClicked(self, state):
  260. index = self._categoryListWidget.currentRow()
  261. key = list(self._userCategoriesModel.keys())[index]
  262. confirmDialog = QMessageBox()
  263. confirmDialog.setWindowTitle(
  264. _("Delete category")
  265. )
  266. confirmDialog.setText(
  267. _("Are you sure you want to delete the category `{0}`?")
  268. .format(self._userCategoriesModel[key].name)
  269. )
  270. confirmDialog.setStandardButtons(
  271. QMessageBox.Yes | QMessageBox.No
  272. )
  273. confirmDialog.button(QMessageBox.Yes).setText(_("Yes"))
  274. confirmDialog.button(QMessageBox.No).setText(_("No"))
  275. if confirmDialog.exec() != QMessageBox.Yes:
  276. return
  277. self._userCategoriesModel.removeCategory(key)
  278. self.__reloadUserCategories()
  279. self.__selectLast()
  280. self.__currentUserCategoryChanged(
  281. self._categoryListWidget.count() - 1
  282. )
  283. def __enginesCategoryFilterChanged(self, index):
  284. if not index: # All
  285. self._enginesTableModel.setCatFilter()
  286. else:
  287. key = list(self._categoriesModel.keys())[index-1]
  288. self._enginesTableModel.setCatFilter(key)
  289. class AddUserCategoryDialog(QDialog):
  290. def __init__(self, existingNames=[], text="", parent=None):
  291. QDialog.__init__(self, parent=parent)
  292. self._existingNames = existingNames
  293. layout = QFormLayout(self)
  294. label = QLabel(_("Name") + ":")
  295. self._nameEdit = QLineEdit(self)
  296. if text:
  297. self._nameEdit.setText(text)
  298. self._nameEdit.setPlaceholderText(text)
  299. else:
  300. self._nameEdit.setPlaceholderText(_("My category"))
  301. self._cancelButton = Button(_("Cancel"), self)
  302. self._addButton = Button(_("Add"), self)
  303. self._addButton.setEnabled(False)
  304. # Add stuff to layout
  305. layout.addRow(label, self._nameEdit)
  306. layout.addRow(self._cancelButton, self._addButton)
  307. # Connections
  308. self._nameEdit.textChanged.connect(self.__inputChanged)
  309. self._addButton.clicked.connect(self.accept)
  310. self._cancelButton.clicked.connect(self.reject)
  311. def __inputChanged(self, text):
  312. if self.isValid():
  313. self._addButton.setEnabled(True)
  314. else:
  315. self._addButton.setEnabled(False)
  316. def isValid(self):
  317. name = self._nameEdit.text().lower()
  318. if not name:
  319. return False
  320. for existingName in self._existingNames:
  321. if name == existingName.lower():
  322. return False
  323. return True
  324. @property
  325. def name(self):
  326. return self._nameEdit.text()
  327. class SearchCategories(CheckboxOptionsButton):
  328. def __init__(self, categoriesModel, enginesModel,
  329. userCategoriesModel, parent=None):
  330. """
  331. @param categoriesModel:
  332. @type categoriesModel: searxqt.models.search.CategoriesModel
  333. @param enginesModel:
  334. @type enginesModel: searxqt.models.instances.EnginesModel
  335. @param userCategoriesModel:
  336. @type userCategoriesModel: searxqt.models.search.UserCategoriesModel
  337. @param parent:
  338. @type parent: QObject or None
  339. """
  340. self._categoriesModel = categoriesModel
  341. self._enginesModel = enginesModel
  342. self._userCategoriesModel = userCategoriesModel
  343. CheckboxOptionsButton.__init__(
  344. self,
  345. labelName=_("Categories"),
  346. parent=parent
  347. )
  348. self._categoriesModel.dataChanged.connect(self.__categoryDataChanged)
  349. def __categoryDataChanged(self):
  350. # This happends after CategoriesModel.setData
  351. self.reGenerate()
  352. def __openUserCategoryEditor(self):
  353. window = CategoryEditor(
  354. self._enginesModel,
  355. self._categoriesModel,
  356. self._userCategoriesModel, self)
  357. window.exec()
  358. def __userCategoryToggled(self, key, state):
  359. if state:
  360. self._userCategoriesModel[key].check()
  361. else:
  362. self._userCategoriesModel[key].uncheck()
  363. self.reGenerate()
  364. """ Below are re-implementations.
  365. """
  366. def addCustomWidgetsTop(self, menu):
  367. menu.addSection(_("Custom"))
  368. action = QWidgetAction(menu)
  369. manageCustomButton = Button(_("Manage"), menu)
  370. action.setDefaultWidget(manageCustomButton)
  371. menu.addAction(action)
  372. for catKey, cat in self._userCategoriesModel.items():
  373. action = QWidgetAction(menu)
  374. widget = QCheckBox(cat.name, menu)
  375. widget.setTristate(False)
  376. widget.setChecked(
  377. cat.isChecked()
  378. )
  379. action.setDefaultWidget(widget)
  380. widget.stateChanged.connect(
  381. lambda state, key=catKey:
  382. self.__userCategoryToggled(key, state)
  383. )
  384. menu.addAction(action)
  385. menu.addSection(_("Default"))
  386. manageCustomButton.clicked.connect(self.__openUserCategoryEditor)
  387. def getCheckedOptionNames(self):
  388. """ Should return a list with checked option names. This will
  389. be used to generate the label.
  390. @return: should return a list with strings.
  391. @rtype: list
  392. """
  393. return(
  394. [
  395. self._categoriesModel[catKey].name
  396. for catKey in self._categoriesModel.checkedCategories()
  397. ] +
  398. [
  399. self._userCategoriesModel[catKey].name
  400. for catKey in self._userCategoriesModel.checkedCategories()
  401. ]
  402. )
  403. def getOptions(self):
  404. """ Should return a list with options tuple(key, name, state)
  405. This will be used to generate the options.
  406. """
  407. list_ = []
  408. for catKey in self._categoriesModel:
  409. list_.append(
  410. (
  411. catKey,
  412. self._categoriesModel[catKey].name,
  413. self._categoriesModel[catKey].isChecked()
  414. )
  415. )
  416. return list_
  417. def optionToggled(self, key, state):
  418. if state:
  419. self._categoriesModel[key].check()
  420. else:
  421. self._categoriesModel[key].uncheck()
  422. class SearchPeriod(QComboBox):
  423. def __init__(self, model, parent=None):
  424. QComboBox.__init__(self, parent=parent)
  425. self._model = model
  426. self.setMinimumContentsLength(2)
  427. for period in model.Periods:
  428. self.addItem(model.Periods[period], QVariant(period))
  429. self.currentIndexChanged.connect(self.__indexChanged)
  430. def __indexChanged(self, index):
  431. self._model.timeRange = self.currentData()
  432. class SearchLanguage(QComboBox):
  433. def __init__(self, model, parent=None):
  434. QComboBox.__init__(self, parent=parent)
  435. self._model = model
  436. self.setMinimumContentsLength(2)
  437. for lang in model.Languages:
  438. self.addItem(model.Languages[lang], QVariant(lang))
  439. self.currentIndexChanged.connect(self.__indexChanged)
  440. def __indexChanged(self, index):
  441. self._model.lang = self.currentData()
  442. class SearchOptionsContainer(QFrame):
  443. """ Custom QFrame to be able to show or hide certain widgets.
  444. """
  445. def __init__(self, searchModel, instancesModel, enginesModel, parent=None):
  446. """
  447. @param searchModel:
  448. @type searchModel: searxqt.models.search.SearchModel
  449. @param instancesModel:
  450. @type instancesModel: searxqt.models.instances.InstanceModelFilter
  451. @param enginesModel:
  452. @type enginesModel: searxqt.models.instances.EnginesModel
  453. @param parent:
  454. @type parent: QObject or None
  455. """
  456. QFrame.__init__(self, parent=parent)
  457. self._enginesModel = enginesModel
  458. self._searchModel = searchModel
  459. self._categoriesModel = CategoriesModel()
  460. self._userCategoriesModel = UserCategoriesModel()
  461. # Backup user checked engines.
  462. self.__userCheckedBackup = []
  463. # Keep track of disabled engines (these engines are disabled
  464. # because they are part of one or more checked categories).
  465. #
  466. # @key: engine name (str)
  467. # @value : list with category keys (str)
  468. self.__disabledByCat = {}
  469. layout = QHBoxLayout(self)
  470. self._widgets = {
  471. 'categories': SearchCategories(
  472. self._categoriesModel,
  473. enginesModel,
  474. self._userCategoriesModel,
  475. self
  476. ),
  477. 'engines': SearchEngines(searchModel, instancesModel, self),
  478. 'period': SearchPeriod(searchModel, self),
  479. 'lang': SearchLanguage(searchModel, self)
  480. }
  481. for widget in self._widgets.values():
  482. layout.addWidget(widget, 0, Qt.AlignTop)
  483. # Keep widgets left aligned.
  484. spacer = QSpacerItem(
  485. 40, 20, QSizePolicy.MinimumExpanding, QSizePolicy.Minimum
  486. )
  487. layout.addItem(spacer)
  488. # Connections
  489. self._categoriesModel.stateChanged.connect(
  490. self.__categoriesStateChanged
  491. )
  492. self._userCategoriesModel.stateChanged.connect(
  493. self.__userCategoriesStateChanged
  494. )
  495. self._userCategoriesModel.changed.connect(self.__userCategoriesChanged)
  496. self._userCategoriesModel.removed.connect(self.__userCategoryRemoved)
  497. self._enginesModel.changed.connect(self.__enginesModelChanged)
  498. def __enginesModelChanged(self):
  499. """Settings loaded or data updated
  500. """
  501. # Remove deleted engines from __disabledByCat
  502. for engine in list(self.__disabledByCat.keys()):
  503. if engine not in self._enginesModel:
  504. del self.__disabledByCat[engine]
  505. self._widgets['engines'].setKeyEnabled(engine)
  506. # Add new categories
  507. for catKey in self._enginesModel.categories():
  508. if catKey not in self._categoriesModel:
  509. name = ""
  510. if catKey in self._searchModel.categories.types:
  511. # Default pre-defined categories are translatable
  512. name = self._searchModel.categories.types[catKey][0]
  513. else:
  514. name = catKey.capitalize()
  515. print("[DEBUG] Found non default category `{0}`"
  516. .format(name))
  517. self._categoriesModel.addCategory(catKey, name)
  518. # Remove old categories
  519. for catKey in self._categoriesModel.copy():
  520. if catKey not in self._enginesModel.categories():
  521. self._categoriesModel.removeCategory(catKey)
  522. # Release potentialy checked engines
  523. self.__processCategoriesStateChange(
  524. [engine.name for engine in
  525. self._enginesModel.getByCategory(catKey)],
  526. catKey,
  527. False
  528. )
  529. self._widgets['categories'].reGenerate()
  530. self.__finalizeCategoriesStateChange()
  531. def __userCategoryRemoved(self, catKey):
  532. for engineKey, catList in self.__disabledByCat.copy().items():
  533. if catKey in catList:
  534. self.__uncheckEngineByCat(catKey, engineKey)
  535. self._widgets['categories'].reGenerate()
  536. self.__finalizeCategoriesStateChange()
  537. def __userCategoriesChanged(self, catKey):
  538. """ When the user edited a existing user-category this should
  539. check freshly added engines to this category and uncheck engines
  540. that have been removed from this category.
  541. """
  542. if self._userCategoriesModel[catKey].isChecked():
  543. engines = self._userCategoriesModel[catKey].engines
  544. # Uncheck removed engines
  545. for engineKey, categories in self.__disabledByCat.copy().items():
  546. if catKey in categories:
  547. if engineKey not in engines:
  548. self.__uncheckEngineByCat(catKey, engineKey)
  549. # Check newly added engines
  550. for engineKey in engines:
  551. if engineKey not in self.__disabledByCat:
  552. self.__checkEngineByCat(catKey, engineKey)
  553. self.__finalizeCategoriesStateChange()
  554. def __checkEngineByCat(self, catKey, engineKey):
  555. """ This method handles checking of a engine by a category.
  556. @param catKey: Category key
  557. @type catKey: str
  558. @param engineKey: Engine key
  559. @type engineKey: str
  560. """
  561. if engineKey not in self._searchModel.engines:
  562. # User did not check this engine so we are going to.
  563. self._searchModel.engines.append(
  564. engineKey
  565. )
  566. elif(engineKey not in self.__userCheckedBackup and
  567. not self._widgets['engines'].keyDisabled(engineKey)):
  568. # User did check this engine, so we backup that.
  569. self.__userCheckedBackup.append(engineKey)
  570. if not self._widgets['engines'].keyDisabled(engineKey):
  571. # Disable the engine from being toggled by the user.
  572. self._widgets['engines'].setKeyDisabled(engineKey)
  573. if engineKey not in self.__disabledByCat:
  574. self.__disabledByCat.update({engineKey: []})
  575. # Backup that this category is blocking this engine from
  576. # being toggled by the user.
  577. self.__disabledByCat[engineKey].append(catKey)
  578. def __uncheckEngineByCat(self, catKey, engineKey):
  579. """ This method handles the unchecking of a engine by a category.
  580. @param catKey: Category key
  581. @type catKey: str
  582. @param engineKey: Engine key
  583. @type engineKey: str
  584. """
  585. if engineKey in self.__disabledByCat:
  586. if catKey in self.__disabledByCat[engineKey]:
  587. # This category no longer blocks this engine from
  588. # being edited by the user.
  589. self.__disabledByCat[engineKey].remove(catKey)
  590. if not self.__disabledByCat[engineKey]:
  591. # No category left that blocks this engine from
  592. # user-toggleing.
  593. self._widgets['engines'].setKeyEnabled(engineKey)
  594. self.__disabledByCat.pop(engineKey)
  595. if engineKey not in self.__userCheckedBackup:
  596. # User didn't check this engine, so we can
  597. # uncheck it.
  598. self._searchModel.engines.remove(
  599. engineKey
  600. )
  601. else:
  602. # User did check this engine before checking
  603. # this category so we won't uncheck it.
  604. self.__userCheckedBackup.remove(engineKey)
  605. def __userCategoriesStateChanged(self, catKey, state):
  606. """ The user checked or unchecked a user-category.
  607. @param catKey: Category key
  608. @type catKey: str
  609. @param state:Category enabled or disabled (checked or unchecked)
  610. @type state: bool
  611. """
  612. self.__processCategoriesStateChange(
  613. self._userCategoriesModel[catKey].engines,
  614. catKey,
  615. state
  616. )
  617. self._widgets['categories'].reGenerate()
  618. self.__finalizeCategoriesStateChange()
  619. def __categoriesStateChanged(self, catKey, state):
  620. """ The user checked or unchecked a default-category.
  621. @param catKey: Category key
  622. @type catKey: str
  623. @param state: Category enabled or disabled (checked or unchecked)
  624. @type state: bool
  625. """
  626. self.__processCategoriesStateChange(
  627. [engine.name for engine in
  628. self._enginesModel.getByCategory(catKey)],
  629. catKey,
  630. state
  631. )
  632. self._widgets['categories'].reGenerate()
  633. self.__finalizeCategoriesStateChange()
  634. def __processCategoriesStateChange(self, engines, catKey, state):
  635. """ The user checked or unchecked a category, depending on the
  636. `state` variable.
  637. When a category gets checked all the engines in that category
  638. will be checked and disabled so that the user can't toggle the
  639. engine.
  640. On uncheck of a category all engines in that category should be
  641. re-enabled. And those engines should be unchecked if they weren't
  642. checked by the user before checking this category.
  643. @param engines: A list with engineKeys (str) that are part of the
  644. category (catKey).
  645. @type engines: list
  646. @param catKey: Category key
  647. @type catKey: str
  648. @param state: Category enabled or disabled (checked or unchecked)
  649. @type state: bool
  650. """
  651. if state:
  652. # Category checked.
  653. for engine in engines:
  654. self.__checkEngineByCat(catKey, engine)
  655. else:
  656. # Category unchecked.
  657. for engine in engines:
  658. self.__uncheckEngineByCat(catKey, engine)
  659. def __finalizeCategoriesStateChange(self):
  660. # Re-generate the engines label
  661. self._widgets['engines'].reGenerate()
  662. # Update the instances filter.
  663. self._widgets['engines'].updateFilter()
  664. def saveSettings(self):
  665. data = {}
  666. for key, widget in self._widgets.items():
  667. data.update(
  668. {
  669. '{0}Visible'.format(key): not widget.isHidden(),
  670. 'userCatModel': self._userCategoriesModel.data(),
  671. 'defaultCatModel': self._categoriesModel.data()
  672. }
  673. )
  674. return data
  675. def loadSettings(self, data):
  676. for key, widget in self._widgets.items():
  677. if data.get('{0}Visible'.format(key), True):
  678. widget.show()
  679. else:
  680. widget.hide()
  681. self._userCategoriesModel.setData(data.get('userCatModel', {}))
  682. self._categoriesModel.setData(data.get('defaultCatModel', {}))
  683. def __checkBoxStateChanged(self, key, state):
  684. if state:
  685. self._widgets[key].show()
  686. else:
  687. self._widgets[key].hide()
  688. """ QFrame re-implementations
  689. """
  690. def contextMenuEvent(self, event):
  691. menu = QMenu(self)
  692. menu.addSection(_("Show / Hide"))
  693. for key, widget in self._widgets.items():
  694. action = QWidgetAction(menu)
  695. checkbox = QCheckBox(key, menu)
  696. checkbox.setTristate(False)
  697. checkbox.setChecked(not widget.isHidden())
  698. action.setDefaultWidget(checkbox)
  699. checkbox.stateChanged.connect(
  700. lambda state, key=key:
  701. self.__checkBoxStateChanged(key, state)
  702. )
  703. menu.addAction(action)
  704. menu.exec_(self.mapToGlobal(event.pos()))
  705. class SearchContainer(QWidget):
  706. def __init__(self, searchModel, instancesModel,
  707. instanceSelecter, enginesModel, parent=None):
  708. """
  709. @type searchModel: models.search.SearchModel
  710. @type instancesModel: models.instances.InstancesModelFilter
  711. @type instanceSelecter: models.instances.InstanceSelecterModel
  712. @type enginesModel: models.instances.EnginesModel
  713. """
  714. QWidget.__init__(self, parent=parent)
  715. layout = QVBoxLayout(self)
  716. self._model = searchModel
  717. self._instancesModel = instancesModel
  718. self._instanceSelecter = instanceSelecter
  719. self._searchThread = None
  720. # Maximum other instances to try on fail.
  721. self._maxSearchFailCount = 10
  722. # Set `_useFallback` to True to try another instance when the
  723. # search failed somehow or set to False to try same instance or
  724. # for pagination which also should use the same instance.
  725. self._useFallback = True
  726. # `_fallbackActive` should be False when a fresh list of fallback
  727. # instances should be picked on failed search and should be True
  728. # when search(es) fail and `_fallbackInstancesQueue` is beeing
  729. # used.
  730. self._fallbackActive = False
  731. # Every first request that has `_useFallback` set to True will
  732. # use this list as a resource for next instance(s) to try until
  733. # it is out of instance url's. Also on the first request this
  734. # list will be cleared and filled again with `_maxSearchFailCount`
  735. # of random instances.
  736. self._fallbackInstancesQueue = []
  737. # Set to True to break out of the fallback loop.
  738. # This is used for the Stop action.
  739. self._breakFallback = False
  740. searchLayout = QHBoxLayout()
  741. layout.addLayout(searchLayout)
  742. # -- Start search bar
  743. self.queryEdit = QLineEdit(self)
  744. self.queryEdit.setPlaceholderText(_("Search for .."))
  745. searchLayout.addWidget(self.queryEdit)
  746. self.searchButton = Button(_("Searx"), self)
  747. self.searchButton.setToolTip(_("Preform search."))
  748. searchLayout.addWidget(self.searchButton)
  749. self.reloadButton = Button("♻", self)
  750. self.reloadButton.setToolTip(_("Reload"))
  751. searchLayout.addWidget(self.reloadButton)
  752. self.randomButton = Button("⤳", self)
  753. self.randomButton.setToolTip(_(
  754. "Search with random instance.\n"
  755. "(Obsolete when 'Random Every is checked')"
  756. ))
  757. searchLayout.addWidget(self.randomButton)
  758. rightLayout = QVBoxLayout()
  759. rightLayout.setSpacing(0)
  760. searchLayout.addLayout(rightLayout)
  761. self._fallbackCheck = QCheckBox(_("Fallback"), self)
  762. self._fallbackCheck.setToolTip(_("Try random other instance on fail."))
  763. rightLayout.addWidget(self._fallbackCheck)
  764. self._randomCheckEvery = QCheckBox(_("Random every"), self)
  765. self._randomCheckEvery.setToolTip(_("Pick a random instance for "
  766. "every request."))
  767. rightLayout.addWidget(self._randomCheckEvery)
  768. # -- End search bar
  769. # -- Start search options toolbar
  770. self._optionsContainer = SearchOptionsContainer(
  771. searchModel, instancesModel, enginesModel, self
  772. )
  773. layout.addWidget(self._optionsContainer)
  774. # -- End search options toolbar
  775. self.resultsContainer = QTextBrowser(self)
  776. self.resultsContainer.setOpenExternalLinks(True)
  777. self.resultsContainer.setLineWrapMode(1)
  778. layout.addWidget(self.resultsContainer)
  779. self.navBar = SearchNavigation(self)
  780. self.navBar.setEnabled(False)
  781. layout.addWidget(self.navBar)
  782. self.queryEdit.textChanged.connect(self.__queryChanged)
  783. self._model.statusChanged.connect(self.__searchStatusChanged)
  784. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  785. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  786. self._randomCheckEvery.stateChanged.connect(
  787. self.__randomEveryRequestChanged)
  788. self._instanceSelecter.instanceChanged.connect(
  789. self.__instanceChanged)
  790. self.queryEdit.returnPressed.connect(self.__searchButtonClicked)
  791. self.searchButton.clicked.connect(self.__searchButtonClicked)
  792. self.reloadButton.clicked.connect(self.__reloadButtonClicked)
  793. self.randomButton.clicked.connect(self.__randomSearchButtonClicked)
  794. self.navBar.requestPage.connect(self.__navBarRequest)
  795. self.__queryChanged("")
  796. def isBusy(self):
  797. return bool(self._searchThread is not None)
  798. def cancelAll(self):
  799. self._breakFallback = True
  800. self._searchThread.wait()
  801. def __searchButtonClicked(self, checked=0):
  802. # Set to use fallback
  803. self._useFallback = self._model.useFallback
  804. if (self._model.randomEvery or
  805. (self._useFallback and
  806. not self._instanceSelecter.currentUrl)):
  807. self._instanceSelecter.randomInstance()
  808. self._resetPagination()
  809. self._newSearch(self._instanceSelecter.currentUrl)
  810. def __stopButtonClicked(self):
  811. self._breakFallback = True
  812. self.searchButton.setEnabled(False)
  813. def __reloadButtonClicked(self):
  814. self._useFallback = False
  815. self._newSearch(self._instanceSelecter.currentUrl)
  816. def __randomSearchButtonClicked(self):
  817. self._useFallback = self._model.useFallback
  818. self._instanceSelecter.randomInstance()
  819. self._newSearch(self._instanceSelecter.currentUrl)
  820. def __navBarRequest(self, pageNo):
  821. self._useFallback = False
  822. self._model.pageno = pageNo
  823. self._newSearch(self._instanceSelecter.currentUrl)
  824. def _resetPagination(self):
  825. self.navBar.reset()
  826. self._model.pageno = 1
  827. def __instanceChanged(self):
  828. self._resetPagination()
  829. def __searchOptionsChanged(self):
  830. """ From the model (on load settings)
  831. """
  832. self._randomCheckEvery.stateChanged.disconnect(
  833. self.__randomEveryRequestChanged)
  834. self._fallbackCheck.stateChanged.disconnect(
  835. self.__useFallbackChanged)
  836. self._randomCheckEvery.setChecked(self._model.randomEvery)
  837. self._fallbackCheck.setChecked(self._model.useFallback)
  838. self.__handleRandomButtonState()
  839. self._randomCheckEvery.stateChanged.connect(
  840. self.__randomEveryRequestChanged)
  841. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  842. def __handleRandomButtonState(self):
  843. """ Hides or shows the 'Random search button'.
  844. We don't need the button when the model it's randomEvery is True.
  845. """
  846. if self._model.randomEvery:
  847. self.randomButton.hide()
  848. else:
  849. self.randomButton.show()
  850. def __randomEveryRequestChanged(self, state):
  851. """ From the checkbox
  852. """
  853. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  854. self._model.randomEvery = bool(state)
  855. self.__handleRandomButtonState()
  856. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  857. def __useFallbackChanged(self, state):
  858. """ From the checkbox
  859. """
  860. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  861. self._model.useFallback = bool(state)
  862. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  863. def __queryChanged(self, q):
  864. if self._model.status() == SearchStatus.Busy:
  865. return
  866. if q:
  867. self.searchButton.setEnabled(True)
  868. self.reloadButton.setEnabled(True)
  869. self.randomButton.setEnabled(True)
  870. else:
  871. self.searchButton.setEnabled(False)
  872. self.reloadButton.setEnabled(False)
  873. self.randomButton.setEnabled(False)
  874. def _setOptionsState(self, state=True):
  875. if self._useFallback:
  876. if state:
  877. # Search stopped
  878. self.searchButton.setText(_("Search"))
  879. self.searchButton.clicked.disconnect(
  880. self.__stopButtonClicked
  881. )
  882. self.searchButton.clicked.connect(self.__searchButtonClicked)
  883. self.searchButton.setEnabled(True)
  884. else:
  885. # Searching
  886. self.searchButton.setText(_("Stop"))
  887. self.searchButton.clicked.disconnect(
  888. self.__searchButtonClicked
  889. )
  890. self.searchButton.clicked.connect(self.__stopButtonClicked)
  891. else:
  892. self.searchButton.setEnabled(state)
  893. self.reloadButton.setEnabled(state)
  894. self.randomButton.setEnabled(state)
  895. self._randomCheckEvery.setEnabled(state)
  896. self._fallbackCheck.setEnabled(state)
  897. self.queryEdit.setEnabled(state)
  898. self._optionsContainer.setEnabled(state)
  899. def __searchStatusChanged(self, status):
  900. if status == SearchStatus.Busy:
  901. self._setOptionsState(False)
  902. elif status == SearchStatus.Done:
  903. self._setOptionsState()
  904. @property
  905. def query(self): return self.queryEdit.text()
  906. def _newSearch(self, url, query=''):
  907. self.resultsContainer.clear()
  908. self._search(url, query)
  909. def _search(self, url, query=''):
  910. if self._searchThread:
  911. return
  912. if not query:
  913. query = self.query
  914. if not query:
  915. self.resultsContainer.setHtml(_("Please enter a search query."))
  916. return
  917. if not url:
  918. self.resultsContainer.setHtml(_("Please select a instance first."))
  919. return
  920. self.navBar.setEnabled(False)
  921. self._model.url = url
  922. self._model.query = query
  923. self._searchThread = Thread(
  924. self._model.search,
  925. args=[self._model],
  926. parent=self
  927. )
  928. self._searchThread.finished.connect(self._searchFinished)
  929. self._searchThread.start()
  930. def _searchFailed(self, result):
  931. if self._useFallback: # Re-try another instance
  932. if self._breakFallback: # Stop button pressed
  933. self._breakFallback = False
  934. return
  935. if not self._fallbackActive:
  936. # Get new list with instances to try same request.
  937. self._fallbackActive = True
  938. self._fallbackInstancesQueue.clear()
  939. self._fallbackInstancesQueue = (
  940. self._instanceSelecter.getRandomInstances(
  941. amount=self._maxSearchFailCount))
  942. if not self._fallbackInstancesQueue:
  943. self.resultsContainer.setHtml(
  944. "{0} ({1})".format(
  945. _("Max fail count reached!"),
  946. self._maxSearchFailCount))
  947. self._fallbackActive = False
  948. return
  949. # Set next instance url to try.
  950. self._instanceSelecter.currentUrl = (
  951. self._fallbackInstancesQueue.pop(0))
  952. self._search(self._instanceSelecter.currentUrl)
  953. return
  954. if self._model.pageno > 1:
  955. self.navBar.setEnabled(True)
  956. self.navBar.setNextEnabled(False)
  957. self.resultsContainer.setHtml("{0}: {1} {2}"
  958. .format(_("Search failed"),
  959. result.errorType(),
  960. result.error()))
  961. def _searchFinished(self):
  962. result = self._searchThread.result()
  963. self._clearSearchThread()
  964. if not bool(result): # Failed
  965. self._searchFailed(result)
  966. return
  967. self._fallbackActive = False
  968. # Create HTML from results
  969. elemStr = ""
  970. for item in result.json().get('results', {}):
  971. elemStr += self.createResultElement(item)
  972. self.resultsContainer.setHtml(self.compileHtml(elemStr))
  973. self.navBar.setEnabled(True)
  974. self.navBar.setNextEnabled(True)
  975. def _clearSearchThread(self):
  976. self._searchThread.finished.disconnect(self._searchFinished)
  977. # Wait before deleting because the `finished` signal is emited
  978. # from the thread itself, so this method could be called before the
  979. # thread is actually finished and result in a crash.
  980. self._searchThread.wait()
  981. self._searchThread.deleteLater()
  982. self._searchThread = None
  983. def createResultElement(self, data):
  984. # Create general elements
  985. elem = """<div class="result" id="result-{id}">
  986. <h4 class="result-title"><i>{engine}: </i><a href="{url}">{title}</a></h4>
  987. <div style="margin-left: 10px;">
  988. <p class="result-description">{content}</p>
  989. <p class="result-url">{url}</p>
  990. """.format(id="TODO-id", title=data.get('title', ''),
  991. url=data.get('url', ''), content=data.get('content', ''),
  992. engine=data.get('engine', '?'))
  993. # Add file data elements
  994. elem += self.htmlFileSection(data)
  995. elem += "</div></div>"
  996. return elem
  997. def compileHtml(self, elemStr=""):
  998. return """<html>
  999. <head>
  1000. <title>Yeah</title>
  1001. </head>
  1002. <body>
  1003. {0}
  1004. </body>
  1005. </html>""".format(elemStr)
  1006. def formatFileSize(self, fileSize):
  1007. sizeStr = ""
  1008. _TiB = 1024 ** 4
  1009. _GiB = 1024 ** 3
  1010. _MiB = 1024 ** 2
  1011. _KiB = 1024
  1012. if fileSize > _TiB: # TiB
  1013. sizeStr = "{0:.2f} TB".format(fileSize / _TiB)
  1014. elif fileSize > _GiB: # GiB
  1015. sizeStr = "{0:.2f} GB".format(fileSize / _GiB)
  1016. elif fileSize > _MiB: # MiB
  1017. sizeStr = "{0:.2f} MB".format(fileSize / _MiB)
  1018. elif fileSize > _KiB: # KiB
  1019. sizeStr = "{0:.2f} KB".format(fileSize / _KiB)
  1020. else:
  1021. sizeStr = "{0} Bytes".format(fileSize)
  1022. return sizeStr
  1023. def formatFileCount(self, fileCount):
  1024. if fileCount == 1:
  1025. return "1" + _("file")
  1026. return "{0} {1}".format(fileCount, _("files"))
  1027. def htmlFileSection(self, data):
  1028. elem = ""
  1029. if data.get('magnetlink', ''):
  1030. elem += "<a href=\"{0}\">Magnet</a> ".format(data['magnetlink'])
  1031. if data.get('torrentfile', ''):
  1032. elem += "<a href=\"{0}\">Torrent</a> ".format(data['torrentfile'])
  1033. if data.get('filesize', 0):
  1034. elem += self.formatFileSize(data['filesize']) + " "
  1035. if data.get('files', 0):
  1036. elem += self.formatFileCount(data['files']) + " "
  1037. if data.get('seed', None) is not None:
  1038. elem += "seeders: {0} ".format(data['seed'])
  1039. if data.get('leech', None) is not None:
  1040. elem += "leechers: {0} ".format(data['leech'])
  1041. if elem:
  1042. elem = "<p class=\"result-file\">" + elem + "</p>"
  1043. return elem
  1044. def saveSettings(self):
  1045. return {
  1046. 'searchOptions': self._optionsContainer.saveSettings()
  1047. }
  1048. def loadSettings(self, data):
  1049. self._optionsContainer.loadSettings(
  1050. data.get('searchOptions', {})
  1051. )