Implement scanlator filter (#8803)

* Implement scanlator filter

* Visual improvement to scanlator filter dialog

* Review changes + Bug fixes

Backup not containing filtered chapters and similar issue fix

* Review Changes + Fix SQL query

* Lint mamma mia
This commit is contained in:
AntsyLich 2023-11-05 21:34:35 +06:00 committed by GitHub
parent e6ca54fd04
commit b97aa23548
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 462 additions and 33 deletions

View file

@ -22,7 +22,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "eu.kanade.tachiyomi" applicationId = "eu.kanade.tachiyomi"
versionCode = 108 versionCode = 109
versionName = "0.14.7" versionName = "0.14.7"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View file

@ -1,11 +1,14 @@
package eu.kanade.domain package eu.kanade.domain
import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.download.interactor.DeleteDownload import eu.kanade.domain.download.interactor.DeleteDownload
import eu.kanade.domain.extension.interactor.GetExtensionLanguages import eu.kanade.domain.extension.interactor.GetExtensionLanguages
import eu.kanade.domain.extension.interactor.GetExtensionSources import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.extension.interactor.GetExtensionsByType import eu.kanade.domain.extension.interactor.GetExtensionsByType
import eu.kanade.domain.manga.interactor.GetExcludedScanlators
import eu.kanade.domain.manga.interactor.SetExcludedScanlators
import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.interactor.SetMangaViewerFlags
import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetEnabledSources
@ -112,6 +115,8 @@ class DomainModule : InjektModule {
addFactory { NetworkToLocalManga(get()) } addFactory { NetworkToLocalManga(get()) }
addFactory { UpdateManga(get(), get()) } addFactory { UpdateManga(get(), get()) }
addFactory { SetMangaCategories(get()) } addFactory { SetMangaCategories(get()) }
addFactory { GetExcludedScanlators(get()) }
addFactory { SetExcludedScanlators(get()) }
addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) } addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
addFactory { GetApplicationRelease(get(), get()) } addFactory { GetApplicationRelease(get(), get()) }
@ -133,7 +138,8 @@ class DomainModule : InjektModule {
addFactory { UpdateChapter(get()) } addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get()) } addFactory { SetReadStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbChapter() } addFactory { ShouldUpdateDbChapter() }
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) } addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get()) }
addFactory { GetAvailableScanlators(get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) } addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
addFactory { GetHistory(get()) } addFactory { GetHistory(get()) }

View file

@ -0,0 +1,24 @@
package eu.kanade.domain.chapter.interactor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import tachiyomi.domain.chapter.repository.ChapterRepository
class GetAvailableScanlators(
private val repository: ChapterRepository,
) {
private fun List<String>.cleanupAvailableScanlators(): Set<String> {
return mapNotNull { it.ifBlank { null } }.toSet()
}
suspend fun await(mangaId: Long): Set<String> {
return repository.getScanlatorsByMangaId(mangaId)
.cleanupAvailableScanlators()
}
fun subscribe(mangaId: Long): Flow<Set<String>> {
return repository.getScanlatorsByMangaIdAsFlow(mangaId)
.map { it.cleanupAvailableScanlators() }
}
}

View file

