Add implementation for favorites

BUG: 459886
This commit is contained in:
Bart De Vries 2023-03-14 16:17:15 +01:00
parent f599380d92
commit db234722cb
21 changed files with 177 additions and 4 deletions

View File

@ -62,6 +62,8 @@ bool Database::migrate()
TRUE_OR_RETURN(migrateTo5());
if (dbversion < 6)
TRUE_OR_RETURN(migrateTo6());
if (dbversion < 7)
TRUE_OR_RETURN(migrateTo7());
return true;
}
@ -151,6 +153,16 @@ bool Database::migrateTo6()
return true;
}
bool Database::migrateTo7()
{
qDebug() << "Migrating database to version 7";
TRUE_OR_RETURN(transaction());
TRUE_OR_RETURN(execute(QStringLiteral("ALTER TABLE Entries ADD COLUMN favorite BOOL DEFAULT 0;")));
TRUE_OR_RETURN(execute(QStringLiteral("PRAGMA user_version = 7;")));
TRUE_OR_RETURN(commit());
return true;
}
bool Database::execute(const QString &query, const QString &connectionName)
{
QSqlQuery q(connectionName);

View File

@ -42,6 +42,7 @@ private:
bool migrateTo4();
bool migrateTo5();
bool migrateTo6();
bool migrateTo7();
void cleanup();
void setWalMode();

View File

@ -185,6 +185,17 @@ int DataManager::newEntryCount(const Feed *feed) const
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;
@ -661,6 +672,22 @@ void DataManager::bulkMarkNew(bool state, QStringList list)
Q_EMIT bulkNewStatusActionFinished();
}
void DataManager::bulkMarkFavoriteByIndex(bool state, QModelIndexList list)
{
bulkMarkFavorite(state, getIdsFromModelIndexList(list));
}
void DataManager::bulkMarkFavorite(bool state, QStringList list)
{
Database::instance().transaction();
for (QString id : list) {
getEntry(id)->setFavoriteInternal(state);
}
Database::instance().commit();
Q_EMIT bulkFavoriteStatusActionFinished();
}
void DataManager::bulkQueueStatusByIndex(bool state, QModelIndexList list)
{
bulkQueueStatus(state, getIdsFromModelIndexList(list));

View File

@ -37,6 +37,7 @@ public:
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);
@ -67,12 +68,14 @@ public:
Q_INVOKABLE void bulkMarkRead(bool state, QStringList list);
Q_INVOKABLE void bulkMarkNew(bool state, QStringList list);
Q_INVOKABLE void bulkMarkFavorite(bool state, QStringList list);
Q_INVOKABLE void bulkQueueStatus(bool state, QStringList list);
Q_INVOKABLE void bulkDownloadEnclosures(QStringList list);
Q_INVOKABLE void bulkDeleteEnclosures(QStringList list);
Q_INVOKABLE void bulkMarkReadByIndex(bool state, QModelIndexList list);
Q_INVOKABLE void bulkMarkNewByIndex(bool state, QModelIndexList list);
Q_INVOKABLE void bulkMarkFavoriteByIndex(bool state, QModelIndexList list);
Q_INVOKABLE void bulkQueueStatusByIndex(bool state, QModelIndexList list);
Q_INVOKABLE void bulkDownloadEnclosuresByIndex(QModelIndexList list);
Q_INVOKABLE void bulkDeleteEnclosuresByIndex(QModelIndexList list);
@ -87,9 +90,11 @@ Q_SIGNALS:
void unreadEntryCountChanged(const QString &url);
void newEntryCountChanged(const QString &url);
void favoriteEntryCountChanged(const QString &url);
void bulkReadStatusActionFinished();
void bulkNewStatusActionFinished();
void bulkFavoriteStatusActionFinished();
// this will relay the AudioManager::playbackRateChanged signal; this is
// required to avoid a dependency loop on startup

View File

