diff --git a/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json new file mode 100644 index 000000000..c890ba854 --- /dev/null +++ b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json @@ -0,0 +1,452 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "d24350fe9440c192c9e8dfa746da1645", + "entities": [ + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `updateAt` INTEGER, `lastArticleId` TEXT, `syncInterval` INTEGER NOT NULL DEFAULT 30, `syncOnStart` INTEGER NOT NULL DEFAULT 0, `syncOnlyOnWiFi` INTEGER NOT NULL DEFAULT 0, `syncOnlyWhenCharging` INTEGER NOT NULL DEFAULT 0, `keepArchived` INTEGER NOT NULL DEFAULT 2592000000, `syncBlockList` TEXT NOT NULL DEFAULT '', `securityKey` TEXT DEFAULT 'CvJ1PKM8EW8=')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastArticleId", + "columnName": "lastArticleId", + "affinity": "TEXT" + }, + { + "fieldPath": "syncInterval", + "columnName": "syncInterval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "syncOnStart", + "columnName": "syncOnStart", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyOnWiFi", + "columnName": "syncOnlyOnWiFi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyWhenCharging", + "columnName": "syncOnlyWhenCharging", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepArchived", + "columnName": "keepArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2592000000" + }, + { + "fieldPath": "syncBlockList", + "columnName": "syncBlockList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "securityKey", + "columnName": "securityKey", + "affinity": "TEXT", + "defaultValue": "'CvJ1PKM8EW8='" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `url` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isNotification` INTEGER NOT NULL, `isFullContent` INTEGER NOT NULL, `isBrowser` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotification", + "columnName": "isNotification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFullContent", + "columnName": "isFullContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBrowser", + "columnName": "isBrowser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feed_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feed_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `date` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `rawDescription` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `fullContent` TEXT, `img` TEXT, `link` TEXT NOT NULL, `feedId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isUnread` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `isReadLater` INTEGER NOT NULL, `updateAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "rawDescription", + "columnName": "rawDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContent", + "columnName": "fullContent", + "affinity": "TEXT" + }, + { + "fieldPath": "img", + "columnName": "img", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnread", + "columnName": "isUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadLater", + "columnName": "isReadLater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_article_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_article_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ] + }, + { + "tableName": "archived_article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `feedId` TEXT NOT NULL, `link` TEXT NOT NULL, FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keyword_filter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedId` TEXT NOT NULL, `keyword` TEXT NOT NULL, PRIMARY KEY(`feedId`, `keyword`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "feedId", + "keyword" + ] + }, + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd24350fe9440c192c9e8dfa746da1645')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithKeywordFilter.kt b/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithKeywordFilter.kt new file mode 100644 index 000000000..687ae431a --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithKeywordFilter.kt @@ -0,0 +1,15 @@ +package me.ash.reader.domain.model.feed + +import androidx.room.Embedded +import androidx.room.Relation +import me.ash.reader.domain.model.group.Group + +/** + * A [feed] with filtered keywords. + */ +data class FeedWithKeywordFilter( + @Embedded + var feed: Feed, + @Relation(parentColumn = "feedId", entityColumn = "id") + var keywordFilters: List, +) diff --git a/app/src/main/java/me/ash/reader/domain/model/feed/KeywordFilter.kt b/app/src/main/java/me/ash/reader/domain/model/feed/KeywordFilter.kt new file mode 100644 index 000000000..8ffcc0108 --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/model/feed/KeywordFilter.kt @@ -0,0 +1,25 @@ +package me.ash.reader.domain.model.feed + +import androidx.room.Entity +import androidx.room.ForeignKey + +/** + * TODO: Add class description + */ +@Entity( + tableName = "keyword_filter", + primaryKeys = ["feedId", "keyword"], + foreignKeys = [ + ForeignKey( + entity = Feed::class, + parentColumns = ["id"], + childColumns = ["feedId"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ) + ] +) +data class KeywordFilter( + val feedId: String, + val keyword: String, +) \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeedWithKeywordFilters.kt b/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeedWithKeywordFilters.kt new file mode 100644 index 000000000..f1bf971af --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeedWithKeywordFilters.kt @@ -0,0 +1,16 @@ +package me.ash.reader.domain.model.group + +import androidx.room.Embedded +import androidx.room.Relation +import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feed.FeedWithKeywordFilter + +/** + * A [group] contains many [feeds], each of which has [keyword_filters]. + */ +data class GroupWithFeedWithKeywordFilters( + @Embedded + val group: Group, + @Relation(parentColumn = "id", entityColumn = "groupId") + val feedsWithKeywordFilters: MutableList, +) diff --git a/app/src/main/java/me/ash/reader/domain/repository/ArticleDao.kt b/app/src/main/java/me/ash/reader/domain/repository/ArticleDao.kt index e50f9603f..2a7872fdc 100644 --- a/app/src/main/java/me/ash/reader/domain/repository/ArticleDao.kt +++ b/app/src/main/java/me/ash/reader/domain/repository/ArticleDao.kt @@ -407,6 +407,16 @@ interface ArticleDao { ) suspend fun deleteByFeedId(accountId: Int, feedId: String, includeStarred: Boolean = false) + @Query( + """ + DELETE FROM article + WHERE accountId = :accountId + AND feedId = :feedId + AND title LIKE '%' || :keyword || '%' + """ + ) + suspend fun deleteByKeyword(accountId: Int, feedId: String, keyword: String) + @Query( """ DELETE FROM article @@ -917,4 +927,12 @@ interface ArticleDao { return articles.filterNot { existingArticles.containsKey(it.link) }.also { insertList(it) } } + + @Transaction + suspend fun deleteList(articles: List
) { + articles.forEach { + println("delete : " + it.title) + delete(it) + } + } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/domain/repository/KeywordFilterDao.kt b/app/src/main/java/me/ash/reader/domain/repository/KeywordFilterDao.kt new file mode 100644 index 000000000..dedbe3713 --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/repository/KeywordFilterDao.kt @@ -0,0 +1,46 @@ +package me.ash.reader.domain.repository + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import me.ash.reader.domain.model.article.Article +import me.ash.reader.domain.model.feed.FeedWithKeywordFilter +import me.ash.reader.domain.model.feed.KeywordFilter + +@Dao +interface KeywordFilterDao { + + @Query( + """ + SELECT * FROM `keyword_filter` + """ + ) + fun queryAll(): Flow> + + @Query( + """ + SELECT * FROM `keyword_filter` + """ + ) + suspend fun queryAllBlocking(): List + + @Query( + """ + SELECT * FROM `keyword_filter` + WHERE feedId = :feedId + """ + ) + suspend fun queryAllWithFeedId(feedId: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg filter: KeywordFilter) + + @Delete + suspend fun delete(vararg filter: KeywordFilter) + + @Insert + suspend fun insertList(filters: List) +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt index 32f66c7ba..10b8782e4 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn +import me.ash.reader.domain.model.feed.KeywordFilter import me.ash.reader.domain.model.account.Account import me.ash.reader.domain.model.article.ArchivedArticle import me.ash.reader.domain.model.article.Article @@ -22,6 +23,7 @@ import me.ash.reader.domain.model.group.GroupWithFeed import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao import me.ash.reader.domain.repository.GroupDao +import me.ash.reader.domain.repository.KeywordFilterDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.preference.KeepArchivedPreference import me.ash.reader.infrastructure.preference.SyncIntervalPreference @@ -32,6 +34,7 @@ import me.ash.reader.ui.ext.spacerDollar abstract class AbstractRssRepository( private val articleDao: ArticleDao, private val groupDao: GroupDao, + private val keywordFilterDao: KeywordFilterDao, private val feedDao: FeedDao, private val workManager: WorkManager, private val rssHelper: RssHelper, @@ -58,6 +61,7 @@ abstract class AbstractRssRepository( isNotification: Boolean, isFullContent: Boolean, isBrowser: Boolean, + filteredKeywords: List ) { val accountId = accountService.getCurrentAccountId() val feed = @@ -72,10 +76,14 @@ abstract class AbstractRssRepository( isNotification = isNotification, isFullContent = isFullContent, ) - val articles = - searchedFeed.entries.map { rssHelper.buildArticleFromSyndEntry(feed, accountId, it) } + val articles = searchedFeed.entries.filter { article -> + filteredKeywords.none { keyword -> + article.title.lowercase().contains(keyword.lowercase()) + } + }.map { rssHelper.buildArticleFromSyndEntry(feed, accountId, it) } feedDao.insert(feed) articleDao.insertList(articles.map { it.copy(feedId = feed.id) }) + keywordFilterDao.insertList(filteredKeywords.map { KeywordFilter(feed.id, it) }) } open suspend fun addGroup(destFeed: Feed?, newGroupName: String): String { @@ -86,6 +94,10 @@ abstract class AbstractRssRepository( } } + open suspend fun addFilteredKeyword(feed: Feed, newFilteredKeyword: String) { + keywordFilterDao.insert(KeywordFilter(feedId = feed.id, keyword = newFilteredKeyword)) + } + abstract suspend fun sync( accountId: Int, feedId: String?, @@ -209,6 +221,9 @@ abstract class AbstractRssRepository( fun pullGroups(): Flow> = groupDao.queryAllGroup(accountService.getCurrentAccountId()).flowOn(dispatcherIO) + fun pullFilteredKeywords(): Flow> = + keywordFilterDao.queryAll().flowOn(dispatcherIO) + fun pullFeeds(): Flow> = groupDao .queryAllGroupWithFeedAsFlow(accountService.getCurrentAccountId()) @@ -341,6 +356,10 @@ abstract class AbstractRssRepository( groupDao.delete(group) } + open suspend fun deleteFilteredKeyword(keyword: KeywordFilter) { + keywordFilterDao.delete(keyword) + } + open suspend fun deleteFeed(feed: Feed, onlyDeleteNoStarred: Boolean? = false) { if ( onlyDeleteNoStarred == true && @@ -359,9 +378,17 @@ abstract class AbstractRssRepository( suspend fun deleteArticles( group: Group? = null, feed: Feed? = null, + keywordFilter: String? = null, includeStarred: Boolean = false, ) { when { + feed != null && keywordFilter != null -> + articleDao.deleteByKeyword( + accountService.getCurrentAccountId(), + feed.id, + keywordFilter, + ) + group != null -> articleDao.deleteByGroupId( accountService.getCurrentAccountId(), diff --git a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt index 91de4281e..9c7890390 100644 --- a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt @@ -24,6 +24,7 @@ import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao import me.ash.reader.domain.repository.GroupDao +import me.ash.reader.domain.repository.KeywordFilterDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.di.DefaultDispatcher import me.ash.reader.infrastructure.di.IODispatcher @@ -47,6 +48,7 @@ constructor( private val rssHelper: RssHelper, private val notificationHelper: NotificationHelper, private val groupDao: GroupDao, + private val keywordFilterDao: KeywordFilterDao, @IODispatcher private val ioDispatcher: CoroutineDispatcher, @MainDispatcher private val mainDispatcher: CoroutineDispatcher, @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @@ -56,6 +58,7 @@ constructor( AbstractRssRepository( articleDao, groupDao, + keywordFilterDao, feedDao, workManager, rssHelper, @@ -98,6 +101,7 @@ constructor( isNotification: Boolean, isFullContent: Boolean, isBrowser: Boolean, + filteredKeywords: List ) { throw FeverAPIException("Unsupported") } @@ -227,6 +231,7 @@ constructor( break } + val allFilteredKeywords = keywordFilterDao.queryAllBlocking() val articlesFromBatch = fetchedItems.map { item -> Article( @@ -248,6 +253,13 @@ constructor( isStarred = (item.is_saved ?: 0) > 0, updateAt = preDate, ) + }.filter { article -> + val filteredKeywordsForThisArticle = allFilteredKeywords.filter { keyword -> + keyword.feedId == article.feedId + } + filteredKeywordsForThisArticle.none { keyword -> + article.title.lowercase().contains(keyword.keyword.lowercase()) + } } allArticles.addAll(articlesFromBatch) diff --git a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt index 909bc85fc..49162be11 100644 --- a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt @@ -33,10 +33,12 @@ import me.ash.reader.domain.model.account.AccountType.Companion.FreshRSS import me.ash.reader.domain.model.account.security.GoogleReaderSecurityKey import me.ash.reader.domain.model.article.Article import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feed.KeywordFilter import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao import me.ash.reader.domain.repository.GroupDao +import me.ash.reader.domain.repository.KeywordFilterDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.di.DefaultDispatcher import me.ash.reader.infrastructure.di.IODispatcher @@ -71,6 +73,7 @@ constructor( private val rssHelper: RssHelper, private val notificationHelper: NotificationHelper, private val groupDao: GroupDao, + private val keywordFilterDao: KeywordFilterDao, @IODispatcher private val ioDispatcher: CoroutineDispatcher, @MainDispatcher private val mainDispatcher: CoroutineDispatcher, @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @@ -81,6 +84,7 @@ constructor( AbstractRssRepository( articleDao, groupDao, + keywordFilterDao, feedDao, workManager, rssHelper, @@ -134,6 +138,7 @@ constructor( isNotification: Boolean, isFullContent: Boolean, isBrowser: Boolean, + filteredKeywords: List ) { val accountId = accountService.getCurrentAccountId() val quickAdd = getGoogleReaderAPI().subscriptionQuickAdd(feedLink) @@ -396,6 +401,7 @@ constructor( unreadIds = remoteUnreadIds.await(), starredIds = remoteStarredIds.await(), scope = this, + allFilteredKeywords = keywordFilterDao.queryAllBlocking() ) .toMutableList() @@ -563,6 +569,7 @@ constructor( accountId = accountId, unreadIds = remoteUnreadIds.await(), starredIds = remoteStarredIds.await(), + allFilteredKeywords = keywordFilterDao.queryAllBlocking() ) if (feed.isNotification) { @@ -626,6 +633,7 @@ constructor( unreadIds: Set, starredIds: Set, scope: CoroutineScope, + allFilteredKeywords: List, ): List>> { if (itemIds.isEmpty()) return emptyList() val currentDate = Date() @@ -671,7 +679,14 @@ constructor( ?: currentDate, ) }, - ) + ).filter { article -> + val filteredKeywordsForThisArticle = allFilteredKeywords.filter { keyword -> + keyword.feedId == article.feedId + } + filteredKeywordsForThisArticle.none { keyword -> + article.title.lowercase().contains(keyword.keyword.lowercase()) + } + } } } } @@ -683,6 +698,7 @@ constructor( accountId: Int, unreadIds: Set, starredIds: Set, + allFilteredKeywords: List ): List
= supervisorScope { fetchItemsContentsDeferred( itemIds = itemIds, @@ -691,6 +707,7 @@ constructor( unreadIds = unreadIds, starredIds = starredIds, scope = this, + allFilteredKeywords = allFilteredKeywords, ) .awaitAll() .flatten() diff --git a/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt b/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt index 509f7b547..a41551eb1 100644 --- a/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt @@ -20,6 +20,7 @@ import me.ash.reader.domain.model.feed.FeedWithArticle import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao import me.ash.reader.domain.repository.GroupDao +import me.ash.reader.domain.repository.KeywordFilterDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.di.DefaultDispatcher import me.ash.reader.infrastructure.di.IODispatcher @@ -37,6 +38,7 @@ constructor( private val rssHelper: RssHelper, private val notificationHelper: NotificationHelper, private val groupDao: GroupDao, + private val keywordFilterDao: KeywordFilterDao, @IODispatcher private val ioDispatcher: CoroutineDispatcher, @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, private val workManager: WorkManager, @@ -46,6 +48,7 @@ constructor( AbstractRssRepository( articleDao, groupDao, + keywordFilterDao, feedDao, workManager, rssHelper, @@ -91,9 +94,17 @@ constructor( archivedArticles.contains(it.link) } + val filteredKeywords = + keywordFilterDao.queryAllWithFeedId(currentFeed.id).map { it.keyword.lowercase() } + val (filteredArticles, removedArticles) = fetchedArticles.partition { article -> + filteredKeywords.none { keyword -> + article.title.lowercase().contains(keyword.lowercase()) + } + } + val newArticles = articleDao.insertListIfNotExist( - articles = fetchedArticles, + articles = filteredArticles, feed = currentFeed, ) if (currentFeed.isNotification && newArticles.isNotEmpty()) { diff --git a/app/src/main/java/me/ash/reader/domain/service/OpmlService.kt b/app/src/main/java/me/ash/reader/domain/service/OpmlService.kt index 3f49ca6ca..37c461339 100644 --- a/app/src/main/java/me/ash/reader/domain/service/OpmlService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/OpmlService.kt @@ -9,9 +9,11 @@ import be.ceau.opml.entity.Outline import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.repository.FeedDao import me.ash.reader.domain.repository.GroupDao +import me.ash.reader.domain.repository.KeywordFilterDao import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.rss.OPMLDataSource import me.ash.reader.ui.ext.currentAccountId @@ -28,6 +30,7 @@ class OpmlService @Inject constructor( private val context: Context, private val groupDao: GroupDao, private val feedDao: FeedDao, + private val keywordFilterDao: KeywordFilterDao, private val accountService: AccountService, private val rssService: RssService, private val OPMLDataSource: OPMLDataSource, @@ -44,20 +47,22 @@ class OpmlService @Inject constructor( suspend fun saveToDatabase(inputStream: InputStream) { withContext(ioDispatcher) { val defaultGroup = groupDao.queryById(getDefaultGroupId(context.currentAccountId))!! - val groupWithFeedList = + val groupWithFeedsWithKeywordFiltersList = OPMLDataSource.parseFileInputStream(inputStream, defaultGroup, context.currentAccountId) - groupWithFeedList.forEach { groupWithFeed -> - if (groupWithFeed.group != defaultGroup) { - groupDao.insert(groupWithFeed.group) + groupWithFeedsWithKeywordFiltersList.forEach { groupWithFeedsWithKeywordFilters -> + if (groupWithFeedsWithKeywordFilters.group != defaultGroup) { + groupDao.insert(groupWithFeedsWithKeywordFilters.group) } val repeatList = mutableListOf() - groupWithFeed.feeds.forEach { - it.groupId = groupWithFeed.group.id - if (rssService.get().isFeedExist(it.url)) { - repeatList.add(it) + groupWithFeedsWithKeywordFilters.feedsWithKeywordFilters.forEach { + it.feed.groupId = groupWithFeedsWithKeywordFilters.group.id + if (rssService.get().isFeedExist(it.feed.url)) { + repeatList.add(it.feed) } } - feedDao.insertList((groupWithFeed.feeds subtract repeatList.toSet()).toList()) + feedDao.insertList((groupWithFeedsWithKeywordFilters.feedsWithKeywordFilters.map { it.feed } subtract repeatList.toSet()).toList()) + keywordFilterDao.insertList(groupWithFeedsWithKeywordFilters.feedsWithKeywordFilters.map { it.keywordFilters } + .flatten()) } } } @@ -68,6 +73,7 @@ class OpmlService @Inject constructor( @Throws(Exception::class) suspend fun saveToString(accountId: Int, attachInfo: Boolean): String { val defaultGroup = groupDao.queryById(getDefaultGroupId(accountId)) + val allFilteredKeywords = keywordFilterDao.queryAllBlocking() return OpmlWriter().write( Opml( "2.0", @@ -99,6 +105,11 @@ class OpmlService @Inject constructor( put("isNotification", feed.isNotification.toString()) put("isFullContent", feed.isFullContent.toString()) put("isBrowser", feed.isBrowser.toString()) + put( + "filteredKeywords", + Json.encodeToString(allFilteredKeywords.filter { keyword -> keyword.feedId == feed.id } + .map { keyword -> keyword.keyword }) + ) } }, listOf() diff --git a/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt b/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt index 925b345b7..c9677bc9e 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.* import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import me.ash.reader.domain.model.feed.KeywordFilter import me.ash.reader.domain.model.account.* import me.ash.reader.domain.model.account.security.DESUtils import me.ash.reader.domain.model.article.ArchivedArticle @@ -14,17 +15,19 @@ import me.ash.reader.domain.repository.AccountDao import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao import me.ash.reader.domain.repository.GroupDao +import me.ash.reader.domain.repository.KeywordFilterDao import me.ash.reader.infrastructure.preference.* import me.ash.reader.ui.ext.toInt import java.util.* @Database( - entities = [Account::class, Feed::class, Article::class, Group::class, ArchivedArticle::class], - version = 7, + entities = [Account::class, Feed::class, Article::class, Group::class, ArchivedArticle::class, KeywordFilter::class], + version = 8, autoMigrations = [ AutoMigration(from = 5, to = 6), AutoMigration(from = 5, to = 7), AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8), ] ) @TypeConverters( @@ -43,6 +46,7 @@ abstract class AndroidDatabase : RoomDatabase() { abstract fun feedDao(): FeedDao abstract fun articleDao(): ArticleDao abstract fun groupDao(): GroupDao + abstract fun keywordFilterDao(): KeywordFilterDao companion object { diff --git a/app/src/main/java/me/ash/reader/infrastructure/di/DatabaseModule.kt b/app/src/main/java/me/ash/reader/infrastructure/di/DatabaseModule.kt index 98e4dc522..6f36dda5d 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/di/DatabaseModule.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/di/DatabaseModule.kt @@ -10,6 +10,7 @@ import me.ash.reader.domain.repository.AccountDao import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao import me.ash.reader.domain.repository.GroupDao +import me.ash.reader.domain.repository.KeywordFilterDao import me.ash.reader.infrastructure.db.AndroidDatabase import javax.inject.Singleton @@ -19,6 +20,7 @@ import javax.inject.Singleton * - [ArticleDao] * - [FeedDao] * - [GroupDao] + * - [KeywordFilterDao] * - [AccountDao] */ @Module @@ -40,6 +42,11 @@ object DatabaseModule { fun provideGroupDao(androidDatabase: AndroidDatabase): GroupDao = androidDatabase.groupDao() + @Provides + @Singleton + fun provideKeywordFilterDao(androidDatabase: AndroidDatabase): KeywordFilterDao = + androidDatabase.keywordFilterDao() + @Provides @Singleton fun provideAccountDao(androidDatabase: AndroidDatabase): AccountDao = diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/OPMLDataSource.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/OPMLDataSource.kt index de7344380..41c1be448 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/OPMLDataSource.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/OPMLDataSource.kt @@ -5,9 +5,13 @@ import be.ceau.opml.OpmlParser import be.ceau.opml.entity.Outline import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.serialization.json.Json import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feed.FeedWithKeywordFilter +import me.ash.reader.domain.model.feed.KeywordFilter import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.model.group.GroupWithFeed +import me.ash.reader.domain.model.group.GroupWithFeedWithKeywordFilters import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.ui.ext.extractDomain import me.ash.reader.ui.ext.spacerDollar @@ -27,9 +31,9 @@ class OPMLDataSource @Inject constructor( inputStream: InputStream, defaultGroup: Group, targetAccountId: Int, - ): List { + ): List { val opml = OpmlParser().parse(inputStream) - val groupWithFeedList = mutableListOf().also { + val groupWithFeedList = mutableListOf().also { it.addGroup(defaultGroup) } @@ -75,9 +79,10 @@ class OPMLDataSource @Inject constructor( } for (subOutline in outline.subElements) { if (subOutline != null && subOutline.attributes != null) { + val feedId = targetAccountId.spacerDollar(UUID.randomUUID().toString()) groupWithFeedList.addFeed( Feed( - id = targetAccountId.spacerDollar(UUID.randomUUID().toString()), + id = feedId, name = subOutline.extractName(), url = subOutline.extractUrl() ?: continue, groupId = groupId, @@ -85,7 +90,8 @@ class OPMLDataSource @Inject constructor( isNotification = subOutline.extractPresetNotification(), isFullContent = subOutline.extractPresetFullContent(), isBrowser = subOutline.extractPresetBrowser(), - ) + ), + subOutline.extractFilteredKeywords().map { KeywordFilter(feedId, it) }, ) } } @@ -94,16 +100,29 @@ class OPMLDataSource @Inject constructor( return groupWithFeedList } - private fun MutableList.addGroup(group: Group) { - add(GroupWithFeed(group = group, feeds = mutableListOf())) + private fun MutableList.addGroup(group: Group) { + add(GroupWithFeedWithKeywordFilters(group = group, feedsWithKeywordFilters = mutableListOf())) } - private fun MutableList.addFeed(feed: Feed) { - last().feeds.add(feed) + private fun MutableList.addFeed( + feed: Feed, + filteredKeywords: List + ) { + last().feedsWithKeywordFilters.add( + FeedWithKeywordFilter( + feed = feed, + keywordFilters = filteredKeywords, + ) + ) } - private fun MutableList.addFeedToDefault(feed: Feed) { - first().feeds.add(feed) + private fun MutableList.addFeedToDefault(feed: Feed) { + first().feedsWithKeywordFilters.add( + FeedWithKeywordFilter( + feed = feed, + keywordFilters = emptyList(), + ) + ) } private fun Outline?.extractName(): String { @@ -132,6 +151,15 @@ class OPMLDataSource @Inject constructor( private fun Outline?.extractPresetBrowser(): Boolean = this?.attributes?.getOrDefault("isBrowser", null).toBoolean() + private fun Outline?.extractFilteredKeywords(): List { + val attribute = this?.attributes?.getOrDefault("filteredKeywords", null); + if (attribute == null) { + return emptyList() + } + + return Json.decodeFromString(attribute) + } + private fun Outline?.isDefaultGroup(): Boolean = this?.attributes?.getOrDefault("isDefault", null).toBoolean() } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt index 33f539193..076243832 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt @@ -11,6 +11,8 @@ import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Delete import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import me.ash.reader.R +import me.ash.reader.domain.model.feed.KeywordFilter import me.ash.reader.domain.model.group.Group import me.ash.reader.ui.component.base.RYSelectionChip import me.ash.reader.ui.component.base.Subtitle @@ -32,6 +35,7 @@ fun FeedOptionView( modifier: Modifier = Modifier, link: String = "", groups: List = emptyList(), + filteredKeywords: List = emptyList(), selectedAllowNotificationPreset: Boolean = false, selectedParseFullContentPreset: Boolean = false, selectedOpenInBrowserPreset: Boolean = false, @@ -49,6 +53,8 @@ fun FeedOptionView( onAddNewGroup: () -> Unit = {}, onFeedUrlClick: () -> Unit = {}, onFeedUrlLongClick: () -> Unit = {}, + onFilteredKeywordClick: (KeywordFilter) -> Unit = {}, + onAddKeywordFilter: () -> Unit = {}, ) { Column(modifier = modifier.verticalScroll(rememberScrollState())) { @@ -68,6 +74,13 @@ fun FeedOptionView( unsubscribeOnClick = unsubscribeOnClick, ) + Spacer(modifier = Modifier.height(26.dp)) + KeywordFiltering( + filteredKeywords, + onFilteredKeywordClick, + onAddKeywordFilter, + ) + if (showGroup) { Spacer(modifier = Modifier.height(26.dp)) @@ -197,6 +210,43 @@ private fun Preset( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun KeywordFiltering( + filteredKeywords: List, + onFilteredKeywordClick: (keywordId: KeywordFilter) -> Unit = {}, + onAddKeywordFilter: () -> Unit = {}, +) { + Subtitle(text = stringResource(R.string.keyword_filtering_title)) + Spacer(modifier = Modifier.height(10.dp)) + LazyRow(verticalAlignment = Alignment.CenterVertically) { + item { + AddButton( + onAddKeywordFilter, + stringResource(R.string.keyword_filtering_desc), + Modifier + ) + } + items(filteredKeywords) { + Spacer(modifier = Modifier.width(10.dp)) + RYSelectionChip( + modifier = Modifier, + content = it.keyword, + selected = true, + selectedIcon = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Rounded.Delete, + contentDescription = null, + ) + } + ) { + onFilteredKeywordClick(it) + } + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun AddToGroup( @@ -223,7 +273,13 @@ private fun AddToGroup( } Spacer(modifier = Modifier.width(10.dp)) } - item { NewGroupButton(onAddNewGroup, Modifier) } + item { + AddButton( + onAddNewGroup, + stringResource(R.string.create_new_group), + Modifier + ) + } } } else { FlowRow( @@ -239,26 +295,34 @@ private fun AddToGroup( onGroupClick(it.id) } } - NewGroupButton(onAddNewGroup, Modifier.align(Alignment.CenterVertically)) + AddButton( + onAddNewGroup, + stringResource(R.string.create_new_group), + Modifier.align(Alignment.CenterVertically) + ) } } } @Composable -private fun NewGroupButton(onAddNewGroup: () -> Unit, modifier: Modifier) { +private fun AddButton( + onAdd: () -> Unit, + contentDescription: String?, + modifier: Modifier, +) { Box( modifier = modifier .size(36.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onAddNewGroup() }, + .clickable { onAdd() }, contentAlignment = Alignment.Center, ) { Icon( modifier = Modifier.size(20.dp), imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.create_new_group), + contentDescription = contentDescription, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt index dac929db9..af980cce7 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt @@ -57,7 +57,12 @@ fun FeedOptionDrawer( val feedOptionUiState = feedOptionViewModel.feedOptionUiState.collectAsStateValue() val feed = feedOptionUiState.feed val toastString = stringResource(R.string.rename_toast, feedOptionUiState.newName) - + val filteredKeywords = + if (feed != null) { + feedOptionUiState.filteredKeywords.filter { it.feedId == feed.id } + } else { + emptyList() + } BackHandler(drawerState.isVisible) { scope.launch { @@ -95,6 +100,7 @@ fun FeedOptionDrawer( FeedOptionView( link = feed?.url ?: stringResource(R.string.unknown), groups = feedOptionUiState.groups, + filteredKeywords = filteredKeywords.reversed(), selectedAllowNotificationPreset = feedOptionUiState.feed?.isNotification ?: false, selectedParseFullContentPreset = feedOptionUiState.feed?.isFullContent ?: false, @@ -133,7 +139,13 @@ fun FeedOptionDrawer( view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) feedOptionViewModel.showFeedUrlDialog() } - } + }, + onFilteredKeywordClick = { + feedOptionViewModel.removeFilteredKeyword(it) + }, + onAddKeywordFilter = { + feedOptionViewModel.showNewKeywordFilterDialog() + }, ) } } @@ -166,6 +178,23 @@ fun FeedOptionDrawer( } ) + TextFieldDialog( + visible = feedOptionUiState.newKeywordFilterDialogVisible, + title = stringResource(R.string.add_keyword_filter), + icon = Icons.Outlined.CreateNewFolder, + value = feedOptionUiState.newKeywordFilterContent, + placeholder = stringResource(R.string.keyword), + onValueChange = { + feedOptionViewModel.inputNewKeywordFilter(it) + }, + onDismissRequest = { + feedOptionViewModel.hideNewKeywordFilterDialog() + }, + onConfirm = { + feedOptionViewModel.addFilteredKeyword() + } + ) + RenameDialog( visible = feedOptionUiState.renameDialogVisible, value = feedOptionUiState.newName, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt index 370f71e42..3534d6764 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feed.KeywordFilter import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.repository.FeedDao import me.ash.reader.domain.service.RssService @@ -47,6 +48,13 @@ constructor( } } } + viewModelScope.launch(ioDispatcher) { + rssService.flow().collectLatest { it -> + it.pullFilteredKeywords().collectLatest { filters -> + _feedOptionUiState.update { it.copy(filteredKeywords = filters) } + } + } + } } suspend fun fetchFeed(feedId: String) { @@ -220,6 +228,50 @@ constructor( } } } + + fun showNewKeywordFilterDialog() { + _feedOptionUiState.update { it.copy(newKeywordFilterDialogVisible = true, newKeywordFilterContent = "") } + } + + fun hideNewKeywordFilterDialog() { + _feedOptionUiState.update { it.copy(newKeywordFilterDialogVisible = false, newKeywordFilterContent = "") } + } + + fun inputNewKeywordFilter(content: String) { + _feedOptionUiState.update { it.copy(newKeywordFilterContent = content) } + } + + fun addFilteredKeyword() { + if (_feedOptionUiState.value.newKeywordFilterContent.isNotBlank()) { + val feed = _feedOptionUiState.value.feed; + val keyword = _feedOptionUiState.value.newKeywordFilterContent + + if (feed != null) { + applicationScope.launch(ioDispatcher) { + rssService + .get() + .addFilteredKeyword(feed, newFilteredKeyword = keyword) + rssService.get().deleteArticles(feed = feed, keywordFilter = keyword) + hideNewKeywordFilterDialog() + } + } + } + } + + fun removeFilteredKeyword(keyword: KeywordFilter) { + applicationScope.launch(ioDispatcher) { + rssService.get().deleteFilteredKeyword(keyword) + + val feed = _feedOptionUiState.value.feed; + if (feed != null) { + rssService.get().sync( + feed.accountId, + feed.id, + feed.groupId, + ) + } + } + } } data class FeedOptionUiState( @@ -228,6 +280,9 @@ data class FeedOptionUiState( val newGroupContent: String = "", val newGroupDialogVisible: Boolean = false, val groups: List = emptyList(), + val newKeywordFilterContent: String = "", + val newKeywordFilterDialogVisible: Boolean = false, + val filteredKeywords: List = emptyList(), val deleteDialogVisible: Boolean = false, val clearDialogVisible: Boolean = false, val newName: String = "", diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt index ef14832db..64a129255 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import me.ash.reader.R +import me.ash.reader.domain.model.feed.KeywordFilter import me.ash.reader.ui.component.FeedIcon import me.ash.reader.ui.component.RenameDialog import me.ash.reader.ui.component.base.ClipboardTextField @@ -137,6 +138,7 @@ fun SubscribeDialog( FeedOptionView( link = state.feedLink, groups = state.groups, + filteredKeywords = state.filteredKeywords.map { KeywordFilter("", it) }, selectedAllowNotificationPreset = state.notification, selectedParseFullContentPreset = state.fullContent, selectedOpenInBrowserPreset = state.browser, @@ -156,6 +158,12 @@ fun SubscribeDialog( onAddNewGroup = { subscribeViewModel.showNewGroupDialog() }, + onFilteredKeywordClick = { + subscribeViewModel.removeFilteredKeyword(it) + }, + onAddKeywordFilter = { + subscribeViewModel.showNewKeywordFilterDialog() + }, ) } @@ -248,5 +256,23 @@ fun SubscribeDialog( subscribeViewModel.addNewGroup() } ) + + TextFieldDialog( + visible = subscribeUiState.newKeywordFilterDialogVisible, + title = stringResource(R.string.add_keyword_filter), + icon = Icons.Outlined.CreateNewFolder, + value = subscribeUiState.newKeywordFilterContent, + placeholder = stringResource(R.string.keyword), + onValueChange = { + subscribeViewModel.inputNewKeywordFilter(it) + }, + onDismissRequest = { + subscribeViewModel.hideNewKeywordFilterDialog() + }, + onConfirm = { + subscribeViewModel.addFilteredKeyword() + } + ) + } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt index ad0eaf313..668c41286 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import me.ash.reader.R +import me.ash.reader.domain.model.feed.KeywordFilter import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.service.AccountService import me.ash.reader.domain.service.OpmlService @@ -192,6 +193,7 @@ constructor( isNotification = state.notification, isFullContent = state.fullContent, isBrowser = state.browser, + filteredKeywords = state.filteredKeywords ) hideDrawer() } @@ -259,6 +261,49 @@ constructor( } } } + + fun showNewKeywordFilterDialog() { + _subscribeUiState.update { it.copy(newKeywordFilterDialogVisible = true, newKeywordFilterContent = "") } + } + + fun hideNewKeywordFilterDialog() { + _subscribeUiState.update { it.copy(newKeywordFilterDialogVisible = false, newKeywordFilterContent = "") } + } + + fun inputNewKeywordFilter(content: String) { + _subscribeUiState.update { it.copy(newKeywordFilterContent = content) } + } + + fun addFilteredKeyword() { + if (_subscribeUiState.value.newKeywordFilterContent.isNotBlank()) { + val state = _subscribeState.value + if (state !is SubscribeState.Configure) return + + val newKeyword = _subscribeUiState.value.newKeywordFilterContent + val keywords = state.filteredKeywords + + _subscribeState.update { + when (it) { + is SubscribeState.Configure -> it.copy(filteredKeywords = listOf(newKeyword) + keywords) + else -> it + } + } + hideNewKeywordFilterDialog() + } + } + + fun removeFilteredKeyword(keyword: KeywordFilter) { + val state = _subscribeState.value + if (state !is SubscribeState.Configure) return + + val keywords = state.filteredKeywords; + _subscribeState.update { + when (it) { + is SubscribeState.Configure -> it.copy(filteredKeywords = keywords - keyword.keyword) + else -> it + } + } + } } data class SubscribeUiState( @@ -266,6 +311,8 @@ data class SubscribeUiState( val newGroupContent: String = "", val newName: String = "", val renameDialogVisible: Boolean = false, + val newKeywordFilterContent: String = "", + val newKeywordFilterDialogVisible: Boolean = false, ) sealed interface SubscribeState { @@ -294,5 +341,6 @@ sealed interface SubscribeState { val fullContent: Boolean = false, val browser: Boolean = false, val selectedGroupId: String, + val filteredKeywords: List = emptyList(), ) : SubscribeState, Visible } diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index f4d8779f6..d76524a50 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -346,6 +346,11 @@ Vue liste Vue carte" Voir les derniers articles non lus à partir de sources sélectionnées + Désactiver + Ajouter un mot-clé filtré + Mot-clé + Filtrage par mot-clé + Ajouter un nouveau filtre Appliquer Notifications Désactivé @@ -354,6 +359,5 @@ Ouvrir les articles dans le navigateur Récupérer les articles complets depuis les pages wev Activer - Désactiver Configurer \"%1$s\" pour les flux dans \"%2$s\" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 329371f02..5e57fd8d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -385,4 +385,8 @@ Fetch full articles from webpages Enable Disable + Add keyword filter + Keyword + Keyword Filtering + Add new keyword filter