@ -2,6 +2,7 @@ package eu.kanade.domain.chapter.interactor
import eu.kanade.domain.chapter.model.copyFromSChapter import eu.kanade.domain.chapter.model.copyFromSChapter
import eu.kanade.domain.chapter.model.toSChapter import eu.kanade.domain.chapter.model.toSChapter
import eu.kanade.domain.manga.interactor.GetExcludedScanlators
import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.toSManga import eu.kanade.domain.manga.model.toSManga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
@ -33,6 +34,7 @@ class SyncChaptersWithSource(
private val updateManga: UpdateManga, private val updateManga: UpdateManga,
private val updateChapter: UpdateChapter, private val updateChapter: UpdateChapter,
private val getChaptersByMangaId: GetChaptersByMangaId, private val getChaptersByMangaId: GetChaptersByMangaId,
private val getExcludedScanlators: GetExcludedScanlators,
) { ) {
/** /**
@ -208,6 +210,10 @@ class SyncChaptersWithSource(
val reAddedUrls = reAdded.map { it.url }.toHashSet() val reAddedUrls = reAdded.map { it.url }.toHashSet()
return updatedToAdd.filterNot { it.url in reAddedUrls } val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
return updatedToAdd.filterNot {
it.url in reAddedUrls || it.scanlator in excludedScanlators
}
} }
} }

View file

@ -0,0 +1,24 @@
package eu.kanade.domain.manga.interactor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import tachiyomi.data.DatabaseHandler
class GetExcludedScanlators(
private val handler: DatabaseHandler,
) {
suspend fun await(mangaId: Long): Set<String> {
return handler.awaitList {
excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId)
}
.toSet()
}
fun subscribe(mangaId: Long): Flow<Set<String>> {
return handler.subscribeToList {
excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId)
}
.map { it.toSet() }
}
}

View file

@ -0,0 +1,22 @@
package eu.kanade.domain.manga.interactor
import tachiyomi.data.DatabaseHandler
class SetExcludedScanlators(
private val handler: DatabaseHandler,
) {
suspend fun await(mangaId: Long, excludedScanlators: Set<String>) {
handler.await(inTransaction = true) {
val currentExcluded = handler.awaitList {
excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId)
}.toSet()
val toAdd = excludedScanlators.minus(currentExcluded)
for (scanlator in toAdd) {
excluded_scanlatorsQueries.insert(mangaId, scanlator)
}
val toRemove = currentExcluded.minus(excludedScanlators)
excluded_scanlatorsQueries.remove(mangaId, toRemove)
}
}
}

View file

@ -1,13 +1,21 @@
package eu.kanade.presentation.manga package eu.kanade.presentation.manga
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PeopleAlt
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -15,6 +23,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -29,6 +38,7 @@ import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.RadioItem import tachiyomi.presentation.core.components.RadioItem
import tachiyomi.presentation.core.components.SortItem import tachiyomi.presentation.core.components.SortItem
import tachiyomi.presentation.core.components.TriStateItem import tachiyomi.presentation.core.components.TriStateItem
import tachiyomi.presentation.core.theme.active
@Composable @Composable
fun ChapterSettingsDialog( fun ChapterSettingsDialog(
@ -37,6 +47,8 @@ fun ChapterSettingsDialog(
onDownloadFilterChanged: (TriState) -> Unit, onDownloadFilterChanged: (TriState) -> Unit,
onUnreadFilterChanged: (TriState) -> Unit, onUnreadFilterChanged: (TriState) -> Unit,
onBookmarkedFilterChanged: (TriState) -> Unit, onBookmarkedFilterChanged: (TriState) -> Unit,
scanlatorFilterActive: Boolean,
onScanlatorFilterClicked: (() -> Unit),
onSortModeChanged: (Long) -> Unit, onSortModeChanged: (Long) -> Unit,
onDisplayModeChanged: (Long) -> Unit, onDisplayModeChanged: (Long) -> Unit,
onSetAsDefault: (applyToExistingManga: Boolean) -> Unit, onSetAsDefault: (applyToExistingManga: Boolean) -> Unit,
@ -89,6 +101,8 @@ fun ChapterSettingsDialog(
onUnreadFilterChanged = onUnreadFilterChanged, onUnreadFilterChanged = onUnreadFilterChanged,
bookmarkedFilter = manga?.bookmarkedFilter ?: TriState.DISABLED, bookmarkedFilter = manga?.bookmarkedFilter ?: TriState.DISABLED,
onBookmarkedFilterChanged = onBookmarkedFilterChanged, onBookmarkedFilterChanged = onBookmarkedFilterChanged,
scanlatorFilterActive = scanlatorFilterActive,
onScanlatorFilterClicked = onScanlatorFilterClicked,
) )
} }
1 -> { 1 -> {
@ -117,6 +131,8 @@ private fun ColumnScope.FilterPage(
onUnreadFilterChanged: (TriState) -> Unit, onUnreadFilterChanged: (TriState) -> Unit,
bookmarkedFilter: TriState, bookmarkedFilter: TriState,
onBookmarkedFilterChanged: (TriState) -> Unit, onBookmarkedFilterChanged: (TriState) -> Unit,
scanlatorFilterActive: Boolean,
onScanlatorFilterClicked: (() -> Unit),
) { ) {
TriStateItem( TriStateItem(
label = stringResource(R.string.label_downloaded), label = stringResource(R.string.label_downloaded),
@ -133,6 +149,39 @@ private fun ColumnScope.FilterPage(
state = bookmarkedFilter, state = bookmarkedFilter,
onClick = onBookmarkedFilterChanged, onClick = onBookmarkedFilterChanged,
) )
ScanlatorFilterItem(
active = scanlatorFilterActive,
onClick = onScanlatorFilterClicked,
)
}
@Composable
fun ScanlatorFilterItem(
active: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.clickable(onClick = onClick)
.fillMaxWidth()
.padding(horizontal = TabbedDialogPaddings.Horizontal, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
Icon(
imageVector = Icons.Outlined.PeopleAlt,
contentDescription = null,
tint = if (active) {
MaterialTheme.colorScheme.active
} else {
LocalContentColor.current
},
)
Text(
text = stringResource(R.string.scanlator),
style = MaterialTheme.typography.bodyMedium,
)
}
} }
@Composable @Composable

View file

@ -48,7 +48,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMap
import eu.kanade.domain.manga.model.chaptersFiltered
import eu.kanade.presentation.manga.components.ChapterDownloadAction import eu.kanade.presentation.manga.components.ChapterDownloadAction
import eu.kanade.presentation.manga.components.ChapterHeader import eu.kanade.presentation.manga.components.ChapterHeader
import eu.kanade.presentation.manga.components.ExpandableMangaDescription import eu.kanade.presentation.manga.components.ExpandableMangaDescription
@ -308,7 +307,7 @@ private fun MangaScreenSmallImpl(
title = state.manga.title, title = state.manga.title,
titleAlphaProvider = { animatedTitleAlpha }, titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha }, backgroundAlphaProvider = { animatedBgAlpha },
hasFilters = state.manga.chaptersFiltered(), hasFilters = state.filterActive,
onBackClicked = internalOnBackPressed, onBackClicked = internalOnBackPressed,
onClickFilter = onFilterClicked, onClickFilter = onFilterClicked,
onClickShare = onShareClicked, onClickShare = onShareClicked,
@ -561,7 +560,7 @@ fun MangaScreenLargeImpl(
title = state.manga.title, title = state.manga.title,
titleAlphaProvider = { if (isAnySelected) 1f else 0f }, titleAlphaProvider = { if (isAnySelected) 1f else 0f },
backgroundAlphaProvider = { 1f }, backgroundAlphaProvider = { 1f },
hasFilters = state.manga.chaptersFiltered(), hasFilters = state.filterActive,
onBackClicked = internalOnBackPressed, onBackClicked = internalOnBackPressed,
onClickFilter = onFilterButtonClicked, onClickFilter = onFilterButtonClicked,
onClickShare = onShareClicked, onClickShare = onShareClicked,

View file

@ -0,0 +1,134 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank
import androidx.compose.material.icons.rounded.DisabledByDefault
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import eu.kanade.tachiyomi.R
import tachiyomi.presentation.core.components.material.TextButton
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
@Composable
fun ScanlatorFilterDialog(
availableScanlators: Set<String>,
excludedScanlators: Set<String>,
onDismissRequest: () -> Unit,
onConfirm: (Set<String>) -> Unit,
) {
val sortedAvailableScanlators = remember(availableScanlators) {
availableScanlators.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it })
}
val mutableExcludedScanlators = remember(excludedScanlators) { excludedScanlators.toMutableStateList() }
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(R.string.exclude_scanlators)) },
text = textFunc@{
if (sortedAvailableScanlators.isEmpty()) {
Text(text = stringResource(R.string.no_scanlators_found))
return@textFunc
}
Box {
val state = rememberLazyListState()
LazyColumn(state = state) {
sortedAvailableScanlators.forEach { scanlator ->
item {
val isExcluded = mutableExcludedScanlators.contains(scanlator)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
if (isExcluded) {
mutableExcludedScanlators.remove(scanlator)
} else {
mutableExcludedScanlators.add(scanlator)
}
}
.minimumInteractiveComponentSize()
.clip(MaterialTheme.shapes.small)
.fillMaxWidth()
.padding(horizontal = MaterialTheme.padding.small),
) {
Icon(
imageVector = if (isExcluded) {
Icons.Rounded.DisabledByDefault
} else {
Icons.Rounded.CheckBoxOutlineBlank
},
tint = if (isExcluded) {
MaterialTheme.colorScheme.primary
} else {
LocalContentColor.current
},
contentDescription = null,
)
Text(
text = scanlator,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 24.dp),
)
}
}
}
}
if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
},
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
confirmButton = {
FlowRow {
if (sortedAvailableScanlators.isEmpty()) {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(R.string.action_cancel))
}
return@FlowRow
}
TextButton(onClick = mutableExcludedScanlators::clear) {
Text(text = stringResource(R.string.action_reset))
}
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(R.string.action_cancel))
}
TextButton(
onClick = {
onConfirm(mutableExcludedScanlators.toSet())
onDismissRequest()
},
) {
Text(text = stringResource(R.string.action_ok))
}
}
},
)
}

View file

@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupHistory import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
@ -189,10 +190,15 @@ class BackupCreator(
// Check if user wants chapter information in backup // Check if user wants chapter information in backup
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
// Backup all the chapters // Backup all the chapters
val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) } handler.awaitList {
if (chapters.isNotEmpty()) { chaptersQueries.getChaptersByMangaId(
mangaObject.chapters = chapters mangaId = manga.id,
applyScanlatorFilter = 0, // false
mapper = backupChapterMapper,
)
} }
.takeUnless(List<BackupChapter>::isEmpty)
?.let { mangaObject.chapters = it }
} }
// Check if user wants category information in backup // Check if user wants category information in backup

View file

@ -414,7 +414,7 @@ class LibraryScreenModel(
} }
suspend fun getNextUnreadChapter(manga: Manga): Chapter? { suspend fun getNextUnreadChapter(manga: Manga): Chapter? {
return getChaptersByMangaId.await(manga.id).getNextUnread(manga, downloadManager) return getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true).getNextUnread(manga, downloadManager)
} }
/** /**

View file

@ -9,8 +9,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -30,6 +32,7 @@ import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.presentation.manga.MangaScreen import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.manga.components.DeleteChaptersDialog import eu.kanade.presentation.manga.components.DeleteChaptersDialog
import eu.kanade.presentation.manga.components.MangaCoverDialog import eu.kanade.presentation.manga.components.MangaCoverDialog
import eu.kanade.presentation.manga.components.ScanlatorFilterDialog
import eu.kanade.presentation.manga.components.SetIntervalDialog import eu.kanade.presentation.manga.components.SetIntervalDialog
import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.AssistContentScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
@ -152,6 +155,8 @@ class MangaScreen(
onInvertSelection = screenModel::invertSelection, onInvertSelection = screenModel::invertSelection,
) )
var showScanlatorsDialog by remember { mutableStateOf(false) }
val onDismissRequest = { screenModel.dismissDialog() } val onDismissRequest = { screenModel.dismissDialog() }
when (val dialog = successState.dialog) { when (val dialog = successState.dialog) {
null -> {} null -> {}
@ -189,6 +194,8 @@ class MangaScreen(
onDisplayModeChanged = screenModel::setDisplayMode, onDisplayModeChanged = screenModel::setDisplayMode,
onSetAsDefault = screenModel::setCurrentSettingsAsDefault, onSetAsDefault = screenModel::setCurrentSettingsAsDefault,
onResetToDefault = screenModel::resetToDefaultSettings, onResetToDefault = screenModel::resetToDefaultSettings,
scanlatorFilterActive = successState.scanlatorFilterActive,
onScanlatorFilterClicked = { showScanlatorsDialog = true },
) )
MangaScreenModel.Dialog.TrackSheet -> { MangaScreenModel.Dialog.TrackSheet -> {
NavigatorAdaptiveSheet( NavigatorAdaptiveSheet(
@ -235,6 +242,15 @@ class MangaScreen(
) )
} }
} }
if (showScanlatorsDialog) {
ScanlatorFilterDialog(
availableScanlators = successState.availableScanlators,
excludedScanlators = successState.excludedScanlators,
onDismissRequest = { showScanlatorsDialog = false },
onConfirm = screenModel::setExcludedScanlators,
)
}
} }
private fun continueReading(context: Context, unreadChapter: Chapter?) { private fun continueReading(context: Context, unreadChapter: Chapter?) {

View file

@ -11,9 +11,13 @@ import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.preference.asState import eu.kanade.core.preference.asState
import eu.kanade.core.util.addOrRemove import eu.kanade.core.util.addOrRemove
import eu.kanade.core.util.insertSeparators import eu.kanade.core.util.insertSeparators
import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.manga.interactor.GetExcludedScanlators
import eu.kanade.domain.manga.interactor.SetExcludedScanlators
import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.chaptersFiltered
import eu.kanade.domain.manga.model.downloadedFilter import eu.kanade.domain.manga.model.downloadedFilter
import eu.kanade.domain.manga.model.toSManga import eu.kanade.domain.manga.model.toSManga
import eu.kanade.domain.track.interactor.AddTracks import eu.kanade.domain.track.interactor.AddTracks
@ -92,6 +96,9 @@ class MangaScreenModel(
private val downloadCache: DownloadCache = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(),
private val getMangaAndChapters: GetMangaWithChapters = Injekt.get(), private val getMangaAndChapters: GetMangaWithChapters = Injekt.get(),
private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(), private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(),
private val getAvailableScanlators: GetAvailableScanlators = Injekt.get(),
private val getExcludedScanlators: GetExcludedScanlators = Injekt.get(),
private val setExcludedScanlators: SetExcludedScanlators = Injekt.get(),
private val setMangaChapterFlags: SetMangaChapterFlags = Injekt.get(), private val setMangaChapterFlags: SetMangaChapterFlags = Injekt.get(),
private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(), private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(), private val setReadStatus: SetReadStatus = Injekt.get(),
@ -154,7 +161,7 @@ class MangaScreenModel(
init { init {
screenModelScope.launchIO { screenModelScope.launchIO {
combine( combine(
getMangaAndChapters.subscribe(mangaId).distinctUntilChanged(), getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true).distinctUntilChanged(),
downloadCache.changes, downloadCache.changes,
downloadManager.queueState, downloadManager.queueState,
) { mangaAndChapters, _, _ -> mangaAndChapters } ) { mangaAndChapters, _, _ -> mangaAndChapters }
@ -168,11 +175,31 @@ class MangaScreenModel(
} }
} }
screenModelScope.launchIO {
getExcludedScanlators.subscribe(mangaId)
.distinctUntilChanged()
.collectLatest { excludedScanlators ->
updateSuccessState {
it.copy(excludedScanlators = excludedScanlators)
}
}
}
screenModelScope.launchIO {
getAvailableScanlators.subscribe(mangaId)
.distinctUntilChanged()
.collectLatest { availableScanlators ->
updateSuccessState {
it.copy(availableScanlators = availableScanlators)
}
}
}
observeDownloads() observeDownloads()
screenModelScope.launchIO { screenModelScope.launchIO {
val manga = getMangaAndChapters.awaitManga(mangaId) val manga = getMangaAndChapters.awaitManga(mangaId)
val chapters = getMangaAndChapters.awaitChapters(mangaId) val chapters = getMangaAndChapters.awaitChapters(mangaId, applyScanlatorFilter = true)
.toChapterListItems(manga) .toChapterListItems(manga)
if (!manga.favorite) { if (!manga.favorite) {
@ -189,6 +216,8 @@ class MangaScreenModel(
source = Injekt.get<SourceManager>().getOrStub(manga.source), source = Injekt.get<SourceManager>().getOrStub(manga.source),
isFromSource = isFromSource, isFromSource = isFromSource,
chapters = chapters, chapters = chapters,
availableScanlators = getAvailableScanlators.await(mangaId),
excludedScanlators = getExcludedScanlators.await(mangaId),
isRefreshingData = needRefreshInfo || needRefreshChapter, isRefreshingData = needRefreshInfo || needRefreshChapter,
dialog = null, dialog = null,
) )
@ -995,6 +1024,12 @@ class MangaScreenModel(
updateSuccessState { it.copy(dialog = Dialog.FullCover) } updateSuccessState { it.copy(dialog = Dialog.FullCover) }
} }
fun setExcludedScanlators(excludedScanlators: Set<String>) {
screenModelScope.launchIO {
setExcludedScanlators.await(mangaId, excludedScanlators)
}
}
sealed interface State { sealed interface State {
@Immutable @Immutable
data object Loading : State data object Loading : State
@ -1005,12 +1040,13 @@ class MangaScreenModel(
val source: Source, val source: Source,
val isFromSource: Boolean, val isFromSource: Boolean,
val chapters: List<ChapterList.Item>, val chapters: List<ChapterList.Item>,
val availableScanlators: Set<String>,
val excludedScanlators: Set<String>,
val trackItems: List<TrackItem> = emptyList(), val trackItems: List<TrackItem> = emptyList(),
val isRefreshingData: Boolean = false, val isRefreshingData: Boolean = false,
val dialog: Dialog? = null, val dialog: Dialog? = null,
val hasPromptedToAddBefore: Boolean = false, val hasPromptedToAddBefore: Boolean = false,
) : State { ) : State {
val processedChapters by lazy { val processedChapters by lazy {
chapters.applyFilters(manga).toList() chapters.applyFilters(manga).toList()
} }
@ -1042,6 +1078,12 @@ class MangaScreenModel(
} }
} }
val scanlatorFilterActive: Boolean
get() = excludedScanlators.intersect(availableScanlators).isNotEmpty()
val filterActive: Boolean
get() = scanlatorFilterActive || manga.chaptersFiltered()
val trackingAvailable: Boolean val trackingAvailable: Boolean
get() = trackItems.isNotEmpty() get() = trackItems.isNotEmpty()

View file

@ -147,7 +147,7 @@ class ReaderViewModel @JvmOverloads constructor(
*/ */
private val chapterList by lazy { private val chapterList by lazy {
val manga = manga!! val manga = manga!!
val chapters = runBlocking { getChaptersByMangaId.await(manga.id) } val chapters = runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) }
val selectedChapter = chapters.find { it.id == chapterId } val selectedChapter = chapters.find { it.id == chapterId }
?: error("Requested chapter of id $chapterId not found in chapter list") ?: error("Requested chapter of id $chapterId not found in chapter list")

View file

@ -2,6 +2,7 @@ package tachiyomi.data.chapter
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.util.lang.toLong
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.data.DatabaseHandler import tachiyomi.data.DatabaseHandler
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
@ -76,8 +77,22 @@ class ChapterRepositoryImpl(
} }
} }
override suspend fun getChapterByMangaId(mangaId: Long): List<Chapter> { override suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean): List<Chapter> {
return handler.awaitList { chaptersQueries.getChaptersByMangaId(mangaId, ::mapChapter) } return handler.awaitList {
chaptersQueries.getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter)
}
}
override suspend fun getScanlatorsByMangaId(mangaId: Long): List<String> {
return handler.awaitList {
chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
}
}
override fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>> {
return handler.subscribeToList {
chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() }
}
} }
override suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List<Chapter> { override suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List<Chapter> {
@ -93,12 +108,9 @@ class ChapterRepositoryImpl(
return handler.awaitOneOrNull { chaptersQueries.getChapterById(id, ::mapChapter) } return handler.awaitOneOrNull { chaptersQueries.getChapterById(id, ::mapChapter) }
} }
override suspend fun getChapterByMangaIdAsFlow(mangaId: Long): Flow<List<Chapter>> { override suspend fun getChapterByMangaIdAsFlow(mangaId: Long, applyScanlatorFilter: Boolean): Flow<List<Chapter>> {
return handler.subscribeToList { return handler.subscribeToList {
chaptersQueries.getChaptersByMangaId( chaptersQueries.getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter)
mangaId,
::mapChapter,
)
} }
} }

