instances.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239
  1. ########################################################################
  2. # Searx-Qt - Lightweight desktop application for Searx.
  3. # Copyright (C) 2020-2022 CYBERDEViL
  4. #
  5. # This file is part of Searx-Qt.
  6. #
  7. # Searx-Qt is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # Searx-Qt is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. #
  20. ########################################################################
  21. from PyQt5.QtWidgets import (
  22. QWidget,
  23. QVBoxLayout,
  24. QLabel,
  25. QTableView,
  26. QHeaderView,
  27. QAbstractItemView,
  28. QHBoxLayout,
  29. QFormLayout,
  30. QCheckBox,
  31. QMessageBox,
  32. QMenu,
  33. QComboBox,
  34. QSizePolicy,
  35. QScrollArea,
  36. QSplitter,
  37. QFrame,
  38. QWidgetAction
  39. )
  40. from PyQt5.QtCore import pyqtSignal, Qt, pyqtProperty
  41. from PyQt5.QtGui import QGuiApplication, QPainter, QFontMetrics, QColor
  42. import json
  43. from searxqt.core.handler import NetworkTypes # for network type filtering.
  44. from searxqt.core.requests import ErrorType
  45. from searxqt.widgets.buttons import Button
  46. from searxqt.widgets.dialogs import UrlDialog, RequestsErrorMessage
  47. from searxqt.models.instances import (
  48. InstanceTableModel,
  49. InstancesModelTypes
  50. )
  51. from searxqt.translations import _, timeToString
  52. class AddUserInstanceDialog(UrlDialog):
  53. def __init__(self, url='', parent=None):
  54. UrlDialog.__init__(self, url=url, acceptTxt=_("Add"), parent=parent)
  55. layout = self.layout()
  56. label = QLabel("Update data on add:")
  57. self._updateCheckBox = QCheckBox("", self)
  58. layout.insertRow(2, label, self._updateCheckBox)
  59. def updateOnAdd(self):
  60. return bool(self._updateCheckBox.isChecked())
  61. class InstancesView(QWidget):
  62. def __init__(self, filterModel, instanceSelecter, parent=None):
  63. """
  64. @type model: searxqt.models.instances.InstanceModelFilter
  65. @type instanceSelecter: searxqt.models.instances.InstanceSelecterModel
  66. @type parent: QObject
  67. """
  68. QWidget.__init__(self, parent=parent)
  69. layout = QVBoxLayout(self)
  70. layout.setContentsMargins(0, 0, 0, 0)
  71. layout.setSpacing(0)
  72. self._filterModel = filterModel
  73. self._instanceSelecter = instanceSelecter
  74. self._tableModel = InstanceTableModel(filterModel, self)
  75. # Diffrent types can have diffrent attibutes.
  76. self._currentInstancesType = filterModel.parentModel().Type
  77. headLabel = QLabel(f"<h2>{_('Instances')}</h2>", self)
  78. layout.addWidget(headLabel)
  79. # Splitter
  80. self._splitter = QSplitter(self)
  81. self._splitter.setOrientation(Qt.Vertical)
  82. layout.addWidget(self._splitter)
  83. # Filter scroll area
  84. self._scrollArea = QScrollArea(self._splitter)
  85. self._scrollContentWidget = QWidget(self._scrollArea)
  86. scrollLayout = QVBoxLayout(self._scrollContentWidget)
  87. self.filterWidget = FilterWidget(
  88. filterModel,
  89. self._scrollContentWidget
  90. )
  91. self._scrollArea.setWidget(self._scrollContentWidget)
  92. self._scrollArea.setFrameShape(QFrame.NoFrame)
  93. self._scrollArea.setWidgetResizable(True)
  94. self._scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
  95. self._scrollArea.setAlignment(
  96. Qt.AlignLeading | Qt.AlignLeft | Qt.AlignTop
  97. )
  98. scrollLayout.addWidget(self.filterWidget, 0, Qt.AlignTop)
  99. # Bottom widgets
  100. bottomWidget = QWidget(self._splitter)
  101. bottomLayout = QVBoxLayout(bottomWidget)
  102. self.statsWidget = InstancesStatsWidget(filterModel, bottomWidget)
  103. bottomLayout.addWidget(self.statsWidget)
  104. self._typeDependantButton = Button("", bottomWidget)
  105. bottomLayout.addWidget(self._typeDependantButton)
  106. self._tableView = InstanceTableView(
  107. filterModel,
  108. self._tableModel,
  109. self
  110. )
  111. bottomLayout.addWidget(self._tableView)
  112. # Connections
  113. self._tableView.selectionModel().selectionChanged.connect(
  114. self.__selectionChanged)
  115. self._tableModel.layoutChanged.connect(self.__tableModelLayoutChanged)
  116. self._instanceSelecter.instanceChanged.connect(self.__instanceChanged)
  117. filterModel.parentModel().typeChanged.connect(
  118. self.__instancesModelTypeChanged
  119. )
  120. """ InstancesType dependant methods below
  121. """
  122. def __instancesModelTypeChanged(self, type_):
  123. """ PersistentInstancesModel emitted changed.
  124. """
  125. previousType = self._currentInstancesType
  126. # Disconnect signals
  127. if previousType == InstancesModelTypes.Stats2:
  128. self._typeDependantButton.clicked.disconnect(self.__updateClicked)
  129. elif previousType == InstancesModelTypes.User:
  130. self._typeDependantButton.clicked.disconnect(self.__addClicked)
  131. if type_ == InstancesModelTypes.Stats2:
  132. self._typeDependantButton.setText(_("Update"))
  133. self._typeDependantButton.clicked.connect(self.__updateClicked)
  134. elif type_ == InstancesModelTypes.User:
  135. self._typeDependantButton.setText(_("Add Instance"))
  136. self._typeDependantButton.clicked.connect(self.__addClicked)
  137. self._currentInstancesType = type_
  138. # InstancesModelTypes.Stats2
  139. def __updateClicked(self, checked):
  140. self._typeDependantButton.setEnabled(False)
  141. handler = self._filterModel.parentModel().handler()
  142. if handler.updateInstances(): # True when thread has started.
  143. handler.updateFinished.connect(self.__updateFinished)
  144. def __updateFinished(self, errorType, errorMsg):
  145. handler = self._filterModel.parentModel().handler()
  146. handler.updateFinished.disconnect(self.__updateFinished)
  147. if errorType != ErrorType.Success:
  148. dialog = RequestsErrorMessage(errorType, errorMsg)
  149. dialog.exec()
  150. self._typeDependantButton.setEnabled(True)
  151. # InstancesModelTypes.User
  152. def __addClicked(self, checked):
  153. dialog = AddUserInstanceDialog()
  154. if dialog.exec():
  155. handler = self._filterModel.parentModel().handler()
  156. handler.addInstance(dialog.url)
  157. if dialog.updateOnAdd():
  158. handler.updateInstance(dialog.url)
  159. """ -----------------------------------------------------------
  160. """
  161. def selectUrl(self, url):
  162. self._tableView.selectionModel().selectionChanged.disconnect(
  163. self.__selectionChanged)
  164. # Backup horizontal scroll value
  165. horScrollVal = self._tableView.horizontalScrollBar().value()
  166. if url in self._filterModel:
  167. rowIndex = self._tableModel.getByUrl(url)
  168. modelIndex = self._tableModel.index(rowIndex, 0)
  169. self._tableView.setCurrentIndex(modelIndex)
  170. self._tableView.scrollTo(
  171. modelIndex,
  172. QAbstractItemView.PositionAtCenter
  173. )
  174. else:
  175. # No URL set so clear the selection.
  176. self._tableView.clearSelection()
  177. # Restore horizontal scroll value
  178. self._tableView.horizontalScrollBar().setValue(horScrollVal)
  179. self._tableView.selectionModel().selectionChanged.connect(
  180. self.__selectionChanged)
  181. def __instanceChanged(self, url):
  182. """ Select and scroll to table item with passed url.
  183. @param url: Instance url to select and scroll to.
  184. @type url: str
  185. """
  186. if url in self._filterModel:
  187. rowIndex = self._tableModel.getByUrl(url)
  188. modelIndex = self._tableModel.index(rowIndex, 0)
  189. self._tableView.setCurrentIndex(modelIndex)
  190. self._tableView.scrollTo(
  191. modelIndex,
  192. QAbstractItemView.PositionAtCenter
  193. )
  194. def __tableModelLayoutChanged(self):
  195. """ Re-select the current URL when the layout changed.
  196. """
  197. self.selectUrl(self._instanceSelecter.currentUrl)
  198. def __selectionChanged(self):
  199. """ Let the InstanceSelecter know that the current instance has
  200. changed. (Selected by the user.)
  201. """
  202. self._instanceSelecter.instanceChanged.disconnect(
  203. self.__instanceChanged)
  204. selectionModel = self._tableView.selectionModel()
  205. if selectionModel.hasSelection():
  206. rows = selectionModel.selectedRows()
  207. selectedIndex = rows[0].row()
  208. self._instanceSelecter.currentUrl = (
  209. self._tableModel.getByIndex(selectedIndex)
  210. )
  211. else:
  212. self._instanceSelecter.currentUrl = ""
  213. self._instanceSelecter.instanceChanged.connect(self.__instanceChanged)
  214. def saveSettings(self):
  215. header = self._tableView.horizontalHeader()
  216. data = {
  217. 'hiddenColumnIndexes': self._tableView.getHiddenColumnIndexes(),
  218. 'headerState': header.saveState(),
  219. 'splitterState': self._splitter.saveState()
  220. }
  221. return data
  222. def loadSettings(self, data):
  223. self._tableView.setHiddenColumnIndexes(
  224. data.get('hiddenColumnIndexes', [])
  225. )
  226. if 'headerState' in data:
  227. header = self._tableView.horizontalHeader()
  228. header.restoreState(data['headerState'])
  229. if 'splitterState' in data:
  230. self._splitter.restoreState(data['splitterState'])
  231. class InstanceTableView(QTableView):
  232. def __init__(self, filterModel, tableModel=None, parent=None):
  233. """
  234. @type filterModel: searxqt.models.instances.InstanceModelFilter
  235. @type tableModel: searxqt.models.instances.InstanceTableModel
  236. """
  237. QTableView.__init__(self, parent=parent)
  238. self._filterModel = filterModel
  239. if tableModel:
  240. self.setModel(tableModel)
  241. self.setAlternatingRowColors(True)
  242. self.setSelectionBehavior(QAbstractItemView.SelectRows)
  243. self.setEditTriggers(QAbstractItemView.NoEditTriggers)
  244. self.setSortingEnabled(True)
  245. self.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
  246. # Horizontal header
  247. header = self.horizontalHeader()
  248. header.setSectionResizeMode(QHeaderView.ResizeToContents)
  249. header.setSectionsMovable(True)
  250. def _hideColumn(self, index, state):
  251. state = state == 0
  252. self.setColumnHidden(index, state)
  253. def getHiddenColumnIndexes(self):
  254. columnIndex = 0
  255. indexes = []
  256. for columnData in self.model().getColumns():
  257. # Set current value
  258. if self.isColumnHidden(columnIndex):
  259. indexes.append(columnIndex)
  260. columnIndex += 1
  261. return indexes
  262. def setHiddenColumnIndexes(self, indexes):
  263. for index in indexes:
  264. self._hideColumn(index, 0)
  265. """ Re-implementations
  266. """
  267. def contextMenuEvent(self, event):
  268. def __removeFromWhitelist(self, url):
  269. # Remove instance url from whitelist without applying the filter.
  270. self._filterModel.delInstanceFromWhitelist(url)
  271. selectionModel = self.selectionModel()
  272. if selectionModel.hasSelection():
  273. menu = QMenu(self)
  274. # Add to blacklist
  275. addToBlacklistAction = menu.addAction(_("Add to blacklist"))
  276. # Add to temporary blacklist
  277. addToTmpBlacklistAction = menu.addAction(_("Temporary blacklist"))
  278. # Add to whitelist
  279. addToWhitelistAction = menu.addAction(_("Add to whitelist"))
  280. menu.addSeparator()
  281. # Copy value(s) for any column
  282. copyMenu = QMenu(_("Copy column text"), self)
  283. copyMenuActions = []
  284. for columnData in self.model().getColumns():
  285. action = copyMenu.addAction(columnData.name)
  286. copyMenuActions.append(action)
  287. menu.addMenu(copyMenu)
  288. # Copy json values of selected instance(s)
  289. copyJson = menu.addAction(_("Copy JSON data"))
  290. menu.addSeparator()
  291. # Select all
  292. selectAllAction = menu.addAction(_("Select All"))
  293. menu.addSeparator()
  294. # Column options
  295. columnsMenu = QMenu(_("Columns"), self)
  296. menu.addMenu(columnsMenu)
  297. # User instances specific
  298. userInstances = False
  299. handler = self._filterModel.parentModel().handler()
  300. if (
  301. self._filterModel.parentModel().Type
  302. == InstancesModelTypes.User
  303. ):
  304. userInstances = True
  305. menu.addSeparator()
  306. delUserInstanceAction = menu.addAction(_("Remove selected"))
  307. updateUserInstanceAction = menu.addAction(_("Update selected"))
  308. if handler.hasActiveJobs():
  309. delUserInstanceAction.setEnabled(False)
  310. columnIndex = 0
  311. for columnData in self.model().getColumns():
  312. action = QWidgetAction(columnsMenu)
  313. widget = QCheckBox(columnData.name, columnsMenu)
  314. widget.setTristate(False)
  315. action.setDefaultWidget(widget)
  316. # Set current value
  317. if not self.isColumnHidden(columnIndex):
  318. widget.setChecked(True)
  319. columnsMenu.addAction(action)
  320. widget.stateChanged.connect(
  321. lambda state, index=columnIndex:
  322. self._hideColumn(index, state)
  323. )
  324. columnIndex += 1
  325. action = menu.exec_(self.mapToGlobal(event.pos()))
  326. if action == addToBlacklistAction:
  327. # Blacklist
  328. indexes = self.selectionModel().selectedRows()
  329. forAll = None
  330. for index in indexes:
  331. url = self.model().getByIndex(index.row())
  332. if url in self._filterModel.whitelist:
  333. # Url is whitelisted, what to do?
  334. if forAll == QMessageBox.YesRole:
  335. __removeFromWhitelist(self, url)
  336. elif forAll == QMessageBox.NoRole:
  337. continue
  338. else:
  339. questionBox = QMessageBox(self)
  340. questionBox.addButton(
  341. _("Yes"), QMessageBox.YesRole
  342. )
  343. questionBox.addButton(
  344. _("No"), QMessageBox.NoRole
  345. )
  346. questionBox.addButton(
  347. _("Cancel"), QMessageBox.RejectRole
  348. )
  349. questionBox.setWindowTitle("Url whitelisted")
  350. questionBox.setText(_(
  351. f"{url} found in the <b>whitelist</b>. Would " \
  352. "you like to <b>remove</b> it from the" \
  353. " <b>whitelist</b> and add it to the" \
  354. " <b>blacklist</b>?")
  355. )
  356. checkBox = QCheckBox(
  357. _("Remember for all"), questionBox
  358. )
  359. questionBox.setCheckBox(checkBox)
  360. questionBox.exec()
  361. answer = questionBox.result()
  362. if answer == 0: # Yes
  363. # Remove url from whitelist and add it to
  364. # the blacklist
  365. __removeFromWhitelist(self, url)
  366. if checkBox.isChecked():
  367. forAll = QMessageBox.YesRole
  368. elif answer == 1 and checkBox.isChecked():
  369. forAll = QMessageBox.NoRole
  370. continue
  371. else:
  372. continue
  373. self._filterModel.putInstanceOnBlacklist(
  374. url, reason=_("Manual")
  375. )
  376. self._filterModel.apply()
  377. elif action == addToTmpBlacklistAction:
  378. index = selectionModel.selectedRows()[0]
  379. url = self.model().getByIndex(index.row())
  380. self._filterModel.putInstanceOnTimeout(url, reason=_("Manual"))
  381. self._filterModel.apply()
  382. elif action == addToWhitelistAction:
  383. # Whitelist
  384. indexes = self.selectionModel().selectedRows()
  385. for index in indexes:
  386. url = self.model().getByIndex(index.row())
  387. if url not in self._filterModel.whitelist:
  388. self._filterModel.putInstanceOnWhitelist(url)
  389. self._filterModel.apply()
  390. elif action == selectAllAction:
  391. self.selectAll()
  392. # Copy a column data
  393. elif action in copyMenuActions:
  394. columnIndex = copyMenuActions.index(action)
  395. indexes = self.selectionModel().selectedRows() # row indexes
  396. clipboard = QGuiApplication.clipboard()
  397. if len(indexes) > 1:
  398. # Copy values from multiple rows
  399. result = []
  400. for index in indexes:
  401. rowIndex = index.row()
  402. newIndex = self.model().index(rowIndex, columnIndex)
  403. data = self.model().data(newIndex, Qt.DisplayRole)
  404. result.append(data)
  405. clipboard.setText(str(result))
  406. else:
  407. # Copy value from one row
  408. rowIndex = indexes[0].row() # row index
  409. newIndex = self.model().index(rowIndex, columnIndex)
  410. data = self.model().data(newIndex, Qt.DisplayRole)
  411. clipboard.setText(str(data))
  412. # Copy JSON
  413. elif action == copyJson:
  414. indexes = self.selectionModel().selectedRows() # row indexes
  415. instancesModel = self.model()._model
  416. result = {}
  417. for index in indexes:
  418. url = self.model().data(index, Qt.DisplayRole)
  419. instance = instancesModel[url]
  420. result.update({instance.url: instance.data})
  421. clipboard = QGuiApplication.clipboard()
  422. clipboard.setText(json.dumps(result))
  423. # User instances actions
  424. if userInstances:
  425. if action == delUserInstanceAction:
  426. indexes = self.selectionModel().selectedRows()
  427. # Confirmation
  428. instancesStr = ""
  429. for index in indexes:
  430. url = self.model().getByIndex(index.row())
  431. instancesStr = f"{instancesStr}\n{url}"
  432. confirmDialog = QMessageBox()
  433. confirmDialog.setWindowTitle(
  434. _("Delete instances")
  435. )
  436. confirmDialog.setText(
  437. _("Are you sure you want to delete the following"
  438. " instances?\n") + instancesStr
  439. )
  440. confirmDialog.setStandardButtons(
  441. QMessageBox.Yes | QMessageBox.No
  442. )
  443. confirmDialog.button(QMessageBox.Yes).setText(_("Yes"))
  444. confirmDialog.button(QMessageBox.No).setText(_("No"))
  445. if confirmDialog.exec() != QMessageBox.Yes:
  446. return
  447. toRemove = []
  448. for index in indexes:
  449. toRemove.append(self.model().getByIndex(index.row()))
  450. handler.removeMultiInstances(toRemove)
  451. elif action == updateUserInstanceAction:
  452. indexes = self.selectionModel().selectedRows()
  453. handler = self._filterModel.parentModel().handler()
  454. for index in indexes:
  455. handler.updateInstance(
  456. self.model().getByIndex(index.row())
  457. )
  458. class UrlFilterLabel(QLabel):
  459. """ Fancy QLabel that eludes text and has a hover color property.
  460. """
  461. def __init__(self, text, parent):
  462. QLabel.__init__(self, text, parent=parent)
  463. self.__hover = False
  464. self.__hoverColor = QColor()
  465. self.setMinimumWidth(100)
  466. @pyqtProperty(QColor)
  467. def hoverColor(self):
  468. return self.__hoverColor
  469. @hoverColor.setter
  470. def hoverColor(self, color):
  471. self.__hoverColor = color
  472. def enterEvent(self, event):
  473. self.__hover = True
  474. self.update()
  475. def leaveEvent(self, event):
  476. self.__hover = False
  477. self.update()
  478. def paintEvent(self, event):
  479. painter = QPainter(self)
  480. if self.__hover and self.hoverColor.isValid():
  481. pen = painter.pen()
  482. pen.setColor(self.hoverColor)
  483. painter.setPen(pen)
  484. metrics = QFontMetrics(self.font())
  485. elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
  486. painter.drawText(self.rect(), self.alignment(), elided)
  487. class UrlFilterItem(QWidget):
  488. deleted = pyqtSignal(str) # url
  489. def __init__(self, url, parent=None):
  490. QWidget.__init__(self, parent=parent)
  491. layout = QHBoxLayout(self)
  492. layout.setContentsMargins(0, 0, 0, 0)
  493. self._urlStr = url
  494. label = UrlFilterLabel(url, self)
  495. delButton = Button("X", self)
  496. delButton.setSizePolicy(
  497. QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
  498. )
  499. layout.addWidget(label, 0, Qt.AlignTop)
  500. layout.addWidget(delButton, 0, Qt.AlignTop)
  501. delButton.clicked.connect(self.__delMe)
  502. def str(self): return self._urlStr
  503. def __delMe(self):
  504. self.deleted.emit(self.str())
  505. self.deleteLater()
  506. class UrlFilterWidget(QWidget):
  507. def __init__(self, model, parent=None):
  508. QWidget.__init__(self, parent=parent)
  509. self._model = model
  510. self._items = {}
  511. QVBoxLayout(self)
  512. self.layout().setContentsMargins(0, 0, 0, 0)
  513. self.layout().setSpacing(0)
  514. self._model.changed.connect(self.__modelChanged)
  515. # -- Override below in subclass.
  516. def filterItems(self):
  517. return []
  518. def delete(self, url):
  519. self._delete(url)
  520. def itemAdded(self, url):
  521. """ Callback from when a item has been added, usefull for subclasses
  522. to do something; for example set a tooltip.
  523. """
  524. pass
  525. # ---
  526. def __modelChanged(self):
  527. # Remove old
  528. for url in list(self._items.keys()):
  529. if url not in self.filterItems():
  530. self._items[url].deleteLater()
  531. del self._items[url]
  532. # Add new
  533. for url in self.filterItems():
  534. if url not in self._items:
  535. self._add(url)
  536. def _add(self, url):
  537. item = UrlFilterItem(url, self)
  538. self._items.update({url: item})
  539. item.deleted.connect(self.delete)
  540. self.layout().addWidget(item)
  541. self.itemAdded(url)
  542. def _delete(self, url):
  543. del self._items[url]
  544. class WhitelistFilterWidget(UrlFilterWidget):
  545. def filterItems(self):
  546. return self._model.whitelist
  547. def delete(self, url):
  548. UrlFilterWidget.delete(self, url)
  549. self._model.delInstanceFromWhitelist(url)
  550. self._model.apply()
  551. class BlacklistFilterWidget(UrlFilterWidget):
  552. def filterItems(self):
  553. return self._model.blacklist
  554. def itemAdded(self, url):
  555. filterItems = self.filterItems()
  556. dateStr = timeToString(filterItems[url][0] * 60)
  557. reasonStr = filterItems[url][1]
  558. tooltipStr = f"<b>{_('Date')}</b>: {dateStr}\n" \
  559. f"<b>{_('Reason')}</b>: {reasonStr}"
  560. self._items[url].setToolTip(tooltipStr)
  561. def delete(self, url):
  562. UrlFilterWidget.delete(self, url)
  563. self._model.delInstanceFromBlacklist(url)
  564. self._model.apply()
  565. class TimeoutFilterWidget(UrlFilterWidget):
  566. def filterItems(self):
  567. return self._model.timeoutList
  568. def itemAdded(self, url):
  569. # Add reason and duration tooltip
  570. durationStr = _("Until restart or manual removal.")
  571. filterItems = self.filterItems()
  572. timer = filterItems[url][0]
  573. if timer:
  574. durationStr = str(timer.interval() / 60000) + "min"
  575. reason = filterItems[url][1]
  576. tooltipStr = f"<b>{_('Reason')}</b>: \n{reason}\n<b>{_('Duration')}" \
  577. f"</b>: {durationStr}"
  578. self._items[url].setToolTip(tooltipStr)
  579. def delete(self, url):
  580. UrlFilterWidget.delete(self, url)
  581. self._model.delInstanceFromTimeout(url)
  582. self._model.apply()
  583. class NetworkFilterWidget(QWidget):
  584. def __init__(self, model, parent=None):
  585. """
  586. @type model: InstancesModel
  587. """
  588. QWidget.__init__(self, parent=parent)
  589. self._model = model
  590. layout = QHBoxLayout(self)
  591. layout.setContentsMargins(0, 0, 0, 0)
  592. layout.setSpacing(0)
  593. self._webBox = QCheckBox("Web", self)
  594. self._torBox = QCheckBox("Tor", self)
  595. self._i2pBox = QCheckBox("i2p", self)
  596. self._webBox.setSizePolicy(
  597. QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
  598. )
  599. self._torBox.setSizePolicy(
  600. QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
  601. )
  602. self._i2pBox.setSizePolicy(
  603. QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
  604. )
  605. layout.addWidget(self._webBox, 0, Qt.AlignLeft)
  606. layout.addWidget(self._torBox, 0, Qt.AlignLeft)
  607. layout.addWidget(self._i2pBox, 1, Qt.AlignLeft)
  608. self._model.changed.connect(self._modelChanged)
  609. self._webBox.stateChanged.connect(self.__changed)
  610. self._torBox.stateChanged.connect(self.__changed)
  611. self._i2pBox.stateChanged.connect(self.__changed)
  612. def __changed(self, state):
  613. self._model.changed.disconnect(self._modelChanged)
  614. self._model.updateKwargs(self.data())
  615. self._model.changed.connect(self._modelChanged)
  616. def _modelChanged(self):
  617. self._webBox.stateChanged.disconnect(self.__changed)
  618. self._torBox.stateChanged.disconnect(self.__changed)
  619. self._i2pBox.stateChanged.disconnect(self.__changed)
  620. self._webBox.setChecked(False)
  621. self._torBox.setChecked(False)
  622. self._i2pBox.setChecked(False)
  623. filter = self._model.filter()
  624. if NetworkTypes.Web in filter['networkTypes']:
  625. self._webBox.setChecked(True)
  626. if NetworkTypes.Tor in filter['networkTypes']:
  627. self._torBox.setChecked(True)
  628. if NetworkTypes.I2P in filter['networkTypes']:
  629. self._i2pBox.setChecked(True)
  630. self._webBox.stateChanged.connect(self.__changed)
  631. self._torBox.stateChanged.connect(self.__changed)
  632. self._i2pBox.stateChanged.connect(self.__changed)
  633. def data(self):
  634. result = {
  635. 'networkTypes': [] # Network Types to pass
  636. }
  637. if self._webBox.isChecked():
  638. result['networkTypes'].append(NetworkTypes.Web)
  639. if self._torBox.isChecked():
  640. result['networkTypes'].append(NetworkTypes.Tor)
  641. if self._i2pBox.isChecked():
  642. result['networkTypes'].append(NetworkTypes.I2P)
  643. return result
  644. class VersionFilter(QWidget):
  645. def __init__(self, model, parent=None):
  646. """
  647. @type model: InstanceModelFilter
  648. """
  649. QWidget.__init__(self, parent=parent)
  650. self._model = model
  651. self._items = {}
  652. layout = QVBoxLayout(self)
  653. layout.setContentsMargins(0, 0, 0, 0)
  654. layout.setSpacing(0)
  655. minVerLayout = QHBoxLayout()
  656. self.minVersionCombo = QComboBox(self)
  657. self.minVersionCombo.setSizePolicy(
  658. QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed))
  659. minLabel = QLabel(_("Minimum:"), self)
  660. minLabel.setSizePolicy(
  661. QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed))
  662. minVerLayout.addWidget(minLabel, 0, Qt.AlignLeft)
  663. minVerLayout.addWidget(self.minVersionCombo)
  664. self.invalidCheck = QCheckBox(_("Invalid"), self)
  665. self.invalidCheck.setToolTip(
  666. _("Include instances with a invalid version")
  667. )
  668. self.gitVersionCheck = QCheckBox(_("Development"), self)
  669. self.gitVersionCheck.setToolTip(
  670. _("Include development versions of SearX/SearXNG (git versions).")
  671. )
  672. self.dirtyVersionFlagCheck = QCheckBox(_("Dirty"), self)
  673. self.dirtyVersionFlagCheck.setToolTip(
  674. _("Include SearXNG git versions with uncommited changes.")
  675. )
  676. # Disable by default since there isn't a version selected by default.
  677. self.dirtyVersionFlagCheck.setEnabled(False)
  678. self.extraVersionFlagCheck = QCheckBox(_("Extra"), self)
  679. self.extraVersionFlagCheck.setToolTip(
  680. _("Include versions flagged as extra (IDK what that is? TODO).")
  681. )
  682. # Disable by default since there isn't a version selected by default.
  683. self.extraVersionFlagCheck.setEnabled(False)
  684. self.unknownVersionFlagCheck = QCheckBox(_("Unknown"), self)
  685. self.unknownVersionFlagCheck.setToolTip(
  686. _("Include Searx versions with unknown git commit.")
  687. )
  688. # Disable by default since there isn't a version selected by default.
  689. self.unknownVersionFlagCheck.setEnabled(False)
  690. # Add widgets to layout
  691. layout.addLayout(minVerLayout, 1)
  692. layout.addWidget(self.invalidCheck)
  693. layout.addWidget(self.gitVersionCheck)
  694. layout.addWidget(self.dirtyVersionFlagCheck)
  695. layout.addWidget(self.extraVersionFlagCheck)
  696. layout.addWidget(self.unknownVersionFlagCheck)
  697. # Signal connections
  698. self._model.changed.connect(self.__modelChanged)
  699. self.minVersionCombo.currentTextChanged.connect(
  700. self._minVersionComboChanged
  701. )
  702. self.invalidCheck.stateChanged.connect(self._invalidChanged)
  703. self.gitVersionCheck.stateChanged.connect(self._gitChanged)
  704. self.dirtyVersionFlagCheck.stateChanged.connect(self._dirtyChanged)
  705. self.extraVersionFlagCheck.stateChanged.connect(self._extraChanged)
  706. self.unknownVersionFlagCheck.stateChanged.connect(self._unknownChanged)
  707. def _minVersionComboChanged(self, versionStr):
  708. """ From the ComboBox
  709. """
  710. self._model.changed.disconnect(self.__modelChanged)
  711. self._model.updateKwargs(self.data())
  712. self._model.changed.connect(self.__modelChanged)
  713. def _invalidChanged(self, state):
  714. self._model.changed.disconnect(self.__modelChanged)
  715. self._model.updateKwargs(self.data())
  716. self._model.changed.connect(self.__modelChanged)
  717. def _gitChanged(self, state):
  718. self._model.changed.disconnect(self.__modelChanged)
  719. self._model.updateKwargs(self.data())
  720. self._model.changed.connect(self.__modelChanged)
  721. enabled = bool(state)
  722. self.dirtyVersionFlagCheck.setEnabled(enabled)
  723. self.extraVersionFlagCheck.setEnabled(enabled)
  724. self.unknownVersionFlagCheck.setEnabled(enabled)
  725. def _dirtyChanged(self, state):
  726. self._model.changed.disconnect(self.__modelChanged)
  727. self._model.updateKwargs(self.data())
  728. self._model.changed.connect(self.__modelChanged)
  729. def _extraChanged(self, state):
  730. self._model.changed.disconnect(self.__modelChanged)
  731. self._model.updateKwargs(self.data())
  732. self._model.changed.connect(self.__modelChanged)
  733. def _unknownChanged(self, state):
  734. self._model.changed.disconnect(self.__modelChanged)
  735. self._model.updateKwargs(self.data())
  736. self._model.changed.connect(self.__modelChanged)
  737. def _compileVersions(self):
  738. """ Fill the combobox with available version strings
  739. """
  740. # Backup selected text so we can find the new index if it still exists.
  741. versionFilter = self._model.filter().get('version', {})
  742. selectedText = versionFilter.get('min', "")
  743. # Clear the combo
  744. self.minVersionCombo.clear()
  745. # Add the default None item
  746. self.minVersionCombo.addItem(_("Off"))
  747. # Unique
  748. uniqueVersions = []
  749. for url, instance in self._model.parentModel().items():
  750. version = instance.version
  751. partsString = version.partsString()
  752. if version.isValid() and partsString not in uniqueVersions:
  753. uniqueVersions.append(partsString)
  754. # Sort
  755. uniqueVersions.sort()
  756. # Add versions to combobox
  757. selectedIndex = 0
  758. for versionStr in uniqueVersions:
  759. if versionStr == selectedText:
  760. # The selected item is still there, store it's index so we can
  761. # set it back later.
  762. selectedIndex = self.minVersionCombo.count()
  763. self.minVersionCombo.addItem(versionStr)
  764. # Restore the selected index
  765. self.minVersionCombo.setCurrentIndex(selectedIndex)
  766. def __modelChanged(self):
  767. # Instances model has changed
  768. # Update versions combo
  769. self.minVersionCombo.currentTextChanged.disconnect(
  770. self._minVersionComboChanged
  771. )
  772. self._compileVersions() # re-generate the combobox items
  773. self.minVersionCombo.currentTextChanged.connect(
  774. self._minVersionComboChanged
  775. )
  776. versionFilter = self._model.filter().get('version', {})
  777. self.gitVersionCheck.setChecked(versionFilter.get('git', True))
  778. self.dirtyVersionFlagCheck.setChecked(
  779. versionFilter.get('dirty', False)
  780. )
  781. self.extraVersionFlagCheck.setChecked(
  782. versionFilter.get('extra', False)
  783. )
  784. self.unknownVersionFlagCheck.setChecked(
  785. versionFilter.get('unknown', False)
  786. )
  787. self._compileVersions()
  788. def data(self):
  789. result = {
  790. 'version': {
  791. 'min': self.minVersionCombo.currentText(),
  792. 'git': bool(self.gitVersionCheck.checkState()),
  793. 'dirty': bool(self.dirtyVersionFlagCheck.checkState()),
  794. 'extra': bool(self.extraVersionFlagCheck.checkState()),
  795. 'unknown': bool(self.unknownVersionFlagCheck.checkState()),
  796. 'invalid': bool(self.invalidCheck.checkState())
  797. }
  798. }
  799. return result
  800. class CheckboxFilter(QCheckBox):
  801. def __init__(self, model, key, parent=None):
  802. """
  803. @type model: InstancesModel
  804. @param key: key to use (from model.filter())
  805. @type key: str
  806. """
  807. QCheckBox.__init__(self, parent=parent)
  808. self._model = model
  809. self._key = key
  810. self._model.changed.connect(self.__modelChanged)
  811. self.stateChanged.connect(self.__stateChanged)
  812. def __modelChanged(self):
  813. """ Model changed, update the checkbox.
  814. """
  815. filter_ = self._model.filter()
  816. state = filter_.get(self._key, True)
  817. self.stateChanged.disconnect(self.__stateChanged)
  818. self.setChecked(state)
  819. self.stateChanged.connect(self.__stateChanged)
  820. def __stateChanged(self):
  821. """ Checkbox state changed, update the model.
  822. """
  823. state = self.isChecked()
  824. self._model.updateKwargs({self._key: state})
  825. class FilterWidget(QWidget):
  826. def __init__(self, model, parent=None):
  827. """
  828. @type model: searxqt.models.instances.InstanceModelFilter
  829. """
  830. QWidget.__init__(self, parent=parent)
  831. self._model = model
  832. layout = QFormLayout(self)
  833. layout.setLabelAlignment(
  834. Qt.AlignLeading | Qt.AlignLeft | Qt.AlignTop
  835. )
  836. layout.addRow(QLabel(_("<h3>Filter</h3>"), self))
  837. self._networkFilter = NetworkFilterWidget(model, self)
  838. layout.addRow(_("Network") + ":", self._networkFilter)
  839. self._asnPrivacyFilter = None
  840. self._ipv6Filter = None
  841. self._analyticsFilter = None
  842. self._versionFilter = VersionFilter(model, self)
  843. layout.addRow(_("Version") + ":", self._versionFilter)
  844. self._urlFilterOut = BlacklistFilterWidget(model, self)
  845. layout.addRow(_("Blacklist") + ":", self._urlFilterOut)
  846. self._urlFilter = WhitelistFilterWidget(model, self)
  847. layout.addRow(_("Whitelist") + ":", self._urlFilter)
  848. self._tmpBlacklist = TimeoutFilterWidget(model, self)
  849. layout.addRow(_("Timeout") + ":", self._tmpBlacklist)
  850. model.parentModel().typeChanged.connect(self.__modelTypeChanged)
  851. def __modelTypeChanged(self, type_):
  852. layout = self.layout()
  853. if type_ == InstancesModelTypes.Stats2:
  854. if not self._asnPrivacyFilter:
  855. self._asnPrivacyFilter = CheckboxFilter(
  856. self._model,
  857. 'asnPrivacy',
  858. self
  859. )
  860. label = QLabel(_("Require ASN privacy") + ":")
  861. label.setWordWrap(True)
  862. label.setToolTip(_(
  863. "Filter out instances that run their server at a known\n"
  864. "malicious network like Google, CloudFlare, Akamai etc..\n"
  865. "\n"
  866. "This does not give any guarantee, it only filters known\n"
  867. "privacy violators!"
  868. ))
  869. layout.insertRow(2, label, self._asnPrivacyFilter)
  870. if not self._ipv6Filter:
  871. self._ipv6Filter = CheckboxFilter(self._model, 'ipv6', self)
  872. label = QLabel(_("Require IPv6") + ":")
  873. label.setWordWrap(True)
  874. label.setToolTip(_(
  875. "Only instances with at least one ipv6-address remain"
  876. " if checked."
  877. ))
  878. layout.insertRow(3, label, self._ipv6Filter)
  879. if not self._analyticsFilter:
  880. self._analyticsFilter = CheckboxFilter(
  881. self._model,
  882. 'analytics',
  883. self
  884. )
  885. label = QLabel(_("Excl. analytics") + ":")
  886. label.setWordWrap(True)
  887. label.setToolTip(_(
  888. "Exclude instances running tracking software."
  889. ))
  890. layout.insertRow(4, label, self._analyticsFilter)
  891. else:
  892. if self._asnPrivacyFilter:
  893. self._asnPrivacyFilter.deleteLater()
  894. layout.removeRow(2)
  895. self._asnPrivacyFilter = None
  896. if self._ipv6Filter:
  897. self._ipv6Filter.deleteLater()
  898. layout.removeRow(2)
  899. self._ipv6Filter = None
  900. if self._analyticsFilter:
  901. self._analyticsFilter.deleteLater()
  902. layout.removeRow(2)
  903. self._analyticsFilter = None
  904. class InstancesStatsWidget(QWidget):
  905. def __init__(self, filterModel, parent=None):
  906. """
  907. @param filterModel:
  908. @type filterModel: searxqt.models.instances.InstanceModelFilter
  909. """
  910. QWidget.__init__(self, parent=parent)
  911. self._filterModel = filterModel
  912. self._currentInstancesType = filterModel.parentModel().Type
  913. self._countLabel = None
  914. self._filterCountLabel = None
  915. self._lastUpdateLabel = None
  916. layout = QFormLayout(self)
  917. layout.setLabelAlignment(
  918. Qt.AlignLeading | Qt.AlignLeft | Qt.AlignTop
  919. )
  920. self._toggleButton = Button("", self)
  921. layout.addRow(_("<h3>Stats</h3>"), self._toggleButton)
  922. self.__show()
  923. self._toggleButton.clicked.connect(self.__toggle)
  924. self._filterModel.parentModel().typeChanged.connect(
  925. self.__instancesModelTypeChanged
  926. )
  927. def collapsed(self):
  928. return bool(self._countLabel is None)
  929. def __toggle(self):
  930. if self.collapsed():
  931. self.__show()
  932. else:
  933. self.__hide()
  934. def __show(self):
  935. self._toggleButton.setText(_("Hide"))
  936. layout = self.layout()
  937. self._countLabel = QLabel("0", self)
  938. layout.addRow(_("Total count") + ":", self._countLabel)
  939. self._filterCountLabel = QLabel("0", self)
  940. layout.addRow(_("After filter count") + ":", self._filterCountLabel)
  941. self._lastUpdateLabel = QLabel("0", self)
  942. self._lastUpdateLabel.setWordWrap(True)
  943. layout.addRow(_("Last update") + ":", self._lastUpdateLabel)
  944. self.__modelChanged() # Update count and after filter count
  945. self.__instancesModelTypeChanged(self._filterModel.parentModel().Type)
  946. self._filterModel.changed.connect(self.__modelChanged)
  947. def __hide(self):
  948. self._filterModel.changed.disconnect(self.__modelChanged)
  949. self.__instancesModelTypeChanged(InstancesModelTypes.NotDefined)
  950. self._toggleButton.setText(_("Show"))
  951. layout = self.layout()
  952. while layout.rowCount() > 1:
  953. layout.removeRow(1)
  954. self._countLabel = None
  955. self._filterCountLabel = None
  956. self._lastUpdateLabel = None
  957. """ InstancesType dependant methods below
  958. """
  959. def __instancesModelTypeChanged(self, type_):
  960. """ PersistentInstancesModel emitted changed.
  961. """
  962. if self.collapsed():
  963. return
  964. previousType = self._currentInstancesType
  965. self._currentInstancesType = type_
  966. # Disconnect signals
  967. if previousType == InstancesModelTypes.Stats2:
  968. self._filterModel.changed.disconnect(self.__updateLastUpdated)
  969. elif previousType == InstancesModelTypes.User:
  970. pass
  971. if type_ == InstancesModelTypes.Stats2:
  972. self._filterModel.changed.connect(self.__updateLastUpdated)
  973. self.__updateLastUpdated()
  974. else:
  975. self._lastUpdateLabel.setText("-")
  976. # InstancesModelTypes.Stats2
  977. def __updateLastUpdated(self):
  978. self._lastUpdateLabel.setText(
  979. timeToString(
  980. self._filterModel.parentModel().handler().lastUpdated()
  981. )
  982. )
  983. """ -------------------------------------------
  984. """
  985. def __modelChanged(self):
  986. self._countLabel.setText(str(len(self._filterModel.parentModel())))
  987. self._filterCountLabel.setText(str(len(self._filterModel)))