diff --git a/src/gui/search/searchjobwidget.cpp b/src/gui/search/searchjobwidget.cpp index 75a9bdbc2822..14e00f783076 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); @@ -153,8 +154,6 @@ SearchJobWidget::SearchJobWidget(SearchHandler *searchHandler, IGUIApplication * fillFilterComboBoxes(); setStatusTip(statusText(m_status)); - assignSearchHandler(searchHandler); - m_lineEditSearchResultsFilter = new LineEdit(this); m_lineEditSearchResultsFilter->setPlaceholderText(tr("Filter search results...")); m_lineEditSearchResultsFilter->setContextMenuPolicy(Qt::CustomContextMenu); @@ -186,19 +185,44 @@ 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_status = Status::Finished; + + 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 +288,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 +298,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 +308,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 +389,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 +631,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..e3740532bbc4 100644 --- a/src/gui/search/searchjobwidget.h +++ b/src/gui/search/searchjobwidget.h @@ -76,10 +76,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 +101,8 @@ private slots: void displayColumnHeaderMenu(); private: + SearchJobWidget(const QString &id, IGUIApplication *app, QWidget *parent); + void loadSettings(); void saveSettings() const; void updateFilter(); @@ -127,6 +132,11 @@ 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; @@ -134,8 +144,6 @@ private slots: LineEdit *m_lineEditSearchResultsFilter = nullptr; Status m_status = Status::Ongoing; 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..6e1a43c07733 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 * @@ -40,6 +40,11 @@ #include #include +#include +#include +#include +#include +#include #include #include #include @@ -49,9 +54,14 @@ #include #include "base/global.h" +#include "base/logger.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" @@ -62,6 +72,9 @@ #define SEARCHHISTORY_MAXSIZE 50 #define URL_COLUMN 5 +const QString DATA_FOLDER_NAME = u"SearchUI"_s; +const QString STATE_FILE_NAME = u"Session.json"_s; + namespace { QString statusIconName(const SearchJobWidget::Status st) @@ -81,6 +94,82 @@ namespace return {}; } } + + 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 {}; + } + + 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(u"Invalid data format."_s); + + const QJsonArray resultsList = searchResultsDoc.array(); + QList searchResults; + for (const QJsonValue &resultVal : resultsList) + { + if (!resultVal.isObject()) + return nonstd::make_unexpected(u"Invalid data format."_s); + + const QJsonObject resultObj = resultVal.toObject(); + SearchResult &searchResult = searchResults.emplaceBack(); + + if (const QJsonValue fileNameVal = resultObj[u"FileName"]; fileNameVal.isString()) + searchResult.fileName = fileNameVal.toString(); + else + return nonstd::make_unexpected(u"Invalid data format."_s); + + if (const QJsonValue fileURLVal = resultObj[u"FileURL"]; fileURLVal.isString()) + searchResult.fileUrl= fileURLVal.toString(); + else + return nonstd::make_unexpected(u"Invalid data format."_s); + + if (const QJsonValue fileSizeVal = resultObj[u"FileSize"]; fileSizeVal.isDouble()) + searchResult.fileSize= fileSizeVal.toInteger(); + else + return nonstd::make_unexpected(u"Invalid data format."_s); + + if (const QJsonValue seedersCountVal = resultObj[u"SeedersCount"]; seedersCountVal.isDouble()) + searchResult.nbSeeders = seedersCountVal.toInteger(); + else + return nonstd::make_unexpected(u"Invalid data format."_s); + + if (const QJsonValue leechersCountVal = resultObj[u"LeechersCount"]; leechersCountVal.isDouble()) + searchResult.nbLeechers = leechersCountVal.toInteger(); + else + return nonstd::make_unexpected(u"Invalid data format."_s); + + if (const QJsonValue siteURLVal = resultObj[u"SiteURL"]; siteURLVal.isString()) + searchResult.siteUrl= siteURLVal.toString(); + else + return nonstd::make_unexpected(u"Invalid data format."_s); + + if (const QJsonValue descrLinkVal = resultObj[u"DescrLink"]; descrLinkVal.isString()) + searchResult.descrLink= descrLinkVal.toString(); + else + return nonstd::make_unexpected(u"Invalid data format."_s); + + if (const QJsonValue pubDateVal = resultObj[u"PubDate"]; pubDateVal.isDouble()) + searchResult.pubDate = QDateTime::fromSecsSinceEpoch(pubDateVal.toInteger()); + else + return nonstd::make_unexpected(u"Invalid data format."_s); + } + + return searchResults; + } } SearchWidget::SearchWidget(IGUIApplication *app, QWidget *parent) @@ -120,6 +209,7 @@ 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->tabBar(), &QTabBar::tabMoved, this, &SearchWidget::saveState); connect(m_ui->tabWidget, &QTabWidget::tabBarDoubleClicked, this, [this](const int tabIndex) { @@ -166,6 +256,8 @@ 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); + + restoreState(); } bool SearchWidget::eventFilter(QObject *object, QEvent *event) @@ -259,6 +351,190 @@ QStringList SearchWidget::selectedPlugins() const return {itemText}; } +QString SearchWidget::generateTabID() const +{ + for (;;) + { + const QString tabID = QString::number(qHash(QDateTime::currentDateTimeUtc())); + if (!m_tabIDs.contains(tabID)) + return tabID; + } + + return {}; +} + +void SearchWidget::addTab(const QString &tabID, SearchJobWidget *searchJobWdget) +{ + Q_ASSERT(!m_tabIDs.contains(tabID)); + + m_tabIDs.insert(tabID); + + QString tabName = searchJobWdget->searchPattern(); + tabName.replace(QRegularExpression(u"&{1}"_s), u"&&"_s); + m_ui->tabWidget->addTab(searchJobWdget, tabName); + + connect(searchJobWdget, &SearchJobWidget::statusChanged, this, [this, searchJobWdget]() { tabStatusChanged(searchJobWdget); }); +} + +Path SearchWidget::makeDataFilePath(const QString &fileName) const +{ + return specialFolderLocation(SpecialFolder::Data) / Path(DATA_FOLDER_NAME) / Path(fileName); +} + +void SearchWidget::saveSearchResults(SearchJobWidget *searchJobWidget) const +{ + QJsonArray searchResultsArray; + for (const SearchResult &searchResult : asConst(searchJobWidget->searchResults())) + { + searchResultsArray.append(QJsonObject { + {u"FileName"_s, searchResult.fileName}, + {u"FileURL"_s, searchResult.fileUrl}, + {u"FileSize"_s, searchResult.fileSize}, + {u"SeedersCount"_s, searchResult.nbSeeders}, + {u"LeechersCount"_s, searchResult.nbLeechers}, + {u"EngineName"_s, searchResult.engineName}, + {u"SiteURL"_s, searchResult.siteUrl}, + {u"DescrLink"_s, searchResult.descrLink}, + {u"PubDate"_s, Utils::DateTime::toSecsSinceEpoch(searchResult.pubDate)} + }); + } + + const QString tabID = searchJobWidget->id(); + 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::saveState() const +{ + QJsonArray tabsList; + for (int tabIndex = 0; tabIndex < m_ui->tabWidget->count(); ++tabIndex) + { + auto *searchJobWidget = static_cast(m_ui->tabWidget->widget(tabIndex)); + const QJsonObject tabObj { + {u"ID"_s, searchJobWidget->id()}, + {u"SearchPattern"_s, searchJobWidget->searchPattern()} + }; + tabsList.append(tabObj); + } + + const QJsonObject stateObj { + {u"Tabs"_s, tabsList} + }; + + const Path stateFilePath = makeDataFilePath(STATE_FILE_NAME); + const auto saveResult = Utils::IO::saveToFile(stateFilePath, QJsonDocument(stateObj).toJson()); + if (!saveResult) + { + LogMsg(tr("Failed to save Search UI state. File: \"%1\". Error: \"%2\"") + .arg(stateFilePath.toString(), saveResult.error()), Log::WARNING); + } +} + +void SearchWidget::restoreState() +{ + const int fileMaxSize = 10 * 1024 * 1024; + const Path stateFilePath = makeDataFilePath(STATE_FILE_NAME); + const auto readResult = Utils::IO::readFile(stateFilePath, fileMaxSize); + if (!readResult) + { + if (readResult.error().status != Utils::IO::ReadError::NotExist) + { + LogMsg(tr("Failed to read Search UI saved state. File: \"%1\". Error: \"%2\"") + .arg(stateFilePath.toString(), readResult.error().message), Log::WARNING); + } + + return; + } + + QJsonParseError jsonError; + const QJsonDocument stateDoc = QJsonDocument::fromJson(readResult.value(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) + { + LogMsg(tr("Failed to parse Search UI saved state data. File: \"%1\". Error: \"%2\"") + .arg(stateFilePath.toString(), jsonError.errorString()), Log::WARNING); + return; + } + + if (!stateDoc.isObject()) + { + LogMsg(tr("Failed to load Search UI saved state data. File: \"%1\". Error: \"Invalid data format.\"") + .arg(stateFilePath.toString()), Log::WARNING); + return; + } + + const QJsonObject stateObj = stateDoc.object(); + const QJsonValue tabsVal = stateObj[u"Tabs"]; + if (!tabsVal.isArray()) + { + LogMsg(tr("Failed to load Search UI saved state data. File: \"%1\". Error: \"Invalid data format.\"") + .arg(stateFilePath.toString()), Log::WARNING); + return; + } + + QHash tabs; + for (const QJsonValue &tabVal : asConst(tabsVal.toArray())) + { + if (!tabVal.isObject()) + { + LogMsg(tr("Failed to load Search UI saved state data. File: \"%1\". Error: \"Invalid data format.\"") + .arg(stateFilePath.toString()), Log::WARNING); + return; + } + + const QJsonObject tabObj = tabVal.toObject(); + + const QJsonValue tabIDVal = tabObj[u"ID"]; + if (!tabIDVal.isString()) + { + LogMsg(tr("Failed to load Search UI saved state data. File: \"%1\". Error: \"Invalid data format.\"") + .arg(stateFilePath.toString()), Log::WARNING); + return; + } + + const QJsonValue patternVal = tabObj[u"SearchPattern"]; + if (!patternVal.isString()) + { + LogMsg(tr("Failed to load Search UI saved state data. File: \"%1\". Error: \"Invalid data format.\"") + .arg(stateFilePath.toString()), Log::WARNING); + return; + } + + const QString tabID = tabIDVal.toString(); + if (m_tabIDs.contains(tabID)) + { + LogMsg(tr("Failed to load Search UI saved state data. File: \"%1\". Error: \"Detected duplicate tab ID.\"") + .arg(stateFilePath.toString()), Log::WARNING); + return; + } + + tabs[tabID] = patternVal.toString(); + } + + for (const auto &[tabID, searchPattern] : tabs.asKeyValueRange()) + { + QList searchResults; + + 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); + } + + auto *restoredTab = new SearchJobWidget(tabID, searchPattern, searchResults, app(), this); + addTab(tabID, restoredTab); + } +} + void SearchWidget::selectActivePage() { if (SearchPluginManager::instance()->allPlugins().isEmpty()) @@ -412,16 +688,12 @@ 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); + addTab(newTabID, newTab); m_ui->tabWidget->setCurrentWidget(newTab); - - connect(newTab, &SearchJobWidget::statusChanged, this, [this, newTab]() { tabStatusChanged(newTab); }); - tabStatusChanged(newTab); + saveState(); } void SearchWidget::stopButtonClicked() @@ -442,19 +714,34 @@ void SearchWidget::tabStatusChanged(SearchJobWidget *tab) adjustSearchButton(); emit searchFinished(tab->status() == SearchJobWidget::Status::Error); + + saveSearchResults(tab); } } void SearchWidget::closeTab(const int index) { - const QWidget *tab = m_ui->tabWidget->widget(index); + const auto *tab = static_cast(m_ui->tabWidget->widget(index)); + const QString tabID = tab->id(); + m_tabIDs.remove(tabID); delete tab; + + Utils::Fs::removeFile(makeDataFilePath(tabID + u".json")); + saveState(); } 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(); + m_tabIDs.remove(tabID); + Utils::Fs::removeFile(makeDataFilePath(tabID + u".json")); + delete tab; + } + + saveState(); } void SearchWidget::refreshTab(SearchJobWidget *searchJobWidget) diff --git a/src/gui/search/searchwidget.h b/src/gui/search/searchwidget.h index 9eca603ebe0f..d2aa1524355a 100644 --- a/src/gui/search/searchwidget.h +++ b/src/gui/search/searchwidget.h @@ -31,8 +31,10 @@ #pragma once #include +#include #include +#include "base/pathfwd.h" #include "gui/guiapplicationcomponent.h" class QEvent; @@ -86,7 +88,16 @@ class SearchWidget : public GUIApplicationComponent QString selectedCategory() const; QStringList selectedPlugins() const; + QString generateTabID() const; + void addTab(const QString &tabID, SearchJobWidget *searchJobWdget); + + Path makeDataFilePath(const QString &fileName) const; + void saveSearchResults(SearchJobWidget *searchJobWidget) const; + void saveState() const; + void restoreState(); + Ui::SearchWidget *m_ui = nullptr; QPointer m_currentSearchTab; // Selected tab bool m_isNewQueryString = false; + QSet m_tabIDs; };