diff --git a/app/src/main/kotlin/org/draken/usagi/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/draken/usagi/core/prefs/AppSettings.kt index a530459d..582fff5a 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/prefs/AppSettings.kt @@ -147,6 +147,16 @@ class AppSettings get() = prefs.getBoolean(KEY_DISABLE_NSFW, false) set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) } + /** Comma-separated tag titles; manga carrying any of them is hidden from the lists. */ + val excludedTags: Set + get() { + val string = prefs.getString(KEY_EXCLUDED_TAGS, null)?.trimEnd(' ', ',') + if (string.isNullOrEmpty()) { + return emptySet() + } + return string.split(',').mapNotNullToSet { it.trim().takeIf { title -> title.isNotEmpty() } } + } + var appLocales: LocaleListCompat get() { val raw = prefs.getString(KEY_APP_LOCALE, null) @@ -872,6 +882,7 @@ class AppSettings const val KEY_SUGGESTIONS_DISABLED_SOURCES = "suggestions_disabled_sources" const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications" const val KEY_FIX_EXCLUDE_SOURCES = "fix_exclude_sources" + const val KEY_EXCLUDED_TAGS = "excluded_tags" const val KEY_SHIKIMORI = "shikimori" const val KEY_ANILIST = "anilist" const val KEY_MAL = "mal" diff --git a/app/src/main/kotlin/org/draken/usagi/details/ui/related/RelatedListViewModel.kt b/app/src/main/kotlin/org/draken/usagi/details/ui/related/RelatedListViewModel.kt index 44ad89a7..1db526bb 100644 --- a/app/src/main/kotlin/org/draken/usagi/details/ui/related/RelatedListViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/details/ui/related/RelatedListViewModel.kt @@ -50,7 +50,9 @@ class RelatedListViewModel override val content = combine( - mangaList, + combine(mangaList, tagExclusion) { list, exclusion -> + list?.let(exclusion::filter) + }, observeListModeWithTriggers(), listError, ) { list, mode, error -> diff --git a/app/src/main/kotlin/org/draken/usagi/explore/domain/ExploreRepository.kt b/app/src/main/kotlin/org/draken/usagi/explore/domain/ExploreRepository.kt index 80fd5de0..cee6d806 100644 --- a/app/src/main/kotlin/org/draken/usagi/explore/domain/ExploreRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/explore/domain/ExploreRepository.kt @@ -7,6 +7,7 @@ import org.draken.usagi.core.util.ext.asArrayList import org.draken.usagi.core.util.ext.printStackTraceDebug import org.draken.usagi.explore.data.MangaSourcesRepository import org.draken.usagi.history.data.HistoryRepository +import org.draken.usagi.list.domain.ExcludedTags import org.draken.usagi.suggestions.domain.TagsBlacklist import tsuki.model.Manga import tsuki.model.MangaListFilter @@ -25,6 +26,7 @@ class ExploreRepository ) { suspend fun findRandomManga(tagsLimit: Int): Manga { val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) + val exclusion = ExcludedTags(settings.excludedTags) val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { if (it in tagsBlacklist) null else it.title @@ -32,13 +34,16 @@ class ExploreRepository val sources = sourcesRepository.getEnabledSources() check(sources.isNotEmpty()) { "No sources available" } for (i in 0..4) { - val list = getList(sources.random(), tags, tagsBlacklist) + val list = getList(sources.random(), tags, tagsBlacklist, exclusion) val manga = list.randomOrNull() ?: continue val details = runCatchingCancellable { mangaRepositoryFactory.create(manga.source).getDetails(manga) }.getOrNull() ?: continue - if ((settings.isSuggestionsExcludeNsfw && details.isNsfw()) || details in tagsBlacklist) { + if ((settings.isSuggestionsExcludeNsfw && details.isNsfw()) || + details in tagsBlacklist || + details in exclusion + ) { continue } return details @@ -51,19 +56,20 @@ class ExploreRepository tagsLimit: Int, ): Manga { val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) + val exclusion = ExcludedTags(settings.excludedTags) val skipNsfw = settings.isSuggestionsExcludeNsfw && !source.isNsfw() val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { if (it in tagsBlacklist) null else it.title } for (i in 0..4) { - val list = getList(source, tags, tagsBlacklist) + val list = getList(source, tags, tagsBlacklist, exclusion) val manga = list.randomOrNull() ?: continue val details = runCatchingCancellable { mangaRepositoryFactory.create(manga.source).getDetails(manga) }.getOrNull() ?: continue - if ((skipNsfw && details.isNsfw()) || details in tagsBlacklist) { + if ((skipNsfw && details.isNsfw()) || details in tagsBlacklist || details in exclusion) { continue } return details @@ -75,6 +81,7 @@ class ExploreRepository source: MangaSource, tags: List, blacklist: TagsBlacklist, + exclusion: ExcludedTags, ): List = runCatchingCancellable { val repository = mangaRepositoryFactory.create(source) @@ -97,6 +104,9 @@ class ExploreRepository if (blacklist.isNotEmpty()) { list.removeAll { manga -> manga in blacklist } } + if (exclusion.isNotEmpty) { + list.removeAll { manga -> manga in exclusion } + } list.shuffle() list }.onFailure { diff --git a/app/src/main/kotlin/org/draken/usagi/list/domain/ExcludedTags.kt b/app/src/main/kotlin/org/draken/usagi/list/domain/ExcludedTags.kt new file mode 100644 index 00000000..3523be24 --- /dev/null +++ b/app/src/main/kotlin/org/draken/usagi/list/domain/ExcludedTags.kt @@ -0,0 +1,24 @@ +package org.draken.usagi.list.domain + +import tsuki.model.Manga + +/** + * Client-side exclusion of manga that carry one of the tags picked in the + * "Exclude tags from all manga" setting. Matching is exact and case-insensitive. + */ +class ExcludedTags(tags: Set) { + private val normalized = tags.mapTo(HashSet(tags.size)) { it.lowercase() } + + val isNotEmpty: Boolean + get() = normalized.isNotEmpty() + + operator fun contains(manga: Manga): Boolean = + normalized.isNotEmpty() && manga.tags.any { it.title.lowercase() in normalized } + + fun filter(manga: List): List = + if (normalized.isEmpty()) { + manga + } else { + manga.filterNot { it in this } + } +} diff --git a/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListViewModel.kt b/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListViewModel.kt index 58eb826b..57bd9bd6 100644 --- a/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListViewModel.kt @@ -21,6 +21,7 @@ import org.draken.usagi.core.prefs.observeAsStateFlow import org.draken.usagi.core.ui.BaseViewModel import org.draken.usagi.core.ui.util.ReversibleAction import org.draken.usagi.core.util.ext.MutableEventFlow +import org.draken.usagi.list.domain.ExcludedTags import org.draken.usagi.list.domain.ListFilterOption import org.draken.usagi.list.ui.model.ListModel import org.draken.usagi.local.data.LocalStorageChanges @@ -45,6 +46,15 @@ abstract class MangaListViewModel( valueProducer = { gridSize / 100f }, ) + protected val tagExclusion = + settings + .observeAsFlow(AppSettings.KEY_EXCLUDED_TAGS) { ExcludedTags(this.excludedTags) } + .stateIn( + viewModelScope + Dispatchers.Default, + SharingStarted.Eagerly, + ExcludedTags(settings.excludedTags), + ) + val isIncognitoModeEnabled: Boolean get() = settings.isIncognitoModeEnabled diff --git a/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt index 4f581c2a..67b69bee 100644 --- a/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus @@ -80,7 +79,9 @@ open class RemoteListViewModel override val content = combine( - mangaList.map { it?.skipNsfwIfNeeded() }, + combine(mangaList, tagExclusion) { list, exclusion -> + list?.let { exclusion.filter(it).skipNsfwIfNeeded() } + }, observeListModeWithTriggers(), listError, hasNextPage, diff --git a/app/src/main/kotlin/org/draken/usagi/search/domain/SearchV2Helper.kt b/app/src/main/kotlin/org/draken/usagi/search/domain/SearchV2Helper.kt index 7994ce6f..ea314ee4 100644 --- a/app/src/main/kotlin/org/draken/usagi/search/domain/SearchV2Helper.kt +++ b/app/src/main/kotlin/org/draken/usagi/search/domain/SearchV2Helper.kt @@ -9,6 +9,7 @@ import org.draken.usagi.core.parser.MangaRepository import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.util.ext.contains import org.draken.usagi.core.util.ext.printStackTraceDebug +import org.draken.usagi.list.domain.ExcludedTags import tsuki.model.Manga import tsuki.model.MangaListFilter import tsuki.model.MangaSource @@ -96,6 +97,10 @@ class SearchV2Helper if (settings.isNsfwContentDisabled) { removeAll { it.isNsfw() } } + val exclusion = ExcludedTags(settings.excludedTags) + if (exclusion.isNotEmpty) { + removeAll { it in exclusion } + } when (kind) { SearchKind.TITLE -> { retainAll { m -> diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsFragment.kt index 103875d3..25257dae 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsFragment.kt @@ -21,6 +21,7 @@ import org.draken.usagi.core.util.ext.setDefaultValueCompat import org.draken.usagi.explore.data.MangaSourcesRepository import org.draken.usagi.explore.data.SourcesSortOrder import org.draken.usagi.settings.utils.MultiAutoCompleteTextViewPreference +import org.draken.usagi.settings.utils.TagsAutoCompleteProvider import tsuki.util.names import javax.inject.Inject @@ -36,6 +37,9 @@ class SourcesSettingsFragment : @Inject lateinit var sourcesRepository: MangaSourcesRepository + @Inject + lateinit var tagsCompletionProvider: TagsAutoCompleteProvider + override fun onCreatePreferences( savedInstanceState: Bundle?, rootKey: String?, @@ -63,6 +67,13 @@ class SourcesSettingsFragment : getString(R.string.fix_excluded_sources_summary), ) } + findPreference(AppSettings.KEY_EXCLUDED_TAGS)?.run { + autoCompleteProvider = tagsCompletionProvider + summaryProvider = + MultiAutoCompleteTextViewPreference.SimpleSummaryProvider( + getString(R.string.excluded_tags_empty), + ) + } } override fun onViewCreated( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bac6d20e..ed1121b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -862,6 +862,9 @@ Show suggestions from all manga sources, including disabled ones Highlight dangerous genres Highlight genres that may be inappropriate for most users + Exclude tags from all manga + Manga containing any of these tags will not be shown in source lists, search results and related manga + No tags excluded The selected path cannot be used because it does not denote a file or directory These changes will affect how manga is displayed in the app Use default cover diff --git a/app/src/main/res/xml/pref_sources.xml b/app/src/main/res/xml/pref_sources.xml index 178ff081..d35d0571 100644 --- a/app/src/main/res/xml/pref_sources.xml +++ b/app/src/main/res/xml/pref_sources.xml @@ -50,6 +50,11 @@ android:summary="@string/disable_nsfw_summary" android:title="@string/disable_nsfw" /> + +