Implement backend to allow Feed list sort and search

The current list of things to sort on (ascending and descending), includes:
- unplayed episodes
- new episodes
- favorite episodes
- title (alphabetical)
For the first three categories, the value of the sort quantity will be shown
in the upper right corner of the delegate.

BUG: 471012
CCBUG: 459885
This commit is contained in:
Bart De Vries 2023-07-27 09:24:03 +00:00
parent 4b1fe5e3f9
commit c3ca038af7
13 changed files with 340 additions and 65 deletions

View File

@ -267,6 +267,8 @@ if(ANDROID)
view-sort
view-sort-descending
view-sort-ascending
view-sort-descending-name
view-sort-ascending-name
)
else()
target_link_libraries(kasts PRIVATE Qt::Widgets)

View File

@ -196,28 +196,6 @@ int DataManager::entryCount(const Feed *feed) const
return m_entrymap[feed->url()].count();
}
int DataManager::newEntryCount(const Feed *feed) const
{
QSqlQuery query;
query.prepare(QStringLiteral("SELECT COUNT (id) FROM Entries where feed=:feed AND new=1;"));
query.bindValue(QStringLiteral(":feed"), feed->url());
Database::instance().execute(query);
if (!query.next())
return -1;
return query.value(0).toInt();
}
int DataManager::favoriteEntryCount(const Feed *feed) const
{
QSqlQuery query;
query.prepare(QStringLiteral("SELECT COUNT (id) FROM Entries where feed=:feed AND favorite=1;"));
query.bindValue(QStringLiteral(":feed"), feed->url());
Database::instance().execute(query);
if (!query.next())
return -1;
return query.value(0).toInt();
}
void DataManager::removeFeed(Feed *feed)
{
QList<Feed *> feeds;

View File

@ -37,8 +37,6 @@ public:
QStringList getIdList(const Feed *feed) const;
int entryCount(const int feed_index) const;
int entryCount(const Feed *feed) const;
int newEntryCount(const Feed *feed) const;
int favoriteEntryCount(const Feed *feed) const;
Q_INVOKABLE void addFeed(const QString &url);
void addFeed(const QString &url, const bool fetch);
void addFeeds(const QStringList &urls);

View File

@ -44,6 +44,8 @@ Feed::Feed(const QString &feedurl)
updateAuthors();
updateUnreadEntryCountFromDB();
updateNewEntryCountFromDB();
updateFavoriteEntryCountFromDB();
connect(&Fetcher::instance(), &Fetcher::feedUpdateStatusChanged, this, [this](const QString &url, bool status) {
if (url == m_url) {
@ -54,11 +56,26 @@ Feed::Feed(const QString &feedurl)
if (url == m_url) {
Q_EMIT entryCountChanged();
updateUnreadEntryCountFromDB();
Q_EMIT DataManager::instance().unreadEntryCountChanged(m_url);
Q_EMIT unreadEntryCountChanged();
Q_EMIT DataManager::instance().newEntryCountChanged(m_url);
Q_EMIT newEntryCountChanged();
setErrorId(0);
setErrorString(QLatin1String(""));
}
});
connect(&DataManager::instance(), &DataManager::newEntryCountChanged, this, [this](const QString &url) {
if (url == m_url) {
updateNewEntryCountFromDB();
Q_EMIT newEntryCountChanged();
}
});
connect(&DataManager::instance(), &DataManager::favoriteEntryCountChanged, this, [this](const QString &url) {
if (url == m_url) {
updateFavoriteEntryCountFromDB();
Q_EMIT favoriteEntryCountChanged();
}
});
connect(&Fetcher::instance(),
&Fetcher::error,
this,
@ -141,6 +158,28 @@ void Feed::updateUnreadEntryCountFromDB()
m_unreadEntryCount = query.value(0).toInt();
}
void Feed::updateNewEntryCountFromDB()
{
QSqlQuery query;
query.prepare(QStringLiteral("SELECT COUNT (id) FROM Entries where feed=:feed AND new=1;"));
query.bindValue(QStringLiteral(":feed"), m_url);
Database::instance().execute(query);
if (!query.next())
m_newEntryCount = -1;
m_newEntryCount = query.value(0).toInt();
}
void Feed::updateFavoriteEntryCountFromDB()
{
QSqlQuery query;
query.prepare(QStringLiteral("SELECT COUNT (id) FROM Entries where feed=:feed AND favorite=1;"));
query.bindValue(QStringLiteral(":feed"), m_url);
Database::instance().execute(query);
if (!query.next())
m_favoriteEntryCount = -1;
m_favoriteEntryCount = query.value(0).toInt();
}
QString Feed::url() const
{
return m_url;
@ -218,12 +257,12 @@ int Feed::unreadEntryCount() const
int Feed::newEntryCount() const
{
return DataManager::instance().newEntryCount(this);
return m_newEntryCount;
}
int Feed::favoriteEntryCount() const
{
return DataManager::instance().favoriteEntryCount(this);
return m_favoriteEntryCount;
}
bool Feed::refreshing() const

View File

@ -111,6 +111,8 @@ Q_SIGNALS:
private:
void updateUnreadEntryCountFromDB();
void updateNewEntryCountFromDB();
void updateFavoriteEntryCountFromDB();
QString m_url;
QString m_name;
@ -127,6 +129,8 @@ private:
int m_errorId;
QString m_errorString;
int m_unreadEntryCount = -1;
int m_newEntryCount = -1;
int m_favoriteEntryCount = -1;
EntriesProxyModel *m_entries;

View File

@ -28,22 +28,10 @@ FeedsModel::FeedsModel(QObject *parent)
beginRemoveRows(QModelIndex(), index, index);
endRemoveRows();
});
connect(&DataManager::instance(), &DataManager::unreadEntryCountChanged, this, [=](const QString &url) {
for (int i = 0; i < rowCount(QModelIndex()); i++) {
if (data(index(i, 0), UrlRole).toString() == url) {
Q_EMIT dataChanged(index(i, 0), index(i, 0));
return;
}
}
});
connect(&Fetcher::instance(), &Fetcher::feedDetailsUpdated, this, [=](const QString &url) {
for (int i = 0; i < rowCount(QModelIndex()); i++) {
if (data(index(i, 0), UrlRole).toString() == url) {
Q_EMIT dataChanged(index(i, 0), index(i, 0));
return;
}
}
});
connect(&DataManager::instance(), &DataManager::unreadEntryCountChanged, this, &FeedsModel::triggerFeedUpdate);
connect(&DataManager::instance(), &DataManager::newEntryCountChanged, this, &FeedsModel::triggerFeedUpdate);
connect(&DataManager::instance(), &DataManager::favoriteEntryCountChanged, this, &FeedsModel::triggerFeedUpdate);
connect(&Fetcher::instance(), &Fetcher::feedDetailsUpdated, this, &FeedsModel::triggerFeedUpdate);
}
QHash<int, QByteArray> FeedsModel::roleNames() const
@ -53,6 +41,8 @@ QHash<int, QByteArray> FeedsModel::roleNames() const
{UrlRole, "url"},
{TitleRole, "title"},
{UnreadCountRole, "unreadCount"},
{NewCountRole, "newCount"},
{FavoriteCountRole, "favoriteCount"},
};
}
@ -74,7 +64,21 @@ QVariant FeedsModel::data(const QModelIndex &index, int role) const
return QVariant::fromValue(DataManager::instance().getFeed(index.row())->name());
case UnreadCountRole:
return QVariant::fromValue(DataManager::instance().getFeed(index.row())->unreadEntryCount());
case NewCountRole:
return QVariant::fromValue(DataManager::instance().getFeed(index.row())->newEntryCount());
case FavoriteCountRole:
return QVariant::fromValue(DataManager::instance().getFeed(index.row())->favoriteEntryCount());
default:
return QVariant();
}
}
void FeedsModel::triggerFeedUpdate(const QString &url)
{
for (int i = 0; i < rowCount(QModelIndex()); i++) {
if (data(index(i, 0), UrlRole).toString() == url) {
Q_EMIT dataChanged(index(i, 0), index(i, 0));
return;
}
}
}