@ -72,6 +72,10 @@ void Entry::updateFromDb(bool emitSignals)
m_new = entryQuery.value(QStringLiteral("new")).toBool();
Q_EMIT newChanged(m_new);
}
if (m_favorite != entryQuery.value(QStringLiteral("favorite")).toBool()) {
m_favorite = entryQuery.value(QStringLiteral("favorite")).toBool();
Q_EMIT favoriteChanged(m_favorite);
}
setHasEnclosure(entryQuery.value(QStringLiteral("hasEnclosure")).toBool(), emitSignals);
setImage(entryQuery.value(QStringLiteral("image")).toString(), emitSignals);
@ -172,6 +176,11 @@ bool Entry::getNew() const
return m_new;
}
bool Entry::favorite() const
{
return m_favorite;
}
QString Entry::baseUrl() const
{
return QUrl(m_link).adjusted(QUrl::RemovePath).toString();
@ -340,6 +349,34 @@ void Entry::setNewInternal(bool state)
}
}
void Entry::setFavorite(bool favorite)
{
if (favorite != m_favorite) {
// Making a detour through DataManager to make bulk operations more
// performant. DataManager will call setFavoriteInternal on every item to
// be marked new/not new. So implement features there.
DataManager::instance().bulkMarkFavorite(favorite, QStringList(m_id));
}
}
void Entry::setFavoriteInternal(bool favorite)
{
if (favorite != m_favorite) {
// Make sure that operations done here can be wrapped inside an sqlite
// transaction. I.e. no calls that trigger a SELECT operation.
m_favorite = favorite;
Q_EMIT favoriteChanged(m_favorite);
QSqlQuery query;
query.prepare(QStringLiteral("UPDATE Entries SET favorite=:favorite WHERE id=:id;"));
query.bindValue(QStringLiteral(":id"), m_id);
query.bindValue(QStringLiteral(":favorite"), m_favorite);
Database::instance().execute(query);
Q_EMIT DataManager::instance().favoriteEntryCountChanged(m_feed->url());
}
}
QString Entry::adjustedContent(int width, int fontSize)
{
QString ret(m_content);

View File

@ -32,6 +32,7 @@ class Entry : public QObject
Q_PROPERTY(QString baseUrl READ baseUrl NOTIFY baseUrlChanged)
Q_PROPERTY(bool read READ read WRITE setRead NOTIFY readChanged)
Q_PROPERTY(bool new READ getNew WRITE setNew NOTIFY newChanged)
Q_PROPERTY(bool favorite READ favorite WRITE setFavorite NOTIFY favoriteChanged)
Q_PROPERTY(Enclosure *enclosure READ enclosure CONSTANT)
Q_PROPERTY(bool hasEnclosure READ hasEnclosure NOTIFY hasEnclosureChanged)
Q_PROPERTY(QString image READ image NOTIFY imageChanged)
@ -51,6 +52,7 @@ public:
QString link() const;
bool read() const;
bool getNew() const;
bool favorite() const;
Enclosure *enclosure() const;
bool hasEnclosure() const;
QString image() const;
@ -62,12 +64,14 @@ public:
void setRead(bool read);
void setNew(bool state);
void setFavorite(bool favorite);
void setQueueStatus(bool status);
Q_INVOKABLE QString adjustedContent(int width, int fontSize);
void setNewInternal(bool state);
void setReadInternal(bool read);
void setFavoriteInternal(bool favorite);
void setQueueStatusInternal(bool state);
Q_SIGNALS:
@ -80,6 +84,7 @@ Q_SIGNALS:
void baseUrlChanged(const QString &baseUrl);
void readChanged(bool read);
void newChanged(bool state);
void favoriteChanged(bool favorite);
void hasEnclosureChanged(bool hasEnclosure);
void imageChanged(const QString &url);
void cachedImageChanged(const QString &imagePath);
@ -106,6 +111,7 @@ private:
QString m_link;
bool m_read;
bool m_new;
bool m_favorite;
Enclosure *m_enclosure = nullptr;
QString m_image;
bool m_hasenclosure = false;

View File

@ -215,6 +215,11 @@ int Feed::newEntryCount() const
return DataManager::instance().newEntryCount(this);
}
int Feed::favoriteEntryCount() const
{
return DataManager::instance().favoriteEntryCount(this);
}
bool Feed::refreshing() const
{
return m_refreshing;

View File

@ -35,6 +35,7 @@ class Feed : public QObject
Q_PROPERTY(int entryCount READ entryCount NOTIFY entryCountChanged)
Q_PROPERTY(int unreadEntryCount READ unreadEntryCount WRITE setUnreadEntryCount NOTIFY unreadEntryCountChanged)
Q_PROPERTY(int newEntryCount READ newEntryCount NOTIFY newEntryCountChanged)
Q_PROPERTY(int favoriteEntryCount READ favoriteEntryCount NOTIFY favoriteEntryCountChanged)
Q_PROPERTY(int errorId READ errorId WRITE setErrorId NOTIFY errorIdChanged)
Q_PROPERTY(QString errorString READ errorString WRITE setErrorString NOTIFY errorStringChanged)
Q_PROPERTY(EntriesProxyModel *entries MEMBER m_entries CONSTANT)
@ -61,6 +62,7 @@ public:
int entryCount() const;
int unreadEntryCount() const;
int newEntryCount() const;
int favoriteEntryCount() const;
bool read() const;
int errorId() const;
QString errorString() const;
@ -97,6 +99,7 @@ Q_SIGNALS:
void entryCountChanged();
void unreadEntryCountChanged();
void newEntryCountChanged();
void favoriteEntryCountChanged();
void errorIdChanged(int &errorId);
void errorStringChanged(const QString &errorString);

View File

@ -19,6 +19,7 @@ QHash<int, QByteArray> AbstractEpisodeModel::roleNames() const
{IdRole, "id"},
{ReadRole, "read"},
{NewRole, "new"},
{FavoriteRole, "favorite"},
{ContentRole, "content"},
{FeedNameRole, "feedname"},
};

