search.py 76 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243
  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. QGridLayout,
  24. QVBoxLayout,
  25. QHBoxLayout,
  26. QLineEdit,
  27. QTextBrowser,
  28. QCheckBox,
  29. QLabel,
  30. QComboBox,
  31. QFrame,
  32. QMenu,
  33. QWidgetAction,
  34. QShortcut,
  35. QSpacerItem,
  36. QStyle,
  37. QSizePolicy,
  38. QDialog,
  39. QListWidget,
  40. QTableView,
  41. QSplitter,
  42. QScrollArea,
  43. QAbstractItemView,
  44. QHeaderView,
  45. QFormLayout,
  46. QMessageBox
  47. )
  48. from PyQt5.QtCore import (
  49. pyqtSignal,
  50. pyqtProperty,
  51. Qt,
  52. QByteArray,
  53. QEvent,
  54. QPoint,
  55. QVariant,
  56. QSize,
  57. QUrl
  58. )
  59. from PyQt5.QtGui import (
  60. QDesktopServices,
  61. QGuiApplication,
  62. QStandardItem,
  63. QStandardItemModel,
  64. QTextDocument,
  65. QPixmap,
  66. QImage
  67. )
  68. import html
  69. from collections import OrderedDict
  70. from searxqt.core.customAnchorCmd import AnchorCMD
  71. from searxqt.core.htmlGen import ResultsHtml, FailedResponsesHtml
  72. from searxqt.core.guard import ConsequenceType
  73. from searxqt.core.requests import ErrorType, ImageResult
  74. from searxqt.core.images import ImagesSettings
  75. from searxqt.models.search import (
  76. SearchStatus,
  77. UserCategoriesModel,
  78. CategoriesModel
  79. )
  80. from searxqt.models.instances import EnginesTableModel
  81. from searxqt.widgets.buttons import Button, CheckboxOptionsButton
  82. from searxqt.thread import Thread
  83. from searxqt.translations import _
  84. from searxqt.themes import Themes
  85. from searxqt.core import log
  86. class SearchNavigation(QWidget):
  87. requestPage = pyqtSignal(int) # pageno
  88. def __init__(self, parent=None):
  89. QWidget.__init__(self, parent=parent)
  90. layout = QHBoxLayout(self)
  91. self.prevPageButton = Button("◄", self)
  92. self.pageNoLabel = QLabel("1", self)
  93. self.nextPageButton = Button("►", self)
  94. layout.addWidget(self.prevPageButton, 0, Qt.AlignLeft)
  95. layout.addWidget(self.pageNoLabel, 0, Qt.AlignCenter)
  96. layout.addWidget(self.nextPageButton, 0, Qt.AlignRight)
  97. self.prevPageButton.setEnabled(False)
  98. self.nextPageButton.clicked.connect(self._nextPage)
  99. self.prevPageButton.clicked.connect(self._prevPage)
  100. self.reset()
  101. def _updateLabel(self):
  102. self.pageNoLabel.setText(str(self._pageno))
  103. def _nextPage(self):
  104. self._pageno += 1
  105. if self._pageno > 1 and not self.prevPageButton.isEnabled():
  106. self.prevPageButton.setEnabled(True)
  107. self._updateLabel()
  108. self.requestPage.emit(self._pageno)
  109. def _prevPage(self):
  110. self._pageno -= 1
  111. if self._pageno == 1:
  112. self.prevPageButton.setEnabled(False)
  113. self.setNextEnabled(True)
  114. self._updateLabel()
  115. self.requestPage.emit(self._pageno)
  116. def reset(self):
  117. self._pageno = 1
  118. self.prevPageButton.setEnabled(False)
  119. self._updateLabel()
  120. def setNextEnabled(self, state):
  121. self.nextPageButton.setEnabled(state)
  122. class SearchEngines(CheckboxOptionsButton):
  123. def __init__(self, searchModel, instancesModel, parent=None):
  124. """
  125. @param searchModel: needed for getting and setting current
  126. enabled/disabled engines.
  127. @type searchModel: SearchModel
  128. @param instancesModel: needed for listing current available
  129. engines and update it's current filter
  130. to filter out instances without atleast
  131. one of the required engine(s).
  132. @type instancesModel: InstanceModelFilter
  133. """
  134. self._instancesModel = instancesModel
  135. self._searchModel = searchModel
  136. CheckboxOptionsButton.__init__(
  137. self,
  138. labelName=_("Engines"),
  139. parent=parent
  140. )
  141. instancesModel.parentModel().changed.connect(self.reGenerate)
  142. def updateFilter(self):
  143. """ Filter out instances that don't support atleast one of the
  144. enabled engines.
  145. """
  146. self._instancesModel.updateKwargs(
  147. {'engines': self.getCheckedOptionNames()}
  148. )
  149. """ Below are re-implementations.
  150. """
  151. def getCheckedOptionNames(self):
  152. """ Should return a list with checked option names. This will
  153. be used to generate the label.
  154. @return: should return a list with strings.
  155. @rtype: list
  156. """
  157. return self._searchModel.engines
  158. def getOptions(self):
  159. """ Should return a list with options tuple(key, name, state)
  160. This will be used to generate the options.
  161. """
  162. list_ = []
  163. tmp = []
  164. for url, instance in self._instancesModel.items():
  165. for engine in instance.engines:
  166. if engine.name not in tmp:
  167. state = bool(engine.name in self._searchModel.engines)
  168. list_.append((engine.name, engine.name, state))
  169. tmp.append(engine.name)
  170. return sorted(list_)
  171. def optionToggled(self, key, state):
  172. if state:
  173. self._searchModel.engines.append(key)
  174. else:
  175. self._searchModel.engines.remove(key)
  176. self.updateFilter()
  177. class CategoryEditor(QDialog):
  178. def __init__(self, enginesModel, categoriesModel,
  179. userCategoriesModel, parent=None):
  180. QDialog.__init__(self, parent=parent)
  181. self._categoriesModel = categoriesModel
  182. self._userCategoriesModel = userCategoriesModel
  183. self.setWindowTitle(_("Category manager"))
  184. layout = QHBoxLayout(self)
  185. # Splitter to horizontal split the categories widget and the engines
  186. # widget so their width becomes adjustable.
  187. self.splitter = QSplitter(self)
  188. self.splitter.setOrientation(Qt.Horizontal)
  189. layout.addWidget(self.splitter)
  190. # Categories
  191. catWidget = QWidget(self.splitter)
  192. catLayout = QVBoxLayout(catWidget)
  193. label = QLabel(f"<h2>{_('Categories')}</h2>", self)
  194. # Categories toolbuttons
  195. catToolLayout = QHBoxLayout()
  196. catAddButton = Button("+", self)
  197. self._catDelButton = Button("-", self)
  198. catToolLayout.addWidget(catAddButton, 0, Qt.AlignLeft)
  199. catToolLayout.addWidget(self._catDelButton, 1, Qt.AlignLeft)
  200. self._categoryListWidget = QListWidget(self)
  201. catLayout.addWidget(label)
  202. catLayout.addLayout(catToolLayout)
  203. catLayout.addWidget(self._categoryListWidget)
  204. # Engines
  205. engWidget = QWidget(self.splitter)
  206. engLayout = QVBoxLayout(engWidget)
  207. label = QLabel(f"<h2>{_('Engines')}</h2>", self)
  208. # Engines filter
  209. filterLayout = QHBoxLayout()
  210. self._enginesCategoryFilterBox = QComboBox(self)
  211. self._enginesCategoryFilterBox.addItem(_("All"))
  212. for key, cat in categoriesModel.items():
  213. self._enginesCategoryFilterBox.addItem(cat.name)
  214. filterLayout.addWidget(
  215. QLabel(_("Category") + ":", self), 1, Qt.AlignRight
  216. )
  217. filterLayout.addWidget(
  218. self._enginesCategoryFilterBox, 0, Qt.AlignRight
  219. )
  220. # Engines table
  221. self._enginesTableView = QTableView(self)
  222. self._enginesTableView.setAlternatingRowColors(True)
  223. self._enginesTableView.setSelectionBehavior(
  224. QAbstractItemView.SelectRows
  225. )
  226. self._enginesTableView.setEditTriggers(
  227. QAbstractItemView.NoEditTriggers
  228. )
  229. self._enginesTableView.setSortingEnabled(True)
  230. self._enginesTableView.setHorizontalScrollMode(
  231. QAbstractItemView.ScrollPerPixel
  232. )
  233. header = self._enginesTableView.horizontalHeader()
  234. header.setSectionResizeMode(QHeaderView.ResizeToContents)
  235. header.setSectionsMovable(True)
  236. self._enginesTableModel = EnginesTableModel(enginesModel, self)
  237. self._enginesTableView.setModel(self._enginesTableModel)
  238. engLayout.addWidget(label)
  239. engLayout.addLayout(filterLayout)
  240. engLayout.addWidget(self._enginesTableView)
  241. # Connections
  242. catAddButton.clicked.connect(self.__addCategoryClicked)
  243. self._catDelButton.clicked.connect(self.__delCategoryClicked)
  244. self._categoryListWidget.currentRowChanged.connect(
  245. self.__currentUserCategoryChanged
  246. )
  247. self._enginesCategoryFilterBox.currentIndexChanged.connect(
  248. self.__enginesCategoryFilterChanged
  249. )
  250. self._enginesTableView.setEnabled(False)
  251. self._catDelButton.setEnabled(False)
  252. self.__addUserCategories()
  253. self.__selectFirst()
  254. def __currentUserCategoryChanged(self, index):
  255. if index < 0:
  256. self._enginesTableView.setEnabled(False)
  257. self._catDelButton.setEnabled(False)
  258. else:
  259. self._enginesTableView.setEnabled(True)
  260. self._catDelButton.setEnabled(True)
  261. if self._userCategoriesModel:
  262. key = list(self._userCategoriesModel.keys())[index]
  263. self._enginesTableModel.setUserModel(
  264. self._userCategoriesModel[key]
  265. )
  266. def __addUserCategories(self):
  267. for catKey, cat in self._userCategoriesModel.items():
  268. self._categoryListWidget.addItem(cat.name)
  269. def __selectFirst(self):
  270. if self._categoryListWidget.count():
  271. self._categoryListWidget.setCurrentRow(0)
  272. def __selectLast(self):
  273. self._categoryListWidget.setCurrentRow(
  274. self._categoryListWidget.count() - 1
  275. )
  276. def __reloadUserCategories(self):
  277. self._categoryListWidget.currentRowChanged.disconnect(
  278. self.__currentUserCategoryChanged
  279. )
  280. self._enginesTableModel.setUserModel(None)
  281. self._categoryListWidget.clear()
  282. self.__addUserCategories()
  283. self._categoryListWidget.currentRowChanged.connect(
  284. self.__currentUserCategoryChanged
  285. )
  286. def __addCategoryClicked(self, state):
  287. dialog = AddUserCategoryDialog(
  288. self._userCategoriesModel.keys()
  289. )
  290. if dialog.exec():
  291. self._userCategoriesModel.addCategory(
  292. dialog.name.lower(),
  293. dialog.name
  294. )
  295. self.__reloadUserCategories()
  296. self.__selectLast()
  297. def __delCategoryClicked(self, state):
  298. index = self._categoryListWidget.currentRow()
  299. key = list(self._userCategoriesModel.keys())[index]
  300. confirmDialog = QMessageBox()
  301. confirmDialog.setWindowTitle(
  302. _("Delete category")
  303. )
  304. categoryName = self._userCategoriesModel[key].name
  305. confirmDialog.setText(_("Are you sure you want to delete the " \
  306. f"category `{categoryName}`?"))
  307. confirmDialog.setStandardButtons(
  308. QMessageBox.Yes | QMessageBox.No
  309. )
  310. confirmDialog.button(QMessageBox.Yes).setText(_("Yes"))
  311. confirmDialog.button(QMessageBox.No).setText(_("No"))
  312. if confirmDialog.exec() != QMessageBox.Yes:
  313. return
  314. self._userCategoriesModel.removeCategory(key)
  315. self.__reloadUserCategories()
  316. self.__selectLast()
  317. self.__currentUserCategoryChanged(
  318. self._categoryListWidget.count() - 1
  319. )
  320. def __enginesCategoryFilterChanged(self, index):
  321. if not index: # All
  322. self._enginesTableModel.setCatFilter()
  323. else:
  324. key = list(self._categoriesModel.keys())[index-1]
  325. self._enginesTableModel.setCatFilter(key)
  326. class AddUserCategoryDialog(QDialog):
  327. def __init__(self, existingNames=[], text="", parent=None):
  328. QDialog.__init__(self, parent=parent)
  329. self._existingNames = existingNames
  330. layout = QFormLayout(self)
  331. label = QLabel(_("Name") + ":")
  332. self._nameEdit = QLineEdit(self)
  333. if text:
  334. self._nameEdit.setText(text)
  335. self._nameEdit.setPlaceholderText(text)
  336. else:
  337. self._nameEdit.setPlaceholderText(_("My category"))
  338. self._cancelButton = Button(_("Cancel"), self)
  339. self._addButton = Button(_("Add"), self)
  340. self._addButton.setEnabled(False)
  341. # Add stuff to layout
  342. layout.addRow(label, self._nameEdit)
  343. layout.addRow(self._cancelButton, self._addButton)
  344. # Connections
  345. self._nameEdit.textChanged.connect(self.__inputChanged)
  346. self._addButton.clicked.connect(self.accept)
  347. self._cancelButton.clicked.connect(self.reject)
  348. def __inputChanged(self, text):
  349. if self.isValid():
  350. self._addButton.setEnabled(True)
  351. else:
  352. self._addButton.setEnabled(False)
  353. def isValid(self):
  354. name = self._nameEdit.text().lower()
  355. if not name:
  356. return False
  357. for existingName in self._existingNames:
  358. if name == existingName.lower():
  359. return False
  360. return True
  361. @property
  362. def name(self):
  363. return self._nameEdit.text()
  364. class SearchCategories(CheckboxOptionsButton):
  365. def __init__(self, categoriesModel, instanceCategoriesModel, enginesModel,
  366. userCategoriesModel, parent=None):
  367. """
  368. @param categoriesModel: Predefined categories (only avaiable when at
  369. least one instance has the category).
  370. @type categoriesModel: searxqt.models.search.CategoriesModel
  371. @param instanceCategoriesModel: Some instances define custom search
  372. categories.
  373. @type instanceCategoriesModel: searxqt.models.search.CategoriesModel
  374. @param enginesModel:
  375. @type enginesModel: searxqt.models.instances.EnginesModel
  376. @param userCategoriesModel:
  377. @type userCategoriesModel: searxqt.models.search.UserCategoriesModel
  378. @param parent:
  379. @type parent: QObject or None
  380. """
  381. self._categoriesModel = categoriesModel
  382. self._instanceCategoriesModel = instanceCategoriesModel
  383. self._enginesModel = enginesModel
  384. self._userCategoriesModel = userCategoriesModel
  385. CheckboxOptionsButton.__init__(
  386. self,
  387. labelName=_("Categories"),
  388. parent=parent
  389. )
  390. self._categoriesModel.dataChanged.connect(self.__categoryDataChanged)
  391. def __categoryDataChanged(self):
  392. # This happends after CategoriesModel.setData
  393. self.reGenerate()
  394. def __openUserCategoryEditor(self):
  395. window = CategoryEditor(
  396. self._enginesModel,
  397. self._categoriesModel,
  398. self._userCategoriesModel, self)
  399. window.exec()
  400. def __userCategoryToggled(self, key, state):
  401. if state:
  402. self._userCategoriesModel[key].check()
  403. else:
  404. self._userCategoriesModel[key].uncheck()
  405. self.reGenerate()
  406. def __instanceCategoryToggled(self, key, state):
  407. if state:
  408. self._instanceCategoriesModel[key].check()
  409. else:
  410. self._instanceCategoriesModel[key].uncheck()
  411. self.reGenerate()
  412. """ Below are re-implementations.
  413. """
  414. def addCustomWidgetsTop(self, menu):
  415. # User specified categories
  416. menu.addSection(_("Custom"))
  417. action = QWidgetAction(menu)
  418. manageCustomButton = Button(_("Manage"), menu)
  419. action.setDefaultWidget(manageCustomButton)
  420. menu.addAction(action)
  421. for catKey, cat in self._userCategoriesModel.items():
  422. action = QWidgetAction(menu)
  423. widget = QCheckBox(cat.name, menu)
  424. widget.setTristate(False)
  425. widget.setChecked(
  426. cat.isChecked()
  427. )
  428. action.setDefaultWidget(widget)
  429. widget.stateChanged.connect(
  430. lambda state, key=catKey:
  431. self.__userCategoryToggled(key, state)
  432. )
  433. menu.addAction(action)
  434. # Custom instance specified categories
  435. menu.addSection(_("Instances"))
  436. for catKey, cat in self._instanceCategoriesModel.items():
  437. action = QWidgetAction(menu)
  438. widget = QCheckBox(cat.name, menu)
  439. widget.setTristate(False)
  440. widget.setChecked(cat.isChecked())
  441. action.setDefaultWidget(widget)
  442. widget.stateChanged.connect(
  443. lambda state, key=catKey:
  444. self.__instanceCategoryToggled(key, state)
  445. )
  446. menu.addAction(action)
  447. # Predefined Searx categories
  448. menu.addSection(_("Default"))
  449. manageCustomButton.clicked.connect(self.__openUserCategoryEditor)
  450. def hasEnabledCheckedKeys(self):
  451. """ Same as CheckboxOptionsButton.hasEnabledCheckedKeys(self) but with
  452. User Categories. Categories don't get enabled/disabled so we can skip
  453. that check.
  454. @rtype: bool
  455. """
  456. if self._userCategoriesModel.checkedCategories():
  457. return True
  458. elif self._instanceCategoriesModel.checkedCategories():
  459. return True
  460. return CheckboxOptionsButton.hasEnabledCheckedKeys(self)
  461. def uncheckAllEnabledKeys(self):
  462. """ Unchecks all checked keys that are enabled.
  463. """
  464. for catKey in self._userCategoriesModel.checkedCategories():
  465. self._userCategoriesModel[catKey].uncheck()
  466. for catKey in self._instanceCategoriesModel.checkedCategories():
  467. self._instanceCategoriesModel[catKey].uncheck()
  468. CheckboxOptionsButton.uncheckAllEnabledKeys(self)
  469. def getCheckedOptionNames(self):
  470. """ Should return a list with checked option names. This will
  471. be used to generate the label.
  472. @return: should return a list with strings.
  473. @rtype: list
  474. """
  475. return(
  476. [
  477. self._categoriesModel[catKey].name
  478. for catKey in self._categoriesModel.checkedCategories()
  479. ] +
  480. [
  481. self._instanceCategoriesModel[catKey].name
  482. for catKey in self._instanceCategoriesModel.checkedCategories()
  483. ] +
  484. [
  485. self._userCategoriesModel[catKey].name
  486. for catKey in self._userCategoriesModel.checkedCategories()
  487. ]
  488. )
  489. def getOptions(self):
  490. """ Should return a list with options tuple(key, name, state)
  491. This will be used to generate the options.
  492. """
  493. list_ = []
  494. for catKey in self._categoriesModel:
  495. list_.append(
  496. (
  497. catKey,
  498. self._categoriesModel[catKey].name,
  499. self._categoriesModel[catKey].isChecked()
  500. )
  501. )
  502. return list_
  503. def optionToggled(self, key, state):
  504. if state:
  505. self._categoriesModel[key].check()
  506. else:
  507. self._categoriesModel[key].uncheck()
  508. class SearchPeriod(QComboBox):
  509. def __init__(self, model, parent=None):
  510. QComboBox.__init__(self, parent=parent)
  511. self._model = model
  512. self.setMinimumContentsLength(2)
  513. for period in model.Periods:
  514. self.addItem(model.Periods[period], QVariant(period))
  515. self.currentIndexChanged.connect(self.__indexChanged)
  516. def __indexChanged(self, index):
  517. self._model.timeRange = self.currentData()
  518. class SearchLanguage(QComboBox):
  519. def __init__(self, model, parent=None):
  520. QComboBox.__init__(self, parent=parent)
  521. self._model = model
  522. self._favorites = []
  523. self.setMinimumContentsLength(2)
  524. self.__itemModel = QStandardItemModel(self)
  525. self.setModel(self.__itemModel)
  526. self.currentIndexChanged.connect(self.__indexChanged)
  527. self.__itemModel.itemChanged.connect(self.__favCheckChanged)
  528. def __indexChanged(self, index):
  529. self._model.lang = self.currentData()
  530. def __favCheckChanged(self, item):
  531. lang = item.data(Qt.UserRole)
  532. index = item.row()
  533. newIndex = 0
  534. if item.checkState():
  535. # Language added to favorites.
  536. self._favorites.append(lang)
  537. else:
  538. # Remove language from favorites.
  539. langList = list(self._model.Languages.keys())
  540. newIndex = langList.index(lang)
  541. self._favorites.remove(lang)
  542. # Index offset
  543. for favLang in self._favorites:
  544. if langList.index(favLang) < newIndex:
  545. newIndex -= 1
  546. newIndex += len(self._favorites)
  547. self.__itemModel.takeRow(index)
  548. self.__itemModel.insertRow(newIndex, item)
  549. def populate(self):
  550. self.__itemModel.clear()
  551. for lang in self._model.Languages:
  552. newItem = QStandardItem(self._model.Languages[lang])
  553. newItem.setCheckable(True)
  554. newItem.setFlags(
  555. Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsEnabled
  556. )
  557. newItem.setData(QVariant(lang), Qt.UserRole)
  558. if lang in self._favorites:
  559. newItem.setData(Qt.Checked, Qt.CheckStateRole)
  560. self.__itemModel.insertRow(0, newItem)
  561. continue
  562. newItem.setData(Qt.Unchecked, Qt.CheckStateRole)
  563. self.__itemModel.appendRow(newItem)
  564. def loadSettings(self, data):
  565. for lang in data.get('favs', []):
  566. self._favorites.append(lang)
  567. self.populate()
  568. # Find and set the index that matches lang
  569. self.setCurrentIndex(
  570. self.findData(QVariant(data.get('lang', '')), Qt.UserRole)
  571. )
  572. def saveSettings(self):
  573. return {
  574. 'lang': str(self.currentData()),
  575. 'favs': self._favorites
  576. }
  577. class SearchOptionsContainer(QFrame):
  578. """ Custom QFrame to be able to show or hide certain widgets.
  579. """
  580. def __init__(self, searchModel, instancesModel, enginesModel, parent=None):
  581. """
  582. @param searchModel:
  583. @type searchModel: searxqt.models.search.SearchModel
  584. @param instancesModel:
  585. @type instancesModel: searxqt.models.instances.InstanceModelFilter
  586. @param enginesModel:
  587. @type enginesModel: searxqt.models.instances.EnginesModel
  588. @param parent:
  589. @type parent: QObject or None
  590. """
  591. QFrame.__init__(self, parent=parent)
  592. self._enginesModel = enginesModel
  593. self._searchModel = searchModel
  594. self._categoriesModel = CategoriesModel()
  595. self._instanceCategoriesModel = CategoriesModel()
  596. self._userCategoriesModel = UserCategoriesModel()
  597. # Backup user checked engines.
  598. self.__userCheckedBackup = []
  599. # Keep track of disabled engines (these engines are disabled
  600. # because they are part of one or more checked categories).
  601. #
  602. # @key: engine name (str)
  603. # @value : list with category keys (str)
  604. self.__disabledByCat = {}
  605. layout = QHBoxLayout(self)
  606. self._widgets = {
  607. 'categories': SearchCategories(
  608. self._categoriesModel,
  609. self._instanceCategoriesModel,
  610. enginesModel,
  611. self._userCategoriesModel,
  612. self
  613. ),
  614. 'engines': SearchEngines(searchModel, instancesModel, self),
  615. 'period': SearchPeriod(searchModel, self),
  616. 'lang': SearchLanguage(searchModel, self)
  617. }
  618. for widget in self._widgets.values():
  619. layout.addWidget(widget, 0, Qt.AlignTop)
  620. # Keep widgets left aligned.
  621. spacer = QSpacerItem(
  622. 40, 20, QSizePolicy.MinimumExpanding, QSizePolicy.Minimum
  623. )
  624. layout.addItem(spacer)
  625. # Connections
  626. self._categoriesModel.stateChanged.connect(
  627. self.__categoriesStateChanged
  628. )
  629. self._instanceCategoriesModel.stateChanged.connect(
  630. self.__categoriesStateChanged
  631. )
  632. self._userCategoriesModel.stateChanged.connect(
  633. self.__userCategoriesStateChanged
  634. )
  635. self._userCategoriesModel.changed.connect(self.__userCategoriesChanged)
  636. self._userCategoriesModel.removed.connect(self.__userCategoryRemoved)
  637. self._enginesModel.changed.connect(self.__enginesModelChanged)
  638. def __enginesModelChanged(self):
  639. """Settings loaded or data updated
  640. """
  641. # Remove deleted engines from __disabledByCat
  642. for engine in list(self.__disabledByCat.keys()):
  643. if engine not in self._enginesModel:
  644. del self.__disabledByCat[engine]
  645. self._widgets['engines'].setKeyEnabled(engine)
  646. # Add new categories
  647. for catKey in self._enginesModel.categories():
  648. if catKey not in self._categoriesModel:
  649. name = ""
  650. if catKey in self._searchModel.categories.types:
  651. # Default pre-defined categories are translatable
  652. name = self._searchModel.categories.types[catKey][0]
  653. self._categoriesModel.addCategory(catKey, name)
  654. else:
  655. name = catKey.capitalize()
  656. log.debug(f"Found non default category `{name}`", self)
  657. self._instanceCategoriesModel.addCategory(catKey, name)
  658. # Remove old categories
  659. for catKey in self._categoriesModel.copy():
  660. if catKey not in self._enginesModel.categories():
  661. self._categoriesModel.removeCategory(catKey)
  662. # Release potentialy checked engines
  663. self.__processCategoriesStateChange(
  664. [engine.name for engine in
  665. self._enginesModel.getByCategory(catKey)],
  666. catKey,
  667. False
  668. )
  669. # Remove old instance specific categories
  670. for catKey in self._instanceCategoriesModel.copy():
  671. if (catKey not in self._enginesModel.categories() and
  672. catKey not in self._categoriesModel):
  673. self._instanceCategoriesModel.removeCategory(catKey)
  674. # Release potentialy checked engines
  675. self.__processCategoriesStateChange(
  676. [engine.name for engine in
  677. self._enginesModel.getByCategory(catKey)],
  678. catKey,
  679. False
  680. )
  681. self._widgets['categories'].reGenerate()
  682. self.__finalizeCategoriesStateChange()
  683. def __userCategoryRemoved(self, catKey):
  684. for engineKey, catList in self.__disabledByCat.copy().items():
  685. if catKey in catList:
  686. self.__uncheckEngineByCat(catKey, engineKey)
  687. self._widgets['categories'].reGenerate()
  688. self.__finalizeCategoriesStateChange()
  689. def __userCategoriesChanged(self, catKey):
  690. """ When the user edited a existing user-category this should
  691. check freshly added engines to this category and uncheck engines
  692. that have been removed from this category.
  693. """
  694. if self._userCategoriesModel[catKey].isChecked():
  695. engines = self._userCategoriesModel[catKey].engines
  696. # Uncheck removed engines
  697. for engineKey, categories in self.__disabledByCat.copy().items():
  698. if catKey in categories:
  699. if engineKey not in engines:
  700. self.__uncheckEngineByCat(catKey, engineKey)
  701. # Check newly added engines
  702. for engineKey in engines:
  703. if engineKey not in self.__disabledByCat:
  704. self.__checkEngineByCat(catKey, engineKey)
  705. self.__finalizeCategoriesStateChange()
  706. def __checkEngineByCat(self, catKey, engineKey):
  707. """ This method handles checking of a engine by a category.
  708. @param catKey: Category key
  709. @type catKey: str
  710. @param engineKey: Engine key
  711. @type engineKey: str
  712. """
  713. if engineKey not in self._searchModel.engines:
  714. # User did not check this engine so we are going to.
  715. self._searchModel.engines.append(
  716. engineKey
  717. )
  718. elif(engineKey not in self.__userCheckedBackup and
  719. not self._widgets['engines'].keyDisabled(engineKey)):
  720. # User did check this engine, so we backup that.
  721. self.__userCheckedBackup.append(engineKey)
  722. if not self._widgets['engines'].keyDisabled(engineKey):
  723. # Disable the engine from being toggled by the user.
  724. self._widgets['engines'].setKeyDisabled(engineKey)
  725. if engineKey not in self.__disabledByCat:
  726. self.__disabledByCat.update({engineKey: []})
  727. # Backup that this category is blocking this engine from
  728. # being toggled by the user.
  729. self.__disabledByCat[engineKey].append(catKey)
  730. def __uncheckEngineByCat(self, catKey, engineKey):
  731. """ This method handles the unchecking of a engine by a category.
  732. @param catKey: Category key
  733. @type catKey: str
  734. @param engineKey: Engine key
  735. @type engineKey: str
  736. """
  737. if engineKey in self.__disabledByCat:
  738. if catKey in self.__disabledByCat[engineKey]:
  739. # This category no longer blocks this engine from
  740. # being edited by the user.
  741. self.__disabledByCat[engineKey].remove(catKey)
  742. if not self.__disabledByCat[engineKey]:
  743. # No category left that blocks this engine from
  744. # user-toggleing.
  745. self._widgets['engines'].setKeyEnabled(engineKey)
  746. self.__disabledByCat.pop(engineKey)
  747. if engineKey not in self.__userCheckedBackup:
  748. # User didn't check this engine, so we can
  749. # uncheck it.
  750. self._searchModel.engines.remove(
  751. engineKey
  752. )
  753. else:
  754. # User did check this engine before checking
  755. # this category so we won't uncheck it.
  756. self.__userCheckedBackup.remove(engineKey)
  757. def __userCategoriesStateChanged(self, catKey, state):
  758. """ The user checked or unchecked a user-category.
  759. @param catKey: Category key
  760. @type catKey: str
  761. @param state:Category enabled or disabled (checked or unchecked)
  762. @type state: bool
  763. """
  764. self.__processCategoriesStateChange(
  765. self._userCategoriesModel[catKey].engines,
  766. catKey,
  767. state
  768. )
  769. self._widgets['categories'].reGenerate()
  770. self.__finalizeCategoriesStateChange()
  771. def __categoriesStateChanged(self, catKey, state):
  772. """ The user checked or unchecked a default-category.
  773. @param catKey: Category key
  774. @type catKey: str
  775. @param state: Category enabled or disabled (checked or unchecked)
  776. @type state: bool
  777. """
  778. self.__processCategoriesStateChange(
  779. [engine.name for engine in
  780. self._enginesModel.getByCategory(catKey)],
  781. catKey,
  782. state
  783. )
  784. self._widgets['categories'].reGenerate()
  785. self.__finalizeCategoriesStateChange()
  786. def __processCategoriesStateChange(self, engines, catKey, state):
  787. """ The user checked or unchecked a category, depending on the
  788. `state` variable.
  789. When a category gets checked all the engines in that category
  790. will be checked and disabled so that the user can't toggle the
  791. engine.
  792. On uncheck of a category all engines in that category should be
  793. re-enabled. And those engines should be unchecked if they weren't
  794. checked by the user before checking this category.
  795. @param engines: A list with engineKeys (str) that are part of the
  796. category (catKey).
  797. @type engines: list
  798. @param catKey: Category key
  799. @type catKey: str
  800. @param state: Category enabled or disabled (checked or unchecked)
  801. @type state: bool
  802. """
  803. if state:
  804. # Category checked.
  805. for engine in engines:
  806. self.__checkEngineByCat(catKey, engine)
  807. else:
  808. # Category unchecked.
  809. for engine in engines:
  810. self.__uncheckEngineByCat(catKey, engine)
  811. def __finalizeCategoriesStateChange(self):
  812. # Re-generate the engines label
  813. self._widgets['engines'].reGenerate()
  814. # Update the instances filter.
  815. self._widgets['engines'].updateFilter()
  816. def saveSettings(self):
  817. data = {}
  818. # Store widgets visible state.
  819. for key, widget in self._widgets.items():
  820. data.update({
  821. f"{key}Visible": not widget.isHidden()
  822. })
  823. # Store category states and CheckboxOptionsButton states (label
  824. # expanded or collapsed)
  825. data.update({
  826. 'userCatModel': self._userCategoriesModel.data(),
  827. 'defaultCatModel': self._categoriesModel.data(),
  828. 'categoriesButton': self._widgets['categories'].saveSettings(),
  829. 'enginesButton': self._widgets['engines'].saveSettings(),
  830. 'language': self._widgets['lang'].saveSettings()
  831. })
  832. return data
  833. def loadSettings(self, data):
  834. # Set widgets visible or hidden depending on their state.
  835. for key, widget in self._widgets.items():
  836. if data.get(f"{key}Visible", True):
  837. widget.show()
  838. else:
  839. widget.hide()
  840. # Load category states
  841. self._userCategoriesModel.setData(data.get('userCatModel', {}))
  842. self._categoriesModel.setData(data.get('defaultCatModel', {}))
  843. # Load CheckboxOptionsButton states (categories and engines label
  844. # states, expanded or collapsed.)
  845. self._widgets['categories'].loadSettings(
  846. data.get('categoriesButton', {})
  847. )
  848. self._widgets['engines'].loadSettings(data.get('enginesButton', {}))
  849. # Load search language.
  850. self._widgets['lang'].loadSettings(data.get('language', {}))
  851. def __checkBoxStateChanged(self, key, state):
  852. if state:
  853. self._widgets[key].show()
  854. else:
  855. self._widgets[key].hide()
  856. """ QFrame re-implementations
  857. """
  858. def contextMenuEvent(self, event):
  859. menu = QMenu(self)
  860. menu.addSection(_("Show / Hide"))
  861. for key, widget in self._widgets.items():
  862. action = QWidgetAction(menu)
  863. checkbox = QCheckBox(key, menu)
  864. checkbox.setTristate(False)
  865. checkbox.setChecked(not widget.isHidden())
  866. action.setDefaultWidget(checkbox)
  867. checkbox.stateChanged.connect(
  868. lambda state, key=key:
  869. self.__checkBoxStateChanged(key, state)
  870. )
  871. menu.addAction(action)
  872. menu.exec_(self.mapToGlobal(event.pos()))
  873. """ Find text in the search results
  874. Shortcuts:
  875. - 'Return' find text.
  876. - 'Shift+Return' find previous text.
  877. """
  878. class ResultSearcher(QWidget):
  879. closeRequest = pyqtSignal()
  880. def __init__(self, resultsContainer, parent):
  881. QWidget.__init__(self, parent)
  882. self.__resultsContainer = resultsContainer
  883. layout = QHBoxLayout(self)
  884. self.__inputEdit = QLineEdit(self)
  885. self.__inputEdit.setPlaceholderText(_("Find .."))
  886. self.__inputEdit.installEventFilter(self)
  887. self.__caseCheckbox = QCheckBox(_("Case sensitive"), self)
  888. self.__wholeCheckbox = QCheckBox(_("Whole words"), self)
  889. nextButton = Button("►", self)
  890. prevButton = Button("◄", self)
  891. closeButton = Button("X", self)
  892. layout.addWidget(self.__inputEdit)
  893. layout.addWidget(prevButton)
  894. layout.addWidget(nextButton)
  895. layout.addWidget(self.__caseCheckbox)
  896. layout.addWidget(self.__wholeCheckbox)
  897. layout.addWidget(closeButton)
  898. closeButton.clicked.connect(self.closeRequest)
  899. nextButton.clicked.connect(self.__search)
  900. prevButton.clicked.connect(self.__searchPrev)
  901. def eventFilter(self, source, event):
  902. if event.type() == QEvent.KeyPress and source is self.__inputEdit:
  903. if event.key() == Qt.Key_Return and event.modifiers() == Qt.ShiftModifier:
  904. self.__search(reverse=True)
  905. elif event.key() == Qt.Key_Return:
  906. self.__search()
  907. elif event.key() == Qt.Key_Escape:
  908. self.closeRequest.emit()
  909. return QLineEdit.eventFilter(self, source, event)
  910. def focusInput(self):
  911. self.__inputEdit.setFocus()
  912. def __searchPrev(self):
  913. self.__search(reverse=True)
  914. def __search(self, reverse=False):
  915. text = self.__inputEdit.text()
  916. flags = QTextDocument.FindFlag(0)
  917. if reverse:
  918. flags |= QTextDocument.FindBackward
  919. if self.__caseCheckbox.isChecked():
  920. flags |= QTextDocument.FindCaseSensitively
  921. if self.__wholeCheckbox.isChecked():
  922. flags |= QTextDocument.FindWholeWords
  923. self.__resultsContainer.find(text, options=flags)
  924. def makeWrappableText(text):
  925. """! Transform text with a invisible whitespace character after each
  926. character so a QLabel does wrap all.
  927. @note This HACK came from here: https://stackoverflow.com/questions/63587018/qlabel-break-word-if-too-long
  928. """
  929. return "\u200b".join(text)
  930. def makeWrappableHtmlText(htmlStr):
  931. """! Same as 'makeWrappableText' but ignores HTML."""
  932. insideTag = False
  933. for i in reversed(range(len(htmlStr) - 1)):
  934. char = htmlStr[i]
  935. if char == ">":
  936. insideTag = True
  937. continue
  938. if insideTag:
  939. if char == "<":
  940. insideTag = False
  941. continue
  942. htmlStr = htmlStr[:i] + "\u200b" + htmlStr[i:]
  943. return htmlStr
  944. class ImageViewerLabel(QLabel):
  945. def __init__(self, text="", parent=None):
  946. QLabel.__init__(self, makeWrappableHtmlText(text), parent)
  947. self.setWordWrap(True)
  948. self.setTextInteractionFlags(Qt.TextSelectableByMouse |
  949. Qt.TextSelectableByKeyboard |
  950. Qt.LinksAccessibleByMouse |
  951. Qt.LinksAccessibleByKeyboard)
  952. def setText(self, text):
  953. QLabel.setText(self, makeWrappableHtmlText(text))
  954. class ImageViewerLinkLabel(ImageViewerLabel):
  955. def __init__(self, text="", link="", parent=None):
  956. text = f"""<a href="{link}">{text}</a>"""
  957. ImageViewerLabel.__init__(self, text, parent)
  958. def setText(self, text, link):
  959. text = f"""<a href="{link}">{text}</a>"""
  960. ImageViewerLabel.setText(self, text)
  961. class ResizeHandle(QWidget):
  962. """! Little handle that makes the parent widget resizable by dragging it.
  963. """
  964. def __init__(self, parent=None):
  965. QWidget.__init__(self, parent=parent)
  966. self.setMinimumHeight(16)
  967. self.setMinimumWidth(16)
  968. self.setCursor(Qt.SizeFDiagCursor)
  969. self.setAttribute(Qt.WA_StyledBackground, True)
  970. self.resize(16, 16)
  971. def mousePressEvent(self, event):
  972. self.__dragStart = event.pos()
  973. event.accept()
  974. def mouseMoveEvent(self, event):
  975. if event.buttons() & Qt.LeftButton:
  976. parent = self.parent()
  977. delta = event.pos() - self.__dragStart
  978. parent.resize(parent.width() + delta.x(), parent.height() + delta.y())
  979. parent.layout().update()
  980. event.accept()
  981. return
  982. event.ignore()
  983. class ImageViewer(QFrame):
  984. """! Displays thumbnail with its information.
  985. @note The window is moveable by dragging the header but also while holding
  986. `CTRL` and then click + drag.
  987. @note Keyboard shortcuts:
  988. - `Escape` close
  989. - `Right-Arrow` next thumb
  990. - `Left-Arrow` previous thumb
  991. @param thumbKey The thumbnail URL
  992. @param thumbs dict object holding thumbs that we use for next/prev
  993. thumb.
  994. """
  995. def __init__(self, thumbKey, thumbs, parent):
  996. QFrame.__init__(self, parent, Qt.Dialog)
  997. self.setWindowTitle(_("Thumbnail viewer"))
  998. self.__thumbKey = thumbKey
  999. self.__thumbs = thumbs
  1000. layout = QVBoxLayout(self)
  1001. self.__scrollArea = QScrollArea(self)
  1002. layout.addWidget(self.__scrollArea, 1)
  1003. layout.addWidget(ResizeHandle(self), 0, Qt.AlignRight)
  1004. self.__scrollContentWidget = QWidget(self.__scrollArea)
  1005. layout = QGridLayout(self.__scrollContentWidget)
  1006. self.__scrollArea.setWidget(self.__scrollContentWidget)
  1007. self.__scrollArea.setFrameShape(QFrame.NoFrame)
  1008. self.__scrollArea.setWidgetResizable(True)
  1009. self.__scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
  1010. self.__scrollArea.setAlignment(
  1011. Qt.AlignLeading | Qt.AlignLeft | Qt.AlignTop
  1012. )
  1013. self.__nextButton = Button(">", self)
  1014. self.__prevButton = Button("<", self)
  1015. self.__imgWidget = QLabel(self)
  1016. self.__errorLabel = ImageViewerLabel(parent=self)
  1017. self.__titleLabel = ImageViewerLabel(parent=self)
  1018. self.__imgFmtLabel = ImageViewerLabel(parent=self)
  1019. self.__contentLabel = ImageViewerLabel(parent=self)
  1020. self.__thumbSrcLabel = ImageViewerLinkLabel(parent=self)
  1021. self.__imgSrcLabel = ImageViewerLinkLabel(parent=self)
  1022. self.__sourceLabel = ImageViewerLinkLabel(parent=self)
  1023. layout.addWidget(self.__prevButton , 1, 0, 1, 1, Qt.AlignVCenter | Qt.AlignLeft)
  1024. layout.addWidget(self.__imgWidget , 1, 1, 1, 3, Qt.AlignCenter)
  1025. layout.addWidget(self.__nextButton , 1, 4, 1, 1, Qt.AlignVCenter | Qt.AlignRight)
  1026. layout.addWidget(self.__errorLabel , 2, 1, 1, 3, Qt.AlignTop | Qt.AlignLeft)
  1027. layout.addWidget(QLabel("Title", self) , 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
  1028. layout.addWidget(self.__titleLabel , 3, 2, 1, 4, Qt.AlignTop)
  1029. layout.addWidget(QLabel("Format", self) , 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
  1030. layout.addWidget(self.__imgFmtLabel , 4, 2, 1, 4, Qt.AlignTop)
  1031. layout.addWidget(QLabel("Content", self) , 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
  1032. layout.addWidget(self.__contentLabel , 5, 2, 1, 4, Qt.AlignTop)
  1033. layout.addWidget(QLabel("Thumbnail", self), 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
  1034. layout.addWidget(self.__thumbSrcLabel , 6, 2, 1, 3, Qt.AlignTop)
  1035. layout.addWidget(QLabel("Image", self) , 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
  1036. layout.addWidget(self.__imgSrcLabel , 7, 2, 1, 3, Qt.AlignTop)
  1037. layout.addWidget(QLabel("Source", self) , 8, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
  1038. layout.addWidget(self.__sourceLabel , 8, 2, 1, 3, Qt.AlignTop)
  1039. layout.setColumnStretch(2, 1)
  1040. layout.setRowStretch(1, 1)
  1041. self.__errorLabel.hide()
  1042. self.setThumb(thumbKey)
  1043. self.setFocus()
  1044. self.__nextButton.setShortcut("right")
  1045. self.__prevButton.setShortcut("left")
  1046. self.__nextButton.clicked.connect(self.setNext)
  1047. self.__prevButton.clicked.connect(self.setPrev)
  1048. self.__thumbSrcLabel.setOpenExternalLinks(False)
  1049. self.__thumbSrcLabel.linkActivated.connect(self.__handleAnchorClicked)
  1050. self.__imgSrcLabel.setOpenExternalLinks(False)
  1051. self.__imgSrcLabel.linkActivated.connect(self.__handleAnchorClicked)
  1052. self.__sourceLabel.setOpenExternalLinks(False)
  1053. self.__sourceLabel.linkActivated.connect(self.__handleAnchorClicked)
  1054. def __handleAnchorClicked(self, link):
  1055. url = QUrl(link)
  1056. scheme = url.scheme()
  1057. # Custom command
  1058. if (AnchorCMD.handle(scheme, url.toString())):
  1059. return # Handled by custom command
  1060. # Let QDesktopServices handle known schemes
  1061. if scheme in ['http', 'https', 'magnet', 'ftp']:
  1062. QDesktopServices.openUrl(url)
  1063. def enterEvent(self, event):
  1064. self.setFocus()
  1065. def hasNext(self):
  1066. keys = list(self.__thumbs.keys())
  1067. currentIndex = keys.index(self.__thumbKey)
  1068. if currentIndex < (len(keys)-1):
  1069. return True
  1070. return False
  1071. def hasPrev(self):
  1072. keys = list(self.__thumbs.keys())
  1073. currentIndex = keys.index(self.__thumbKey)
  1074. if currentIndex > 0:
  1075. return True
  1076. return False
  1077. def setNext(self):
  1078. keys = list(self.__thumbs.keys())
  1079. currentIndex = keys.index(self.__thumbKey)
  1080. self.setThumb(keys[currentIndex + 1])
  1081. def setPrev(self):
  1082. keys = list(self.__thumbs.keys())
  1083. currentIndex = keys.index(self.__thumbKey)
  1084. self.setThumb(keys[currentIndex - 1])
  1085. def clear(self):
  1086. self.__imgWidget.clear()
  1087. self.__titleLabel.clear()
  1088. self.__imgFmtLabel.clear()
  1089. self.__contentLabel.clear()
  1090. self.__thumbSrcLabel.clear()
  1091. self.__imgSrcLabel.clear()
  1092. self.__sourceLabel.clear()
  1093. self.__nextButton.setEnabled(False)
  1094. self.__prevButton.setEnabled(False)
  1095. def setThumb(self, thumbKey):
  1096. self.__thumbKey = thumbKey
  1097. thumbData = self.__thumbs[thumbKey]
  1098. img = thumbData['image']
  1099. res = thumbData['result']
  1100. title=html.escape(res.get('title', ''))
  1101. img_format=html.escape(res.get('img_format', ''))
  1102. url=html.escape(res.get('url', ''))
  1103. img_src=html.escape(res.get('img_src', ''))
  1104. content=html.escape(res.get('content', ''))
  1105. thumbnail_src=html.escape(res.get('thumbnail_src', ''))
  1106. engines = ''
  1107. for e in thumbData.get('engines', []):
  1108. engines += f"{html.escape(e).replace(' ', '-')} "
  1109. engines = engines.rstrip()
  1110. self.__imgWidget.clear()
  1111. self.__errorLabel.hide()
  1112. if thumbData['error'] is not None:
  1113. self.__errorLabel.setText(f"<h3>Error:</h3> {thumbData['error']}")
  1114. self.__errorLabel.show()
  1115. elif img:
  1116. self.__imgWidget.setPixmap(QPixmap.fromImage(img))
  1117. htmlStr = f"""<b>{engines}: {title}</b>"""
  1118. self.__titleLabel.setText(htmlStr)
  1119. self.__imgFmtLabel.setText(img_format)
  1120. self.__contentLabel.setText(content)
  1121. self.__thumbSrcLabel.setText(thumbnail_src, thumbnail_src)
  1122. self.__imgSrcLabel.setText(img_src, img_src)
  1123. self.__sourceLabel.setText(url, url)
  1124. if self.hasNext():
  1125. self.__nextButton.setEnabled(True)
  1126. else:
  1127. self.__nextButton.setEnabled(False)
  1128. if self.hasPrev():
  1129. self.__prevButton.setEnabled(True)
  1130. else:
  1131. self.__prevButton.setEnabled(False)
  1132. class ResultsBrowser(QTextBrowser):
  1133. """! This is where search results are shown. It has the ability to
  1134. download remote images."""
  1135. ## Image download state: No image downloading threads are active.
  1136. IDLE = 0
  1137. ## Image download state: Image downloading thread(s) are active.
  1138. LOADING = 1
  1139. ## Signal that is emitted when the image(s) download state has changed.
  1140. ## This will emit `ResultsBrowser.LOADING` when new results have been set
  1141. ## and the first thumbnail downloading thread has been created. It will
  1142. ## emit `ResultsBrowser.IDLE` when the last thread finished, it will not
  1143. ## emit when `cancelAllThreads()` as been called before.
  1144. loadingStateChanged = pyqtSignal(int)
  1145. def __init__(self, imgRequestHandler, *args, **kwargs):
  1146. QTextBrowser.__init__(self, *args, **kwargs)
  1147. self._imgRequestHandler = imgRequestHandler
  1148. self._imgQueue = []
  1149. self._imgThreads = []
  1150. self._imgViewer = None
  1151. self._cancelledThreads = []
  1152. self._status = ResultsBrowser.IDLE
  1153. ## Icons
  1154. self.__iconLoading = ":/default/32_loading.png"
  1155. self.__iconFailed = ":/default/32_failed.png"
  1156. # {
  1157. # thumbnail_src: {
  1158. # 'image': QImage,
  1159. # 'result': {},
  1160. # 'error': None
  1161. # }
  1162. # }
  1163. self._thumbs = OrderedDict()
  1164. self._others = {}
  1165. self.setContextMenuPolicy(Qt.CustomContextMenu)
  1166. self.customContextMenuRequested.connect(self.__showCustomContextMenu)
  1167. @pyqtProperty(str)
  1168. def iconLoading(self):
  1169. return self.__iconLoading
  1170. @iconLoading.setter
  1171. def iconLoading(self, path):
  1172. self.__iconLoading = path
  1173. @pyqtProperty(str)
  1174. def iconFailed(self):
  1175. return self.__iconFailed
  1176. @iconFailed.setter
  1177. def iconFailed(self, path):
  1178. self.__iconFailed = path
  1179. @property
  1180. def status(self):
  1181. return self._status
  1182. def reset(self):
  1183. """! Reset content"""
  1184. self.setHtml("")
  1185. self.clearHistory()
  1186. self._thumbs.clear()
  1187. if self._imgViewer is not None:
  1188. self._imgViewer.close()
  1189. self._imgViewer.deleteLater()
  1190. self._imgViewer = None
  1191. def openThumbViewer(self, url):
  1192. if self._imgViewer is None:
  1193. self._imgViewer = ImageViewer(url, self._thumbs, parent=self)
  1194. else:
  1195. self._imgViewer.setThumb(url)
  1196. self._imgViewer.show()
  1197. def getThumbsData(self):
  1198. return self._thumbs
  1199. def getThumbData(self, thumbnail_src):
  1200. return self._thumbs[thumbnail_src]
  1201. def setJsonResult(self, jsonResult):
  1202. """! Set a valid JSON search result"""
  1203. self._thumbs.clear()
  1204. self._others.clear()
  1205. self.setHtml(
  1206. ResultsHtml.create(jsonResult, Themes.htmlCssResults)
  1207. )
  1208. # Create thumb map for later use (ImageViewer)
  1209. for result in jsonResult.get('results', []):
  1210. if result.get('category') != 'images':
  1211. continue
  1212. thumbnail_src = result.get('thumbnail_src')
  1213. if not thumbnail_src:
  1214. continue
  1215. self._thumbs.update({
  1216. thumbnail_src: {
  1217. 'image': None,
  1218. 'result': result,
  1219. 'error': None
  1220. }
  1221. })
  1222. def clear(self):
  1223. """! Overrides QTextBrowser.clear() to also cancell all current
  1224. image downloading threads."""
  1225. self.cancelAllThreads()
  1226. if self._imgViewer is not None:
  1227. self._imgViewer.clear()
  1228. QTextBrowser.clear(self)
  1229. def hasActiveThreads(self):
  1230. """! Are there any image downloading thread(s) active?
  1231. @return <bool>
  1232. """
  1233. return bool(self._imgThreads) or bool(self._cancelledThreads)
  1234. def cancelAllThreads(self):
  1235. """! Cancel all current threads.
  1236. """
  1237. self._imgQueue.clear()
  1238. for thread in self._imgThreads:
  1239. self._cancelledThreads.append(thread)
  1240. thread.requestInterruption()
  1241. thread.finished.disconnect(self._imgThreadFinished)
  1242. thread.finished.connect(self.__cancelledThreadFinished)
  1243. self._imgThreads.clear()
  1244. self._status = ResultsBrowser.IDLE
  1245. def waitOnThreads(self):
  1246. for thread in self._imgThreads:
  1247. thread.wait()
  1248. for thread in self._cancelledThreads:
  1249. thread.wait()
  1250. def __cancelledThreadFinished(self, thread):
  1251. self._cancelledThreads.remove(thread)
  1252. thread.finished.disconnect(self.__cancelledThreadFinished)
  1253. thread.wait()
  1254. thread.deleteLater()
  1255. def __showCustomContextMenu(self, pos):
  1256. # Apparently we need to add the vertical scroll pos ourselves ..
  1257. # .. else the default actions won't work.
  1258. pos2 = pos + QPoint(0, self.verticalScrollBar().value())
  1259. menu = self.createStandardContextMenu(pos2)
  1260. cursor = self.cursorForPosition(pos);
  1261. cf = cursor.charFormat()
  1262. url = cf.toImageFormat().name()
  1263. if not cf.isImageFormat() or url not in self._thumbs:
  1264. menu.exec_(self.mapToGlobal(pos))
  1265. return
  1266. thumb = self._thumbs[url]['result']
  1267. copyMenu = QMenu(_("Copy URL"), self)
  1268. actionCopyThumbUrl = copyMenu.addAction(_("Thumbnail"))
  1269. actionCopyImageUrl = copyMenu.addAction(_("Image"))
  1270. actionCopySourceUrl = copyMenu.addAction(_("Source"))
  1271. menu.addMenu(copyMenu)
  1272. actionRefreshThumb = None
  1273. if self._thumbs[url]['error'] is not None:
  1274. actionRefreshThumb = menu.addAction(_("Re-try thumb download"))
  1275. action = menu.exec_(self.mapToGlobal(pos))
  1276. if action == actionCopyThumbUrl:
  1277. clipboard = QGuiApplication.clipboard()
  1278. clipboard.setText(url)
  1279. elif action == actionCopyImageUrl:
  1280. clipboard = QGuiApplication.clipboard()
  1281. clipboard.setText(thumb.get('img_src', ''))
  1282. elif action == actionCopySourceUrl:
  1283. clipboard = QGuiApplication.clipboard()
  1284. clipboard.setText(thumb.get('source', ''))
  1285. elif self._thumbs[url]['error'] is not None:
  1286. if action == actionRefreshThumb:
  1287. self.getImage(QUrl(url))
  1288. def __getImageThreadFunc(self, url):
  1289. """! Fetch image and create Pixmap from it, this will run from a
  1290. thread."""
  1291. result = self._imgRequestHandler.get(url, ResultType=ImageResult)
  1292. # Assume the thread cancelled
  1293. if url not in self._thumbs and url not in self._others:
  1294. return None
  1295. if result.errorType() != ErrorType.Success:
  1296. log.debug(f"Failed to get image '{result.error()}'", self)
  1297. # Return error
  1298. return result.error()
  1299. img = QImage.fromData(result.content())
  1300. pix = QPixmap.fromImage(img)
  1301. ## @see https://doc.qt.io/qt-6/qt.html#TransformationMode-enum
  1302. transform = Qt.FastTransformation if ImagesSettings.fastTransform else Qt.SmoothTransformation
  1303. # Honor max width/height
  1304. if pix.width() > ImagesSettings.maxWidth:
  1305. pix = pix.scaledToWidth(ImagesSettings.maxWidth, transform)
  1306. if pix.height() > ImagesSettings.maxHeight:
  1307. pix = pix.scaledToHeight(ImagesSettings.maxHeight, transform)
  1308. return (img, pix)
  1309. def getImage(self, url):
  1310. """! Start a new thread or queue for the downloading remote images.
  1311. @param url <QUrl> object
  1312. """
  1313. # Max thread count reached, add to img jobs for future processing
  1314. if len(self._imgThreads) == ImagesSettings.maxThreads:
  1315. self._imgQueue.append(url)
  1316. return QPixmap(self.iconLoading)
  1317. urlStr = url.toString()
  1318. if urlStr in self._thumbs:
  1319. self._thumbs[urlStr]['error'] = None
  1320. else:
  1321. self._others.update({urlStr: {'error': None}})
  1322. if not self._imgThreads:
  1323. # This will be the first thread for this result.
  1324. self.loadingStateChanged.emit(ResultsBrowser.LOADING)
  1325. self._status = ResultsBrowser.LOADING
  1326. newThread = Thread(
  1327. self.__getImageThreadFunc,
  1328. args=[urlStr],
  1329. extra=url,
  1330. parent=self
  1331. )
  1332. self._imgThreads.append(newThread)
  1333. newThread.finished.connect(self._imgThreadFinished)
  1334. newThread.start()
  1335. return QPixmap(self.iconLoading)
  1336. def _imgThreadFinalize(self, thread):
  1337. """! Cleanup/Remove the given thread and start a new one when the
  1338. queue is not empty.
  1339. @param thread: <Thread>
  1340. """
  1341. self._imgThreads.remove(thread)
  1342. thread.finished.disconnect(self._imgThreadFinished)
  1343. thread.wait()
  1344. thread.deleteLater()
  1345. if self._imgQueue and len(self._imgThreads) < ImagesSettings.maxThreads:
  1346. self.getImage(self._imgQueue.pop(0))
  1347. elif not self._imgThreads:
  1348. # This was the last thread, done loading all thumbnails.
  1349. self.loadingStateChanged.emit(ResultsBrowser.IDLE)
  1350. self._status = ResultsBrowser.IDLE
  1351. def _imgThreadFinished(self, thread):
  1352. """! Add the downloaded image as resource so it will be loaded and
  1353. finalized the thread.
  1354. @param thread: <Thread>
  1355. """
  1356. # Thumb download failed
  1357. if type(thread.result()) is str:
  1358. # Set failed icon
  1359. pix = QPixmap(self.iconFailed)
  1360. url = thread.extra
  1361. doc = self.document()
  1362. doc.addResource(QTextDocument.ImageResource, url, QVariant(pix))
  1363. doc.setTextWidth(doc.idealWidth()); # Trigger stuff to resize/repaint
  1364. # Set error
  1365. self._thumbs[thread.extra.toString()]['error'] = thread.result()
  1366. self._imgThreadFinalize(thread)
  1367. return
  1368. # Thumb download success
  1369. img, pix = thread.result()
  1370. url = thread.extra
  1371. thumbnail_src = url.toString()
  1372. # Backup the full sized thumb, we may need it later in the ImageViewer
  1373. if thumbnail_src in self._thumbs:
  1374. self._thumbs[thumbnail_src]['image'] = img
  1375. doc = self.document()
  1376. doc.addResource(QTextDocument.ImageResource, url, QVariant(pix))
  1377. doc.setTextWidth(doc.idealWidth()); # Trigger stuff to resize/repaint
  1378. self._imgThreadFinalize(thread)
  1379. def loadResource(self, type, name):
  1380. """! Override QTextBrowser.loadResource() to download remote images.
  1381. @see https://doc.qt.io/qt-6/qtextbrowser.html#loadResource
  1382. """
  1383. if type != QTextDocument.ImageResource:
  1384. return QTextBrowser.loadResource(self, type, name)
  1385. return self.getImage(name)
  1386. class SearchContainer(QWidget):
  1387. def __init__(self, searchModel, imgRequestHandler, instancesModel,
  1388. instanceSelecter, enginesModel, guard, parent=None):
  1389. """
  1390. @type searchModel: models.search.SearchModel
  1391. @type instancesModel: models.instances.InstancesModelFilter
  1392. @type instanceSelecter: models.instances.InstanceSelecterModel
  1393. @type enginesModel: models.instances.EnginesModel
  1394. @type guard: core.guard.Guard
  1395. """
  1396. QWidget.__init__(self, parent=parent)
  1397. layout = QVBoxLayout(self)
  1398. self._model = searchModel
  1399. self._instancesModel = instancesModel
  1400. self._instanceSelecter = instanceSelecter
  1401. self._guard = guard
  1402. self._searchThread = None
  1403. # Maximum other instances to try on fail.
  1404. self._maxSearchFailCount = 10
  1405. # Set `_useFallback` to True to try another instance when the
  1406. # search failed somehow or set to False to try same instance or
  1407. # for pagination which also should use the same instance.
  1408. self._useFallback = True
  1409. # `_fallbackActive` should be False when a fresh list of fallback
  1410. # instances should be picked on failed search and should be True
  1411. # when search(es) fail and `_fallbackInstancesQueue` is beeing
  1412. # used.
  1413. self._fallbackActive = False
  1414. # Used to store errors when fallback is active so they can be listed
  1415. # to the user when even the fallback fails.
  1416. self._fallbackErrors = []
  1417. # Every first request that has `_useFallback` set to True will
  1418. # use this list as a resource for next instance(s) to try until
  1419. # it is out of instance url's. Also on the first request this
  1420. # list will be cleared and filled again with `_maxSearchFailCount`
  1421. # of random instances.
  1422. self._fallbackInstancesQueue = []
  1423. # Set to True to break out of the fallback loop.
  1424. # This is used for the Stop action.
  1425. self._breakFallback = False
  1426. searchLayout = QHBoxLayout()
  1427. layout.addLayout(searchLayout)
  1428. # -- Start search bar
  1429. self.queryEdit = QLineEdit(self)
  1430. self.queryEdit.setPlaceholderText(_("Search for .."))
  1431. searchLayout.addWidget(self.queryEdit)
  1432. self.searchButton = Button(_("Searx"), self)
  1433. self.searchButton.setToolTip(_("Preform search."))
  1434. searchLayout.addWidget(self.searchButton)
  1435. self.reloadButton = Button("♻", self)
  1436. self.reloadButton.setToolTip(_("Reload"))
  1437. searchLayout.addWidget(self.reloadButton)
  1438. self.randomButton = Button("⤳", self)
  1439. self.randomButton.setToolTip(_(
  1440. "Search with random instance.\n"
  1441. "(Obsolete when 'Random Every is checked')"
  1442. ))
  1443. searchLayout.addWidget(self.randomButton)
  1444. rightLayout = QVBoxLayout()
  1445. rightLayout.setSpacing(0)
  1446. searchLayout.addLayout(rightLayout)
  1447. self._fallbackCheck = QCheckBox(_("Fallback"), self)
  1448. self._fallbackCheck.setToolTip(_("Try random other instance on fail."))
  1449. rightLayout.addWidget(self._fallbackCheck)
  1450. self._randomCheckEvery = QCheckBox(_("Random every"), self)
  1451. self._randomCheckEvery.setToolTip(_("Pick a random instance for "
  1452. "every request."))
  1453. rightLayout.addWidget(self._randomCheckEvery)
  1454. # -- End search bar
  1455. # -- Start splitter
  1456. self.splitter = QSplitter(self)
  1457. self.splitter.setOrientation(Qt.Vertical)
  1458. layout.addWidget(self.splitter)
  1459. # ---- Start search options toolbar
  1460. self._optionsContainer = SearchOptionsContainer(
  1461. searchModel, instancesModel, enginesModel, self.splitter
  1462. )
  1463. # ---- End search options toolbar
  1464. # --- Start search results container
  1465. searchBottomWidget = QWidget(self.splitter)
  1466. self.searchBottomWidgetLayout = QVBoxLayout(searchBottomWidget)
  1467. self.resultsContainer = ResultsBrowser(imgRequestHandler, self)
  1468. self.resultsContainer.setOpenLinks(False)
  1469. self.resultsContainer.setOpenExternalLinks(False)
  1470. self.resultsContainer.setLineWrapMode(1)
  1471. self.searchBottomWidgetLayout.addWidget(self.resultsContainer)
  1472. self.navBar = SearchNavigation(self)
  1473. self.navBar.setEnabled(False)
  1474. self.searchBottomWidgetLayout.addWidget(self.navBar)
  1475. # --- End search results container
  1476. # -- End splitter
  1477. # Find text
  1478. self.__findShortcut = QShortcut('Ctrl+F', self);
  1479. self.__findShortcut.activated.connect(self.__openFind);
  1480. self.__resultsSearcher = None
  1481. self.queryEdit.textChanged.connect(self.__queryChanged)
  1482. self._model.statusChanged.connect(self.__searchStatusChanged)
  1483. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1484. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  1485. self._randomCheckEvery.stateChanged.connect(
  1486. self.__randomEveryRequestChanged)
  1487. self._instanceSelecter.instanceChanged.connect(
  1488. self.__instanceChanged)
  1489. self.queryEdit.returnPressed.connect(self.__searchButtonClicked)
  1490. self.searchButton.clicked.connect(self.__searchButtonClicked)
  1491. self.reloadButton.clicked.connect(self.__reloadButtonClicked)
  1492. self.randomButton.clicked.connect(self.__randomSearchButtonClicked)
  1493. self.navBar.requestPage.connect(self.__navBarRequest)
  1494. self.resultsContainer.anchorClicked.connect(self.__handleAnchorClicked)
  1495. self.resultsContainer.loadingStateChanged.connect(self.__loadingStateChanged)
  1496. self.__queryChanged("")
  1497. def isBusy(self):
  1498. return bool(self._searchThread is not None or
  1499. self.resultsContainer.hasActiveThreads())
  1500. def cancelAll(self):
  1501. self._breakFallback = True
  1502. if self._searchThread:
  1503. self._searchThread.wait()
  1504. self.resultsContainer.cancelAllThreads()
  1505. self.resultsContainer.waitOnThreads()
  1506. def reset(self):
  1507. self._model.reset()
  1508. self.resultsContainer.reset()
  1509. def __openFind(self):
  1510. if self.__resultsSearcher is None:
  1511. self.__resultsSearcher = ResultSearcher(self.resultsContainer, self)
  1512. self.searchBottomWidgetLayout.insertWidget(1, self.__resultsSearcher)
  1513. self.__resultsSearcher.closeRequest.connect(self.__closeFind)
  1514. self.__resultsSearcher.focusInput()
  1515. def __closeFind(self):
  1516. self.searchBottomWidgetLayout.removeWidget(self.__resultsSearcher)
  1517. self.__resultsSearcher.deleteLater()
  1518. self.__resultsSearcher = None
  1519. def __handleAnchorClicked(self, url):
  1520. scheme = url.scheme()
  1521. # Internal from sugestions/corrections
  1522. if scheme == 'search':
  1523. self.queryEdit.setText(url.path())
  1524. self._newSearch(self._instanceSelecter.currentUrl)
  1525. return
  1526. if scheme == 'thumb':
  1527. fixedUrl = url.toString()[8:].replace("//", "://", 1)
  1528. self.resultsContainer.openThumbViewer(fixedUrl)
  1529. return
  1530. # Custom command
  1531. if (AnchorCMD.handle(scheme, url.toString())):
  1532. return # Handled by custom command
  1533. # Let QDesktopServices handle known schemes
  1534. if scheme in ['http', 'https', 'magnet', 'ftp']:
  1535. QDesktopServices.openUrl(url)
  1536. # Unknown schemes are not doing anything .. :-)
  1537. def __searchButtonClicked(self, checked=0):
  1538. # Set to use fallback
  1539. self._useFallback = self._model.useFallback
  1540. if (self._model.randomEvery or
  1541. (self._useFallback and
  1542. not self._instanceSelecter.currentUrl)):
  1543. self._instanceSelecter.randomInstance()
  1544. self._resetPagination()
  1545. self._newSearch(self._instanceSelecter.currentUrl)
  1546. def __stopButtonClicked(self):
  1547. self._breakFallback = True
  1548. self.searchButton.setEnabled(False)
  1549. def __stopImagesButtonClicked(self):
  1550. self.resultsContainer.cancelAllThreads()
  1551. self.searchButton.setText(_("Search"))
  1552. self.searchButton.clicked.disconnect(self.__stopImagesButtonClicked)
  1553. self.searchButton.clicked.connect(self.__searchButtonClicked)
  1554. def __reloadButtonClicked(self):
  1555. self._useFallback = False
  1556. self._newSearch(self._instanceSelecter.currentUrl)
  1557. def __randomSearchButtonClicked(self):
  1558. self._useFallback = self._model.useFallback
  1559. self._instanceSelecter.randomInstance()
  1560. self._newSearch(self._instanceSelecter.currentUrl)
  1561. def __navBarRequest(self, pageNo):
  1562. self._useFallback = False
  1563. self._model.pageno = pageNo
  1564. self._newSearch(self._instanceSelecter.currentUrl)
  1565. def _resetPagination(self):
  1566. self.navBar.reset()
  1567. self._model.pageno = 1
  1568. def __instanceChanged(self):
  1569. self._resetPagination()
  1570. def __searchOptionsChanged(self):
  1571. """ From the model (on load settings)
  1572. """
  1573. self._randomCheckEvery.stateChanged.disconnect(
  1574. self.__randomEveryRequestChanged)
  1575. self._fallbackCheck.stateChanged.disconnect(
  1576. self.__useFallbackChanged)
  1577. self._randomCheckEvery.setChecked(self._model.randomEvery)
  1578. self._fallbackCheck.setChecked(self._model.useFallback)
  1579. self.__handleRandomButtonState()
  1580. self._randomCheckEvery.stateChanged.connect(
  1581. self.__randomEveryRequestChanged)
  1582. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  1583. def __handleRandomButtonState(self):
  1584. """ Hides or shows the 'Random search button'.
  1585. We don't need the button when the model it's randomEvery is True.
  1586. """
  1587. if self._model.randomEvery:
  1588. self.randomButton.hide()
  1589. else:
  1590. self.randomButton.show()
  1591. def __randomEveryRequestChanged(self, state):
  1592. """ From the checkbox
  1593. """
  1594. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  1595. self._model.randomEvery = bool(state)
  1596. self.__handleRandomButtonState()
  1597. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1598. def __useFallbackChanged(self, state):
  1599. """ From the checkbox
  1600. """
  1601. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  1602. self._model.useFallback = bool(state)
  1603. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  1604. def __queryChanged(self, q):
  1605. if self._model.status() == SearchStatus.Busy:
  1606. return
  1607. if q:
  1608. self.searchButton.setEnabled(True)
  1609. self.reloadButton.setEnabled(True)
  1610. self.randomButton.setEnabled(True)
  1611. else:
  1612. self.searchButton.setEnabled(False)
  1613. self.reloadButton.setEnabled(False)
  1614. self.randomButton.setEnabled(False)
  1615. def _setOptionsState(self, state=True):
  1616. if self._useFallback:
  1617. if state:
  1618. # Search stopped
  1619. self.searchButton.setText(_("Search"))
  1620. self.searchButton.clicked.disconnect(
  1621. self.__stopButtonClicked
  1622. )
  1623. self.searchButton.clicked.connect(self.__searchButtonClicked)
  1624. self.searchButton.setEnabled(True)
  1625. else:
  1626. # Searching
  1627. self.searchButton.setText(_("Stop"))
  1628. self.searchButton.clicked.disconnect(
  1629. self.__searchButtonClicked
  1630. )
  1631. self.searchButton.clicked.connect(self.__stopButtonClicked)
  1632. else:
  1633. self.searchButton.setEnabled(state)
  1634. self.reloadButton.setEnabled(state)
  1635. self.randomButton.setEnabled(state)
  1636. self._randomCheckEvery.setEnabled(state)
  1637. self._fallbackCheck.setEnabled(state)
  1638. self.queryEdit.setEnabled(state)
  1639. self._optionsContainer.setEnabled(state)
  1640. def __searchStatusChanged(self, status):
  1641. if status == SearchStatus.Busy:
  1642. self._setOptionsState(False)
  1643. elif status == SearchStatus.Done:
  1644. self._setOptionsState()
  1645. def __loadingStateChanged(self, status):
  1646. if status == ResultsBrowser.IDLE:
  1647. self.searchButton.setText(_("Search"))
  1648. self.searchButton.clicked.disconnect(self.__stopImagesButtonClicked)
  1649. self.searchButton.clicked.connect(self.__searchButtonClicked)
  1650. else:
  1651. self.searchButton.setText(_("Stop"))
  1652. self.searchButton.clicked.disconnect(self.__searchButtonClicked)
  1653. self.searchButton.clicked.connect(self.__stopImagesButtonClicked)
  1654. @property
  1655. def query(self): return self.queryEdit.text()
  1656. def _newSearch(self, url, query=''):
  1657. if self.resultsContainer.status == ResultsBrowser.LOADING:
  1658. self.__stopImagesButtonClicked()
  1659. self.resultsContainer.clear()
  1660. self._search(url, query)
  1661. def _search(self, url, query=''):
  1662. if self._searchThread:
  1663. return
  1664. if not query:
  1665. query = self.query
  1666. if not query:
  1667. self.resultsContainer.setHtml(_("Please enter a search query."))
  1668. return
  1669. if not url:
  1670. self.resultsContainer.setHtml(_("Please select a instance first."))
  1671. return
  1672. self.navBar.setEnabled(False)
  1673. self._model.url = url
  1674. self._model.query = query
  1675. self._searchThread = Thread(
  1676. self._model.search,
  1677. args=[self._model],
  1678. parent=self
  1679. )
  1680. self._searchThread.finished.connect(self._searchFinished)
  1681. self._searchThread.start()
  1682. def _searchFailed(self, result):
  1683. currentUrl = self._instanceSelecter.currentUrl # backup
  1684. # Don't go further on proxy errors.
  1685. # - Guard should not handle proxy errors.
  1686. # - Fallback should be disabled for proxy errors.
  1687. if result.errorType() == ErrorType.ProxyError:
  1688. self._breakFallback = False
  1689. self.resultsContainer.setHtml(
  1690. FailedResponsesHtml.create([result], Themes.htmlCssFail)
  1691. )
  1692. return
  1693. if self._guard.isEnabled():
  1694. # See if the Guard has any consequence for this instance.
  1695. consequence = self._guard.getConsequence(currentUrl)
  1696. if consequence:
  1697. # Apply the consequence.
  1698. if consequence.type == ConsequenceType.Blacklist:
  1699. # Blacklist the instance.
  1700. self._instancesModel.putInstanceOnBlacklist(
  1701. currentUrl,
  1702. reason=result.error()
  1703. )
  1704. else:
  1705. # Put the instance on a timeout.
  1706. self._instancesModel.putInstanceOnTimeout(
  1707. currentUrl,
  1708. duration=consequence.duration,
  1709. reason=result.error()
  1710. )
  1711. self._instancesModel.apply() # Apply the changed filter.
  1712. if self._useFallback: # Re-try another instance
  1713. if self._breakFallback: # Stop button pressed
  1714. self._breakFallback = False
  1715. self._fallbackActive = False
  1716. self._fallbackErrors.append(result)
  1717. self.resultsContainer.setHtml(
  1718. FailedResponsesHtml.create(
  1719. self._fallbackErrors,
  1720. f"{_('Max fail count reached!')} " \
  1721. f"({self._maxSearchFailCount})",
  1722. Themes.htmlCssFail)
  1723. )
  1724. self._fallbackErrors.clear()
  1725. return
  1726. if not self._fallbackActive:
  1727. # Get new list with instances to try same request.
  1728. self._fallbackActive = True
  1729. self._fallbackErrors.clear()
  1730. self._fallbackInstancesQueue.clear()
  1731. self._fallbackInstancesQueue = (
  1732. self._instanceSelecter.getRandomInstances(
  1733. amount=self._maxSearchFailCount))
  1734. if not self._fallbackInstancesQueue:
  1735. self.resultsContainer.setHtml(
  1736. FailedResponsesHtml.create(
  1737. self._fallbackErrors,
  1738. f"{_('Max fail count reached!')} " \
  1739. f"({self._maxSearchFailCount})",
  1740. Themes.htmlCssFail)
  1741. )
  1742. self._fallbackActive = False
  1743. self._fallbackErrors.clear()
  1744. return
  1745. # Append current error to error list
  1746. self._fallbackErrors.append(result)
  1747. # Set next instance url to try.
  1748. self._instanceSelecter.currentUrl = (
  1749. self._fallbackInstancesQueue.pop(0))
  1750. self._search(self._instanceSelecter.currentUrl)
  1751. return
  1752. if self._model.pageno > 1:
  1753. self.navBar.setEnabled(True)
  1754. self.navBar.setNextEnabled(False)
  1755. self.resultsContainer.setHtml(
  1756. FailedResponsesHtml.create([result], _("Search request failed."), Themes.htmlCssFail)
  1757. )
  1758. def _searchFinished(self):
  1759. result = self._searchThread.result()
  1760. self._clearSearchThread()
  1761. # Guard
  1762. if self._guard.isEnabled():
  1763. currentUrl = self._instanceSelecter.currentUrl
  1764. # Report the search result to Guard.
  1765. self._guard.reportSearchResult(currentUrl, result)
  1766. if not bool(result): # Failed
  1767. self._searchFailed(result)
  1768. return
  1769. self._fallbackActive = False
  1770. self.resultsContainer.setJsonResult(result.json())
  1771. self.navBar.setEnabled(True)
  1772. self.navBar.setNextEnabled(True)
  1773. def _clearSearchThread(self):
  1774. self._searchThread.finished.disconnect(self._searchFinished)
  1775. # Wait before deleting because the `finished` signal is emited
  1776. # from the thread itself, so this method could be called before the
  1777. # thread is actually finished and result in a crash.
  1778. self._searchThread.wait()
  1779. self._searchThread.deleteLater()
  1780. self._searchThread = None
  1781. def saveSettings(self):
  1782. return {
  1783. 'searchOptions': self._optionsContainer.saveSettings(),
  1784. 'splitterState': self.splitter.saveState()
  1785. }
  1786. def loadSettings(self, data):
  1787. self.queryEdit.setText("")
  1788. self._optionsContainer.loadSettings(
  1789. data.get('searchOptions', {})
  1790. )
  1791. self.splitter.restoreState(
  1792. data.get('splitterState', QByteArray())
  1793. )