View file

@ -36,7 +36,19 @@ FROM chapters
WHERE _id = :id; WHERE _id = :id;
getChaptersByMangaId: getChaptersByMangaId:
SELECT * SELECT C.*
FROM chapters C
LEFT JOIN excluded_scanlators ES
ON C.manga_id = ES.manga_id
AND C.scanlator = ES.scanlator
WHERE C.manga_id = :mangaId
AND (
:applyScanlatorFilter = 0
OR ES.scanlator IS NULL
);
getScanlatorsByMangaId:
SELECT scanlator
FROM chapters FROM chapters
WHERE manga_id = :mangaId; WHERE manga_id = :mangaId;

View file

@ -0,0 +1,22 @@
CREATE TABLE excluded_scanlators(
manga_id INTEGER NOT NULL,
scanlator TEXT NOT NULL,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);
CREATE INDEX excluded_scanlators_manga_id_index ON excluded_scanlators(manga_id);
insert:
INSERT INTO excluded_scanlators(manga_id, scanlator)
VALUES (:mangaId, :scanlator);
remove:
DELETE FROM excluded_scanlators
WHERE manga_id = :mangaId
AND scanlator IN :scanlators;
getExcludedScanlatorsByMangaId:
SELECT scanlator
FROM excluded_scanlators
WHERE manga_id = :mangaId;