View File

@ -22,6 +22,7 @@ public:
IdRole,
ReadRole,
NewRole,
FavoriteRole,
ContentRole,
FeedNameRole,
};

View File

@ -40,6 +40,12 @@ bool AbstractEpisodeProxyModel::filterAcceptsRow(int sourceRow, const QModelInde
case NotNewFilter:
accepted = !sourceModel()->data(index, AbstractEpisodeModel::Roles::NewRole).value<bool>();
break;
case FavoriteFilter:
accepted = sourceModel()->data(index, AbstractEpisodeModel::Roles::FavoriteRole).value<bool>();
break;
case NotFavoriteFilter:
accepted = !sourceModel()->data(index, AbstractEpisodeModel::Roles::FavoriteRole).value<bool>();
break;
default:
accepted = true;
break;
@ -94,6 +100,7 @@ void AbstractEpisodeProxyModel::setFilterType(FilterType type)
if (type != m_currentFilter) {
disconnect(&DataManager::instance(), &DataManager::bulkReadStatusActionFinished, this, nullptr);
disconnect(&DataManager::instance(), &DataManager::bulkNewStatusActionFinished, this, nullptr);
disconnect(&DataManager::instance(), &DataManager::bulkFavoriteStatusActionFinished, this, nullptr);
beginResetModel();
m_currentFilter = type;
@ -113,6 +120,12 @@ void AbstractEpisodeProxyModel::setFilterType(FilterType type)
dynamic_cast<AbstractEpisodeModel *>(sourceModel())->updateInternalState();
endResetModel();
});
} else if (type == FavoriteFilter || type == NotFavoriteFilter) {
connect(&DataManager::instance(), &DataManager::bulkFavoriteStatusActionFinished, this, [this]() {
beginResetModel();
dynamic_cast<AbstractEpisodeModel *>(sourceModel())->updateInternalState();
endResetModel();
});
}
Q_EMIT filterTypeChanged();
@ -152,6 +165,10 @@ QString AbstractEpisodeProxyModel::getFilterName(FilterType type) const
return i18nc("@label:chooser Choice of filter for episode list", "Episodes marked as \"New\"");
case FilterType::NotNewFilter:
return i18nc("@label:chooser Choice of filter for episode list", "Episodes not marked as \"New\"");
case FilterType::FavoriteFilter:
return i18nc("@label:chooser Choice of filter for episode list", "Episodes marked as Favorite");
case FilterType::NotFavoriteFilter:
return i18nc("@label:chooser Choice of filter for episode list", "Episodes not marked as Favorite");
default:
return QString();
}

View File

@ -25,6 +25,8 @@ public:
NotReadFilter,
NewFilter,
NotNewFilter,
FavoriteFilter,
NotFavoriteFilter,
};
Q_ENUM(FilterType)

View File

@ -38,6 +38,7 @@ QHash<int, QByteArray> DownloadModel::roleNames() const
{EpisodeModel::Roles::IdRole, "id"},
{EpisodeModel::Roles::ReadRole, "read"},
{EpisodeModel::Roles::NewRole, "new"},
{EpisodeModel::Roles::FavoriteRole, "favorite"},
};
}

