diff --git a/src/base/bittorrent/addtorrentparams.h b/src/base/bittorrent/addtorrentparams.h index 4427bc7fc..32269e868 100644 --- a/src/base/bittorrent/addtorrentparams.h +++ b/src/base/bittorrent/addtorrentparams.h @@ -28,6 +28,7 @@ #pragma once +#include #include #include @@ -39,6 +40,7 @@ namespace BitTorrent { QString name; QString category; + QSet tags; QString savePath; bool disableTempPath = false; // e.g. for imported torrents bool sequential = false; diff --git a/src/base/bittorrent/session.cpp b/src/base/bittorrent/session.cpp index 02d361a82..f3b9774ed 100644 --- a/src/base/bittorrent/session.cpp +++ b/src/base/bittorrent/session.cpp @@ -127,6 +127,43 @@ namespace return result; } + template + QSet entryListToSetImpl(const Entry &entry) + { + Q_ASSERT(entry.type() == Entry::list_t); + QSet output; + for (int i = 0; i < entry.list_size(); ++i) { + const QString tag = QString::fromStdString(entry.list_string_value_at(i)); + if (Session::isValidTag(tag)) + output.insert(tag); + else + qWarning() << QString("Dropping invalid stored tag: %1").arg(tag); + } + return output; + } + +#if LIBTORRENT_VERSION_NUM < 10100 + bool isList(const libt::lazy_entry *entry) + { + return entry && (entry->type() == libt::lazy_entry::list_t); + } + + QSet entryListToSet(const libt::lazy_entry *entry) + { + return entry ? entryListToSetImpl(*entry) : QSet(); + } +#else + bool isList(const libt::bdecode_node &entry) + { + return entry.type() == libt::bdecode_node::list_t; + } + + QSet entryListToSet(const libt::bdecode_node &entry) + { + return entryListToSetImpl(entry); + } +#endif + QString normalizePath(const QString &path) { QString tmp = Utils::Fs::fromNativePath(path.trimmed()); @@ -260,6 +297,7 @@ Session::Session(QObject *parent) , m_isForceProxyEnabled(BITTORRENT_SESSION_KEY("ForceProxy"), true) , m_isProxyPeerConnectionsEnabled(BITTORRENT_SESSION_KEY("ProxyPeerConnections"), false) , m_storedCategories(BITTORRENT_SESSION_KEY("Categories")) + , m_storedTags(BITTORRENT_SESSION_KEY("Tags")) , m_maxRatioAction(BITTORRENT_SESSION_KEY("MaxRatioAction"), Pause) , m_defaultSavePath(BITTORRENT_SESSION_KEY("DefaultSavePath"), specialFolderLocation(SpecialFolder::Downloads), normalizePath) , m_tempPath(BITTORRENT_SESSION_KEY("TempPath"), defaultSavePath() + "temp/", normalizePath) @@ -400,6 +438,8 @@ Session::Session(QObject *parent) m_storedCategories = map_cast(m_categories); } + m_tags = QSet::fromList(m_storedTags.value()); + m_refreshTimer = new QTimer(this); m_refreshTimer->setInterval(refreshInterval()); connect(m_refreshTimer, SIGNAL(timeout()), SLOT(refresh())); @@ -724,6 +764,47 @@ void Session::setSubcategoriesEnabled(bool value) emit subcategoriesSupportChanged(); } +QSet Session::tags() const +{ + return m_tags; +} + +bool Session::isValidTag(const QString &tag) +{ + return (!tag.trimmed().isEmpty() && !tag.contains(',')); +} + +bool Session::hasTag(const QString &tag) const +{ + return m_tags.contains(tag); +} + +bool Session::addTag(const QString &tag) +{ + if (!isValidTag(tag)) + return false; + + if (!hasTag(tag)) { + m_tags.insert(tag); + m_storedTags = m_tags.toList(); + emit tagAdded(tag); + return true; + } + return false; +} + +bool Session::removeTag(const QString &tag) +{ + if (m_tags.remove(tag)) { + foreach (TorrentHandle *const torrent, torrents()) + torrent->removeTag(tag); + m_storedTags = m_tags.toList(); + emit tagRemoved(tag); + return true; + } + return false; +} + bool Session::isAutoTMMDisabledByDefault() const { return m_isAutoTMMDisabledByDefault; @@ -2997,6 +3078,16 @@ void Session::handleTorrentCategoryChanged(TorrentHandle *const torrent, const Q emit torrentCategoryChanged(torrent, oldCategory); } +void Session::handleTorrentTagAdded(TorrentHandle *const torrent, const QString &tag) +{ + emit torrentTagAdded(torrent, tag); +} + +void Session::handleTorrentTagRemoved(TorrentHandle *const torrent, const QString &tag) +{ + emit torrentTagRemoved(torrent, tag); +} + void Session::handleTorrentSavingModeChanged(TorrentHandle * const torrent) { emit torrentSavingModeChanged(torrent); @@ -3930,6 +4021,10 @@ namespace if (torrentData.category.isEmpty()) // ************************************************************************************** torrentData.category = QString::fromStdString(fast.dict_find_string_value("qBt-category")); + // auto because the return type depends on the #if above. + const auto tagsEntry = fast.dict_find_list("qBt-tags"); + if (isList(tagsEntry)) + torrentData.tags = entryListToSet(tagsEntry); torrentData.name = QString::fromStdString(fast.dict_find_string_value("qBt-name")); torrentData.hasSeedStatus = fast.dict_find_int_value("qBt-seedStatus"); torrentData.disableTempPath = fast.dict_find_int_value("qBt-tempPathDisabled"); diff --git a/src/base/bittorrent/session.h b/src/base/bittorrent/session.h index eda074d31..35221b657 100644 --- a/src/base/bittorrent/session.h +++ b/src/base/bittorrent/session.h @@ -44,6 +44,7 @@ #endif #include #include +#include #include #include #include @@ -223,6 +224,12 @@ namespace BitTorrent bool isSubcategoriesEnabled() const; void setSubcategoriesEnabled(bool value); + static bool isValidTag(const QString &tag); + QSet tags() const; + bool hasTag(const QString &tag) const; + bool addTag(const QString &tag); + bool removeTag(const QString &tag); + // Torrent Management Mode subsystem (TMM) // // Each torrent can be either in Manual mode or in Automatic mode @@ -400,6 +407,8 @@ namespace BitTorrent void handleTorrentShareLimitChanged(TorrentHandle *const torrent); void handleTorrentSavePathChanged(TorrentHandle *const torrent); void handleTorrentCategoryChanged(TorrentHandle *const torrent, const QString &oldCategory); + void handleTorrentTagAdded(TorrentHandle *const torrent, const QString &tag); + void handleTorrentTagRemoved(TorrentHandle *const torrent, const QString &tag); void handleTorrentSavingModeChanged(TorrentHandle *const torrent); void handleTorrentMetadataReceived(TorrentHandle *const torrent); void handleTorrentPaused(TorrentHandle *const torrent); @@ -431,6 +440,8 @@ namespace BitTorrent void torrentFinishedChecking(BitTorrent::TorrentHandle *const torrent); void torrentSavePathChanged(BitTorrent::TorrentHandle *const torrent); void torrentCategoryChanged(BitTorrent::TorrentHandle *const torrent, const QString &oldCategory); + void torrentTagAdded(TorrentHandle *const torrent, const QString &tag); + void torrentTagRemoved(TorrentHandle *const torrent, const QString &tag); void torrentSavingModeChanged(BitTorrent::TorrentHandle *const torrent); void allTorrentsFinished(); void metadataLoaded(const BitTorrent::TorrentInfo &info); @@ -452,6 +463,8 @@ namespace BitTorrent void categoryAdded(const QString &categoryName); void categoryRemoved(const QString &categoryName); void subcategoriesSupportChanged(); + void tagAdded(const QString &tag); + void tagRemoved(const QString &tag); private slots: void configureDeferred(); @@ -606,6 +619,7 @@ namespace BitTorrent CachedSettingValue m_isForceProxyEnabled; CachedSettingValue m_isProxyPeerConnectionsEnabled; CachedSettingValue m_storedCategories; + CachedSettingValue m_storedTags; CachedSettingValue m_maxRatioAction; CachedSettingValue m_defaultSavePath; CachedSettingValue m_tempPath; @@ -650,6 +664,7 @@ namespace BitTorrent QHash m_downloadedTorrents; TorrentStatusReport m_torrentStatusReport; QStringMap m_categories; + QSet m_tags; #if LIBTORRENT_VERSION_NUM < 10100 QMutex m_alertsMutex; diff --git a/src/base/bittorrent/torrenthandle.cpp b/src/base/bittorrent/torrenthandle.cpp index ac899faa6..91af8f13c 100644 --- a/src/base/bittorrent/torrenthandle.cpp +++ b/src/base/bittorrent/torrenthandle.cpp @@ -68,6 +68,19 @@ const QString QB_EXT {".!qB"}; namespace libt = libtorrent; using namespace BitTorrent; +namespace +{ + using ListType = libt::entry::list_type; + + ListType setToEntryList(const QSet &input) + { + ListType entryList; + foreach (const QString &setValue, input) + entryList.emplace_back(setValue.toStdString()); + return entryList; + } +} + // AddTorrentData AddTorrentData::AddTorrentData() @@ -89,6 +102,7 @@ AddTorrentData::AddTorrentData(const AddTorrentParams ¶ms) : resumed(false) , name(params.name) , category(params.category) + , tags(params.tags) , savePath(params.savePath) , disableTempPath(params.disableTempPath) , sequential(params.sequential) @@ -213,6 +227,7 @@ TorrentHandle::TorrentHandle(Session *session, const libtorrent::torrent_handle , m_name(data.name) , m_savePath(Utils::Fs::toNativePath(data.savePath)) , m_category(data.category) + , m_tags(data.tags) , m_hasSeedStatus(data.hasSeedStatus) , m_ratioLimit(data.ratioLimit) , m_seedingTimeLimit(data.seedingTimeLimit) @@ -578,6 +593,50 @@ bool TorrentHandle::belongsToCategory(const QString &category) const return false; } +QSet TorrentHandle::tags() const +{ + return m_tags; +} + +bool TorrentHandle::hasTag(const QString &tag) const +{ + return m_tags.contains(tag); +} + +bool TorrentHandle::addTag(const QString &tag) +{ + if (!Session::isValidTag(tag)) + return false; + + if (!hasTag(tag)) { + if (!m_session->hasTag(tag)) + if (!m_session->addTag(tag)) + return false; + m_tags.insert(tag); + m_session->handleTorrentTagAdded(this, tag); + m_needSaveResumeData = true; + return true; + } + return false; +} + +bool TorrentHandle::removeTag(const QString &tag) +{ + if (m_tags.remove(tag)) { + m_session->handleTorrentTagRemoved(this, tag); + m_needSaveResumeData = true; + return true; + } + return false; +} + +void TorrentHandle::removeAllTags() +{ + // QT automatically copies the container in foreach, so it's safe to mutate it. + foreach (const QString &tag, m_tags) + removeTag(tag); +} + QDateTime TorrentHandle::addedTime() const { return QDateTime::fromTime_t(m_nativeStatus.added_time); @@ -1617,6 +1676,7 @@ void TorrentHandle::handleSaveResumeDataAlert(libtorrent::save_resume_data_alert resumeData["qBt-ratioLimit"] = QString::number(m_ratioLimit).toStdString(); resumeData["qBt-seedingTimeLimit"] = QString::number(m_seedingTimeLimit).toStdString(); resumeData["qBt-category"] = m_category.toStdString(); + resumeData["qBt-tags"] = setToEntryList(m_tags); resumeData["qBt-name"] = m_name.toStdString(); resumeData["qBt-seedStatus"] = m_hasSeedStatus; resumeData["qBt-tempPathDisabled"] = m_tempPathDisabled; diff --git a/src/base/bittorrent/torrenthandle.h b/src/base/bittorrent/torrenthandle.h index 9de52e4d9..8dcba9812 100644 --- a/src/base/bittorrent/torrenthandle.h +++ b/src/base/bittorrent/torrenthandle.h @@ -30,12 +30,13 @@ #ifndef BITTORRENT_TORRENTHANDLE_H #define BITTORRENT_TORRENTHANDLE_H -#include -#include #include +#include +#include #include +#include +#include #include -#include #include #include @@ -93,6 +94,7 @@ namespace BitTorrent // for both new and resumed torrents QString name; QString category; + QSet tags; QString savePath; bool disableTempPath; bool sequential; @@ -248,6 +250,12 @@ namespace BitTorrent bool belongsToCategory(const QString &category) const; bool setCategory(const QString &category); + QSet tags() const; + bool hasTag(const QString &tag) const; + bool addTag(const QString &tag); + bool removeTag(const QString &tag); + void removeAllTags(); + bool hasRootFolder() const; int filesCount() const; @@ -445,6 +453,7 @@ namespace BitTorrent QString m_name; QString m_savePath; QString m_category; + QSet m_tags; bool m_hasSeedStatus; qreal m_ratioLimit; int m_seedingTimeLimit; diff --git a/src/base/preferences.cpp b/src/base/preferences.cpp index 810413620..4b413420e 100644 --- a/src/base/preferences.cpp +++ b/src/base/preferences.cpp @@ -1064,6 +1064,16 @@ void Preferences::setConfirmTorrentRecheck(bool enabled) setValue("Preferences/Advanced/confirmTorrentRecheck", enabled); } +bool Preferences::confirmRemoveAllTags() const +{ + return value("Preferences/Advanced/confirmRemoveAllTags", true).toBool(); +} + +void Preferences::setConfirmRemoveAllTags(bool enabled) +{ + setValue("Preferences/Advanced/confirmRemoveAllTags", enabled); +} + TrayIcon::Style Preferences::trayIconStyle() const { return TrayIcon::Style(value("Preferences/Advanced/TrayIconStyle", TrayIcon::NORMAL).toInt()); @@ -1327,6 +1337,16 @@ void Preferences::setCategoryFilterState(const bool checked) setValue("TransferListFilters/CategoryFilterState", checked); } +bool Preferences::getTagFilterState() const +{ + return value("TransferListFilters/TagFilterState", true).toBool(); +} + +void Preferences::setTagFilterState(const bool checked) +{ + setValue("TransferListFilters/TagFilterState", checked); +} + bool Preferences::getTrackerFilterState() const { return value("TransferListFilters/trackerFilterState", true).toBool(); diff --git a/src/base/preferences.h b/src/base/preferences.h index 3a806549e..9ff99216c 100644 --- a/src/base/preferences.h +++ b/src/base/preferences.h @@ -260,6 +260,8 @@ public: void setConfirmTorrentDeletion(bool enabled); bool confirmTorrentRecheck() const; void setConfirmTorrentRecheck(bool enabled); + bool confirmRemoveAllTags() const; + void setConfirmRemoveAllTags(bool enabled); TrayIcon::Style trayIconStyle() const; void setTrayIconStyle(TrayIcon::Style style); @@ -313,6 +315,7 @@ public: void setTorImportGeometry(const QByteArray &geometry); bool getStatusFilterState() const; bool getCategoryFilterState() const; + bool getTagFilterState() const; bool getTrackerFilterState() const; int getTransSelFilter() const; void setTransSelFilter(const int &index); @@ -340,6 +343,7 @@ public: public slots: void setStatusFilterState(bool checked); void setCategoryFilterState(bool checked); + void setTagFilterState(bool checked); void setTrackerFilterState(bool checked); void apply(); diff --git a/src/base/torrentfilter.cpp b/src/base/torrentfilter.cpp index db9519063..716f93041 100644 --- a/src/base/torrentfilter.cpp +++ b/src/base/torrentfilter.cpp @@ -31,6 +31,7 @@ const QString TorrentFilter::AnyCategory; const QStringSet TorrentFilter::AnyHash = (QStringSet() << QString()); +const QString TorrentFilter::AnyTag; const TorrentFilter TorrentFilter::DownloadingTorrent(TorrentFilter::Downloading); const TorrentFilter TorrentFilter::SeedingTorrent(TorrentFilter::Seeding); @@ -49,16 +50,18 @@ TorrentFilter::TorrentFilter() { } -TorrentFilter::TorrentFilter(const Type type, const QStringSet &hashSet, const QString &category) +TorrentFilter::TorrentFilter(const Type type, const QStringSet &hashSet, const QString &category, const QString &tag) : m_type(type) , m_category(category) + , m_tag(tag) , m_hashSet(hashSet) { } -TorrentFilter::TorrentFilter(const QString &filter, const QStringSet &hashSet, const QString &category) +TorrentFilter::TorrentFilter(const QString &filter, const QStringSet &hashSet, const QString &category, const QString &tag) : m_type(All) , m_category(category) + , m_tag(tag) , m_hashSet(hashSet) { setTypeByName(filter); @@ -121,11 +124,24 @@ bool TorrentFilter::setCategory(const QString &category) return false; } +bool TorrentFilter::setTag(const QString &tag) +{ + // QString::operator==() doesn't distinguish between empty and null strings. + if ((m_tag != tag) + || (m_tag.isNull() && !tag.isNull()) + || (!m_tag.isNull() && tag.isNull())) { + m_tag = tag; + return true; + } + + return false; +} + bool TorrentFilter::match(TorrentHandle *const torrent) const { if (!torrent) return false; - return (matchState(torrent) && matchHash(torrent) && matchCategory(torrent)); + return (matchState(torrent) && matchHash(torrent) && matchCategory(torrent) && matchTag(torrent)); } bool TorrentFilter::matchState(BitTorrent::TorrentHandle *const torrent) const @@ -165,3 +181,11 @@ bool TorrentFilter::matchCategory(BitTorrent::TorrentHandle *const torrent) cons if (m_category.isNull()) return true; else return (torrent->belongsToCategory(m_category)); } + +bool TorrentFilter::matchTag(BitTorrent::TorrentHandle *const torrent) const +{ + // Empty tag is a special value to indicate we're filtering for untagged torrents. + if (m_tag.isNull()) return true; + else if (m_tag.isEmpty()) return torrent->tags().isEmpty(); + else return (torrent->hasTag(m_tag)); +} diff --git a/src/base/torrentfilter.h b/src/base/torrentfilter.h index 06c18b346..cb2c150ad 100644 --- a/src/base/torrentfilter.h +++ b/src/base/torrentfilter.h @@ -58,8 +58,10 @@ public: Errored }; + // These mean any permutation, including no category / tag. static const QString AnyCategory; static const QStringSet AnyHash; + static const QString AnyTag; static const TorrentFilter DownloadingTorrent; static const TorrentFilter SeedingTorrent; @@ -71,14 +73,16 @@ public: static const TorrentFilter ErroredTorrent; TorrentFilter(); - // category: pass empty string for "no category" or null string (QString()) for "any category" - TorrentFilter(const Type type, const QStringSet &hashSet = AnyHash, const QString &category = AnyCategory); - TorrentFilter(const QString &filter, const QStringSet &hashSet = AnyHash, const QString &category = AnyCategory); + // category & tags: pass empty string for uncategorized / untagged torrents. + // Pass null string (QString()) to disable filtering (i.e. all torrents). + TorrentFilter(const Type type, const QStringSet &hashSet = AnyHash, const QString &category = AnyCategory, const QString &tag = AnyTag); + TorrentFilter(const QString &filter, const QStringSet &hashSet = AnyHash, const QString &category = AnyCategory, const QString &tags = AnyTag); bool setType(Type type); bool setTypeByName(const QString &filter); bool setHashSet(const QStringSet &hashSet); bool setCategory(const QString &category); + bool setTag(const QString &tag); bool match(BitTorrent::TorrentHandle *const torrent) const; @@ -86,9 +90,11 @@ private: bool matchState(BitTorrent::TorrentHandle *const torrent) const; bool matchHash(BitTorrent::TorrentHandle *const torrent) const; bool matchCategory(BitTorrent::TorrentHandle *const torrent) const; + bool matchTag(BitTorrent::TorrentHandle *const torrent) const; Type m_type; QString m_category; + QString m_tag; QStringSet m_hashSet; }; diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 1b45cefe7..66b84381f 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -56,6 +56,9 @@ shutdownconfirmdlg.h speedlimitdlg.h statsdialog.h statusbar.h +tagfiltermodel.h +tagfilterproxymodel.h +tagfilterwidget.h torrentcontentfiltermodel.h torrentcontentmodel.h torrentcontentmodelfile.h @@ -96,6 +99,9 @@ shutdownconfirmdlg.cpp speedlimitdlg.cpp statsdialog.cpp statusbar.cpp +tagfiltermodel.cpp +tagfilterproxymodel.cpp +tagfilterwidget.cpp torrentcontentfiltermodel.cpp torrentcontentmodel.cpp torrentcontentmodelfile.cpp diff --git a/src/gui/advancedsettings.cpp b/src/gui/advancedsettings.cpp index 811745c88..6d5926cd3 100644 --- a/src/gui/advancedsettings.cpp +++ b/src/gui/advancedsettings.cpp @@ -66,6 +66,7 @@ enum AdvSettingsRows RESOLVE_COUNTRIES, PROGRAM_NOTIFICATIONS, TORRENT_ADDED_NOTIFICATIONS, + CONFIRM_REMOVE_ALL_TAGS, DOWNLOAD_TRACKER_FAVICON, #if (defined(Q_OS_UNIX) && !defined(Q_OS_MAC)) USE_ICON_THEME, @@ -185,6 +186,9 @@ void AdvancedSettings::saveAdvancedSettings() pref->useSystemIconTheme(cb_use_icon_theme.isChecked()); #endif pref->setConfirmTorrentRecheck(cb_confirm_torrent_recheck.isChecked()); + + pref->setConfirmRemoveAllTags(cb_confirm_remove_all_tags.isChecked()); + session->setAnnounceToAllTrackers(cb_announce_all_trackers.isChecked()); } @@ -377,6 +381,11 @@ void AdvancedSettings::loadAdvancedSettings() // Torrent recheck confirmation cb_confirm_torrent_recheck.setChecked(pref->confirmTorrentRecheck()); addRow(CONFIRM_RECHECK_TORRENT, tr("Confirm torrent recheck"), &cb_confirm_torrent_recheck); + + // Remove all tags confirmation + cb_confirm_remove_all_tags.setChecked(pref->confirmRemoveAllTags()); + addRow(CONFIRM_REMOVE_ALL_TAGS, tr("Confirm remove all tags"), &cb_confirm_remove_all_tags); + // Announce to all trackers cb_announce_all_trackers.setChecked(session->announceToAllTrackers()); addRow(ANNOUNCE_ALL_TRACKERS, tr("Always announce to all trackers"), &cb_announce_all_trackers); diff --git a/src/gui/advancedsettings.h b/src/gui/advancedsettings.h index 188592f16..57ae1b643 100644 --- a/src/gui/advancedsettings.h +++ b/src/gui/advancedsettings.h @@ -79,7 +79,7 @@ private: QSpinBox spin_cache, spin_save_resume_data_interval, outgoing_ports_min, outgoing_ports_max, spin_list_refresh, spin_maxhalfopen, spin_tracker_port, spin_cache_ttl; QCheckBox cb_os_cache, cb_recheck_completed, cb_resolve_countries, cb_resolve_hosts, cb_super_seeding, cb_program_notifications, cb_torrent_added_notifications, cb_tracker_favicon, cb_tracker_status, - cb_confirm_torrent_recheck, cb_listen_ipv6, cb_announce_all_trackers; + cb_confirm_torrent_recheck, cb_confirm_remove_all_tags, cb_listen_ipv6, cb_announce_all_trackers; QComboBox combo_iface, combo_iface_address; QLineEdit txtAnnounceIP; diff --git a/src/gui/gui.pri b/src/gui/gui.pri index 46abdac2e..889a076c8 100644 --- a/src/gui/gui.pri +++ b/src/gui/gui.pri @@ -51,6 +51,9 @@ HEADERS += \ $$PWD/categoryfiltermodel.h \ $$PWD/categoryfilterproxymodel.h \ $$PWD/categoryfilterwidget.h \ + $$PWD/tagfiltermodel.h \ + $$PWD/tagfilterproxymodel.h \ + $$PWD/tagfilterwidget.h \ $$PWD/banlistoptions.h \ $$PWD/rss/rsswidget.h \ $$PWD/rss/articlelistwidget.h \ @@ -101,6 +104,9 @@ SOURCES += \ $$PWD/categoryfiltermodel.cpp \ $$PWD/categoryfilterproxymodel.cpp \ $$PWD/categoryfilterwidget.cpp \ + $$PWD/tagfiltermodel.cpp \ + $$PWD/tagfilterproxymodel.cpp \ + $$PWD/tagfilterwidget.cpp \ $$PWD/banlistoptions.cpp \ $$PWD/rss/rsswidget.cpp \ $$PWD/rss/articlelistwidget.cpp \ diff --git a/src/gui/tagfiltermodel.cpp b/src/gui/tagfiltermodel.cpp new file mode 100644 index 000000000..1b99e8f78 --- /dev/null +++ b/src/gui/tagfiltermodel.cpp @@ -0,0 +1,337 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2017 Tony Gregerson + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "tagfiltermodel.h" + +#include +#include +#include + +#include "base/bittorrent/session.h" +#include "base/bittorrent/torrenthandle.h" +#include "guiiconprovider.h" + +namespace +{ + QString getSpecialAllTag() + { + static const QString *const ALL_TAG = new QString(" "); + Q_ASSERT(!BitTorrent::Session::isValidTag(*ALL_TAG)); + return *ALL_TAG; + } + + QString getSpecialUntaggedTag() + { + static const QString *const UNTAGGED_TAG = new QString(" "); + Q_ASSERT(!BitTorrent::Session::isValidTag(*UNTAGGED_TAG)); + return *UNTAGGED_TAG; + } +} + +class TagModelItem +{ +public: + TagModelItem(const QString &tag, int torrentsCount = 0) + : m_tag(tag) + , m_torrentsCount(torrentsCount) + { + } + + QString tag() const + { + return m_tag; + } + + int torrentsCount() const + { + return m_torrentsCount; + } + + void increaseTorrentsCount() + { + ++m_torrentsCount; + } + + void decreaseTorrentsCount() + { + Q_ASSERT(m_torrentsCount > 0); + --m_torrentsCount; + } + +private: + const QString m_tag; + int m_torrentsCount; +}; + +TagFilterModel::TagFilterModel(QObject *parent) + : QAbstractListModel(parent) +{ + using Session = BitTorrent::Session; + auto session = Session::instance(); + + connect(session, &Session::tagAdded, this, &TagFilterModel::tagAdded); + connect(session, &Session::tagRemoved, this, &TagFilterModel::tagRemoved); + connect(session, &Session::torrentTagAdded, this, &TagFilterModel::torrentTagAdded); + connect(session, &Session::torrentTagRemoved, this, &TagFilterModel::torrentTagRemoved); + connect(session, &Session::torrentAdded, this, &TagFilterModel::torrentAdded); + connect(session, &Session::torrentAboutToBeRemoved, this, &TagFilterModel::torrentAboutToBeRemoved); + populate(); +} + +TagFilterModel::~TagFilterModel() = default; + +bool TagFilterModel::isSpecialItem(const QModelIndex &index) +{ + // the first two items are special items: 'All' and 'Untagged' + return (!index.parent().isValid() && (index.row() <= 1)); +} + +QVariant TagFilterModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.column() != 0) + return QVariant(); + + const int row = index.internalId(); + Q_ASSERT(isValidRow(row)); + const TagModelItem &item = m_tagItems[row]; + + switch (role) { + case Qt::DecorationRole: + return GuiIconProvider::instance()->getIcon("inode-directory"); + case Qt::DisplayRole: + return QString(QLatin1String("%1 (%2)")) + .arg(tagDisplayName(item.tag())).arg(item.torrentsCount()); + case Qt::UserRole: + return item.torrentsCount(); + default: + return QVariant(); + } +} + +Qt::ItemFlags TagFilterModel::flags(const QModelIndex &index) const +{ + if (!index.isValid()) + return 0; + return Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +QVariant TagFilterModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if ((orientation == Qt::Horizontal) && (role == Qt::DisplayRole)) + if (section == 0) + return tr("Tags"); + return QVariant(); +} + +QModelIndex TagFilterModel::index(int row, int, const QModelIndex &) const +{ + if (!isValidRow(row)) + return QModelIndex(); + return createIndex(row, 0, row); +} + +int TagFilterModel::rowCount(const QModelIndex &parent) const +{ + if (!parent.isValid()) + return m_tagItems.count(); + return 0; +} + +bool TagFilterModel::isValidRow(int row) const +{ + return (row >= 0) && (row < m_tagItems.size()); +} + +QModelIndex TagFilterModel::index(const QString &tag) const +{ + const int row = findRow(tag); + if (!isValidRow(row)) + return QModelIndex(); + return index(row, 0, QModelIndex()); +} + +QString TagFilterModel::tag(const QModelIndex &index) const +{ + if (!index.isValid()) + return QString(); + const int row = index.internalId(); + Q_ASSERT(isValidRow(row)); + return m_tagItems[row].tag(); +} + +void TagFilterModel::tagAdded(const QString &tag) +{ + const int row = m_tagItems.count(); + beginInsertRows(QModelIndex(), row, row); + addToModel(tag, 0); + endInsertRows(); +} + +void TagFilterModel::tagRemoved(const QString &tag) +{ + QModelIndex i = index(tag); + beginRemoveRows(i.parent(), i.row(), i.row()); + removeFromModel(i.row()); + endRemoveRows(); +} + +void TagFilterModel::torrentTagAdded(BitTorrent::TorrentHandle *const torrent, const QString &tag) +{ + if (torrent->tags().count() == 1) + untaggedItem()->decreaseTorrentsCount(); + + const int row = findRow(tag); + Q_ASSERT(isValidRow(row)); + TagModelItem &item = m_tagItems[row]; + + item.increaseTorrentsCount(); + const QModelIndex i = index(row, 0, QModelIndex()); + emit dataChanged(i, i); +} + +void TagFilterModel::torrentTagRemoved(BitTorrent::TorrentHandle* const torrent, const QString &tag) +{ + Q_ASSERT(torrent->tags().count() >= 0); + if (torrent->tags().count() == 0) + untaggedItem()->increaseTorrentsCount(); + + const int row = findRow(tag); + Q_ASSERT(isValidRow(row)); + TagModelItem &item = m_tagItems[row]; + + item.decreaseTorrentsCount(); + const QModelIndex i = index(row, 0, QModelIndex()); + emit dataChanged(i, i); +} + +void TagFilterModel::torrentAdded(BitTorrent::TorrentHandle *const torrent) +{ + allTagsItem()->increaseTorrentsCount(); + + const QVector items = findItems(torrent->tags()); + if (items.isEmpty()) + untaggedItem()->increaseTorrentsCount(); + + foreach (TagModelItem *item, items) + item->increaseTorrentsCount(); +} + +void TagFilterModel::torrentAboutToBeRemoved(BitTorrent::TorrentHandle *const torrent) +{ + allTagsItem()->decreaseTorrentsCount(); + + if (torrent->tags().isEmpty()) + untaggedItem()->decreaseTorrentsCount(); + + foreach (TagModelItem *item, findItems(torrent->tags())) + item->decreaseTorrentsCount(); +} + +QString TagFilterModel::tagDisplayName(const QString &tag) +{ + if (tag == getSpecialAllTag()) + return tr("All"); + if (tag == getSpecialUntaggedTag()) + return tr("Untagged"); + return tag; +} + +void TagFilterModel::populate() +{ + using Torrent = BitTorrent::TorrentHandle; + + auto session = BitTorrent::Session::instance(); + auto torrents = session->torrents(); + + // All torrents + addToModel(getSpecialAllTag(), torrents.count()); + + const int untaggedCount = std::count_if(torrents.begin(), torrents.end(), + [](Torrent *torrent) { return torrent->tags().isEmpty(); }); + addToModel(getSpecialUntaggedTag(), untaggedCount); + + foreach (const QString &tag, session->tags()) { + const int count = std::count_if(torrents.begin(), torrents.end(), + [tag](Torrent *torrent) { return torrent->hasTag(tag); }); + addToModel(tag, count); + } +} + +void TagFilterModel::addToModel(const QString &tag, int count) +{ + m_tagItems.append(TagModelItem(tag, count)); +} + +void TagFilterModel::removeFromModel(int row) +{ + Q_ASSERT(isValidRow(row)); + m_tagItems.removeAt(row); +} + +int TagFilterModel::findRow(const QString &tag) const +{ + for (int i = 0; i < m_tagItems.size(); ++i) { + if (m_tagItems[i].tag() == tag) + return i; + } + return -1; +} + +TagModelItem *TagFilterModel::findItem(const QString &tag) +{ + const int row = findRow(tag); + if (!isValidRow(row)) + return nullptr; + return &m_tagItems[row]; +} + +QVector TagFilterModel::findItems(const QSet &tags) +{ + QVector items; + items.reserve(tags.size()); + foreach (const QString &tag, tags) { + TagModelItem *item = findItem(tag); + if (item) + items.push_back(item); + else + qWarning() << QString("Requested tag '%1' missing from the model.").arg(tag); + } + return items; +} + +TagModelItem *TagFilterModel::allTagsItem() +{ + Q_ASSERT(m_tagItems.size() > 0); + return &m_tagItems[0]; +} + +TagModelItem *TagFilterModel::untaggedItem() +{ + Q_ASSERT(m_tagItems.size() > 1); + return &m_tagItems[1]; +} diff --git a/src/gui/tagfiltermodel.h b/src/gui/tagfiltermodel.h new file mode 100644 index 000000000..f2f3cc67a --- /dev/null +++ b/src/gui/tagfiltermodel.h @@ -0,0 +1,88 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2017 Tony Gregerson + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#ifndef TAGFILTERMODEL_H +#define TAGFILTERMODEL_H + +#include +#include +#include +#include +#include + +namespace BitTorrent +{ + class TorrentHandle; +} + +class TagModelItem; + +class TagFilterModel: public QAbstractListModel +{ + Q_OBJECT + +public: + explicit TagFilterModel(QObject *parent = nullptr); + ~TagFilterModel(); + + static bool isSpecialItem(const QModelIndex &index); + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + QModelIndex index(const QString &tag) const; + QString tag(const QModelIndex &index) const; + +private slots: + void tagAdded(const QString &tag); + void tagRemoved(const QString &tag); + void torrentTagAdded(BitTorrent::TorrentHandle *const torrent, const QString &tag); + void torrentTagRemoved(BitTorrent::TorrentHandle *const, const QString &tag); + void torrentAdded(BitTorrent::TorrentHandle *const torrent); + void torrentAboutToBeRemoved(BitTorrent::TorrentHandle *const torrent); + +private: + static QString tagDisplayName(const QString &tag); + + void populate(); + void addToModel(const QString &tag, int count); + void removeFromModel(int row); + bool isValidRow(int row) const; + int findRow(const QString &tag) const; + TagModelItem *findItem(const QString &tag); + QVector findItems(const QSet &tags); + TagModelItem *allTagsItem(); + TagModelItem *untaggedItem(); + + QList m_tagItems; // Index corresponds to its row +}; + +#endif // TAGFILTERMODEL_H diff --git a/src/gui/tagfilterproxymodel.cpp b/src/gui/tagfilterproxymodel.cpp new file mode 100644 index 000000000..5bfb9036f --- /dev/null +++ b/src/gui/tagfilterproxymodel.cpp @@ -0,0 +1,56 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2017 Tony Gregerson + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "tagfilterproxymodel.h" + +#include "base/utils/string.h" +#include "tagfiltermodel.h" + +TagFilterProxyModel::TagFilterProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ +} + +QModelIndex TagFilterProxyModel::index(const QString &tag) const +{ + return mapFromSource(static_cast(sourceModel())->index(tag)); +} + +QString TagFilterProxyModel::tag(const QModelIndex &index) const +{ + return static_cast(sourceModel())->tag(mapToSource(index)); +} + +bool TagFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const +{ + // "All" and "Untagged" must be left in place + if (TagFilterModel::isSpecialItem(left) || TagFilterModel::isSpecialItem(right)) + return left.row() < right.row(); + return Utils::String::naturalCompareCaseInsensitive( + left.data().toString(), right.data().toString()); +} diff --git a/src/gui/tagfilterproxymodel.h b/src/gui/tagfilterproxymodel.h new file mode 100644 index 000000000..60e4174df --- /dev/null +++ b/src/gui/tagfilterproxymodel.h @@ -0,0 +1,52 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2017 Tony Gregerson + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#ifndef TAGFILTERPROXYMODEL_H +#define TAGFILTERPROXYMODEL_H + +#include +#include + +class TagFilterProxyModel: public QSortFilterProxyModel +{ +public: + explicit TagFilterProxyModel(QObject *parent = nullptr); + + // TagFilterModel methods which we need to relay + QModelIndex index(const QString &tag) const; + QString tag(const QModelIndex &index) const; + +protected: + bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; + +private: + // we added another overload of index(), hence this using directive: + using QSortFilterProxyModel::index; +}; + +#endif // TAGFILTERPROXYMODEL_H diff --git a/src/gui/tagfilterwidget.cpp b/src/gui/tagfilterwidget.cpp new file mode 100644 index 000000000..06612c319 --- /dev/null +++ b/src/gui/tagfilterwidget.cpp @@ -0,0 +1,224 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2017 Tony Gregerson + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "tagfilterwidget.h" + +#include +#include +#include +#include +#include +#include + +#include "base/bittorrent/session.h" +#include "base/utils/misc.h" +#include "autoexpandabledialog.h" +#include "guiiconprovider.h" +#include "tagfiltermodel.h" +#include "tagfilterproxymodel.h" + +namespace +{ + QString getTagFilter(const TagFilterProxyModel *const model, const QModelIndex &index) + { + QString tagFilter; // Defaults to All + if (index.isValid()) { + if (index.row() == 1) + tagFilter = ""; // Untagged + else if (index.row() > 1) + tagFilter = model->tag(index); + } + return tagFilter; + } +} + +TagFilterWidget::TagFilterWidget(QWidget *parent) + : QTreeView(parent) +{ + TagFilterProxyModel *proxyModel = new TagFilterProxyModel(this); + proxyModel->setSortCaseSensitivity(Qt::CaseInsensitive); + proxyModel->setSourceModel(new TagFilterModel(this)); + setModel(proxyModel); + setFrameShape(QFrame::NoFrame); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + setUniformRowHeights(true); + setHeaderHidden(true); + setIconSize(Utils::Misc::smallIconSize()); +#if defined(Q_OS_MAC) + setAttribute(Qt::WA_MacShowFocusRect, false); +#endif + setContextMenuPolicy(Qt::CustomContextMenu); + sortByColumn(0, Qt::AscendingOrder); + setCurrentIndex(model()->index(0, 0)); + + connect(this, &TagFilterWidget::collapsed, this, &TagFilterWidget::callUpdateGeometry); + connect(this, &TagFilterWidget::expanded, this, &TagFilterWidget::callUpdateGeometry); + connect(this, &TagFilterWidget::customContextMenuRequested, this, &TagFilterWidget::showMenu); + connect(selectionModel(), &QItemSelectionModel::currentRowChanged, this + , &TagFilterWidget::onCurrentRowChanged); + connect(model(), &QAbstractItemModel::modelReset, this, &TagFilterWidget::callUpdateGeometry); +} + +QString TagFilterWidget::currentTag() const +{ + QModelIndex current; + auto selectedRows = selectionModel()->selectedRows(); + if (!selectedRows.isEmpty()) + current = selectedRows.first(); + + return getTagFilter(static_cast(model()), current); +} + +void TagFilterWidget::onCurrentRowChanged(const QModelIndex ¤t, const QModelIndex &previous) +{ + Q_UNUSED(previous); + + emit tagChanged(getTagFilter(static_cast(model()), current)); +} + +void TagFilterWidget::showMenu(QPoint) +{ + QMenu menu(this); + + QAction *addAct = menu.addAction( + GuiIconProvider::instance()->getIcon("list-add") + , tr("Add tag...")); + connect(addAct, &QAction::triggered, this, &TagFilterWidget::addTag); + + auto selectedRows = selectionModel()->selectedRows(); + if (!selectedRows.empty() && !TagFilterModel::isSpecialItem(selectedRows.first())) { + QAction *removeAct = menu.addAction( + GuiIconProvider::instance()->getIcon("list-remove") + , tr("Remove tag")); + connect(removeAct, &QAction::triggered, this, &TagFilterWidget::removeTag); + } + + QAction *removeUnusedAct = menu.addAction( + GuiIconProvider::instance()->getIcon("list-remove") + , tr("Remove unused tags")); + connect(removeUnusedAct, &QAction::triggered, this, &TagFilterWidget::removeUnusedTags); + + menu.addSeparator(); + + QAction *startAct = menu.addAction( + GuiIconProvider::instance()->getIcon("media-playback-start") + , tr("Resume torrents")); + connect(startAct, &QAction::triggered + , this, &TagFilterWidget::actionResumeTorrentsTriggered); + + QAction *pauseAct = menu.addAction( + GuiIconProvider::instance()->getIcon("media-playback-pause") + , tr("Pause torrents")); + connect(pauseAct, &QAction::triggered, this + , &TagFilterWidget::actionPauseTorrentsTriggered); + + QAction *deleteTorrentsAct = menu.addAction( + GuiIconProvider::instance()->getIcon("edit-delete") + , tr("Delete torrents")); + connect(deleteTorrentsAct, &QAction::triggered, this + , &TagFilterWidget::actionDeleteTorrentsTriggered); + + menu.exec(QCursor::pos()); +} + +void TagFilterWidget::callUpdateGeometry() +{ + updateGeometry(); +} + +QSize TagFilterWidget::sizeHint() const +{ + return viewportSizeHint(); +} + +QSize TagFilterWidget::minimumSizeHint() const +{ + QSize size = sizeHint(); + size.setWidth(6); + return size; +} + +void TagFilterWidget::rowsInserted(const QModelIndex &parent, int start, int end) +{ + QTreeView::rowsInserted(parent, start, end); + updateGeometry(); +} + +QString TagFilterWidget::askTagName() +{ + bool ok = false; + QString tag = ""; + bool invalid = true; + while (invalid) { + invalid = false; + tag = AutoExpandableDialog::getText( + this, tr("New Tag"), tr("Tag:"), QLineEdit::Normal, tag, &ok).trimmed(); + if (ok && !tag.isEmpty()) { + if (!BitTorrent::Session::isValidTag(tag)) { + QMessageBox::warning( + this, tr("Invalid tag name") + , tr("Tag name '%1' is invalid").arg(tag)); + invalid = true; + } + } + } + + return ok ? tag : QString(); +} + +void TagFilterWidget::addTag() +{ + const QString tag = askTagName(); + if (tag.isEmpty()) return; + + if (BitTorrent::Session::instance()->tags().contains(tag)) + QMessageBox::warning(this, tr("Tag exists"), tr("Tag name already exists.")); + else + BitTorrent::Session::instance()->addTag(tag); +} + +void TagFilterWidget::removeTag() +{ + auto selectedRows = selectionModel()->selectedRows(); + if (!selectedRows.empty() && !TagFilterModel::isSpecialItem(selectedRows.first())) { + BitTorrent::Session::instance()->removeTag( + static_cast(model())->tag(selectedRows.first())); + updateGeometry(); + } +} + +void TagFilterWidget::removeUnusedTags() +{ + auto session = BitTorrent::Session::instance(); + foreach (const QString &tag, session->tags()) + if (model()->data(static_cast(model())->index(tag), Qt::UserRole) == 0) + session->removeTag(tag); + updateGeometry(); +} diff --git a/src/gui/tagfilterwidget.h b/src/gui/tagfilterwidget.h new file mode 100644 index 000000000..c24b0d34f --- /dev/null +++ b/src/gui/tagfilterwidget.h @@ -0,0 +1,64 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2017 Tony Gregerson + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#ifndef TAGFILTERWIDGET_H +#define TAGFILTERWIDGET_H + +#include + +class TagFilterWidget: public QTreeView +{ + Q_OBJECT + +public: + explicit TagFilterWidget(QWidget *parent = nullptr); + + QString currentTag() const; + +signals: + void tagChanged(const QString &tag); + void actionResumeTorrentsTriggered(); + void actionPauseTorrentsTriggered(); + void actionDeleteTorrentsTriggered(); + +private slots: + void onCurrentRowChanged(const QModelIndex ¤t, const QModelIndex &previous); + void showMenu(QPoint); + void callUpdateGeometry(); + void addTag(); + void removeTag(); + void removeUnusedTags(); + +private: + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + void rowsInserted(const QModelIndex &parent, int start, int end) override; + QString askTagName(); +}; + +#endif // TAGFILTERWIDGET_H diff --git a/src/gui/torrentmodel.cpp b/src/gui/torrentmodel.cpp index 2971f0107..4feef0897 100644 --- a/src/gui/torrentmodel.cpp +++ b/src/gui/torrentmodel.cpp @@ -105,6 +105,7 @@ QVariant TorrentModel::headerData(int section, Qt::Orientation orientation, int case TR_RATIO: return tr("Ratio", "Share ratio"); case TR_ETA: return tr("ETA", "i.e: Estimated Time of Arrival / Time left"); case TR_CATEGORY: return tr("Category"); + case TR_TAGS: return tr("Tags"); case TR_ADD_DATE: return tr("Added On", "Torrent was added to transfer list on 01/01/2010 08:00"); case TR_SEED_DATE: return tr("Completed On", "Torrent was completed on 01/01/2010 08:00"); case TR_TRACKER: return tr("Tracker"); @@ -198,6 +199,11 @@ QVariant TorrentModel::data(const QModelIndex &index, int role) const return torrent->realRatio(); case TR_CATEGORY: return torrent->category(); + case TR_TAGS: { + QStringList tagsList = torrent->tags().toList(); + tagsList.sort(); + return tagsList.join(", "); + } case TR_ADD_DATE: return torrent->addedTime(); case TR_SEED_DATE: diff --git a/src/gui/torrentmodel.h b/src/gui/torrentmodel.h index 8bd3b0ecf..fd9161ec8 100644 --- a/src/gui/torrentmodel.h +++ b/src/gui/torrentmodel.h @@ -62,6 +62,7 @@ public: TR_ETA, TR_RATIO, TR_CATEGORY, + TR_TAGS, TR_ADD_DATE, TR_SEED_DATE, TR_TRACKER, diff --git a/src/gui/transferlistfilterswidget.cpp b/src/gui/transferlistfilterswidget.cpp index 7a2288868..05188ac41 100644 --- a/src/gui/transferlistfilterswidget.cpp +++ b/src/gui/transferlistfilterswidget.cpp @@ -53,6 +53,7 @@ #include "autoexpandabledialog.h" #include "categoryfilterwidget.h" #include "guiiconprovider.h" +#include "tagfilterwidget.h" #include "torrentmodel.h" #include "transferlistdelegate.h" #include "transferlistwidget.h" @@ -77,11 +78,13 @@ FiltersBase::FiltersBase(QWidget *parent, TransferListWidget *transferList) #endif setContextMenuPolicy(Qt::CustomContextMenu); - connect(this, SIGNAL(customContextMenuRequested(QPoint)), SLOT(showMenu(QPoint))); - connect(this, SIGNAL(currentRowChanged(int)), SLOT(applyFilter(int))); + connect(this, &FiltersBase::customContextMenuRequested, this, &FiltersBase::showMenu); + connect(this, &FiltersBase::currentRowChanged, this, &FiltersBase::applyFilter); - connect(BitTorrent::Session::instance(), SIGNAL(torrentAdded(BitTorrent::TorrentHandle *const)), SLOT(handleNewTorrent(BitTorrent::TorrentHandle *const))); - connect(BitTorrent::Session::instance(), SIGNAL(torrentAboutToBeRemoved(BitTorrent::TorrentHandle *const)), SLOT(torrentAboutToBeDeleted(BitTorrent::TorrentHandle *const))); + connect(BitTorrent::Session::instance(), &BitTorrent::Session::torrentAdded + , this, &FiltersBase::handleNewTorrent); + connect(BitTorrent::Session::instance(), &BitTorrent::Session::torrentAboutToBeRemoved + , this, &FiltersBase::torrentAboutToBeDeleted); } QSize FiltersBase::sizeHint() const @@ -113,7 +116,8 @@ void FiltersBase::toggleFilter(bool checked) StatusFiltersWidget::StatusFiltersWidget(QWidget *parent, TransferListWidget *transferList) : FiltersBase(parent, transferList) { - connect(BitTorrent::Session::instance(), SIGNAL(torrentsUpdated()), SLOT(updateTorrentNumbers())); + connect(BitTorrent::Session::instance(), &BitTorrent::Session::torrentsUpdated + , this, &StatusFiltersWidget::updateTorrentNumbers); // Add status filters QListWidgetItem *all = new QListWidgetItem(this); @@ -389,8 +393,11 @@ void TrackerFiltersList::downloadFavicon(const QString& url) { if (!m_downloadTrackerFavicon) return; Net::DownloadHandler *h = Net::DownloadManager::instance()->downloadUrl(url, true); - connect(h, SIGNAL(downloadFinished(QString, QString)), this, SLOT(handleFavicoDownload(QString, QString))); - connect(h, SIGNAL(downloadFailed(QString, QString)), this, SLOT(handleFavicoFailure(QString, QString))); + using Func = void (Net::DownloadHandler::*)(const QString &, const QString &); + connect(h, static_cast(&Net::DownloadHandler::downloadFinished), this + , &TrackerFiltersList::handleFavicoDownload); + connect(h, static_cast(&Net::DownloadHandler::downloadFailed), this + , &TrackerFiltersList::handleFavicoFailure); } void TrackerFiltersList::handleFavicoDownload(const QString& url, const QString& filePath) @@ -577,21 +584,40 @@ TransferListFiltersWidget::TransferListFiltersWidget(QWidget *parent, TransferLi QCheckBox *categoryLabel = new QCheckBox(tr("Categories"), this); categoryLabel->setChecked(pref->getCategoryFilterState()); categoryLabel->setFont(font); - connect(categoryLabel, SIGNAL(toggled(bool)), SLOT(onCategoryFilterStateChanged(bool))); + connect(categoryLabel, &QCheckBox::toggled, this + , &TransferListFiltersWidget::onCategoryFilterStateChanged); frameLayout->addWidget(categoryLabel); m_categoryFilterWidget = new CategoryFilterWidget(this); - connect(m_categoryFilterWidget, SIGNAL(actionDeleteTorrentsTriggered()) - , transferList, SLOT(deleteVisibleTorrents())); - connect(m_categoryFilterWidget, SIGNAL(actionPauseTorrentsTriggered()) - , transferList, SLOT(pauseVisibleTorrents())); - connect(m_categoryFilterWidget, SIGNAL(actionResumeTorrentsTriggered()) - , transferList, SLOT(startVisibleTorrents())); - connect(m_categoryFilterWidget, SIGNAL(categoryChanged(QString)) - , transferList, SLOT(applyCategoryFilter(QString))); + connect(m_categoryFilterWidget, &CategoryFilterWidget::actionDeleteTorrentsTriggered + , transferList, &TransferListWidget::deleteVisibleTorrents); + connect(m_categoryFilterWidget, &CategoryFilterWidget::actionPauseTorrentsTriggered + , transferList, &TransferListWidget::pauseVisibleTorrents); + connect(m_categoryFilterWidget, &CategoryFilterWidget::actionResumeTorrentsTriggered + , transferList, &TransferListWidget::startVisibleTorrents); + connect(m_categoryFilterWidget, &CategoryFilterWidget::categoryChanged + , transferList, &TransferListWidget::applyCategoryFilter); toggleCategoryFilter(pref->getCategoryFilterState()); frameLayout->addWidget(m_categoryFilterWidget); + QCheckBox *tagsLabel = new QCheckBox(tr("Tags"), this); + tagsLabel->setChecked(pref->getTagFilterState()); + tagsLabel->setFont(font); + connect(tagsLabel, &QCheckBox::toggled, this, &TransferListFiltersWidget::onTagFilterStateChanged); + frameLayout->addWidget(tagsLabel); + + m_tagFilterWidget = new TagFilterWidget(this); + connect(m_tagFilterWidget, &TagFilterWidget::actionDeleteTorrentsTriggered + , transferList, &TransferListWidget::deleteVisibleTorrents); + connect(m_tagFilterWidget, &TagFilterWidget::actionPauseTorrentsTriggered + , transferList, &TransferListWidget::pauseVisibleTorrents); + connect(m_tagFilterWidget, &TagFilterWidget::actionResumeTorrentsTriggered + , transferList, &TransferListWidget::startVisibleTorrents); + connect(m_tagFilterWidget, &TagFilterWidget::tagChanged + , transferList, &TransferListWidget::applyTagFilter); + toggleTagFilter(pref->getTagFilterState()); + frameLayout->addWidget(m_tagFilterWidget); + QCheckBox *trackerLabel = new QCheckBox(tr("Trackers"), this); trackerLabel->setChecked(pref->getTrackerFilterState()); trackerLabel->setFont(font); @@ -600,13 +626,18 @@ TransferListFiltersWidget::TransferListFiltersWidget(QWidget *parent, TransferLi m_trackerFilters = new TrackerFiltersList(this, transferList); frameLayout->addWidget(m_trackerFilters); - connect(statusLabel, SIGNAL(toggled(bool)), statusFilters, SLOT(toggleFilter(bool))); - connect(statusLabel, SIGNAL(toggled(bool)), pref, SLOT(setStatusFilterState(const bool))); - connect(trackerLabel, SIGNAL(toggled(bool)), m_trackerFilters, SLOT(toggleFilter(bool))); - connect(trackerLabel, SIGNAL(toggled(bool)), pref, SLOT(setTrackerFilterState(const bool))); - connect(this, SIGNAL(trackerSuccess(const QString &, const QString &)), m_trackerFilters, SLOT(trackerSuccess(const QString &, const QString &))); - connect(this, SIGNAL(trackerError(const QString &, const QString &)), m_trackerFilters, SLOT(trackerError(const QString &, const QString &))); - connect(this, SIGNAL(trackerWarning(const QString &, const QString &)), m_trackerFilters, SLOT(trackerWarning(const QString &, const QString &))); + connect(statusLabel, &QCheckBox::toggled, statusFilters, &StatusFiltersWidget::toggleFilter); + connect(statusLabel, &QCheckBox::toggled, pref, &Preferences::setStatusFilterState); + connect(trackerLabel, &QCheckBox::toggled, m_trackerFilters, &TrackerFiltersList::toggleFilter); + connect(trackerLabel, &QCheckBox::toggled, pref, &Preferences::setTrackerFilterState); + + using Func = void (TransferListFiltersWidget::*)(const QString&, const QString&); + connect(this, static_cast(&TransferListFiltersWidget::trackerSuccess) + , m_trackerFilters, &TrackerFiltersList::trackerSuccess); + connect(this, static_cast(&TransferListFiltersWidget::trackerError) + , m_trackerFilters, &TrackerFiltersList::trackerError); + connect(this, static_cast(&TransferListFiltersWidget::trackerWarning) + , m_trackerFilters, &TrackerFiltersList::trackerWarning); } void TransferListFiltersWidget::setDownloadTrackerFavicon(bool value) @@ -657,3 +688,15 @@ void TransferListFiltersWidget::toggleCategoryFilter(bool enabled) m_categoryFilterWidget->setVisible(enabled); m_transferList->applyCategoryFilter(enabled ? m_categoryFilterWidget->currentCategory() : QString()); } + +void TransferListFiltersWidget::onTagFilterStateChanged(bool enabled) +{ + toggleTagFilter(enabled); + Preferences::instance()->setTagFilterState(enabled); +} + +void TransferListFiltersWidget::toggleTagFilter(bool enabled) +{ + m_tagFilterWidget->setVisible(enabled); + m_transferList->applyTagFilter(enabled ? m_tagFilterWidget->currentTag() : QString()); +} diff --git a/src/gui/transferlistfilterswidget.h b/src/gui/transferlistfilterswidget.h index 2a661a7a1..98b1d6e9f 100644 --- a/src/gui/transferlistfilterswidget.h +++ b/src/gui/transferlistfilterswidget.h @@ -136,6 +136,7 @@ private: }; class CategoryFilterWidget; +class TagFilterWidget; class TransferListFiltersWidget: public QFrame { @@ -160,13 +161,16 @@ signals: private slots: void onCategoryFilterStateChanged(bool enabled); + void onTagFilterStateChanged(bool enabled); private: void toggleCategoryFilter(bool enabled); + void toggleTagFilter(bool enabled); TransferListWidget *m_transferList; TrackerFiltersList *m_trackerFilters; CategoryFilterWidget *m_categoryFilterWidget; + TagFilterWidget *m_tagFilterWidget; }; #endif // TRANSFERLISTFILTERSWIDGET_H diff --git a/src/gui/transferlistsortmodel.cpp b/src/gui/transferlistsortmodel.cpp index 4fedf7787..2bf3051e9 100644 --- a/src/gui/transferlistsortmodel.cpp +++ b/src/gui/transferlistsortmodel.cpp @@ -59,6 +59,18 @@ void TransferListSortModel::disableCategoryFilter() invalidateFilter(); } +void TransferListSortModel::setTagFilter(const QString &tag) +{ + if (m_filter.setTag(tag)) + invalidateFilter(); +} + +void TransferListSortModel::disableTagFilter() +{ + if (m_filter.setTag(TorrentFilter::AnyTag)) + invalidateFilter(); +} + void TransferListSortModel::setTrackerFilter(const QStringList &hashes) { if (m_filter.setHashSet(hashes.toSet())) @@ -75,6 +87,7 @@ bool TransferListSortModel::lessThan(const QModelIndex &left, const QModelIndex { switch (sortColumn()) { case TorrentModel::TR_CATEGORY: + case TorrentModel::TR_TAGS: case TorrentModel::TR_NAME: { QVariant vL = left.data(); QVariant vR = right.data(); diff --git a/src/gui/transferlistsortmodel.h b/src/gui/transferlistsortmodel.h index fb2fea88c..1e092340d 100644 --- a/src/gui/transferlistsortmodel.h +++ b/src/gui/transferlistsortmodel.h @@ -46,6 +46,8 @@ public: void setStatusFilter(TorrentFilter::Type filter); void setCategoryFilter(const QString &category); void disableCategoryFilter(); + void setTagFilter(const QString &tag); + void disableTagFilter(); void setTrackerFilter(const QStringList &hashes); void disableTrackerFilter(); diff --git a/src/gui/transferlistwidget.cpp b/src/gui/transferlistwidget.cpp index 8f77bef06..9f9ad27aa 100644 --- a/src/gui/transferlistwidget.cpp +++ b/src/gui/transferlistwidget.cpp @@ -607,6 +607,62 @@ void TransferListWidget::askNewCategoryForSelection() } while(invalid); } +void TransferListWidget::askAddTagsForSelection() +{ + const QStringList tags = askTagsForSelection(tr("Add Tags")); + foreach (const QString &tag, tags) + addSelectionTag(tag); +} + +void TransferListWidget::askRemoveTagsForSelection() +{ + const QStringList tags = askTagsForSelection(tr("Remove Tags")); + foreach (const QString &tag, tags) + removeSelectionTag(tag); +} + +void TransferListWidget::confirmRemoveAllTagsForSelection() +{ + QMessageBox::StandardButton response = QMessageBox::question( + this, tr("Remove All Tags"), tr("Remove all tags from selected torrents?"), + QMessageBox::Yes | QMessageBox::No); + if (response == QMessageBox::Yes) + clearSelectionTags(); +} + +QStringList TransferListWidget::askTagsForSelection(const QString &dialogTitle) +{ + QStringList tags; + bool invalid = true; + while (invalid) { + bool ok = false; + invalid = false; + const QString tagsInput = AutoExpandableDialog::getText( + this, dialogTitle, tr("Comma-separated tags:"), QLineEdit::Normal, "", &ok).trimmed(); + if (!ok || tagsInput.isEmpty()) + return QStringList(); + tags = tagsInput.split(',', QString::SkipEmptyParts); + for (QString &tag : tags) { + tag = tag.trimmed(); + if (!BitTorrent::Session::isValidTag(tag)) { + QMessageBox::warning(this, tr("Invalid tag") + , tr("Tag name: '%1' is invalid").arg(tag)); + invalid = true; + } + } + } + return tags; +} + +void TransferListWidget::applyToSelectedTorrents(const std::function &fn) +{ + foreach (const QModelIndex &index, selectionModel()->selectedRows()) { + BitTorrent::TorrentHandle *const torrent = listModel->torrentHandle(mapToSource(index)); + Q_ASSERT(torrent); + fn(torrent); + } +} + void TransferListWidget::renameSelectedTorrent() { const QModelIndexList selectedIndexes = selectionModel()->selectedRows(); @@ -632,6 +688,21 @@ void TransferListWidget::setSelectionCategory(QString category) listModel->setData(listModel->index(mapToSource(index).row(), TorrentModel::TR_CATEGORY), category, Qt::DisplayRole); } +void TransferListWidget::addSelectionTag(const QString &tag) +{ + applyToSelectedTorrents([&tag](BitTorrent::TorrentHandle *const torrent) { torrent->addTag(tag); }); +} + +void TransferListWidget::removeSelectionTag(const QString &tag) +{ + applyToSelectedTorrents([&tag](BitTorrent::TorrentHandle *const torrent) { torrent->removeTag(tag); }); +} + +void TransferListWidget::clearSelectionTags() +{ + applyToSelectedTorrents([](BitTorrent::TorrentHandle *const torrent) { torrent->removeAllTags(); }); +} + void TransferListWidget::displayListMenu(const QPoint&) { QModelIndexList selectedIndexes = selectionModel()->selectedRows(); @@ -701,6 +772,7 @@ void TransferListWidget::displayListMenu(const QPoint&) bool firstAutoTMM = false; QString firstCategory; bool first = true; + QSet tagsInSelection; BitTorrent::TorrentHandle *torrent; qDebug("Displaying menu"); @@ -715,6 +787,8 @@ void TransferListWidget::displayListMenu(const QPoint&) if (firstCategory != torrent->category()) allSameCategory = false; + tagsInSelection.unite(torrent->tags()); + if (first) firstAutoTMM = torrent->isAutoTMMEnabled(); if (firstAutoTMM != torrent->isAutoTMMEnabled()) @@ -798,6 +872,25 @@ void TransferListWidget::displayListMenu(const QPoint&) categoryActions << cat; } + // Tag Menu + QStringList tags(BitTorrent::Session::instance()->tags().toList()); + std::sort(tags.begin(), tags.end(), Utils::String::naturalCompareCaseInsensitive); + QList tagsActions; + QMenu *tagsMenu = listMenu.addMenu(GuiIconProvider::instance()->getIcon("view-categories"), tr("Tags")); + tagsActions << tagsMenu->addAction(GuiIconProvider::instance()->getIcon("list-add"), tr("Add...", "Add / assign multiple tags...")); + tagsActions << tagsMenu->addAction(GuiIconProvider::instance()->getIcon("edit-clear"), tr("Remove...", "Remove multiple tags...")); + tagsActions << tagsMenu->addAction(GuiIconProvider::instance()->getIcon("edit-clear"), tr("Remove All", "Remove all tags")); + tagsMenu->addSeparator(); + foreach (QString tag, tags) { + const bool setChecked = tagsInSelection.contains(tag); + tag.replace('&', "&&"); // avoid '&' becomes accelerator key + QAction *tagSelection = new QAction(GuiIconProvider::instance()->getIcon("inode-directory"), tag, tagsMenu); + tagSelection->setCheckable(true); + tagSelection->setChecked(setChecked); + tagsMenu->addAction(tagSelection); + tagsActions << tagSelection; + } + if (allSameAutoTMM) { actionAutoTMM.setChecked(firstAutoTMM); listMenu.addAction(&actionAutoTMM); @@ -853,7 +946,7 @@ void TransferListWidget::displayListMenu(const QPoint&) QAction *act = 0; act = listMenu.exec(QCursor::pos()); if (act) { - // Parse category actions only (others have slots assigned) + // Parse category & tag actions only (others have slots assigned) int i = categoryActions.indexOf(act); if (i >= 0) { // Category action @@ -869,6 +962,29 @@ void TransferListWidget::displayListMenu(const QPoint&) setSelectionCategory(category); } } + i = tagsActions.indexOf(act); + if (i >= 0) { + if (i == 0) { + askAddTagsForSelection(); + } + else if (i == 1) { + askRemoveTagsForSelection(); + } + else if (i == 2) { + if (Preferences::instance()->confirmRemoveAllTags()) + confirmRemoveAllTagsForSelection(); + else + clearSelectionTags(); + } + else { + // Individual tag toggles. + const QString &tag = tags.at(i - 3); + if (act->isChecked()) + addSelectionTag(tag); + else + removeSelectionTag(tag); + } + } } } @@ -892,6 +1008,14 @@ void TransferListWidget::applyCategoryFilter(QString category) nameFilterModel->setCategoryFilter(category); } +void TransferListWidget::applyTagFilter(const QString &tag) +{ + if (tag.isNull()) + nameFilterModel->disableTagFilter(); + else + nameFilterModel->setTagFilter(tag); +} + void TransferListWidget::applyTrackerFilterAll() { nameFilterModel->disableTrackerFilter(); diff --git a/src/gui/transferlistwidget.h b/src/gui/transferlistwidget.h index f2c217b28..9b021f1f3 100644 --- a/src/gui/transferlistwidget.h +++ b/src/gui/transferlistwidget.h @@ -31,6 +31,7 @@ #ifndef TRANSFERLISTWIDGET_H #define TRANSFERLISTWIDGET_H +#include #include namespace BitTorrent @@ -60,6 +61,9 @@ public: public slots: void setSelectionCategory(QString category); + void addSelectionTag(const QString &tag); + void removeSelectionTag(const QString &tag); + void clearSelectionTags(); void setSelectedTorrentsLocation(); void pauseAllTorrents(); void resumeAllTorrents(); @@ -89,6 +93,7 @@ public slots: void applyNameFilter(const QString& name); void applyStatusFilter(int f); void applyCategoryFilter(QString category); + void applyTagFilter(const QString &tag); void applyTrackerFilterAll(); void applyTrackerFilter(const QStringList &hashes); void previewFile(QString filePath); @@ -116,6 +121,11 @@ signals: private: void wheelEvent(QWheelEvent *event) override; + void askAddTagsForSelection(); + void askRemoveTagsForSelection(); + void confirmRemoveAllTagsForSelection(); + QStringList askTagsForSelection(const QString &dialogTitle); + void applyToSelectedTorrents(const std::function &fn); TransferListDelegate *listDelegate; TorrentModel *listModel;