View file

@ -0,0 +1,44 @@
CREATE TABLE excluded_scanlators(
manga_id INTEGER NOT NULL,
scanlator TEXT NOT NULL,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);
CREATE INDEX excluded_scanlators_manga_id_index ON excluded_scanlators(manga_id);
DROP VIEW IF EXISTS libraryView;
CREATE VIEW libraryView AS
SELECT
M.*,
coalesce(C.total, 0) AS totalCount,
coalesce(C.readCount, 0) AS readCount,
coalesce(C.latestUpload, 0) AS latestUpload,
coalesce(C.fetchedAt, 0) AS chapterFetchedAt,
coalesce(C.lastRead, 0) AS lastRead,
coalesce(C.bookmarkCount, 0) AS bookmarkCount,
coalesce(MC.category_id, 0) AS category
FROM mangas M
LEFT JOIN(
SELECT
chapters.manga_id,
count(*) AS total,
sum(read) AS readCount,
coalesce(max(chapters.date_upload), 0) AS latestUpload,
coalesce(max(history.last_read), 0) AS lastRead,
coalesce(max(chapters.date_fetch), 0) AS fetchedAt,
sum(chapters.bookmark) AS bookmarkCount
FROM chapters
LEFT JOIN excluded_scanlators
ON chapters.manga_id = excluded_scanlators.manga_id
AND chapters.scanlator = excluded_scanlators.scanlator
LEFT JOIN history
ON chapters._id = history.chapter_id
WHERE excluded_scanlators.scanlator IS NULL
GROUP BY chapters.manga_id
) AS C
ON M._id = C.manga_id
LEFT JOIN mangas_categories AS MC
ON MC.manga_id = M._id
WHERE M.favorite = 1;