View File

@ -42,6 +42,8 @@ QVariant EntriesModel::data(const QModelIndex &index, int role) const
return QVariant::fromValue(entry->read());
case AbstractEpisodeModel::Roles::NewRole:
return QVariant::fromValue(entry->getNew());
case AbstractEpisodeModel::Roles::FavoriteRole:
return QVariant::fromValue(entry->favorite());
case AbstractEpisodeModel::Roles::ContentRole:
return QVariant::fromValue(entry->content());
case AbstractEpisodeModel::Roles::FeedNameRole:

View File

@ -41,6 +41,8 @@ QVariant EpisodeModel::data(const QModelIndex &index, int role) const
return QVariant::fromValue(m_read[index.row()]);
case AbstractEpisodeModel::Roles::NewRole:
return QVariant::fromValue(m_new[index.row()]);
case AbstractEpisodeModel::Roles::FavoriteRole:
return QVariant::fromValue(m_favorite[index.row()]);
case AbstractEpisodeModel::Roles::ContentRole:
return QVariant::fromValue(m_contents[index.row()]);
case AbstractEpisodeModel::Roles::FeedNameRole:
@ -61,17 +63,19 @@ void EpisodeModel::updateInternalState()
m_entryIds.clear();
m_read.clear();
m_new.clear();
m_favorite.clear();
m_titles.clear();
m_contents.clear();
m_feedNames.clear();
QSqlQuery query;
query.prepare(QStringLiteral("SELECT id, read, new, title, content, feed FROM Entries ORDER BY updated DESC;"));
query.prepare(QStringLiteral("SELECT id, read, new, favorite, title, content, feed FROM Entries ORDER BY updated DESC;"));
Database::instance().execute(query);
while (query.next()) {
m_entryIds += query.value(QStringLiteral("id")).toString();
m_read += query.value(QStringLiteral("read")).toBool();
m_new += query.value(QStringLiteral("new")).toBool();
m_favorite += query.value(QStringLiteral("favorite")).toBool();
m_titles += query.value(QStringLiteral("title")).toString();
m_contents += query.value(QStringLiteral("content")).toString();
m_feedNames += DataManager::instance().getFeed(query.value(QStringLiteral("feed")).toString())->name();

View File

@ -35,6 +35,7 @@ private:
QStringList m_entryIds;
QVector<bool> m_read;
QVector<bool> m_new;
QVector<bool> m_favorite;
QStringList m_titles;
QStringList m_contents;
QStringList m_feedNames;

View File

@ -206,6 +206,14 @@ Kirigami.ScrollablePage {
entry.new = !entry.new
}
},
Kirigami.Action {
text: entry.favorite ? i18nc("@action:intoolbar Button to remove the \"favorite\" property of a podcast episode", "Remove from Favorites") : i18nc("@action:intoolbar Button to add a podcast episode as favorite", "Add to Favorites")
icon.name: !entry.favorite ? "starred-symbolic" : "non-starred-symbolic"
displayHint: Kirigami.DisplayHint.AlwaysHide
onTriggered: {
entry.favorite = !entry.favorite
}
},
Kirigami.Action {
text: i18nc("@action:intoolbar Button to open the podcast URL in browser", "Open Podcast")
displayHint: Kirigami.DisplayHint.AlwaysHide

View File

@ -180,6 +180,13 @@ Kirigami.SwipeListItem {
visible: entry ? entry.new : false
opacity: 0.7
}
Kirigami.Icon {
Layout.maximumHeight: 0.8 * supertitle.implicitHeight
Layout.maximumWidth: 0.8 * supertitle.implicitHeight
source: "starred-symbolic"
visible: entry ? (entry.favorite) : false
opacity: 0.7
}
Kirigami.Icon {
Layout.maximumHeight: 0.8 * supertitle.implicitHeight
Layout.maximumWidth: 0.8 * supertitle.implicitHeight
@ -189,7 +196,7 @@ Kirigami.SwipeListItem {
}
Controls.Label {
id: supertitle
text: entry ? ((!isQueue && entry.queueStatus ? "· " : "") + entry.updated.toLocaleDateString(Qt.locale(), Locale.NarrowFormat) + (entry.enclosure ? ( entry.enclosure.size !== 0 ? " · " + entry.enclosure.formattedSize : "") : "" )) : ""
text: entry ? (((!isQueue && entry.queueStatus) || entry.favorite ? "· " : "") + entry.updated.toLocaleDateString(Qt.locale(), Locale.NarrowFormat) + (entry.enclosure ? ( entry.enclosure.size !== 0 ? " · " + entry.enclosure.formattedSize : "") : "" )) : ""
Layout.fillWidth: true
elide: Text.ElideRight
font: Kirigami.Theme.smallFont

View File

@ -193,6 +193,24 @@ ListView {
}
}
property var markFavoriteAction: Kirigami.Action {
text: i18nc("@action:intoolbar Button to add a podcast episode as favorite", "Add to Favorites")
icon.name: "starred-symbolic"
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? !singleSelectedEntry.favorite : true)
onTriggered: {
DataManager.bulkMarkFavoriteByIndex(true, selectionForContextMenu);
}
}
property var markNotFavoriteAction: Kirigami.Action {
text: i18nc("@action:intoolbar Button to remove the \"favorite\" property of a podcast episode", "Remove from Favorites")
icon.name: "non-starred-symbolic"
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? singleSelectedEntry.favorite : true)
onTriggered: {
DataManager.bulkMarkFavoriteByIndex(false, selectionForContextMenu);
}
}
property var downloadEnclosureAction: Kirigami.Action {
text: i18n("Download")
icon.name: "download"
@ -231,6 +249,8 @@ ListView {
markNotPlayedAction,
markNewAction,
markNotNewAction,
markFavoriteAction,
markNotFavoriteAction,
downloadEnclosureAction,
deleteEnclosureAction,
streamAction,
@ -270,6 +290,16 @@ ListView {
visible: singleSelectedEntry ? singleSelectedEntry.new : true
height: visible ? implicitHeight : 0 // workaround for qqc2-breeze-style
}
Controls.MenuItem {
action: listView.markFavoriteAction
visible: singleSelectedEntry ? !singleSelectedEntry.favorite : true
height: visible ? implicitHeight : 0 // workaround for qqc2-breeze-style
}
Controls.MenuItem {
action: listView.markNotFavoriteAction
visible: singleSelectedEntry ? singleSelectedEntry.favorite : true
height: visible ? implicitHeight : 0 // workaround for qqc2-breeze-style
}
Controls.MenuItem {
action: listView.downloadEnclosureAction
visible: singleSelectedEntry ? (singleSelectedEntry.hasEnclosure ? singleSelectedEntry.enclosure.status !== Enclosure.Downloaded : false) : true

View File

@ -113,7 +113,9 @@ Controls.Control {
AbstractEpisodeProxyModel.ReadFilter,
AbstractEpisodeProxyModel.NotReadFilter,
AbstractEpisodeProxyModel.NewFilter,
AbstractEpisodeProxyModel.NotNewFilter]
AbstractEpisodeProxyModel.NotNewFilter,
AbstractEpisodeProxyModel.FavoriteFilter,
AbstractEpisodeProxyModel.NotFavoriteFilter]
for (var i in filterList) {
filterModel.append({"name": proxyModel.getFilterName(filterList[i]),
"filterType": filterList[i]});

View File

@ -490,7 +490,7 @@ void UpdateFeedJob::writeToDatabase()
// new entries
writeQuery.prepare(
QStringLiteral("INSERT INTO Entries VALUES (:feed, :id, :title, :content, :created, :updated, :link, :read, :new, :hasEnclosure, :image);"));
QStringLiteral("INSERT INTO Entries VALUES (:feed, :id, :title, :content, :created, :updated, :link, :read, :new, :hasEnclosure, :image, :favorite);"));
for (const EntryDetails &entryDetails : m_newEntries) {
writeQuery.bindValue(QStringLiteral(":feed"), entryDetails.feed);
writeQuery.bindValue(QStringLiteral(":id"), entryDetails.id);
@ -503,6 +503,7 @@ void UpdateFeedJob::writeToDatabase()
writeQuery.bindValue(QStringLiteral(":read"), entryDetails.read);
writeQuery.bindValue(QStringLiteral(":new"), entryDetails.isNew);
writeQuery.bindValue(QStringLiteral(":image"), entryDetails.image);
writeQuery.bindValue(QStringLiteral(":favorite"), false);
Database::execute(writeQuery);
}