From 397813753402ad27ec762df409baeeccd0625cde Mon Sep 17 00:00:00 2001 From: Vladimir Golovnev Date: Sun, 26 Jan 2025 17:12:50 +0300 Subject: [PATCH] Store opened search tabs PR #22163. Closes #167. --- src/base/preferences.cpp | 26 ++ src/base/preferences.h | 6 + src/gui/optionsdialog.cpp | 22 ++ src/gui/optionsdialog.h | 4 + src/gui/optionsdialog.ui | 84 +++++ src/gui/search/searchjobwidget.cpp | 50 ++- src/gui/search/searchjobwidget.h | 17 +- src/gui/search/searchwidget.cpp | 491 +++++++++++++++++++++++++++-- src/gui/search/searchwidget.h | 19 ++ 9 files changed, 681 insertions(+), 38 deletions(-) diff --git a/src/base/preferences.cpp b/src/base/preferences.cpp index ed9ecd316dc1..6a6b143edf70 100644 --- a/src/base/preferences.cpp +++ b/src/base/preferences.cpp @@ -655,6 +655,32 @@ void Preferences::setSearchEnabled(const bool enabled) setValue(u"Preferences/Search/SearchEnabled"_s, enabled); } +bool Preferences::storeOpenedSearchTabs() const +{ + return value(u"Search/StoreOpenedSearchTabs"_s, false); +} + +void Preferences::setStoreOpenedSearchTabs(const bool enabled) +{ + if (enabled == storeOpenedSearchTabs()) + return; + + setValue(u"Search/StoreOpenedSearchTabs"_s, enabled); +} + +bool Preferences::storeOpenedSearchTabResults() const +{ + return value(u"Search/StoreOpenedSearchTabResults"_s, false); +} + +void Preferences::setStoreOpenedSearchTabResults(const bool enabled) +{ + if (enabled == storeOpenedSearchTabResults()) + return; + + setValue(u"Search/StoreOpenedSearchTabResults"_s, enabled); +} + bool Preferences::isWebUIEnabled() const { #ifdef DISABLE_GUI diff --git a/src/base/preferences.h b/src/base/preferences.h index b1a912477bda..7e07446d6541 100644 --- a/src/base/preferences.h +++ b/src/base/preferences.h @@ -172,6 +172,12 @@ class Preferences final : public QObject bool isSearchEnabled() const; void setSearchEnabled(bool enabled); + // Search UI + bool storeOpenedSearchTabs() const; + void setStoreOpenedSearchTabs(bool enabled); + bool storeOpenedSearchTabResults() const; + void setStoreOpenedSearchTabResults(bool enabled); + // HTTP Server bool isWebUIEnabled() const; void setWebUIEnabled(bool enabled); diff --git a/src/gui/optionsdialog.cpp b/src/gui/optionsdialog.cpp index 676612146589..ba4ee232c8a3 100644 --- a/src/gui/optionsdialog.cpp +++ b/src/gui/optionsdialog.cpp @@ -164,6 +164,7 @@ OptionsDialog::OptionsDialog(IGUIApplication *app, QWidget *parent) m_ui->tabSelection->item(TAB_DOWNLOADS)->setIcon(UIThemeManager::instance()->getIcon(u"download"_s, u"folder-download"_s)); m_ui->tabSelection->item(TAB_SPEED)->setIcon(UIThemeManager::instance()->getIcon(u"speedometer"_s, u"chronometer"_s)); m_ui->tabSelection->item(TAB_RSS)->setIcon(UIThemeManager::instance()->getIcon(u"application-rss"_s, u"application-rss+xml"_s)); + m_ui->tabSelection->item(TAB_SEARCH)->setIcon(UIThemeManager::instance()->getIcon(u"edit-find"_s)); #ifdef DISABLE_WEBUI m_ui->tabSelection->item(TAB_WEBUI)->setHidden(true); #else @@ -190,6 +191,7 @@ OptionsDialog::OptionsDialog(IGUIApplication *app, QWidget *parent) loadSpeedTabOptions(); loadBittorrentTabOptions(); loadRSSTabOptions(); + loadSearchTabOptions(); #ifndef DISABLE_WEBUI loadWebUITabOptions(); #endif @@ -1273,6 +1275,25 @@ void OptionsDialog::saveRSSTabOptions() const autoDownloader->setDownloadRepacks(m_ui->checkSmartFilterDownloadRepacks->isChecked()); } +void OptionsDialog::loadSearchTabOptions() +{ + const auto *pref = Preferences::instance(); + + m_ui->groupStoreOpenedTabs->setChecked(pref->storeOpenedSearchTabs()); + m_ui->checkStoreTabsSearchResults->setChecked(pref->storeOpenedSearchTabResults()); + + connect(m_ui->groupStoreOpenedTabs, &QGroupBox::toggled, this, &OptionsDialog::enableApplyButton); + connect(m_ui->checkStoreTabsSearchResults, &QCheckBox::toggled, this, &OptionsDialog::enableApplyButton); +} + +void OptionsDialog::saveSearchTabOptions() const +{ + auto *pref = Preferences::instance(); + + pref->setStoreOpenedSearchTabs(m_ui->groupStoreOpenedTabs->isChecked()); + pref->setStoreOpenedSearchTabResults(m_ui->checkStoreTabsSearchResults->isChecked()); +} + #ifndef DISABLE_WEBUI void OptionsDialog::loadWebUITabOptions() { @@ -1465,6 +1486,7 @@ void OptionsDialog::saveOptions() const saveSpeedTabOptions(); saveBittorrentTabOptions(); saveRSSTabOptions(); + saveSearchTabOptions(); #ifndef DISABLE_WEBUI saveWebUITabOptions(); #endif diff --git a/src/gui/optionsdialog.h b/src/gui/optionsdialog.h index 534ca4078ef8..c1e29c1d6c3c 100644 --- a/src/gui/optionsdialog.h +++ b/src/gui/optionsdialog.h @@ -73,6 +73,7 @@ class OptionsDialog final : public GUIApplicationComponent TAB_CONNECTION, TAB_SPEED, TAB_BITTORRENT, + TAB_SEARCH, TAB_RSS, TAB_WEBUI, TAB_ADVANCED @@ -136,6 +137,9 @@ private slots: void loadRSSTabOptions(); void saveRSSTabOptions() const; + void loadSearchTabOptions(); + void saveSearchTabOptions() const; + #ifndef DISABLE_WEBUI void loadWebUITabOptions(); void saveWebUITabOptions() const; diff --git a/src/gui/optionsdialog.ui b/src/gui/optionsdialog.ui index 16484c71705e..94309cd9a802 100644 --- a/src/gui/optionsdialog.ui +++ b/src/gui/optionsdialog.ui @@ -72,6 +72,11 @@ BitTorrent + + + Search + + RSS @@ -3210,6 +3215,85 @@ Disable encryption: Only connect to peers without protocol encryption + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + 0 + 0 + 521 + 541 + + + + + + + Search UI + + + + + + Store opened tabs + + + true + + + false + + + + + + Also store search results + + + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 422 + + + + + + + + + + diff --git a/src/gui/search/searchjobwidget.cpp b/src/gui/search/searchjobwidget.cpp index 75a9bdbc2822..aae330ce42fa 100644 --- a/src/gui/search/searchjobwidget.cpp +++ b/src/gui/search/searchjobwidget.cpp @@ -82,10 +82,11 @@ namespace } } -SearchJobWidget::SearchJobWidget(SearchHandler *searchHandler, IGUIApplication *app, QWidget *parent) +SearchJobWidget::SearchJobWidget(const QString &id, IGUIApplication *app, QWidget *parent) : GUIApplicationComponent(app, parent) - , m_ui {new Ui::SearchJobWidget} , m_nameFilteringMode {u"Search/FilteringMode"_s} + , m_id {id} + , m_ui {new Ui::SearchJobWidget} { m_ui->setupUi(this); @@ -151,9 +152,6 @@ SearchJobWidget::SearchJobWidget(SearchHandler *searchHandler, IGUIApplication * connect(header(), &QHeaderView::sortIndicatorChanged, this, &SearchJobWidget::saveSettings); fillFilterComboBoxes(); - setStatusTip(statusText(m_status)); - - assignSearchHandler(searchHandler); m_lineEditSearchResultsFilter = new LineEdit(this); m_lineEditSearchResultsFilter->setPlaceholderText(tr("Filter search results...")); @@ -186,19 +184,42 @@ SearchJobWidget::SearchJobWidget(SearchHandler *searchHandler, IGUIApplication * connect(UIThemeManager::instance(), &UIThemeManager::themeChanged, this, &SearchJobWidget::onUIThemeChanged); } +SearchJobWidget::SearchJobWidget(const QString &id, const QString &searchPattern + , const QList &searchResults, IGUIApplication *app, QWidget *parent) + : SearchJobWidget(id, app, parent) +{ + m_searchPattern = searchPattern; + m_proxyModel->setNameFilter(m_searchPattern); + updateFilter(); + + appendSearchResults(searchResults); +} + +SearchJobWidget::SearchJobWidget(const QString &id, SearchHandler *searchHandler, IGUIApplication *app, QWidget *parent) + : SearchJobWidget(id, app, parent) +{ + assignSearchHandler(searchHandler); +} + SearchJobWidget::~SearchJobWidget() { saveSettings(); delete m_ui; } +QString SearchJobWidget::id() const +{ + return m_id; +} + QString SearchJobWidget::searchPattern() const { - Q_ASSERT(m_searchHandler); - if (!m_searchHandler) [[unlikely]] - return {}; + return m_searchPattern; +} - return m_searchHandler->pattern(); +QList SearchJobWidget::searchResults() const +{ + return m_searchResults; } void SearchJobWidget::onItemDoubleClicked(const QModelIndex &index) @@ -264,6 +285,7 @@ void SearchJobWidget::assignSearchHandler(SearchHandler *searchHandler) if (!searchHandler) [[unlikely]] return; + m_searchResults.clear(); m_searchListModel->removeRows(0, m_searchListModel->rowCount()); delete m_searchHandler; @@ -273,7 +295,9 @@ void SearchJobWidget::assignSearchHandler(SearchHandler *searchHandler) connect(m_searchHandler, &SearchHandler::searchFinished, this, &SearchJobWidget::searchFinished); connect(m_searchHandler, &SearchHandler::searchFailed, this, &SearchJobWidget::searchFailed); - m_proxyModel->setNameFilter(m_searchHandler->pattern()); + m_searchPattern = m_searchHandler->pattern(); + + m_proxyModel->setNameFilter(m_searchPattern); updateFilter(); setStatus(Status::Ongoing); @@ -281,8 +305,7 @@ void SearchJobWidget::assignSearchHandler(SearchHandler *searchHandler) void SearchJobWidget::cancelSearch() { - Q_ASSERT(m_searchHandler); - if (!m_searchHandler) [[unlikely]] + if (!m_searchHandler) return; m_searchHandler->cancelSearch(); @@ -363,7 +386,7 @@ void SearchJobWidget::downloadTorrent(const QModelIndex &rowIndex, const AddTorr } else { - SearchDownloadHandler *downloadHandler = m_searchHandler->manager()->downloadTorrent(engineName, torrentUrl); + SearchDownloadHandler *downloadHandler = SearchPluginManager::instance()->downloadTorrent(engineName, torrentUrl); connect(downloadHandler, &SearchDownloadHandler::downloadFinished , this, [this, option](const QString &source) { addTorrentToSession(source, option); }); connect(downloadHandler, &SearchDownloadHandler::downloadFinished, downloadHandler, &SearchDownloadHandler::deleteLater); @@ -605,6 +628,7 @@ void SearchJobWidget::appendSearchResults(const QList &results) setModelData(SearchSortModel::PUB_DATE, QLocale().toString(result.pubDate.toLocalTime(), QLocale::ShortFormat), result.pubDate); } + m_searchResults.append(results); updateResultsCount(); } diff --git a/src/gui/search/searchjobwidget.h b/src/gui/search/searchjobwidget.h index ca3ab6ce219c..7ef36cf3884d 100644 --- a/src/gui/search/searchjobwidget.h +++ b/src/gui/search/searchjobwidget.h @@ -69,6 +69,7 @@ class SearchJobWidget final : public GUIApplicationComponent enum class Status { + Ready, Ongoing, Finished, Error, @@ -76,10 +77,13 @@ class SearchJobWidget final : public GUIApplicationComponent NoResults }; - SearchJobWidget(SearchHandler *searchHandler, IGUIApplication *app, QWidget *parent = nullptr); + SearchJobWidget(const QString &id, const QString &searchPattern, const QList &searchResults, IGUIApplication *app, QWidget *parent = nullptr); + SearchJobWidget(const QString &id, SearchHandler *searchHandler, IGUIApplication *app, QWidget *parent = nullptr); ~SearchJobWidget() override; + QString id() const; QString searchPattern() const; + QList searchResults() const; Status status() const; int visibleResultsCount() const; LineEdit *lineEditSearchResultsFilter() const; @@ -98,6 +102,8 @@ private slots: void displayColumnHeaderMenu(); private: + SearchJobWidget(const QString &id, IGUIApplication *app, QWidget *parent); + void loadSettings(); void saveSettings() const; void updateFilter(); @@ -127,15 +133,18 @@ private slots: void copyTorrentNames() const; void copyField(int column) const; + SettingValue m_nameFilteringMode; + + QString m_id; + QString m_searchPattern; + QList m_searchResults; Ui::SearchJobWidget *m_ui = nullptr; SearchHandler *m_searchHandler = nullptr; QStandardItemModel *m_searchListModel = nullptr; SearchSortModel *m_proxyModel = nullptr; LineEdit *m_lineEditSearchResultsFilter = nullptr; - Status m_status = Status::Ongoing; + Status m_status = Status::Ready; bool m_noSearchResults = true; - - SettingValue m_nameFilteringMode; }; Q_DECLARE_METATYPE(SearchJobWidget::NameFilteringMode) diff --git a/src/gui/search/searchwidget.cpp b/src/gui/search/searchwidget.cpp index 7aa678b2d7e4..ceb13c2146e6 100644 --- a/src/gui/search/searchwidget.cpp +++ b/src/gui/search/searchwidget.cpp @@ -1,6 +1,6 @@ /* * Bittorrent Client using Qt and libtorrent. - * Copyright (C) 2015-2024 Vladimir Golovnev + * Copyright (C) 2015-2025 Vladimir Golovnev * Copyright (C) 2020, Will Da Silva * Copyright (C) 2006 Christophe Dumez * @@ -34,12 +34,13 @@ #include -#ifdef Q_OS_WIN -#include -#endif - #include #include +#include +#include +#include +#include +#include #include #include #include @@ -47,11 +48,18 @@ #include #include #include +#include #include "base/global.h" +#include "base/logger.h" +#include "base/preferences.h" +#include "base/profile.h" #include "base/search/searchhandler.h" #include "base/search/searchpluginmanager.h" +#include "base/utils/datetime.h" +#include "base/utils/fs.h" #include "base/utils/foreignapps.h" +#include "base/utils/io.h" #include "gui/desktopintegration.h" #include "gui/interfaces/iguiapplication.h" #include "gui/uithememanager.h" @@ -59,11 +67,37 @@ #include "searchjobwidget.h" #include "ui_searchwidget.h" -#define SEARCHHISTORY_MAXSIZE 50 -#define URL_COLUMN 5 +const QString DATA_FOLDER_NAME = u"SearchUI"_s; +const QString SESSION_FILE_NAME = u"Session.json"_s; + +const QString KEY_SESSION_TABS = u"Tabs"_s; +const QString KEY_SESSION_CURRENTTAB = u"CurrentTab"_s; +const QString KEY_TAB_ID = u"ID"_s; +const QString KEY_TAB_SEARCHPATTERN = u"SearchPattern"_s; +const QString KEY_RESULT_FILENAME = u"FileName"_s; +const QString KEY_RESULT_FILEURL = u"FileURL"_s; +const QString KEY_RESULT_FILESIZE = u"FileSize"_s; +const QString KEY_RESULT_SEEDERSCOUNT = u"SeedersCount"_s; +const QString KEY_RESULT_LEECHERSCOUNT = u"LeechersCount"_s; +const QString KEY_RESULT_ENGINENAME = u"EngineName"_s; +const QString KEY_RESULT_SITEURL = u"SiteURL"_s; +const QString KEY_RESULT_DESCRLINK = u"DescrLink"_s; +const QString KEY_RESULT_PUBDATE = u"PubDate"_s; namespace { + struct TabData + { + QString tabID; + QString searchPattern; + }; + + struct SessionData + { + QList tabs; + QString currentTabID; + }; + QString statusIconName(const SearchJobWidget::Status st) { switch (st) @@ -81,11 +115,187 @@ namespace return {}; } } + + Path makeDataFilePath(const QString &fileName) + { + return specialFolderLocation(SpecialFolder::Data) / Path(DATA_FOLDER_NAME) / Path(fileName); + } + + QString makeTabName(SearchJobWidget *searchJobWdget) + { + Q_ASSERT(searchJobWdget); + if (!searchJobWdget) [[unlikely]] + return {}; + + QString tabName = searchJobWdget->searchPattern(); + tabName.replace(QRegularExpression(u"&{1}"_s), u"&&"_s); + return tabName; + } + + nonstd::expected loadSession(const Path &filePath) + { + const int fileMaxSize = 10 * 1024 * 1024; + const auto readResult = Utils::IO::readFile(filePath, fileMaxSize); + if (!readResult) + { + if (readResult.error().status == Utils::IO::ReadError::NotExist) + return {}; + + return nonstd::make_unexpected(readResult.error().message); + } + + const QString formatErrorMsg = SearchWidget::tr("Invalid data format."); + QJsonParseError jsonError; + const QJsonDocument sessionDoc = QJsonDocument::fromJson(readResult.value(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) + return nonstd::make_unexpected(jsonError.errorString()); + + if (!sessionDoc.isObject()) + return nonstd::make_unexpected(formatErrorMsg); + + const QJsonObject sessionObj = sessionDoc.object(); + const QJsonValue tabsVal = sessionObj[KEY_SESSION_TABS]; + if (!tabsVal.isArray()) + return nonstd::make_unexpected(formatErrorMsg); + + QList tabs; + QSet tabIDs; + for (const QJsonValue &tabVal : asConst(tabsVal.toArray())) + { + if (!tabVal.isObject()) + return nonstd::make_unexpected(formatErrorMsg); + + const QJsonObject tabObj = tabVal.toObject(); + + const QJsonValue tabIDVal = tabObj[KEY_TAB_ID]; + if (!tabIDVal.isString()) + return nonstd::make_unexpected(formatErrorMsg); + + const QJsonValue patternVal = tabObj[KEY_TAB_SEARCHPATTERN]; + if (!patternVal.isString()) + return nonstd::make_unexpected(formatErrorMsg); + + const QString tabID = tabIDVal.toString(); + tabIDs.insert(tabID); + tabs.emplaceBack(TabData {tabID, patternVal.toString()}); + if (tabs.size() != tabIDs.size()) // duplicate ID + return nonstd::make_unexpected(formatErrorMsg); + } + + const QJsonValue currentTabVal = sessionObj[KEY_SESSION_CURRENTTAB]; + if (!currentTabVal.isString()) + return nonstd::make_unexpected(formatErrorMsg); + + return SessionData {.tabs = tabs, .currentTabID = currentTabVal.toString()}; + } + + nonstd::expected, QString> loadSearchResults(const Path &filePath) + { + const int fileMaxSize = 10 * 1024 * 1024; + const auto readResult = Utils::IO::readFile(filePath, fileMaxSize); + if (!readResult) + { + if (readResult.error().status != Utils::IO::ReadError::NotExist) + { + return nonstd::make_unexpected(readResult.error().message); + } + + return {}; + } + + const QString formatErrorMsg = SearchWidget::tr("Invalid data format."); + QJsonParseError jsonError; + const QJsonDocument searchResultsDoc = QJsonDocument::fromJson(readResult.value(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) + return nonstd::make_unexpected(jsonError.errorString()); + + if (!searchResultsDoc.isArray()) + return nonstd::make_unexpected(formatErrorMsg); + + const QJsonArray resultsList = searchResultsDoc.array(); + QList searchResults; + for (const QJsonValue &resultVal : resultsList) + { + if (!resultVal.isObject()) + return nonstd::make_unexpected(formatErrorMsg); + + const QJsonObject resultObj = resultVal.toObject(); + SearchResult &searchResult = searchResults.emplaceBack(); + + if (const QJsonValue fileNameVal = resultObj[KEY_RESULT_FILENAME]; fileNameVal.isString()) + searchResult.fileName = fileNameVal.toString(); + else + return nonstd::make_unexpected(formatErrorMsg); + + if (const QJsonValue fileURLVal = resultObj[KEY_RESULT_FILEURL]; fileURLVal.isString()) + searchResult.fileUrl= fileURLVal.toString(); + else + return nonstd::make_unexpected(formatErrorMsg); + + if (const QJsonValue fileSizeVal = resultObj[KEY_RESULT_FILESIZE]; fileSizeVal.isDouble()) + searchResult.fileSize= fileSizeVal.toInteger(); + else + return nonstd::make_unexpected(formatErrorMsg); + + if (const QJsonValue seedersCountVal = resultObj[KEY_RESULT_SEEDERSCOUNT]; seedersCountVal.isDouble()) + searchResult.nbSeeders = seedersCountVal.toInteger(); + else + return nonstd::make_unexpected(formatErrorMsg); + + if (const QJsonValue leechersCountVal = resultObj[KEY_RESULT_LEECHERSCOUNT]; leechersCountVal.isDouble()) + searchResult.nbLeechers = leechersCountVal.toInteger(); + else + return nonstd::make_unexpected(formatErrorMsg); + + if (const QJsonValue engineNameVal = resultObj[KEY_RESULT_ENGINENAME]; engineNameVal.isString()) + searchResult.engineName= engineNameVal.toString(); + else + return nonstd::make_unexpected(formatErrorMsg); + + if (const QJsonValue siteURLVal = resultObj[KEY_RESULT_SITEURL]; siteURLVal.isString()) + searchResult.siteUrl= siteURLVal.toString(); + else + return nonstd::make_unexpected(formatErrorMsg); + + if (const QJsonValue descrLinkVal = resultObj[KEY_RESULT_DESCRLINK]; descrLinkVal.isString()) + searchResult.descrLink= descrLinkVal.toString(); + else + return nonstd::make_unexpected(formatErrorMsg); + + if (const QJsonValue pubDateVal = resultObj[KEY_RESULT_PUBDATE]; pubDateVal.isDouble()) + searchResult.pubDate = QDateTime::fromSecsSinceEpoch(pubDateVal.toInteger()); + else + return nonstd::make_unexpected(formatErrorMsg); + } + + return searchResults; + } } +class SearchWidget::DataStorage final : public QObject +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(DataStorage) + +public: + using QObject::QObject; + + void loadSession(bool withSearchResults); + void storeSession(const SessionData &sessionData); + void removeSession(); + void storeTab(const QString &tabID, const QList &searchResults); + void removeTab(const QString &tabID); + +signals: + void sessionLoaded(const SessionData &sessionData); + void tabLoaded(const QString &tabID, const QString &searchPattern, const QList &searchResults); +}; + SearchWidget::SearchWidget(IGUIApplication *app, QWidget *parent) : GUIApplicationComponent(app, parent) , m_ui {new Ui::SearchWidget()} + , m_ioThread {new QThread} + , m_dataStorage {new DataStorage(this)} { m_ui->setupUi(this); @@ -120,6 +330,8 @@ SearchWidget::SearchWidget(IGUIApplication *app, QWidget *parent) #endif connect(m_ui->tabWidget, &QTabWidget::tabCloseRequested, this, &SearchWidget::closeTab); connect(m_ui->tabWidget, &QTabWidget::currentChanged, this, &SearchWidget::currentTabChanged); + connect(m_ui->tabWidget, &QTabWidget::currentChanged, this, &SearchWidget::saveSession); + connect(m_ui->tabWidget->tabBar(), &QTabBar::tabMoved, this, &SearchWidget::saveSession); connect(m_ui->tabWidget, &QTabWidget::tabBarDoubleClicked, this, [this](const int tabIndex) { @@ -166,6 +378,17 @@ SearchWidget::SearchWidget(IGUIApplication *app, QWidget *parent) connect(focusSearchHotkey, &QShortcut::activated, this, &SearchWidget::toggleFocusBetweenLineEdits); const auto *focusSearchHotkeyAlternative = new QShortcut((Qt::CTRL | Qt::Key_E), this); connect(focusSearchHotkeyAlternative, &QShortcut::activated, this, &SearchWidget::toggleFocusBetweenLineEdits); + + m_storeOpenedTabs = Preferences::instance()->storeOpenedSearchTabs(); + m_storeOpenedTabsResults = Preferences::instance()->storeOpenedSearchTabResults(); + connect(Preferences::instance(), &Preferences::changed, this, &SearchWidget::onPreferencesChanged); + + m_dataStorage->moveToThread(m_ioThread.get()); + connect(m_ioThread.get(), &QThread::finished, m_dataStorage, &QObject::deleteLater); + m_ioThread->setObjectName("SearchWidget m_ioThread"); + m_ioThread->start(); + + restoreSession(); } bool SearchWidget::eventFilter(QObject *object, QEvent *event) @@ -199,6 +422,55 @@ bool SearchWidget::eventFilter(QObject *object, QEvent *event) return QWidget::eventFilter(object, event); } +void SearchWidget::onPreferencesChanged() +{ + const auto *pref = Preferences::instance(); + + const bool storeOpenedTabs = pref->storeOpenedSearchTabs(); + const bool isStoreOpenedTabsChanged = storeOpenedTabs != m_storeOpenedTabs; + if (isStoreOpenedTabsChanged) + { + m_storeOpenedTabs = storeOpenedTabs; + if (m_storeOpenedTabs) + { + saveSession(); + } + else + { + QMetaObject::invokeMethod(m_dataStorage, [this] { m_dataStorage->removeSession(); }); + } + } + + + const bool storeOpenedTabsResults = pref->storeOpenedSearchTabResults(); + const bool isStoreOpenedTabsResultsChanged = storeOpenedTabsResults != m_storeOpenedTabsResults; + if (isStoreOpenedTabsResultsChanged) + m_storeOpenedTabsResults = storeOpenedTabsResults; + + if (isStoreOpenedTabsResultsChanged || isStoreOpenedTabsChanged) + { + if (m_storeOpenedTabsResults) + { + for (int tabIndex = (m_ui->tabWidget->count() - 1); tabIndex >= 0; --tabIndex) + { + const auto *tab = static_cast(m_ui->tabWidget->widget(tabIndex)); + QMetaObject::invokeMethod(m_dataStorage, [this, tabID = tab->id(), searchResults = tab->searchResults()] + { + m_dataStorage->storeTab(tabID, searchResults); + }); + } + } + else + { + for (int tabIndex = (m_ui->tabWidget->count() - 1); tabIndex >= 0; --tabIndex) + { + const auto *tab = static_cast(m_ui->tabWidget->widget(tabIndex)); + QMetaObject::invokeMethod(m_dataStorage, [this, tabID = tab->id()] { m_dataStorage->removeTab(tabID); }); + } + } + } +} + void SearchWidget::fillCatCombobox() { m_ui->comboCategory->clear(); @@ -259,6 +531,65 @@ QStringList SearchWidget::selectedPlugins() const return {itemText}; } +QString SearchWidget::generateTabID() const +{ + for (;;) + { + const QString tabID = QString::number(qHash(QDateTime::currentDateTimeUtc())); + if (!m_tabWidgets.contains(tabID)) + return tabID; + } + + return {}; +} + +int SearchWidget::addTab(const QString &tabID, SearchJobWidget *searchJobWdget) +{ + Q_ASSERT(!m_tabWidgets.contains(tabID)); + + connect(searchJobWdget, &SearchJobWidget::statusChanged, this, [this, searchJobWdget]() { tabStatusChanged(searchJobWdget); }); + m_tabWidgets.insert(tabID, searchJobWdget); + return m_ui->tabWidget->addTab(searchJobWdget, makeTabName(searchJobWdget)); +} + +void SearchWidget::saveSession() const +{ + if (!m_storeOpenedTabs) + return; + + const int currentIndex = m_ui->tabWidget->currentIndex(); + SessionData sessionData; + for (int tabIndex = 0; tabIndex < m_ui->tabWidget->count(); ++tabIndex) + { + auto *searchJobWidget = static_cast(m_ui->tabWidget->widget(tabIndex)); + sessionData.tabs.emplaceBack(TabData {searchJobWidget->id(), searchJobWidget->searchPattern()}); + if (currentIndex == tabIndex) + sessionData.currentTabID = searchJobWidget->id(); + } + + QMetaObject::invokeMethod(m_dataStorage, [this, sessionData] { m_dataStorage->storeSession(sessionData); }); +} + +void SearchWidget::restoreSession() +{ + if (!m_storeOpenedTabs) + return; + + connect(m_dataStorage, &DataStorage::tabLoaded, this + , [this](const QString &tabID, const QString &searchPattern, const QList &searchResults) + { + auto *restoredTab = new SearchJobWidget(tabID, searchPattern, searchResults, app(), this); + addTab(tabID, restoredTab); + }); + + connect(m_dataStorage, &DataStorage::sessionLoaded, this, [this](const SessionData &sessionData) + { + m_ui->tabWidget->setCurrentWidget(m_tabWidgets.value(sessionData.currentTabID)); + }); + + QMetaObject::invokeMethod(m_dataStorage, [this] { m_dataStorage->loadSession(m_storeOpenedTabsResults); }); +} + void SearchWidget::selectActivePage() { if (SearchPluginManager::instance()->allPlugins().isEmpty()) @@ -412,16 +743,14 @@ void SearchWidget::searchButtonClicked() auto *searchHandler = SearchPluginManager::instance()->startSearch(pattern, selectedCategory(), selectedPlugins()); // Tab Addition - auto *newTab = new SearchJobWidget(searchHandler, app(), this); - - QString tabName = pattern; - tabName.replace(QRegularExpression(u"&{1}"_s), u"&&"_s); - m_ui->tabWidget->addTab(newTab, tabName); + const QString newTabID = generateTabID(); + auto *newTab = new SearchJobWidget(newTabID, searchHandler, app(), this); + const int tabIndex = addTab(newTabID, newTab); + m_ui->tabWidget->setTabToolTip(tabIndex, newTab->statusTip()); + m_ui->tabWidget->setTabIcon(tabIndex, UIThemeManager::instance()->getIcon(statusIconName(newTab->status()))); m_ui->tabWidget->setCurrentWidget(newTab); - - connect(newTab, &SearchJobWidget::statusChanged, this, [this, newTab]() { tabStatusChanged(newTab); }); - - tabStatusChanged(newTab); + adjustSearchButton(); + saveSession(); } void SearchWidget::stopButtonClicked() @@ -442,19 +771,38 @@ void SearchWidget::tabStatusChanged(SearchJobWidget *tab) adjustSearchButton(); emit searchFinished(tab->status() == SearchJobWidget::Status::Error); + + if (m_storeOpenedTabsResults) + { + QMetaObject::invokeMethod(m_dataStorage, [this, tabID = tab->id(), searchResults = tab->searchResults()] + { + m_dataStorage->storeTab(tabID, searchResults); + }); + } } } void SearchWidget::closeTab(const int index) { - const QWidget *tab = m_ui->tabWidget->widget(index); - delete tab; + const auto *tab = static_cast(m_ui->tabWidget->widget(index)); + const QString tabID = tab->id(); + delete m_tabWidgets.take(tabID); + + QMetaObject::invokeMethod(m_dataStorage, [this, tabID] { m_dataStorage->removeTab(tabID); }); + saveSession(); } void SearchWidget::closeAllTabs() { - for (int i = (m_ui->tabWidget->count() - 1); i >= 0; --i) - closeTab(i); + for (int tabIndex = (m_ui->tabWidget->count() - 1); tabIndex >= 0; --tabIndex) + { + const auto *tab = static_cast(m_ui->tabWidget->widget(tabIndex)); + const QString tabID = tab->id(); + delete m_tabWidgets.take(tabID); + QMetaObject::invokeMethod(m_dataStorage, [this, tabID] { m_dataStorage->removeTab(tabID); }); + } + + saveSession(); } void SearchWidget::refreshTab(SearchJobWidget *searchJobWidget) @@ -468,5 +816,106 @@ void SearchWidget::refreshTab(SearchJobWidget *searchJobWidget) // Re-launch search auto *searchHandler = SearchPluginManager::instance()->startSearch(searchJobWidget->searchPattern(), selectedCategory(), selectedPlugins()); searchJobWidget->assignSearchHandler(searchHandler); - tabStatusChanged(searchJobWidget); } + +void SearchWidget::DataStorage::loadSession(const bool withSearchResults) +{ + const Path sessionFilePath = makeDataFilePath(SESSION_FILE_NAME); + const auto loadResult = ::loadSession(sessionFilePath); + if (!loadResult) + { + LogMsg(tr("Failed to load Search UI saved state data. File: \"%1\". Error: \"%2\"") + .arg(sessionFilePath.toString(), loadResult.error()), Log::WARNING); + return; + } + + const SessionData &sessionData = loadResult.value(); + + for (const auto &[tabID, searchPattern] : sessionData.tabs) + { + QList searchResults; + + if (withSearchResults) + { + const Path tabStateFilePath = makeDataFilePath(tabID + u".json"); + if (const auto loadTabStateResult = loadSearchResults(tabStateFilePath)) + { + searchResults = loadTabStateResult.value(); + } + else + { + LogMsg(tr("Failed to load saved search results. Tab: \"%1\". File: \"%2\". Error: \"%3\"") + .arg(searchPattern, tabStateFilePath.toString(), loadTabStateResult.error()), Log::WARNING); + } + } + + emit tabLoaded(tabID, searchPattern, searchResults); + } + + emit sessionLoaded(sessionData); +} + +void SearchWidget::DataStorage::storeSession(const SessionData &sessionData) +{ + QJsonArray tabsList; + for (const auto &[tabID, searchPattern] : sessionData.tabs) + { + const QJsonObject tabObj { + {u"ID"_s, tabID}, + {u"SearchPattern"_s, searchPattern} + }; + tabsList.append(tabObj); + } + + const QJsonObject sessionObj { + {u"Tabs"_s, tabsList}, + {u"CurrentTab"_s, sessionData.currentTabID} + }; + + const Path sessionFilePath = makeDataFilePath(SESSION_FILE_NAME); + const auto saveResult = Utils::IO::saveToFile(sessionFilePath, QJsonDocument(sessionObj).toJson()); + if (!saveResult) + { + LogMsg(tr("Failed to save Search UI state. File: \"%1\". Error: \"%2\"") + .arg(sessionFilePath.toString(), saveResult.error()), Log::WARNING); + } +} + +void SearchWidget::DataStorage::removeSession() +{ + Utils::Fs::removeFile(makeDataFilePath(SESSION_FILE_NAME)); +} + +void SearchWidget::DataStorage::storeTab(const QString &tabID, const QList &searchResults) +{ + QJsonArray searchResultsArray; + for (const SearchResult &searchResult : searchResults) + { + searchResultsArray.append(QJsonObject { + {KEY_RESULT_FILENAME, searchResult.fileName}, + {KEY_RESULT_FILEURL, searchResult.fileUrl}, + {KEY_RESULT_FILESIZE, searchResult.fileSize}, + {KEY_RESULT_SEEDERSCOUNT, searchResult.nbSeeders}, + {KEY_RESULT_LEECHERSCOUNT, searchResult.nbLeechers}, + {KEY_RESULT_ENGINENAME, searchResult.engineName}, + {KEY_RESULT_SITEURL, searchResult.siteUrl}, + {KEY_RESULT_DESCRLINK, searchResult.descrLink}, + {KEY_RESULT_PUBDATE, Utils::DateTime::toSecsSinceEpoch(searchResult.pubDate)} + }); + } + + const Path filePath = makeDataFilePath(tabID + u".json"); + const auto saveResult = Utils::IO::saveToFile(filePath, QJsonDocument(searchResultsArray).toJson()); + if (!saveResult) + { + LogMsg(tr("Failed to save search results. Tab: \"%1\". File: \"%2\". Error: \"%3\"") + .arg(tabID, filePath.toString(), saveResult.error()), Log::WARNING); + } +} + +void SearchWidget::DataStorage::removeTab(const QString &tabID) +{ + Utils::Fs::removeFile(makeDataFilePath(tabID + u".json")); +} + +#include "searchwidget.moc" diff --git a/src/gui/search/searchwidget.h b/src/gui/search/searchwidget.h index 9eca603ebe0f..330450c374c3 100644 --- a/src/gui/search/searchwidget.h +++ b/src/gui/search/searchwidget.h @@ -31,8 +31,10 @@ #pragma once #include +#include #include +#include "base/utils/thread.h" #include "gui/guiapplicationcomponent.h" class QEvent; @@ -62,6 +64,8 @@ class SearchWidget : public GUIApplicationComponent private: bool eventFilter(QObject *object, QEvent *event) override; + void onPreferencesChanged(); + void pluginsButtonClicked(); void searchButtonClicked(); void stopButtonClicked(); @@ -86,7 +90,22 @@ class SearchWidget : public GUIApplicationComponent QString selectedCategory() const; QStringList selectedPlugins() const; + QString generateTabID() const; + int addTab(const QString &tabID, SearchJobWidget *searchJobWdget); + + void saveSession() const; + void restoreSession(); + Ui::SearchWidget *m_ui = nullptr; QPointer m_currentSearchTab; // Selected tab bool m_isNewQueryString = false; + QHash m_tabWidgets; + + bool m_storeOpenedTabs = false; + bool m_storeOpenedTabsResults = false; + + Utils::Thread::UniquePtr m_ioThread; + + class DataStorage; + DataStorage *m_dataStorage = nullptr; };