View File

@ -24,6 +24,8 @@ public:
UrlRole,
TitleRole,
UnreadCountRole,
NewCountRole,
FavoriteCountRole,
};
Q_ENUM(Roles)
@ -31,4 +33,7 @@ public:
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
int rowCount(const QModelIndex &parent) const override;
private:
void triggerFeedUpdate(const QString &url);
};

View File

@ -6,26 +6,154 @@
#include "models/feedsproxymodel.h"
#include <QDebug>
#include <KLocalizedString>
FeedsProxyModel::FeedsProxyModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
m_feedsModel = new FeedsModel(this);
setSourceModel(m_feedsModel);
setDynamicSortFilter(true);
sort(0, Qt::AscendingOrder);
sort(0);
}
bool FeedsProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
QString leftTitle = sourceModel()->data(left, FeedsModel::TitleRole).toString();
QString rightTitle = sourceModel()->data(right, FeedsModel::TitleRole).toString();
if (m_currentSort == SortType::UnreadDescending || m_currentSort == SortType::UnreadAscending) {
int leftUnreadCount = sourceModel()->data(left, FeedsModel::UnreadCountRole).toInt();
int rightUnreadCount = sourceModel()->data(right, FeedsModel::UnreadCountRole).toInt();
if (leftUnreadCount == rightUnreadCount) {
return QString::localeAwareCompare(leftTitle, rightTitle) < 0;
} else {
if (leftUnreadCount != rightUnreadCount) {
if (m_currentSort == SortType::UnreadDescending) {
return leftUnreadCount > rightUnreadCount;
} else {
return leftUnreadCount < rightUnreadCount;
}
}
} else if (m_currentSort == SortType::NewDescending || m_currentSort == SortType::NewAscending) {
int leftNewCount = sourceModel()->data(left, FeedsModel::NewCountRole).toInt();
int rightNewCount = sourceModel()->data(right, FeedsModel::NewCountRole).toInt();
if (leftNewCount != rightNewCount) {
if (m_currentSort == SortType::NewDescending) {
return leftNewCount > rightNewCount;
} else {
return leftNewCount < rightNewCount;
}
}
} else if (m_currentSort == SortType::FavoriteDescending || m_currentSort == SortType::FavoriteAscending) {
int leftFavoriteCount = sourceModel()->data(left, FeedsModel::FavoriteCountRole).toInt();
int rightFavoriteCount = sourceModel()->data(right, FeedsModel::FavoriteCountRole).toInt();
if (leftFavoriteCount != rightFavoriteCount) {
if (m_currentSort == SortType::FavoriteDescending) {
return leftFavoriteCount > rightFavoriteCount;
} else {
return leftFavoriteCount < rightFavoriteCount;
}
}
} else if (m_currentSort == SortType::TitleDescending) {
return QString::localeAwareCompare(leftTitle, rightTitle) > 0;
}
// In case there is a "tie" always use ascending alphabetical ordering
return QString::localeAwareCompare(leftTitle, rightTitle) < 0;
}
bool FeedsProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
bool found = m_searchFilter.isEmpty();
if (!m_searchFilter.isEmpty()) {
if (sourceModel()->data(index, FeedsModel::Roles::TitleRole).value<QString>().contains(m_searchFilter, Qt::CaseInsensitive)) {
found |= true;
}
}
return found;
}
QString FeedsProxyModel::searchFilter() const
{
return m_searchFilter;
}
FeedsProxyModel::SortType FeedsProxyModel::sortType() const
{
return m_currentSort;
}
void FeedsProxyModel::setSearchFilter(const QString &searchString)
{
if (searchString != m_searchFilter) {
beginResetModel();
m_searchFilter = searchString;
endResetModel();
Q_EMIT searchFilterChanged();
}
}
void FeedsProxyModel::setSortType(SortType type)
{
if (type != m_currentSort) {
m_currentSort = type;
// HACK: get the list re-sorted with a custom lessThan implementation
sort(-1);
sort(0);
Q_EMIT sortTypeChanged();
}
}
QString FeedsProxyModel::getSortName(SortType type) const
{
switch (type) {
case SortType::UnreadDescending:
return i18nc("@label:chooser Sort podcasts by decreasing number of unplayed episodes", "Unplayed count: descending");
case SortType::UnreadAscending:
return i18nc("@label:chooser Sort podcasts by increasing number of unplayed episodes", "Unplayed count: ascending");
case SortType::NewDescending:
return i18nc("@label:chooser Sort podcasts by decreasing number of new episodes", "New count: descending");
case SortType::NewAscending:
return i18nc("@label:chooser Sort podcasts by increasing number of new episodes", "New count: ascending");
case SortType::FavoriteDescending:
return i18nc("@label:chooser Sort podcasts by decreasing number of favorites", "Favorite count: descending");
case SortType::FavoriteAscending:
return i18nc("@label:chooser Sort podcasts by increasing number of favorites", "Favorite count: ascending");
case SortType::TitleAscending:
return i18nc("@label:chooser Sort podcasts titles alphabetically", "Podcast title: A → Z");
case SortType::TitleDescending:
return i18nc("@label:chooser Sort podcasts titles in reverse alphabetical order", "Podcast title: Z → A");
default:
return QString();
}
}
QString FeedsProxyModel::getSortIconName(SortType type) const
{
switch (type) {
case SortType::UnreadDescending:
case SortType::NewDescending:
case SortType::FavoriteDescending:
return QStringLiteral("view-sort-descending");
case SortType::UnreadAscending:
case SortType::NewAscending:
case SortType::FavoriteAscending:
return QStringLiteral("view-sort-ascending");
case SortType::TitleDescending:
return QStringLiteral("view-sort-descending-name");
case SortType::TitleAscending:
return QStringLiteral("view-sort-ascending-name");
default:
return QString();
}
}