View file

@ -19,8 +19,12 @@ LEFT JOIN(
coalesce(max(chapters.date_fetch), 0) AS fetchedAt, coalesce(max(chapters.date_fetch), 0) AS fetchedAt,
sum(chapters.bookmark) AS bookmarkCount sum(chapters.bookmark) AS bookmarkCount
FROM chapters FROM chapters
LEFT JOIN excluded_scanlators
ON chapters.manga_id = excluded_scanlators.manga_id
AND chapters.scanlator = excluded_scanlators.scanlator
LEFT JOIN history LEFT JOIN history
ON chapters._id = history.chapter_id ON chapters._id = history.chapter_id
WHERE excluded_scanlators.scanlator IS NULL
GROUP BY chapters.manga_id GROUP BY chapters.manga_id
) AS C ) AS C
ON M._id = C.manga_id ON M._id = C.manga_id

View file

@ -9,9 +9,9 @@ class GetChaptersByMangaId(
private val chapterRepository: ChapterRepository, private val chapterRepository: ChapterRepository,
) { ) {
suspend fun await(mangaId: Long): List<Chapter> { suspend fun await(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter> {
return try { return try {
chapterRepository.getChapterByMangaId(mangaId) chapterRepository.getChapterByMangaId(mangaId, applyScanlatorFilter)
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
emptyList() emptyList()

View file

@ -14,13 +14,17 @@ interface ChapterRepository {
suspend fun removeChaptersWithIds(chapterIds: List<Long>) suspend fun removeChaptersWithIds(chapterIds: List<Long>)
suspend fun getChapterByMangaId(mangaId: Long): List<Chapter> suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter>
suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow<List<String>>
suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List<Chapter> suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List<Chapter>
suspend fun getChapterById(id: Long): Chapter? suspend fun getChapterById(id: Long): Chapter?
suspend fun getChapterByMangaIdAsFlow(mangaId: Long): Flow<List<Chapter>> suspend fun getChapterByMangaIdAsFlow(mangaId: Long, applyScanlatorFilter: Boolean = false): Flow<List<Chapter>>
suspend fun getChapterByUrlAndMangaId(url: String, mangaId: Long): Chapter? suspend fun getChapterByUrlAndMangaId(url: String, mangaId: Long): Chapter?
} }

View file

@ -20,7 +20,7 @@ class GetNextChapters(
suspend fun await(mangaId: Long, onlyUnread: Boolean = true): List<Chapter> { suspend fun await(mangaId: Long, onlyUnread: Boolean = true): List<Chapter> {
val manga = getManga.await(mangaId) ?: return emptyList() val manga = getManga.await(mangaId) ?: return emptyList()
val chapters = getChaptersByMangaId.await(mangaId) val chapters = getChaptersByMangaId.await(mangaId, applyScanlatorFilter = true)
.sortedWith(getChapterSort(manga, sortDescending = false)) .sortedWith(getChapterSort(manga, sortDescending = false))
return if (onlyUnread) { return if (onlyUnread) {

View file

@ -24,7 +24,7 @@ class FetchInterval(
} else { } else {
window window
} }
val chapters = getChaptersByMangaId.await(manga.id) val chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true)
val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval( val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval(
chapters, chapters,
dateTime.zone, dateTime.zone,

View file

@ -12,10 +12,10 @@ class GetMangaWithChapters(
private val chapterRepository: ChapterRepository, private val chapterRepository: ChapterRepository,
) { ) {
suspend fun subscribe(id: Long): Flow<Pair<Manga, List<Chapter>>> { suspend fun subscribe(id: Long, applyScanlatorFilter: Boolean = false): Flow<Pair<Manga, List<Chapter>>> {
return combine( return combine(
mangaRepository.getMangaByIdAsFlow(id), mangaRepository.getMangaByIdAsFlow(id),
chapterRepository.getChapterByMangaIdAsFlow(id), chapterRepository.getChapterByMangaIdAsFlow(id, applyScanlatorFilter),
) { manga, chapters -> ) { manga, chapters ->
Pair(manga, chapters) Pair(manga, chapters)
} }
@ -25,7 +25,7 @@ class GetMangaWithChapters(
return mangaRepository.getMangaById(id) return mangaRepository.getMangaById(id)
} }
suspend fun awaitChapters(id: Long): List<Chapter> { suspend fun awaitChapters(id: Long, applyScanlatorFilter: Boolean = false): List<Chapter> {
return chapterRepository.getChapterByMangaId(id) return chapterRepository.getChapterByMangaId(id, applyScanlatorFilter)
} }
} }

View file

@ -14,6 +14,7 @@
<string name="track">Tracking</string> <string name="track">Tracking</string>
<string name="delete_downloaded">Delete downloaded</string> <string name="delete_downloaded">Delete downloaded</string>
<string name="history">History</string> <string name="history">History</string>
<string name="scanlator">Scanlator</string>
<!-- Screen titles --> <!-- Screen titles -->
<string name="label_more">More</string> <string name="label_more">More</string>
@ -702,6 +703,8 @@
<string name="set_chapter_settings_as_default">Set as default</string> <string name="set_chapter_settings_as_default">Set as default</string>
<string name="no_chapters_error">No chapters found</string> <string name="no_chapters_error">No chapters found</string>
<string name="are_you_sure">Are you sure?</string> <string name="are_you_sure">Are you sure?</string>
<string name="exclude_scanlators">Exclude scanlators</string>
<string name="no_scanlators_found">No scanlators found</string>
<!-- Tracking Screen --> <!-- Tracking Screen -->
<string name="manga_tracking_tab">Tracking</string> <string name="manga_tracking_tab">Tracking</string>