Cleanup migrate manga dialog and related code (#2156)

(cherry picked from commit 2b126f1ff56b63e470b48a04149e28c609f01148)

# Conflicts:
#	app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/MigrationFlags.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenDialogScreenModel.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt
This commit is contained in:
AntsyLich
2025-05-31 20:48:39 +05:45
committed by NGB-Was-Taken
parent 9e113d80f7
commit 92b48319ed
23 changed files with 559 additions and 178 deletions
@@ -38,6 +38,7 @@ import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl
@@ -133,6 +134,11 @@ class DomainModule : InjektModule {
addFactory { SetMangaCategories(get()) }
addFactory { GetExcludedScanlators(get()) }
addFactory { SetExcludedScanlators(get()) }
addFactory {
MigrateMangaUseCase(
get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
)
}
addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
addFactory { GetApplicationRelease(get(), get()) }
@@ -2,6 +2,7 @@ package eu.kanade.domain.source.service
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.tachiyomi.util.system.LocaleHelper
import mihon.domain.migration.models.MigrationFlag
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
@@ -12,7 +13,7 @@ class SourcePreferences(
private val preferenceStore: PreferenceStore,
) {
fun sourceDisplayMode() = preferenceStore.getObject(
fun sourceDisplayMode() = preferenceStore.getObjectFromString(
"pref_display_mode_catalogue",
LibraryDisplayMode.default,
LibraryDisplayMode.Serializer::serialize,
@@ -119,4 +120,11 @@ class SourcePreferences(
fun recommendationSearchFlags() = preferenceStore.getInt("rec_search_flags", Int.MAX_VALUE)
// SY <--
fun migrationFlags() = preferenceStore.getObjectFromInt(
key = "migrate_flags",
defaultValue = MigrationFlag.entries.toSet(),
serializer = { MigrationFlag.toBit(it) },
deserializer = { value: Int -> MigrationFlag.fromBit(value) },
)
}
@@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration
object MigrationFlags {
const val CHAPTERS = 0b00001
const val CATEGORIES = 0b00010
const val TRACK = 0b00100
const val CUSTOM_COVER = 0b01000
const val EXTRA = 0b10000
const val DELETE_CHAPTERS = 0b100000
const val NOTES = 0b1000000
fun hasChapters(value: Int): Boolean {
return value and CHAPTERS != 0
}
fun hasCategories(value: Int): Boolean {
return value and CATEGORIES != 0
}
fun hasTracks(value: Int): Boolean {
return value and TRACK != 0
}
fun hasCustomCover(value: Int): Boolean {
return value and CUSTOM_COVER != 0
}
fun hasExtra(value: Int): Boolean {
return value and EXTRA != 0
}
fun hasDeleteChapters(value: Int): Boolean {
return value and DELETE_CHAPTERS != 0
}
fun hasNotes(value: Int): Boolean {
return value and NOTES != 0
}
}
@@ -8,45 +8,53 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.MigrateSearchScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigrationListScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
class MigrateSearchScreen(private val mangaId: Long, private val validSources: List<Long>) : Screen() {
class MigrateSearchScreen(private val mangaId: Long) : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel =
rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId, validSources = validSources) }
val state by screenModel.state.collectAsState()
val dialogScreenModel = rememberScreenModel { MigrateSearchScreenDialogScreenModel(mangaId = mangaId) }
val dialogState by dialogScreenModel.state.collectAsState()
val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) }
val state by screenModel.state.collectAsState()
MigrateSearchScreen(
state = state,
fromSourceId = state.fromSourceId,
fromSourceId = state.from?.source,
navigateUp = navigator::pop,
onChangeSearchQuery = screenModel::updateSearchQuery,
onSearch = { screenModel.search() },
getManga = { screenModel.getManga(it) },
onChangeSearchFilter = screenModel::setSourceFilter,
onToggleResults = screenModel::toggleFilterResults,
onClickSource = {
// SY -->
navigator.push(SourceSearchScreen(dialogState.manga!!, it.id, state.searchQuery))
// SY <--
},
onClickItem = {
// SY -->
navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.newSelectedItem = mangaId to it.id
navigator.popUntil { it is MigrationListScreen }
// SY <--
},
onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) },
onClickItem = { screenModel.setMigrateDialog(mangaId, it) },
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
)
when (val dialog = state.dialog) {
is SearchScreenModel.Dialog.Migrate -> {
MigrateMangaDialog(
current = dialog.current,
target = dialog.target,
// Initiated from the context of [dialog.current] so we show [dialog.target].
onClickTitle = { navigator.push(MangaScreen(dialog.target.id, true)) },
onDismissRequest = { screenModel.clearDialog() },
onComplete = {
if (navigator.lastItem is MangaScreen) {
val lastItem = navigator.lastItem
navigator.popUntil { navigator.items.contains(lastItem) }
navigator.push(MangaScreen(dialog.target.id))
} else {
navigator.replace(MangaScreen(dialog.target.id))
}
},
)
}
else -> {}
}
}
}
@@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MigrateSearchScreenDialogScreenModel(
val mangaId: Long,
getManga: GetManga = Injekt.get(),
) : StateScreenModel<MigrateSearchScreenDialogScreenModel.State>(State()) {
init {
screenModelScope.launch {
val manga = getManga.await(mangaId)!!
mutableState.update {
it.copy(manga = manga)
}
}
}
@Immutable
data class State(
val manga: Manga? = null,
)
}
@@ -36,7 +36,7 @@ class MigrateSearchScreenModel(
val manga = getManga.await(mangaId)!!
mutableState.update {
it.copy(
fromSourceId = manga.source,
from = manga,
searchQuery = manga.title,
)
}
@@ -1,37 +1,47 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalUriHandler
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.browse.components.BrowseSourceFloatingActionButton
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigrationListScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog
import eu.kanade.tachiyomi.ui.home.HomeScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
import exh.ui.ifSourcesLoaded
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.source.local.LocalSource
data class SourceSearchScreen(
private val oldManga: Manga,
data class MigrateSourceSearchScreen(
private val currentManga: Manga,
private val sourceId: Long,
private val query: String?,
) : Screen() {
@@ -45,6 +55,7 @@ data class SourceSearchScreen(
val uriHandler = LocalUriHandler.current
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId, query) }
val state by screenModel.state.collectAsState()
@@ -62,23 +73,18 @@ data class SourceSearchScreen(
)
},
floatingActionButton = {
// SY -->
BrowseSourceFloatingActionButton(
isVisible = state.filters.isNotEmpty(),
onFabClick = screenModel::openFilterSheet,
)
// SY <--
AnimatedVisibility(visible = state.filters.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_filter)) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = null) },
onClick = screenModel::openFilterSheet,
)
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues ->
val openMigrateDialog: (Manga) -> Unit = {
// SY -->
navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.newSelectedItem = oldManga.id to it.id
navigator.popUntil { it is MigrationListScreen }
// SY <--
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga))
}
BrowseSourceContent(
source = screenModel.source,
@@ -108,7 +114,7 @@ data class SourceSearchScreen(
}
val onDismissRequest = { screenModel.setDialog(null) }
when (state.dialog) {
when (val dialog = state.dialog) {
is BrowseSourceScreenModel.Dialog.Filter -> {
SourceFilterDialog(
onDismissRequest = onDismissRequest,
@@ -127,6 +133,22 @@ data class SourceSearchScreen(
// SY <--
)
}
is BrowseSourceScreenModel.Dialog.Migrate -> {
MigrateMangaDialog(
current = currentManga,
target = dialog.target,
// Initiated from the context of [currentManga] so we show [dialog.target].
onClickTitle = { navigator.push(MangaScreen(dialog.target.id)) },
onDismissRequest = onDismissRequest,
onComplete = {
scope.launch {
navigator.popUntilRoot()
HomeScreen.openTab(HomeScreen.Tab.Browse())
navigator.push(MangaScreen(dialog.target.id))
}
},
)
}
else -> {}
}
}
@@ -36,7 +36,6 @@ import androidx.compose.ui.platform.LocalUriHandler
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.browse.MissingSourceScreen
import eu.kanade.presentation.browse.components.BrowseSourceToolbar
@@ -50,7 +49,6 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -62,6 +60,7 @@ import exh.ui.ifSourcesLoaded
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO
@@ -72,8 +71,6 @@ import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.source.local.LocalSource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
data class BrowseSourceScreen(
val sourceId: Long,
@@ -324,16 +321,17 @@ data class BrowseSourceScreen(
onDismissRequest = onDismissRequest,
onConfirm = { screenModel.addFavorite(dialog.manga) },
onOpenManga = { navigator.push(MangaScreen(it.id)) },
onMigrate = {
// SY -->
PreMigrationScreen.navigateToMigration(
Injekt.get<SourcePreferences>().skipPreMigration().get(),
navigator,
it.id,
dialog.manga.id,
)
// SY <--
},
onMigrate = { screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(dialog.manga, it)) },
)
}
is BrowseSourceScreenModel.Dialog.Migrate -> {
MigrateMangaDialog(
current = dialog.current,
target = dialog.target,
// Initiated from the context of [dialog.target] so we show [dialog.current].
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
onDismissRequest = onDismissRequest,
)
}
is BrowseSourceScreenModel.Dialog.RemoveManga -> {
@@ -450,7 +450,7 @@ open class BrowseSourceScreenModel(
val manga: Manga,
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
data class Migrate(val target: Manga, val current: Manga) : Dialog
// SY -->
data class DeleteSavedSearch(val idToDelete: Long, val name: String) : Dialog
@@ -26,6 +26,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mihon.domain.manga.model.toDomainManga
import tachiyomi.core.common.preference.toggle
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.model.Manga
@@ -205,19 +206,35 @@ abstract class SearchScreenModel(
updateItems(newItems)
}
fun setMigrateDialog(currentId: Long, target: Manga) {
screenModelScope.launchIO {
val current = getManga.await(currentId) ?: return@launchIO
mutableState.update { it.copy(dialog = Dialog.Migrate(target, current)) }
}
}
fun clearDialog() {
mutableState.update { it.copy(dialog = null) }
}
@Immutable
data class State(
val fromSourceId: Long? = null,
val from: Manga? = null,
val searchQuery: String? = null,
val sourceFilter: SourceFilter = SourceFilter.PinnedOnly,
val onlyShowHasResults: Boolean = false,
val items: PersistentMap<CatalogueSource, SearchItemResult> = persistentMapOf(),
val dialog: Dialog? = null,
) {
val progress: Int = items.count { it.value !is SearchItemResult.Loading }
val total: Int = items.size
val filteredItems = items.filter { (_, result) -> result.isVisible(onlyShowHasResults) }
.toImmutableMap()
}
sealed interface Dialog {
data class Migrate(val target: Manga, val current: Manga) : Dialog
}
}
enum class SourceFilter {
@@ -216,9 +216,9 @@ class HistoryScreenModel(
}
}
/*SY -->fun showMigrateDialog(currentManga: Manga, duplicate: Manga) {
/*SY -->fun showMigrateDialog(target: Manga, current: Manga) {
mutableState.update { currentState ->
currentState.copy(dialog = Dialog.Migrate(newManga = currentManga, oldManga = duplicate))
currentState.copy(dialog = Dialog.Migrate(target = target, current = current))
}
} SY <--*/
@@ -252,7 +252,7 @@ class HistoryScreenModel(
val manga: Manga,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog
/* SY --> data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog SY <-- */
/* SY --> data class Migrate(val target: Manga, val current: Manga) : Dialog SY <-- */
}
sealed interface Event {
@@ -19,7 +19,6 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.TabOptions
import eu.kanade.core.preference.asState
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.history.HistoryScreen
@@ -28,7 +27,6 @@ import eu.kanade.presentation.history.components.HistoryDeleteDialog
import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaScreen
@@ -116,20 +114,9 @@ data object HistoryTab : Tab {
DuplicateMangaDialog(
duplicates = dialog.duplicates,
onDismissRequest = onDismissRequest,
onConfirm = {
screenModel.addFavorite(dialog.manga)
},
onConfirm = { screenModel.addFavorite(dialog.manga) },
onOpenManga = { navigator.push(MangaScreen(it.id)) },
onMigrate = {
// SY -->
PreMigrationScreen.navigateToMigration(
Injekt.get<SourcePreferences>().skipPreMigration().get(),
navigator,
it.id,
dialog.manga.id,
)
// SY <--
},
onMigrate = { screenModel.showMigrateDialog(dialog.manga, it) },
)
}
is HistoryScreenModel.Dialog.ChangeCategory -> {
@@ -143,13 +130,12 @@ data object HistoryTab : Tab {
)
}
/*SY -->is HistoryScreenModel.Dialog.Migrate -> {
MigrateDialog(
oldManga = dialog.oldManga,
newManga = dialog.newManga,
screenModel = MigrateDialogScreenModel(),
MigrateMangaDialog(
current = dialog.current,
target = dialog.target,
// Initiated from the context of [dialog.target] so we show [dialog.current].
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
onDismissRequest = onDismissRequest,
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
onPopScreen = onDismissRequest,
)
} SY <--*/
null -> {}
@@ -43,8 +43,6 @@ import eu.kanade.presentation.util.isTabletUi
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.isLocalOrStub
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedScreen
@@ -74,6 +72,7 @@ import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.feature.migration.config.MigrationConfigScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchUI
import tachiyomi.core.common.util.lang.withIOContext
@@ -273,13 +272,12 @@ class MangaScreen(
}
is MangaScreenModel.Dialog.Migrate -> {
MigrateDialog(
oldManga = dialog.oldManga,
newManga = dialog.newManga,
screenModel = MigrateDialogScreenModel(),
MigrateMangaDialog(
current = dialog.current,
target = dialog.target,
// Initiated from the context of [dialog.target] so we show [dialog.current].
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
onDismissRequest = onDismissRequest,
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
onPopScreen = onDismissRequest,
)
}
MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog(
@@ -141,8 +141,6 @@ import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import kotlin.collections.filter
import kotlin.collections.forEach
import kotlin.math.floor
class MangaScreenModel(
@@ -1657,7 +1655,7 @@ class MangaScreenModel(
data class DuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog
/* SY -->
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
data class Migrate(val target: Manga, val current: Manga) : Dialog
SY <-- */
data class SetFetchInterval(val manga: Manga) : Dialog
@@ -1694,7 +1692,7 @@ class MangaScreenModel(
/* SY -->
fun showMigrateDialog(duplicate: Manga) {
val manga = successState?.manga ?: return
updateSuccessState { it.copy(dialog = Dialog.Migrate(newManga = manga, oldManga = duplicate)) }
updateSuccessState { it.copy(dialog = Dialog.Migrate(target = manga, current = duplicate)) }
} SY <-- */
fun setExcludedScanlators(excludedScanlators: Set<String>) {
@@ -0,0 +1,28 @@
package mihon.domain.migration.models
enum class MigrationFlag(val flag: Int) {
CHAPTER(0b00001),
CATEGORY(0b00010),
// 0b00100 was used for manga trackers
CUSTOM_COVER(0b01000),
NOTES(0b100000),
REMOVE_DOWNLOAD(0b10000),
;
companion object {
fun fromBit(bit: Int): Set<MigrationFlag> {
return buildSet {
entries.forEach { entry ->
if (bit and entry.flag != 0) add(entry)
}
}
}
fun toBit(flags: Set<MigrationFlag>): Int {
return flags.map { it.flag }
.reduceOrNull { acc, mask -> acc or mask }
?: 0
}
}
}
@@ -0,0 +1,145 @@
package mihon.domain.migration.usecases
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.domain.manga.model.toSManga
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.CancellationException
import mihon.domain.migration.models.MigrationFlag
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.toChapterUpdate
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.InsertTrack
import java.time.Instant
class MigrateMangaUseCase(
private val sourcePreferences: SourcePreferences,
private val trackerManager: TrackerManager,
private val sourceManager: SourceManager,
private val downloadManager: DownloadManager,
private val updateManga: UpdateManga,
private val getChaptersByMangaId: GetChaptersByMangaId,
private val syncChaptersWithSource: SyncChaptersWithSource,
private val updateChapter: UpdateChapter,
private val getCategories: GetCategories,
private val setMangaCategories: SetMangaCategories,
private val getTracks: GetTracks,
private val insertTrack: InsertTrack,
private val coverCache: CoverCache,
) {
private val enhancedServices by lazy { trackerManager.trackers.filterIsInstance<EnhancedTracker>() }
suspend operator fun invoke(current: Manga, target: Manga, replace: Boolean) {
val targetSource = sourceManager.get(target.source) ?: return
val currentSource = sourceManager.get(current.source)
val flags = sourcePreferences.migrationFlags().get()
try {
val chapters = targetSource.getChapterList(target.toSManga())
try {
syncChaptersWithSource.await(chapters, target, targetSource)
} catch (_: Exception) {
// Worst case, chapters won't be synced
}
// Update chapters read, bookmark and dateFetch
if (MigrationFlag.CHAPTER in flags) {
val prevMangaChapters = getChaptersByMangaId.await(current.id)
val mangaChapters = getChaptersByMangaId.await(target.id)
val maxChapterRead = prevMangaChapters
.filter { it.read }
.maxOfOrNull { it.chapterNumber }
val updatedMangaChapters = mangaChapters.map { mangaChapter ->
var updatedChapter = mangaChapter
if (updatedChapter.isRecognizedNumber) {
val prevChapter = prevMangaChapters
.find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber }
if (prevChapter != null) {
updatedChapter = updatedChapter.copy(
dateFetch = prevChapter.dateFetch,
bookmark = prevChapter.bookmark,
)
}
if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) {
updatedChapter = updatedChapter.copy(read = true)
}
}
updatedChapter
}
val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates)
}
// Update categories
if (MigrationFlag.CHAPTER in flags) {
val categoryIds = getCategories.await(current.id).map { it.id }
setMangaCategories.await(target.id, categoryIds)
}
// Update track
getTracks.await(current.id).mapNotNull { track ->
val updatedTrack = track.copy(mangaId = target.id)
val service = enhancedServices
.firstOrNull { it.isTrackFrom(updatedTrack, current, currentSource) }
if (service != null) {
service.migrateTrack(updatedTrack, target, targetSource)
} else {
updatedTrack
}
}
.takeIf { it.isNotEmpty() }
?.let { insertTrack.awaitAll(it) }
// Delete downloaded
if (MigrationFlag.REMOVE_DOWNLOAD in flags && currentSource != null) {
downloadManager.deleteManga(current, currentSource)
}
// Update custom cover (recheck if custom cover exists)
if (MigrationFlag.CUSTOM_COVER in flags && current.hasCustomCover()) {
coverCache.setCustomCoverToCache(target, coverCache.getCustomCoverFile(current.id).inputStream())
}
val currentMangaUpdate = MangaUpdate(
id = current.id,
favorite = false,
dateAdded = 0,
)
.takeIf { replace }
val targetMangaUpdate = MangaUpdate(
id = target.id,
favorite = true,
chapterFlags = current.chapterFlags,
viewerFlags = current.viewerFlags,
dateAdded = if (replace) current.dateAdded else Instant.now().toEpochMilli(),
notes = if (MigrationFlag.NOTES in flags) current.notes else null,
)
updateManga.awaitAll(listOfNotNull(currentMangaUpdate, targetMangaUpdate))
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
}
}
}
@@ -0,0 +1,15 @@
package mihon.feature.common.utils
import dev.icerock.moko.resources.StringResource
import mihon.domain.migration.models.MigrationFlag
import tachiyomi.i18n.MR
fun MigrationFlag.getLabel(): StringResource {
return when (this) {
MigrationFlag.CHAPTER -> MR.strings.chapters
MigrationFlag.CATEGORY -> MR.strings.categories
MigrationFlag.CUSTOM_COVER -> MR.strings.custom_cover
MigrationFlag.NOTES -> MR.strings.action_notes
MigrationFlag.REMOVE_DOWNLOAD -> MR.strings.delete_downloaded
}
}
@@ -0,0 +1,167 @@
package mihon.feature.migration.dialog
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastForEach
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import kotlinx.coroutines.flow.update
import mihon.domain.migration.models.MigrationFlag
import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.feature.common.utils.getLabel
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@Composable
internal fun Screen.MigrateMangaDialog(
current: Manga,
target: Manga,
onClickTitle: () -> Unit,
onDismissRequest: () -> Unit,
onComplete: () -> Unit = onDismissRequest,
) {
val scope = rememberCoroutineScope()
val screenModel = rememberScreenModel { MigrateDialogScreenModel(current, target) }
val state by screenModel.state.collectAsState()
if (state.isMigrating) {
LoadingScreen(
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
)
return
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.migration_dialog_what_to_include))
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
state.applicableFlags.fastForEach { flag ->
LabeledCheckbox(
label = stringResource(flag.getLabel()),
checked = flag in state.selectedFlags,
onCheckedChange = { screenModel.toggleSelection(flag) },
)
}
}
},
confirmButton = {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
) {
TextButton(
onClick = {
onDismissRequest()
onClickTitle()
},
) {
Text(text = stringResource(MR.strings.action_show_manga))
}
Spacer(modifier = Modifier.weight(1f))
TextButton(
onClick = {
scope.launchIO {
screenModel.migrateManga(replace = false)
withUIContext { onComplete() }
}
},
) {
Text(text = stringResource(MR.strings.copy))
}
TextButton(
onClick = {
scope.launchIO {
screenModel.migrateManga(replace = true)
withUIContext { onComplete() }
}
},
) {
Text(text = stringResource(MR.strings.migrate))
}
}
},
)
}
private class MigrateDialogScreenModel(
private val current: Manga,
private val target: Manga,
private val sourcePreference: SourcePreferences = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val migrateManga: MigrateMangaUseCase = Injekt.get(),
) : StateScreenModel<MigrateDialogScreenModel.State>(State()) {
init {
val applicableFlags = buildList {
MigrationFlag.entries.forEach {
val applicable = when (it) {
MigrationFlag.CHAPTER -> true
MigrationFlag.CATEGORY -> true
MigrationFlag.CUSTOM_COVER -> current.hasCustomCover(coverCache)
MigrationFlag.NOTES -> current.notes.isNotBlank()
MigrationFlag.REMOVE_DOWNLOAD -> downloadManager.getDownloadCount(current) > 0
}
if (applicable) add(it)
}
}
val selectedFlags = sourcePreference.migrationFlags().get()
mutableState.update { it.copy(applicableFlags = applicableFlags, selectedFlags = selectedFlags) }
}
fun toggleSelection(flag: MigrationFlag) {
mutableState.update {
val selectedFlags = it.selectedFlags.toMutableSet()
.apply { if (contains(flag)) remove(flag) else add(flag) }
.toSet()
it.copy(selectedFlags = selectedFlags)
}
}
suspend fun migrateManga(replace: Boolean) {
sourcePreference.migrationFlags().set(state.value.selectedFlags)
mutableState.update { it.copy(isMigrating = true) }
migrateManga(current, target, replace)
mutableState.update { it.copy(isMigrating = false) }
}
data class State(
val applicableFlags: List<MigrationFlag> = emptyList(),
val selectedFlags: Set<MigrationFlag> = emptySet(),
val isMigrating: Boolean = false,
)
}
@@ -171,13 +171,13 @@ sealed class AndroidPreference<T>(
}
}
class Object<T>(
class ObjectAsString<T>(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: T,
val serializer: (T) -> String,
val deserializer: (String) -> T,
private val serializer: (T) -> String,
private val deserializer: (String) -> T,
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
return try {
@@ -191,4 +191,25 @@ sealed class AndroidPreference<T>(
putString(key, serializer(value))
}
}
class ObjectAsInt<T>(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: T,
private val serializer: (T) -> Int,
private val deserializer: (Int) -> T,
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
return try {
if (preferences.contains(key)) preferences.getInt(key, 0).let(deserializer) else defaultValue
} catch (e: Exception) {
defaultValue
}
}
override fun write(key: String, value: T): Editor.() -> Unit = {
putInt(key, serializer(value))
}
}
}
@@ -9,7 +9,8 @@ import tachiyomi.core.common.preference.AndroidPreference.BooleanPrimitive
import tachiyomi.core.common.preference.AndroidPreference.FloatPrimitive
import tachiyomi.core.common.preference.AndroidPreference.IntPrimitive
import tachiyomi.core.common.preference.AndroidPreference.LongPrimitive
import tachiyomi.core.common.preference.AndroidPreference.Object
import tachiyomi.core.common.preference.AndroidPreference.ObjectAsInt
import tachiyomi.core.common.preference.AndroidPreference.ObjectAsString
import tachiyomi.core.common.preference.AndroidPreference.StringPrimitive
import tachiyomi.core.common.preference.AndroidPreference.StringSetPrimitive
@@ -44,13 +45,29 @@ class AndroidPreferenceStore(
return StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
}
override fun <T> getObject(
override fun <T> getObjectFromString(
key: String,
defaultValue: T,
serializer: (T) -> String,
deserializer: (String) -> T,
): Preference<T> {
return Object(
return ObjectAsString(
preferences = sharedPreferences,
keyFlow = keyFlow,
key = key,
defaultValue = defaultValue,
serializer = serializer,
deserializer = deserializer,
)
}
override fun <T> getObjectFromInt(
key: String,
defaultValue: T,
serializer: (T) -> Int,
deserializer: (Int) -> T,
): Preference<T> {
return ObjectAsInt(
preferences = sharedPreferences,
keyFlow = keyFlow,
key = key,
@@ -52,7 +52,7 @@ class InMemoryPreferenceStore(
}
@Suppress("UNCHECKED_CAST")
override fun <T> getObject(
override fun <T> getObjectFromString(
key: String,
defaultValue: T,
serializer: (T) -> String,
@@ -63,6 +63,18 @@ class InMemoryPreferenceStore(
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
}
@Suppress("UNCHECKED_CAST")
override fun <T> getObjectFromInt(
key: String,
defaultValue: T,
serializer: (T) -> Int,
deserializer: (Int) -> T,
): Preference<T> {
val default = InMemoryPreference(key, null, defaultValue)
val data: T? = preferences[key]?.get() as? T
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
}
override fun getAll(): Map<String, *> {
return preferences
}
@@ -14,13 +14,20 @@ interface PreferenceStore {
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
fun <T> getObject(
fun <T> getObjectFromString(
key: String,
defaultValue: T,
serializer: (T) -> String,
deserializer: (String) -> T,
): Preference<T>
fun <T> getObjectFromInt(
key: String,
defaultValue: T,
serializer: (T) -> Int,
deserializer: (Int) -> T,
): Preference<T>
fun getAll(): Map<String, *>
}
@@ -28,7 +35,7 @@ fun PreferenceStore.getLongArray(
key: String,
defaultValue: List<Long>,
): Preference<List<Long>> {
return getObject(
return getObjectFromString(
key = key,
defaultValue = defaultValue,
serializer = { it.joinToString(",") },
@@ -40,7 +47,7 @@ inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
key: String,
defaultValue: T,
): Preference<T> {
return getObject(
return getObjectFromString(
key = key,
defaultValue = defaultValue,
serializer = { it.name },
@@ -14,14 +14,14 @@ class LibraryPreferences(
private val preferenceStore: PreferenceStore,
) {
fun displayMode() = preferenceStore.getObject(
fun displayMode() = preferenceStore.getObjectFromString(
"pref_display_mode_library",
LibraryDisplayMode.default,
LibraryDisplayMode.Serializer::serialize,
LibraryDisplayMode.Serializer::deserialize,
)
fun sortingMode() = preferenceStore.getObject(
fun sortingMode() = preferenceStore.getObjectFromString(
"library_sorting_mode",
LibrarySort.default,
LibrarySort.Serializer::serialize,