View File

@ -7,7 +7,9 @@
#pragma once
#include <QItemSelection>
#include <QModelIndex>
#include <QSortFilterProxyModel>
#include <QString>
#include "models/feedsmodel.h"
@ -18,12 +20,46 @@ class FeedsProxyModel : public QSortFilterProxyModel
Q_OBJECT
public:
enum SortType {
UnreadDescending,
UnreadAscending,
NewDescending,
NewAscending,
FavoriteDescending,
FavoriteAscending,
TitleAscending,
TitleDescending,
};
Q_ENUM(SortType)
Q_PROPERTY(QString searchFilter READ searchFilter WRITE setSearchFilter NOTIFY searchFilterChanged)
Q_PROPERTY(SortType sortType READ sortType WRITE setSortType NOTIFY sortTypeChanged)
explicit FeedsProxyModel(QObject *parent = nullptr);
bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
QString searchFilter() const;
SortType sortType() const;
void setSearchFilter(const QString &searchString);
void setSortType(SortType type);
Q_INVOKABLE QString getSortName(SortType type) const;
Q_INVOKABLE QString getSortIconName(SortType type) const;
Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb);
Q_SIGNALS:
void searchFilterChanged();
void sortTypeChanged();
private:
FeedsModel *m_feedsModel;
QString m_searchFilter;
SortType m_currentSort = SortType::UnreadDescending;
};
Q_DECLARE_METATYPE(FeedsProxyModel::SortType)

