Store opened search tabs

PR #22163.
Closes #167.
This commit is contained in:
Vladimir Golovnev 2025-01-26 17:12:50 +03:00 committed by GitHub
parent 3ef4d0d798
commit 3978137534
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 681 additions and 38 deletions

View file

@ -655,6 +655,32 @@ void Preferences::setSearchEnabled(const bool enabled)
setValue(u"Preferences/Search/SearchEnabled"_s, 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 bool Preferences::isWebUIEnabled() const
{ {
#ifdef DISABLE_GUI #ifdef DISABLE_GUI

View file

@ -172,6 +172,12 @@ public:
bool isSearchEnabled() const; bool isSearchEnabled() const;
void setSearchEnabled(bool enabled); void setSearchEnabled(bool enabled);
// Search UI
bool storeOpenedSearchTabs() const;
void setStoreOpenedSearchTabs(bool enabled);
bool storeOpenedSearchTabResults() const;
void setStoreOpenedSearchTabResults(bool enabled);
// HTTP Server // HTTP Server
bool isWebUIEnabled() const; bool isWebUIEnabled() const;
void setWebUIEnabled(bool enabled); void setWebUIEnabled(bool enabled);

View file

@ -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_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_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_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 #ifdef DISABLE_WEBUI
m_ui->tabSelection->item(TAB_WEBUI)->setHidden(true); m_ui->tabSelection->item(TAB_WEBUI)->setHidden(true);
#else #else
@ -190,6 +191,7 @@ OptionsDialog::OptionsDialog(IGUIApplication *app, QWidget *parent)
loadSpeedTabOptions(); loadSpeedTabOptions();
loadBittorrentTabOptions(); loadBittorrentTabOptions();
loadRSSTabOptions(); loadRSSTabOptions();
loadSearchTabOptions();
#ifndef DISABLE_WEBUI #ifndef DISABLE_WEBUI
loadWebUITabOptions(); loadWebUITabOptions();
#endif #endif
@ -1273,6 +1275,25 @@ void OptionsDialog::saveRSSTabOptions() const
autoDownloader->setDownloadRepacks(m_ui->checkSmartFilterDownloadRepacks->isChecked()); 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 #ifndef DISABLE_WEBUI
void OptionsDialog::loadWebUITabOptions() void OptionsDialog::loadWebUITabOptions()
{ {
@ -1465,6 +1486,7 @@ void OptionsDialog::saveOptions() const
saveSpeedTabOptions(); saveSpeedTabOptions();
saveBittorrentTabOptions(); saveBittorrentTabOptions();
saveRSSTabOptions(); saveRSSTabOptions();
saveSearchTabOptions();
#ifndef DISABLE_WEBUI #ifndef DISABLE_WEBUI
saveWebUITabOptions(); saveWebUITabOptions();
#endif #endif

View file

@ -73,6 +73,7 @@ class OptionsDialog final : public GUIApplicationComponent<QDialog>
TAB_CONNECTION, TAB_CONNECTION,
TAB_SPEED, TAB_SPEED,
TAB_BITTORRENT, TAB_BITTORRENT,
TAB_SEARCH,
TAB_RSS, TAB_RSS,
TAB_WEBUI, TAB_WEBUI,
TAB_ADVANCED TAB_ADVANCED
@ -136,6 +137,9 @@ private:
void loadRSSTabOptions(); void loadRSSTabOptions();
void saveRSSTabOptions() const; void saveRSSTabOptions() const;
void loadSearchTabOptions();
void saveSearchTabOptions() const;
#ifndef DISABLE_WEBUI #ifndef DISABLE_WEBUI
void loadWebUITabOptions(); void loadWebUITabOptions();
void saveWebUITabOptions() const; void saveWebUITabOptions() const;

View file

@ -72,6 +72,11 @@
<string>BitTorrent</string> <string>BitTorrent</string>
</property> </property>
</item> </item>
<item>
<property name="text">
<string>Search</string>
</property>
</item>
<item> <item>
<property name="text"> <property name="text">
<string>RSS</string> <string>RSS</string>
@ -3210,6 +3215,85 @@ Disable encryption: Only connect to peers without protocol encryption</string>
</item> </item>
</layout> </layout>
</widget> </widget>
<widget class="QWidget" name="tabSearchPage">
<layout class="QVBoxLayout" name="searchPageLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QScrollArea" name="searchPageScrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="searchPageScrollAreaWidgetContents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>521</width>
<height>541</height>
</rect>
</property>
<layout class="QVBoxLayout" name="searchPageScrollAreaWidgetContentsLayout">
<item>
<widget class="QGroupBox" name="groupSearchUI">
<property name="title">
<string>Search UI</string>
</property>
<layout class="QVBoxLayout" name="groupSearchUILayout">
<item>
<widget class="QGroupBox" name="groupStoreOpenedTabs">
<property name="title">
<string>Store opened tabs</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>false</bool>
</property>
<layout class="QVBoxLayout" name="groupStoreOpenedTabsLayout">
<item>
<widget class="QCheckBox" name="checkStoreTabsSearchResults">
<property name="text">
<string>Also store search results</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="searchPageScrollAreaSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>422</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tabRSSPage"> <widget class="QWidget" name="tabRSSPage">
<layout class="QVBoxLayout" name="verticalLayout_25"> <layout class="QVBoxLayout" name="verticalLayout_25">
<property name="leftMargin"> <property name="leftMargin">

View file

@ -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) : GUIApplicationComponent(app, parent)
, m_ui {new Ui::SearchJobWidget}
, m_nameFilteringMode {u"Search/FilteringMode"_s} , m_nameFilteringMode {u"Search/FilteringMode"_s}
, m_id {id}
, m_ui {new Ui::SearchJobWidget}
{ {
m_ui->setupUi(this); m_ui->setupUi(this);
@ -151,9 +152,6 @@ SearchJobWidget::SearchJobWidget(SearchHandler *searchHandler, IGUIApplication *
connect(header(), &QHeaderView::sortIndicatorChanged, this, &SearchJobWidget::saveSettings); connect(header(), &QHeaderView::sortIndicatorChanged, this, &SearchJobWidget::saveSettings);
fillFilterComboBoxes(); fillFilterComboBoxes();
setStatusTip(statusText(m_status));
assignSearchHandler(searchHandler);
m_lineEditSearchResultsFilter = new LineEdit(this); m_lineEditSearchResultsFilter = new LineEdit(this);
m_lineEditSearchResultsFilter->setPlaceholderText(tr("Filter search results...")); m_lineEditSearchResultsFilter->setPlaceholderText(tr("Filter search results..."));
@ -186,19 +184,42 @@ SearchJobWidget::SearchJobWidget(SearchHandler *searchHandler, IGUIApplication *
connect(UIThemeManager::instance(), &UIThemeManager::themeChanged, this, &SearchJobWidget::onUIThemeChanged); connect(UIThemeManager::instance(), &UIThemeManager::themeChanged, this, &SearchJobWidget::onUIThemeChanged);
} }
SearchJobWidget::SearchJobWidget(const QString &id, const QString &searchPattern
, const QList<SearchResult> &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() SearchJobWidget::~SearchJobWidget()
{ {
saveSettings(); saveSettings();
delete m_ui; delete m_ui;
} }
QString SearchJobWidget::id() const
{
return m_id;
}
QString SearchJobWidget::searchPattern() const QString SearchJobWidget::searchPattern() const
{ {
Q_ASSERT(m_searchHandler); return m_searchPattern;
if (!m_searchHandler) [[unlikely]] }
return {};
return m_searchHandler->pattern(); QList<SearchResult> SearchJobWidget::searchResults() const
{
return m_searchResults;
} }
void SearchJobWidget::onItemDoubleClicked(const QModelIndex &index) void SearchJobWidget::onItemDoubleClicked(const QModelIndex &index)
@ -264,6 +285,7 @@ void SearchJobWidget::assignSearchHandler(SearchHandler *searchHandler)
if (!searchHandler) [[unlikely]] if (!searchHandler) [[unlikely]]
return; return;
m_searchResults.clear();
m_searchListModel->removeRows(0, m_searchListModel->rowCount()); m_searchListModel->removeRows(0, m_searchListModel->rowCount());
delete m_searchHandler; delete m_searchHandler;
@ -273,7 +295,9 @@ void SearchJobWidget::assignSearchHandler(SearchHandler *searchHandler)
connect(m_searchHandler, &SearchHandler::searchFinished, this, &SearchJobWidget::searchFinished); connect(m_searchHandler, &SearchHandler::searchFinished, this, &SearchJobWidget::searchFinished);
connect(m_searchHandler, &SearchHandler::searchFailed, this, &SearchJobWidget::searchFailed); 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(); updateFilter();
setStatus(Status::Ongoing); setStatus(Status::Ongoing);
@ -281,8 +305,7 @@ void SearchJobWidget::assignSearchHandler(SearchHandler *searchHandler)
void SearchJobWidget::cancelSearch() void SearchJobWidget::cancelSearch()
{ {
Q_ASSERT(m_searchHandler); if (!m_searchHandler)
if (!m_searchHandler) [[unlikely]]
return; return;
m_searchHandler->cancelSearch(); m_searchHandler->cancelSearch();
@ -363,7 +386,7 @@ void SearchJobWidget::downloadTorrent(const QModelIndex &rowIndex, const AddTorr
} }
else else
{ {
SearchDownloadHandler *downloadHandler = m_searchHandler->manager()->downloadTorrent(engineName, torrentUrl); SearchDownloadHandler *downloadHandler = SearchPluginManager::instance()->downloadTorrent(engineName, torrentUrl);
connect(downloadHandler, &SearchDownloadHandler::downloadFinished connect(downloadHandler, &SearchDownloadHandler::downloadFinished
, this, [this, option](const QString &source) { addTorrentToSession(source, option); }); , this, [this, option](const QString &source) { addTorrentToSession(source, option); });
connect(downloadHandler, &SearchDownloadHandler::downloadFinished, downloadHandler, &SearchDownloadHandler::deleteLater); connect(downloadHandler, &SearchDownloadHandler::downloadFinished, downloadHandler, &SearchDownloadHandler::deleteLater);
@ -605,6 +628,7 @@ void SearchJobWidget::appendSearchResults(const QList<SearchResult> &results)
setModelData(SearchSortModel::PUB_DATE, QLocale().toString(result.pubDate.toLocalTime(), QLocale::ShortFormat), result.pubDate); setModelData(SearchSortModel::PUB_DATE, QLocale().toString(result.pubDate.toLocalTime(), QLocale::ShortFormat), result.pubDate);
} }
m_searchResults.append(results);
updateResultsCount(); updateResultsCount();
} }

View file

@ -69,6 +69,7 @@ public:
enum class Status enum class Status
{ {
Ready,
Ongoing, Ongoing,
Finished, Finished,
Error, Error,
@ -76,10 +77,13 @@ public:
NoResults NoResults
}; };
SearchJobWidget(SearchHandler *searchHandler, IGUIApplication *app, QWidget *parent = nullptr); SearchJobWidget(const QString &id, const QString &searchPattern, const QList<SearchResult> &searchResults, IGUIApplication *app, QWidget *parent = nullptr);
SearchJobWidget(const QString &id, SearchHandler *searchHandler, IGUIApplication *app, QWidget *parent = nullptr);
~SearchJobWidget() override; ~SearchJobWidget() override;
QString id() const;
QString searchPattern() const; QString searchPattern() const;
QList<SearchResult> searchResults() const;
Status status() const; Status status() const;
int visibleResultsCount() const; int visibleResultsCount() const;
LineEdit *lineEditSearchResultsFilter() const; LineEdit *lineEditSearchResultsFilter() const;
@ -98,6 +102,8 @@ private slots:
void displayColumnHeaderMenu(); void displayColumnHeaderMenu();
private: private:
SearchJobWidget(const QString &id, IGUIApplication *app, QWidget *parent);
void loadSettings(); void loadSettings();
void saveSettings() const; void saveSettings() const;
void updateFilter(); void updateFilter();
@ -127,15 +133,18 @@ private:
void copyTorrentNames() const; void copyTorrentNames() const;
void copyField(int column) const; void copyField(int column) const;
SettingValue<NameFilteringMode> m_nameFilteringMode;
QString m_id;
QString m_searchPattern;
QList<SearchResult> m_searchResults;
Ui::SearchJobWidget *m_ui = nullptr; Ui::SearchJobWidget *m_ui = nullptr;
SearchHandler *m_searchHandler = nullptr; SearchHandler *m_searchHandler = nullptr;
QStandardItemModel *m_searchListModel = nullptr; QStandardItemModel *m_searchListModel = nullptr;
SearchSortModel *m_proxyModel = nullptr; SearchSortModel *m_proxyModel = nullptr;
LineEdit *m_lineEditSearchResultsFilter = nullptr; LineEdit *m_lineEditSearchResultsFilter = nullptr;
Status m_status = Status::Ongoing; Status m_status = Status::Ready;
bool m_noSearchResults = true; bool m_noSearchResults = true;
SettingValue<NameFilteringMode> m_nameFilteringMode;
}; };
Q_DECLARE_METATYPE(SearchJobWidget::NameFilteringMode) Q_DECLARE_METATYPE(SearchJobWidget::NameFilteringMode)

View file

@ -1,6 +1,6 @@
/* /*
* Bittorrent Client using Qt and libtorrent. * Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2015-2024 Vladimir Golovnev <glassez@yandex.ru> * Copyright (C) 2015-2025 Vladimir Golovnev <glassez@yandex.ru>
* Copyright (C) 2020, Will Da Silva <will@willdasilva.xyz> * Copyright (C) 2020, Will Da Silva <will@willdasilva.xyz>
* Copyright (C) 2006 Christophe Dumez <chris@qbittorrent.org> * Copyright (C) 2006 Christophe Dumez <chris@qbittorrent.org>
* *
@ -34,12 +34,13 @@
#include <utility> #include <utility>
#ifdef Q_OS_WIN
#include <cstdlib>
#endif
#include <QDebug> #include <QDebug>
#include <QEvent> #include <QEvent>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QList> #include <QList>
#include <QMenu> #include <QMenu>
#include <QMessageBox> #include <QMessageBox>
@ -47,11 +48,18 @@
#include <QObject> #include <QObject>
#include <QRegularExpression> #include <QRegularExpression>
#include <QShortcut> #include <QShortcut>
#include <QThread>
#include "base/global.h" #include "base/global.h"
#include "base/logger.h"
#include "base/preferences.h"
#include "base/profile.h"
#include "base/search/searchhandler.h" #include "base/search/searchhandler.h"
#include "base/search/searchpluginmanager.h" #include "base/search/searchpluginmanager.h"
#include "base/utils/datetime.h"
#include "base/utils/fs.h"
#include "base/utils/foreignapps.h" #include "base/utils/foreignapps.h"
#include "base/utils/io.h"
#include "gui/desktopintegration.h" #include "gui/desktopintegration.h"
#include "gui/interfaces/iguiapplication.h" #include "gui/interfaces/iguiapplication.h"
#include "gui/uithememanager.h" #include "gui/uithememanager.h"
@ -59,11 +67,37 @@
#include "searchjobwidget.h" #include "searchjobwidget.h"
#include "ui_searchwidget.h" #include "ui_searchwidget.h"
#define SEARCHHISTORY_MAXSIZE 50 const QString DATA_FOLDER_NAME = u"SearchUI"_s;
#define URL_COLUMN 5 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 namespace
{ {
struct TabData
{
QString tabID;
QString searchPattern;
};
struct SessionData
{
QList<TabData> tabs;
QString currentTabID;
};
QString statusIconName(const SearchJobWidget::Status st) QString statusIconName(const SearchJobWidget::Status st)
{ {
switch (st) switch (st)
@ -81,11 +115,187 @@ namespace
return {}; 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<SessionData, QString> 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<TabData> tabs;
QSet<QString> 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<QList<SearchResult>, 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<SearchResult> 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<SearchResult> &searchResults);
void removeTab(const QString &tabID);
signals:
void sessionLoaded(const SessionData &sessionData);
void tabLoaded(const QString &tabID, const QString &searchPattern, const QList<SearchResult> &searchResults);
};
SearchWidget::SearchWidget(IGUIApplication *app, QWidget *parent) SearchWidget::SearchWidget(IGUIApplication *app, QWidget *parent)
: GUIApplicationComponent(app, parent) : GUIApplicationComponent(app, parent)
, m_ui {new Ui::SearchWidget()} , m_ui {new Ui::SearchWidget()}
, m_ioThread {new QThread}
, m_dataStorage {new DataStorage(this)}
{ {
m_ui->setupUi(this); m_ui->setupUi(this);
@ -120,6 +330,8 @@ SearchWidget::SearchWidget(IGUIApplication *app, QWidget *parent)
#endif #endif
connect(m_ui->tabWidget, &QTabWidget::tabCloseRequested, this, &SearchWidget::closeTab); 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::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) 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); connect(focusSearchHotkey, &QShortcut::activated, this, &SearchWidget::toggleFocusBetweenLineEdits);
const auto *focusSearchHotkeyAlternative = new QShortcut((Qt::CTRL | Qt::Key_E), this); const auto *focusSearchHotkeyAlternative = new QShortcut((Qt::CTRL | Qt::Key_E), this);
connect(focusSearchHotkeyAlternative, &QShortcut::activated, this, &SearchWidget::toggleFocusBetweenLineEdits); 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) bool SearchWidget::eventFilter(QObject *object, QEvent *event)
@ -199,6 +422,55 @@ bool SearchWidget::eventFilter(QObject *object, QEvent *event)
return QWidget::eventFilter(object, 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<SearchJobWidget *>(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<SearchJobWidget *>(m_ui->tabWidget->widget(tabIndex));
QMetaObject::invokeMethod(m_dataStorage, [this, tabID = tab->id()] { m_dataStorage->removeTab(tabID); });
}
}
}
}
void SearchWidget::fillCatCombobox() void SearchWidget::fillCatCombobox()
{ {
m_ui->comboCategory->clear(); m_ui->comboCategory->clear();
@ -259,6 +531,65 @@ QStringList SearchWidget::selectedPlugins() const
return {itemText}; 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<SearchJobWidget *>(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<SearchResult> &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() void SearchWidget::selectActivePage()
{ {
if (SearchPluginManager::instance()->allPlugins().isEmpty()) if (SearchPluginManager::instance()->allPlugins().isEmpty())
@ -412,16 +743,14 @@ void SearchWidget::searchButtonClicked()
auto *searchHandler = SearchPluginManager::instance()->startSearch(pattern, selectedCategory(), selectedPlugins()); auto *searchHandler = SearchPluginManager::instance()->startSearch(pattern, selectedCategory(), selectedPlugins());
// Tab Addition // Tab Addition
auto *newTab = new SearchJobWidget(searchHandler, app(), this); const QString newTabID = generateTabID();
auto *newTab = new SearchJobWidget(newTabID, searchHandler, app(), this);
QString tabName = pattern; const int tabIndex = addTab(newTabID, newTab);
tabName.replace(QRegularExpression(u"&{1}"_s), u"&&"_s); m_ui->tabWidget->setTabToolTip(tabIndex, newTab->statusTip());
m_ui->tabWidget->addTab(newTab, tabName); m_ui->tabWidget->setTabIcon(tabIndex, UIThemeManager::instance()->getIcon(statusIconName(newTab->status())));
m_ui->tabWidget->setCurrentWidget(newTab); m_ui->tabWidget->setCurrentWidget(newTab);
adjustSearchButton();
connect(newTab, &SearchJobWidget::statusChanged, this, [this, newTab]() { tabStatusChanged(newTab); }); saveSession();
tabStatusChanged(newTab);
} }
void SearchWidget::stopButtonClicked() void SearchWidget::stopButtonClicked()
@ -442,19 +771,38 @@ void SearchWidget::tabStatusChanged(SearchJobWidget *tab)
adjustSearchButton(); adjustSearchButton();
emit searchFinished(tab->status() == SearchJobWidget::Status::Error); 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) void SearchWidget::closeTab(const int index)
{ {
const QWidget *tab = m_ui->tabWidget->widget(index); const auto *tab = static_cast<SearchJobWidget *>(m_ui->tabWidget->widget(index));
delete tab; const QString tabID = tab->id();
delete m_tabWidgets.take(tabID);
QMetaObject::invokeMethod(m_dataStorage, [this, tabID] { m_dataStorage->removeTab(tabID); });
saveSession();
} }
void SearchWidget::closeAllTabs() void SearchWidget::closeAllTabs()
{ {
for (int i = (m_ui->tabWidget->count() - 1); i >= 0; --i) for (int tabIndex = (m_ui->tabWidget->count() - 1); tabIndex >= 0; --tabIndex)
closeTab(i); {
const auto *tab = static_cast<SearchJobWidget *>(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) void SearchWidget::refreshTab(SearchJobWidget *searchJobWidget)
@ -468,5 +816,106 @@ void SearchWidget::refreshTab(SearchJobWidget *searchJobWidget)
// Re-launch search // Re-launch search
auto *searchHandler = SearchPluginManager::instance()->startSearch(searchJobWidget->searchPattern(), selectedCategory(), selectedPlugins()); auto *searchHandler = SearchPluginManager::instance()->startSearch(searchJobWidget->searchPattern(), selectedCategory(), selectedPlugins());
searchJobWidget->assignSearchHandler(searchHandler); 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<SearchResult> 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<SearchResult> &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"

View file

@ -31,8 +31,10 @@
#pragma once #pragma once
#include <QPointer> #include <QPointer>
#include <QSet>
#include <QWidget> #include <QWidget>
#include "base/utils/thread.h"
#include "gui/guiapplicationcomponent.h" #include "gui/guiapplicationcomponent.h"
class QEvent; class QEvent;
@ -62,6 +64,8 @@ signals:
private: private:
bool eventFilter(QObject *object, QEvent *event) override; bool eventFilter(QObject *object, QEvent *event) override;
void onPreferencesChanged();
void pluginsButtonClicked(); void pluginsButtonClicked();
void searchButtonClicked(); void searchButtonClicked();
void stopButtonClicked(); void stopButtonClicked();
@ -86,7 +90,22 @@ private:
QString selectedCategory() const; QString selectedCategory() const;
QStringList selectedPlugins() const; QStringList selectedPlugins() const;
QString generateTabID() const;
int addTab(const QString &tabID, SearchJobWidget *searchJobWdget);
void saveSession() const;
void restoreSession();
Ui::SearchWidget *m_ui = nullptr; Ui::SearchWidget *m_ui = nullptr;
QPointer<SearchJobWidget> m_currentSearchTab; // Selected tab QPointer<SearchJobWidget> m_currentSearchTab; // Selected tab
bool m_isNewQueryString = false; bool m_isNewQueryString = false;
QHash<QString, SearchJobWidget *> m_tabWidgets;
bool m_storeOpenedTabs = false;
bool m_storeOpenedTabsResults = false;
Utils::Thread::UniquePtr m_ioThread;
class DataStorage;
DataStorage *m_dataStorage = nullptr;
}; };