main.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  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. import sys
  22. from copy import deepcopy
  23. from PyQt5.QtWidgets import (
  24. QApplication,
  25. QAction,
  26. QSplitter,
  27. QMenuBar,
  28. QMenu,
  29. QMainWindow,
  30. QHBoxLayout,
  31. QVBoxLayout,
  32. QWidget,
  33. QStatusBar,
  34. QLabel,
  35. QMessageBox
  36. )
  37. from PyQt5.QtCore import (
  38. Qt,
  39. QSettings,
  40. QByteArray,
  41. QSize
  42. )
  43. from searxqt.models.instances import (
  44. Stats2InstancesModel,
  45. UserInstancesModel,
  46. Stats2EnginesModel,
  47. UserEnginesModel,
  48. Stats2Model,
  49. InstanceModelFilter,
  50. InstanceSelecterModel,
  51. PersistentInstancesModel,
  52. PersistentEnginesModel,
  53. InstancesModelTypes
  54. )
  55. from searxqt.models.settings import SettingsModel
  56. from searxqt.models.search import (
  57. SearchModel,
  58. UserInstancesHandler,
  59. SearchStatus
  60. )
  61. from searxqt.models.profiles import Profiles, ProfileItem
  62. from searxqt.views.instances import InstancesView
  63. from searxqt.views.settings import SettingsWindow
  64. from searxqt.views.search import SearchContainer
  65. from searxqt.views import about
  66. from searxqt.views.profiles import ProfileChooserDialog
  67. from searxqt.core.customAnchorCmd import AnchorCMD
  68. from searxqt.core.images import ImagesSettings
  69. from searxqt.core.requests import RequestsHandler, RequestSettingsWithParent
  70. from searxqt.core.guard import Guard
  71. from searxqt.translations import _
  72. from searxqt.version import __version__
  73. from searxqt import PROFILES_PATH, SETTINGS_PATH
  74. from searxqt.core import log
  75. class MainWindow(QMainWindow):
  76. def __init__(self, *args, **kwargs):
  77. QMainWindow.__init__(self, *args, **kwargs)
  78. self.setWindowTitle("Searx-Qt")
  79. self._handler = None
  80. self._settingsWindow = None
  81. # Request handler
  82. self._requestHandler = RequestsHandler()
  83. imgRequestSettings = RequestSettingsWithParent(self._requestHandler.settings)
  84. self._imgRequestHandler = RequestsHandler(imgRequestSettings)
  85. self._settingsModel = SettingsModel(self._requestHandler.settings,
  86. self._imgRequestHandler.settings,
  87. self)
  88. # Persistent models
  89. self._persistantInstancesModel = PersistentInstancesModel()
  90. self._persistantEnginesModel = PersistentEnginesModel()
  91. # Profiles
  92. self._profiles = Profiles()
  93. self.instanceFilter = InstanceModelFilter(
  94. self._persistantInstancesModel, self
  95. )
  96. self.instanceSelecter = InstanceSelecterModel(self.instanceFilter)
  97. self._searchModel = SearchModel(self._requestHandler, self)
  98. # Guard
  99. self._guard = Guard()
  100. # -- Menu bar
  101. menubar = QMenuBar(self)
  102. # Menu file
  103. menuFile = QMenu(menubar)
  104. menuFile.setTitle(_("File"))
  105. saveAction = QAction(_("Save"), menuFile)
  106. menuFile.addAction(saveAction)
  107. saveAction.setShortcut('Ctrl+S')
  108. saveAction.triggered.connect(self.saveSettings)
  109. actionExit = QAction(_("Exit"), menuFile)
  110. menuFile.addAction(actionExit)
  111. actionExit.setShortcut('Ctrl+Q')
  112. actionExit.triggered.connect(self.close)
  113. menubar.addAction(menuFile.menuAction())
  114. # Menu settings
  115. settingsAction = QAction(_("Settings"), menubar)
  116. menubar.addAction(settingsAction)
  117. settingsAction.triggered.connect(self._openSettingsWindow)
  118. # Menu profiles
  119. profilesAction = QAction(_("Profiles"), menubar)
  120. menubar.addAction(profilesAction)
  121. profilesAction.triggered.connect(self._openProfileChooser)
  122. # Menu about dialog
  123. aboutAction = QAction(_("About"), menubar)
  124. menubar.addAction(aboutAction)
  125. aboutAction.triggered.connect(self._openAboutDialog)
  126. self.setMenuBar(menubar)
  127. # -- End menu bar
  128. # -- Status bar
  129. self.statusBar = QStatusBar(self)
  130. statusWidget = QWidget(self)
  131. statusLayout = QHBoxLayout(statusWidget)
  132. self._handlerThreadStatusLabel = QLabel(self)
  133. statusLayout.addWidget(self._handlerThreadStatusLabel)
  134. self._statusInstanceLabel = QLabel(self)
  135. statusLayout.addWidget(self._statusInstanceLabel)
  136. self.statusBar.addPermanentWidget(statusWidget)
  137. self.setStatusBar(self.statusBar)
  138. # -- End status bar
  139. centralWidget = QWidget(self)
  140. layout = QVBoxLayout(centralWidget)
  141. self.setCentralWidget(centralWidget)
  142. self.splitter = QSplitter(centralWidget)
  143. self.splitter.setOrientation(Qt.Horizontal)
  144. layout.addWidget(self.splitter)
  145. self.searchContainer = SearchContainer(
  146. self._searchModel,
  147. self._imgRequestHandler,
  148. self.instanceFilter,
  149. self.instanceSelecter,
  150. self._persistantEnginesModel,
  151. self._guard,
  152. self.splitter
  153. )
  154. self.instancesWidget = InstancesView(
  155. self.instanceFilter,
  156. self.instanceSelecter,
  157. self.splitter
  158. )
  159. self.instanceSelecter.instanceChanged.connect(self.__instanceChanged)
  160. self._searchModel.statusChanged.connect(self.__searchStatusChanged)
  161. self.__profileChooserInit()
  162. self.resize(800, 600)
  163. self.loadSharedSettings()
  164. self.loadProfile()
  165. def __instanceChanged(self, url):
  166. self._statusInstanceLabel.setText(f"<b>{_('Instance')}:</b> {url}")
  167. # Disable/enable instances view on search status change.
  168. def __searchStatusChanged(self, status):
  169. if status == SearchStatus.Busy:
  170. self.instancesWidget.setEnabled(False)
  171. else:
  172. self.instancesWidget.setEnabled(True)
  173. def closeEvent(self, event=None):
  174. # Disable everything.
  175. self.setEnabled(False)
  176. # Wait till all threads finished
  177. if self.searchContainer.isBusy():
  178. log.info("- Waiting for search thread to finish...", self)
  179. self.searchContainer.cancelAll()
  180. log.info("- Search thread finished.")
  181. if self._handler and self._handler.hasActiveJobs():
  182. log.info(
  183. "- Waiting for update instances thread to finish...",
  184. self
  185. )
  186. self._handler.cancelAll()
  187. log.info("- Instances update thread finished.", self)
  188. self.saveSettings()
  189. log.info("- Settings saved.", self)
  190. # Remove currently active profile id from the active list.
  191. self._profiles.setProfile(
  192. self._profiles.settings(),
  193. ProfileItem()
  194. )
  195. QApplication.closeAllWindows()
  196. log.info("Bye!", self)
  197. def _openAboutDialog(self):
  198. about.show(self)
  199. def __execProfileChooser(self):
  200. """ This only sets the profile, it does not load it.
  201. Returns True on success, False when something went wrong.
  202. """
  203. profiles = self._profiles
  204. profilesSettings = profiles.settings()
  205. profiles.loadProfiles(profilesSettings) # read profiles.conf
  206. dialog = ProfileChooserDialog(profiles)
  207. if dialog.exec():
  208. currentProfile = profiles.current()
  209. selectedProfile = dialog.selectedProfile()
  210. # Save current profile if one is set.
  211. if currentProfile.id:
  212. self.saveProfile()
  213. profiles.setProfile(
  214. profilesSettings,
  215. selectedProfile
  216. )
  217. else:
  218. self.__finalizeProfileChooser(dialog)
  219. return False
  220. self.__finalizeProfileChooser(dialog)
  221. return True
  222. def __profileChooserInit(self):
  223. profiles = self._profiles
  224. profilesSettings = profiles.settings()
  225. profiles.loadProfiles(profilesSettings) # read profiles.conf
  226. activeProfiles = profiles.getActiveProfiles(profilesSettings)
  227. defaultProfile = profiles.default()
  228. if defaultProfile is None or defaultProfile.id in activeProfiles:
  229. if not self.__execProfileChooser():
  230. sys.exit()
  231. else:
  232. # Load default profile.
  233. profiles.setProfile(
  234. profilesSettings,
  235. defaultProfile
  236. )
  237. def _openProfileChooser(self):
  238. if self._handler and self._handler.hasActiveJobs():
  239. QMessageBox.information(
  240. self,
  241. _("Instances update thread active"),
  242. _("Please wait until instances finished updating before\n"
  243. "switching profiles.")
  244. )
  245. return
  246. if self.__execProfileChooser():
  247. self.loadProfile()
  248. def __finalizeProfileChooser(self, dialog):
  249. """ Profiles may have been added or removed.
  250. - Store profiles.conf
  251. - Remove removed profile conf files
  252. """
  253. self._profiles.saveProfiles()
  254. self._profiles.removeProfileFiles(dialog.removedProfiles())
  255. def _openSettingsWindow(self):
  256. if not self._settingsWindow:
  257. self._settingsWindow = SettingsWindow(
  258. self._settingsModel,
  259. self._searchModel,
  260. self._guard
  261. )
  262. self._settingsWindow.resize(self.__lastSettingsWindowSize)
  263. self._settingsWindow.show()
  264. self._settingsWindow.activateWindow() # Bring it to front
  265. self._settingsWindow.closed.connect(self._delSettingsWindow)
  266. def _delSettingsWindow(self):
  267. self._settingsWindow.closed.disconnect()
  268. self._settingsWindow.deleteLater()
  269. self._settingsWindow = None
  270. def __handlerThreadChanged(self):
  271. self._handlerThreadStatusLabel.setText(
  272. self._handler.currentJobStr()
  273. )
  274. def loadProfile(self):
  275. profile = self._profiles.current()
  276. self.setWindowTitle(f"Searx-Qt - {profile.name}")
  277. profileSettings = QSettings(PROFILES_PATH, profile.id, self)
  278. # Clean previous stuff
  279. if self._handler:
  280. self._handler.threadStarted.disconnect(
  281. self.__handlerThreadChanged
  282. )
  283. self._handler.threadFinished.disconnect(
  284. self.__handlerThreadChanged
  285. )
  286. self._handler.deleteLater()
  287. self._handler = None
  288. if self._settingsWindow:
  289. self._delSettingsWindow()
  290. self.searchContainer.reset()
  291. # Set new models
  292. if profile.type == InstancesModelTypes.Stats2:
  293. self._handler = Stats2Model(self._requestHandler, self)
  294. instancesModel = Stats2InstancesModel(self._handler, parent=self)
  295. enginesModel = Stats2EnginesModel(self._handler, parent=self)
  296. self._settingsModel.loadSettings(
  297. profileSettings.value('settings', dict(), dict),
  298. stats2=True
  299. )
  300. elif profile.type == InstancesModelTypes.User:
  301. self._handler = UserInstancesHandler(self._requestHandler, self)
  302. instancesModel = UserInstancesModel(self._handler, parent=self)
  303. enginesModel = UserEnginesModel(self._handler, parent=self)
  304. self._settingsModel.loadSettings(
  305. profileSettings.value('settings', dict(), dict),
  306. stats2=False
  307. )
  308. else:
  309. print(f"ERROR unknown profile type '{profile.type}'")
  310. sys.exit(1)
  311. self._persistantInstancesModel.setModel(instancesModel)
  312. self._persistantEnginesModel.setModel(enginesModel)
  313. self._handler.setData(
  314. profileSettings.value('data', dict(), dict)
  315. )
  316. self._handler.threadStarted.connect(self.__handlerThreadChanged)
  317. self._handler.threadFinished.connect(self.__handlerThreadChanged)
  318. # Load settings
  319. self.instanceFilter.loadSettings(
  320. profileSettings.value('instanceFilter', dict(), dict)
  321. )
  322. self.instancesWidget.loadSettings(
  323. profileSettings.value('instancesView', dict(), dict)
  324. )
  325. self.instanceSelecter.loadSettings(
  326. profileSettings.value('instanceSelecter', dict(), dict)
  327. )
  328. self.searchContainer.loadSettings(
  329. profileSettings.value('searchContainer', dict(), dict)
  330. )
  331. self._searchModel.loadSettings(
  332. profileSettings.value('searchModel', dict(), dict)
  333. )
  334. ImagesSettings.deserialize(
  335. profileSettings.value('imgSettings', dict(), dict)
  336. )
  337. # Guard
  338. self._guard.deserialize(profileSettings.value('Guard', dict(), dict))
  339. # Load main window splitter state (between search and instances)
  340. self.splitter.restoreState(
  341. profileSettings.value('splitterState', QByteArray(), QByteArray)
  342. )
  343. # Custom Anchor commands
  344. AnchorCMD.deserialize(
  345. profileSettings.value('customAnchorCmd', dict(), dict)
  346. )
  347. # Load defaults for new profiles.
  348. if profile.preset:
  349. from searxqt import defaults
  350. preset = defaults.Presets[profile.preset]
  351. # Settings
  352. if profile.type == InstancesModelTypes.Stats2:
  353. self._settingsModel.loadSettings(
  354. deepcopy(preset.get('settings', {})),
  355. stats2=True
  356. )
  357. elif profile.type == InstancesModelTypes.User:
  358. self._settingsModel.loadSettings(
  359. deepcopy(preset.get('settings', {})),
  360. stats2=False
  361. )
  362. # Guard
  363. self._guard.deserialize(
  364. deepcopy(preset.get('guard', {}))
  365. )
  366. # InstancesView
  367. self.instancesWidget.loadSettings(
  368. deepcopy(preset.get('instancesView', {}))
  369. )
  370. # InstancesFilter
  371. self.instanceFilter.loadSettings(
  372. deepcopy(preset.get('instancesFilter', {}))
  373. )
  374. # Instances
  375. self._handler.setData(
  376. deepcopy(preset.get('data', {}))
  377. )
  378. del preset
  379. del defaults
  380. def saveProfile(self):
  381. """ Save current profile
  382. """
  383. profile = self._profiles.current()
  384. profileSettings = QSettings(PROFILES_PATH, profile.id, self)
  385. profileSettings.setValue(
  386. 'settings', self._settingsModel.saveSettings()
  387. )
  388. profileSettings.setValue(
  389. 'instanceFilter', self.instanceFilter.saveSettings()
  390. )
  391. profileSettings.setValue(
  392. 'instancesView', self.instancesWidget.saveSettings()
  393. )
  394. profileSettings.setValue(
  395. 'instanceSelecter', self.instanceSelecter.saveSettings()
  396. )
  397. profileSettings.setValue(
  398. 'searchContainer', self.searchContainer.saveSettings()
  399. )
  400. profileSettings.setValue(
  401. 'searchModel', self._searchModel.saveSettings()
  402. )
  403. profileSettings.setValue(
  404. 'imgSettings', ImagesSettings.serialize()
  405. )
  406. # Guard
  407. profileSettings.setValue('Guard', self._guard.serialize())
  408. # Store the main window splitter state (between search and instances)
  409. profileSettings.setValue('splitterState', self.splitter.saveState())
  410. # Custom Anchor commands
  411. profileSettings.setValue('customAnchorCmd', AnchorCMD.serialize())
  412. # Store searx-qt version (for backward compatibility)
  413. profileSettings.setValue('version', __version__)
  414. if self._handler:
  415. profileSettings.setValue('data', self._handler.data())
  416. def loadSharedSettings(self):
  417. """ Load shared settings
  418. """
  419. settings = QSettings(SETTINGS_PATH, 'shared', self)
  420. self.resize(
  421. settings.value('windowSize', QSize(), QSize)
  422. )
  423. self.__lastSettingsWindowSize = settings.value(
  424. 'settingsWindowSize',
  425. QSize(400, 400),
  426. QSize
  427. )
  428. def saveSettings(self):
  429. # save current profile
  430. if self._profiles.current().id:
  431. self.saveProfile()
  432. # shared.conf
  433. settings = QSettings(SETTINGS_PATH, 'shared', self)
  434. settings.setValue('windowSize', self.size())
  435. if self._settingsWindow:
  436. settings.setValue(
  437. 'settingsWindowSize',
  438. self._settingsWindow.size()
  439. )