View File

@ -18,6 +18,7 @@ import org.kde.kasts 1.0
Controls.ItemDelegate {
id: feedDelegate
property var countProperty: (kastsMainWindow.feedSorting === FeedsProxyModel.UnreadDescending || kastsMainWindow.feedSorting === FeedsProxyModel.UnreadAscending) ? feed.unreadEntryCount : ((kastsMainWindow.feedSorting === FeedsProxyModel.NewDescending || kastsMainWindow.feedSorting === FeedsProxyModel.NewAscending) ? feed.newEntryCount : ((kastsMainWindow.feedSorting === FeedsProxyModel.FavoriteDescending || kastsMainWindow.feedSorting === FeedsProxyModel.FavoriteAscending) ? feed.favoriteEntryCount : 0))
property int cardSize: 0
property int cardMargin: 0
property int borderWidth: 1
@ -170,7 +171,7 @@ Controls.ItemDelegate {
Rectangle {
id: countRectangle
visible: feed.unreadEntryCount > 0
visible: countProperty > 0
anchors.top: img.top
anchors.right: img.right
width: actionsButton.width
@ -181,10 +182,10 @@ Controls.ItemDelegate {
Controls.Label {
id: countLabel
visible: feed.unreadEntryCount > 0
visible: countProperty > 0
anchors.centerIn: countRectangle
anchors.margins: Kirigami.Units.smallSpacing
text: feed.unreadEntryCount
text: countProperty
font.bold: true
color: Kirigami.Theme.highlightedTextColor
}

View File

@ -57,6 +57,62 @@ Kirigami.ScrollablePage {
addSheet.open()
}
},
Kirigami.Action {
id: sortActionRoot
icon.name: "view-sort"
text: i18nc("@action:intoolbar Open menu with options to sort subscriptions", "Sort")
tooltip: i18nc("@info:tooltip", "Select how to sort subscriptions")
property Controls.ActionGroup sortGroup: Controls.ActionGroup { }
property Instantiator repeater: Instantiator {
model: ListModel {
id: sortModel
// have to use script because i18n doesn't work within ListElement
Component.onCompleted: {
if (sortActionRoot.visible) {
var sortList = [FeedsProxyModel.UnreadDescending,
FeedsProxyModel.UnreadAscending,
FeedsProxyModel.NewDescending,
FeedsProxyModel.NewAscending,
FeedsProxyModel.FavoriteDescending,
FeedsProxyModel.FavoriteAscending,
FeedsProxyModel.TitleAscending,
FeedsProxyModel.TitleDescending]
for (var i in sortList) {
sortModel.append({"name": feedsModel.getSortName(sortList[i]),
"iconName": feedsModel.getSortIconName(sortList[i]),
"sortType": sortList[i]});
}
}
}
}
Kirigami.Action {
visible: sortActionRoot.visible
icon.name: model.iconName
text: model.name
checkable: true
checked: kastsMainWindow.feedSorting === model.sortType
Controls.ActionGroup.group: sortActionRoot.sortGroup
onTriggered: {
kastsMainWindow.feedSorting = model.sortType;
}
}
onObjectAdded: (index, object) => {
sortActionRoot.children.push(object);
}
}
},
Kirigami.Action {
id: searchActionButton
icon.name: "search"
text: i18nc("@action:intoolbar", "Search")
checkable: true
},
Kirigami.Action {
id: importAction
text: i18nc("@action:intoolbar", "Import Podcasts…")
@ -83,6 +139,19 @@ Kirigami.ScrollablePage {
// TODO: KF6 replace contextualActions with actions
contextualActions: pageActions
header: Loader {
anchors.right: parent.right
anchors.left: parent.left
active: searchActionButton.checked
visible: active
sourceComponent: SearchBar {
proxyModel: feedsModel
parentKey: searchActionButton
showSearchFilters: false
}
}
AddFeedSheet {
id: addSheet
}
@ -114,9 +183,9 @@ Kirigami.ScrollablePage {
visible: feedList.count === 0
width: Kirigami.Units.gridUnit * 20
anchors.centerIn: parent
type: Kirigami.PlaceholderMessage.Actionable
text: i18nc("@info Placeholder message for empty podcast list", "No podcasts added yet")
explanation: i18nc("@info:tipoftheday", "Get started by adding podcasts:")
type: feedsModel.searchFilter === "" ? Kirigami.PlaceholderMessage.Actionable : Kirigami.PlaceholderMessage.Informational
text: feedsModel.searchFilter === "" ? i18nc("@info Placeholder message for empty podcast list", "No podcasts added yet") : i18nc("@info Placeholder message for podcast list when no podcast matches the search criteria", "No podcasts found")
explanation: feedsModel.searchFilter === "" ? i18nc("@info:tipoftheday", "Get started by adding podcasts:") : null
readonly property int buttonSize: Math.max(discoverButton.implicitWidth, addButton.implicitWidth, importButton.implicitWidth, syncButton.implicitWidth)
@ -124,6 +193,7 @@ Kirigami.ScrollablePage {
// to give them more descriptive names
Controls.Button {
id: discoverButton
visible: feedsModel.searchFilter === ""
Layout.preferredWidth: placeholderMessage.buttonSize
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Kirigami.Units.gridUnit
@ -136,6 +206,7 @@ Kirigami.ScrollablePage {
Controls.Button {
id: addButton
visible: feedsModel.searchFilter === ""
Layout.preferredWidth: placeholderMessage.buttonSize
Layout.alignment: Qt.AlignHCenter
action: addAction
@ -143,6 +214,7 @@ Kirigami.ScrollablePage {
Controls.Button {
id: importButton
visible: feedsModel.searchFilter === ""
Layout.preferredWidth: placeholderMessage.buttonSize
Layout.alignment: Qt.AlignHCenter
action: importAction
@ -150,6 +222,7 @@ Kirigami.ScrollablePage {
Controls.Button {
id: syncButton
visible: feedsModel.searchFilter === ""
Layout.preferredWidth: placeholderMessage.buttonSize
Layout.alignment: Qt.AlignHCenter
action: Kirigami.Action {
@ -184,6 +257,7 @@ Kirigami.ScrollablePage {
model: FeedsProxyModel {
id: feedsModel
sortType: kastsMainWindow.feedSorting
}
delegate: FeedListDelegate {
@ -207,9 +281,9 @@ Kirigami.ScrollablePage {
Connections {
target: feedList.model
function onModelAboutToBeReset() {
selectionForContextMenu = [];
feedList.selectionForContextMenu = [];
feedList.selectionModel.clear();
feedList.selectionModel.setCurrentIndex(model.index(0, 0), ItemSelectionModel.Current); // Only set current item; don't select it
feedList.selectionModel.setCurrentIndex(feedList.model.index(0, 0), ItemSelectionModel.Current); // Only set current item; don't select it
currentIndex = 0;
}
}

View File

@ -16,6 +16,7 @@ Controls.Control {
required property var proxyModel
required property var parentKey
property bool showSearchFilters: true
leftPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing
rightPadding: Kirigami.Units.largeSpacing
@ -46,6 +47,8 @@ Controls.Control {
Kirigami.Action {
id: searchSettingsButton
visible: showSearchFilters
enabled: visible
icon.name: "settings-configure"
text: i18nc("@action:intoolbar", "Advanced Search Options")
@ -68,7 +71,6 @@ Controls.Control {
}
Keys.onEscapePressed: {
proxyModel.filterType = AbstractEpisodeProxyModel.NoFilter;
proxyModel.searchFilter = "";
parentKey.checked = false;
event.accepted = true;
@ -88,6 +90,7 @@ Controls.Control {
function reload() {
clear();
if (showSearchFilters) {
var searchList = [AbstractEpisodeProxyModel.TitleFlag,
AbstractEpisodeProxyModel.ContentFlag,
AbstractEpisodeProxyModel.FeedNameFlag]
@ -97,6 +100,7 @@ Controls.Control {
"checked": proxyModel.searchFlags & searchList[i]});
}
}
}
Component.onCompleted: {
reload();

View File

@ -43,6 +43,7 @@ Kirigami.ApplicationWindow {
}
property var lastFeed: ""
property string currentPage: ""
property int feedSorting: FeedsProxyModel.UnreadDescending
property bool isWidescreen: kastsMainWindow.width > kastsMainWindow.height
@ -85,6 +86,7 @@ Kirigami.ApplicationWindow {
property var desktopHeight
property int headerSize: Kirigami.Units.gridUnit * 5
property alias lastOpenedPage: kastsMainWindow.currentPage
property alias feedSorting: kastsMainWindow.feedSorting
}
function saveWindowLayout() {