Cleanup migrate manga dialog and related code (#2156)

This commit is contained in:
AntsyLich
2025-05-31 21:03:39 +06:00
committed by GitHub
parent 5919f34fc9
commit 2b126f1ff5
24 changed files with 510 additions and 525 deletions

View File

@ -35,6 +35,7 @@ import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.upcoming.interactor.GetUpcomingManga import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl import tachiyomi.data.chapter.ChapterRepositoryImpl
@ -134,6 +135,11 @@ class DomainModule : InjektModule {
addFactory { SetMangaCategories(get()) } addFactory { SetMangaCategories(get()) }
addFactory { GetExcludedScanlators(get()) } addFactory { GetExcludedScanlators(get()) }
addFactory { SetExcludedScanlators(get()) } addFactory { SetExcludedScanlators(get()) }
addFactory {
MigrateMangaUseCase(
get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
)
}
addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) } addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
addFactory { GetApplicationRelease(get(), get()) } addFactory { GetApplicationRelease(get(), get()) }

View File

@ -2,6 +2,7 @@ package eu.kanade.domain.source.service
import eu.kanade.domain.source.interactor.SetMigrateSorting import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import mihon.domain.migration.models.MigrationFlag
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum import tachiyomi.core.common.preference.getEnum
@ -12,7 +13,7 @@ class SourcePreferences(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
) { ) {
fun sourceDisplayMode() = preferenceStore.getObject( fun sourceDisplayMode() = preferenceStore.getObjectFromString(
"pref_display_mode_catalogue", "pref_display_mode_catalogue",
LibraryDisplayMode.default, LibraryDisplayMode.default,
LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::serialize,
@ -58,4 +59,11 @@ class SourcePreferences(
Preference.appStateKey("has_filters_toggle_state"), Preference.appStateKey("has_filters_toggle_state"),
false, false,
) )
fun migrationFlags() = preferenceStore.getObjectFromInt(
key = "migrate_flags",
defaultValue = MigrationFlag.entries.toSet(),
serializer = { MigrationFlag.toBit(it) },
deserializer = { value: Int -> MigrationFlag.fromBit(value) },
)
} }

View File

@ -1,89 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadCache
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
data class MigrationFlag(
val flag: Int,
val isDefaultSelected: Boolean,
val titleId: StringResource,
) {
companion object {
fun create(flag: Int, defaultSelectionMap: Int, titleId: StringResource): MigrationFlag {
return MigrationFlag(
flag = flag,
isDefaultSelected = defaultSelectionMap and flag != 0,
titleId = titleId,
)
}
}
}
object MigrationFlags {
private const val CHAPTERS = 0b00001
private const val CATEGORIES = 0b00010
private const val CUSTOM_COVER = 0b01000
private const val DELETE_DOWNLOADED = 0b10000
private const val NOTES = 0b100000
private val coverCache: CoverCache by injectLazy()
private val downloadCache: DownloadCache by injectLazy()
fun hasChapters(value: Int): Boolean {
return value and CHAPTERS != 0
}
fun hasCategories(value: Int): Boolean {
return value and CATEGORIES != 0
}
fun hasCustomCover(value: Int): Boolean {
return value and CUSTOM_COVER != 0
}
fun hasDeleteDownloaded(value: Int): Boolean {
return value and DELETE_DOWNLOADED != 0
}
fun hasNotes(value: Int): Boolean {
return value and NOTES != 0
}
/** Returns information about applicable flags with default selections. */
fun getFlags(manga: Manga?, defaultSelectedBitMap: Int): List<MigrationFlag> {
val flags = mutableListOf<MigrationFlag>()
flags += MigrationFlag.create(CHAPTERS, defaultSelectedBitMap, MR.strings.chapters)
flags += MigrationFlag.create(CATEGORIES, defaultSelectedBitMap, MR.strings.categories)
if (manga != null) {
if (manga.hasCustomCover(coverCache)) {
flags += MigrationFlag.create(CUSTOM_COVER, defaultSelectedBitMap, MR.strings.custom_cover)
}
if (downloadCache.getDownloadCount(manga) > 0) {
flags += MigrationFlag.create(DELETE_DOWNLOADED, defaultSelectedBitMap, MR.strings.delete_downloaded)
}
if (manga.notes.isNotBlank()) {
flags += MigrationFlag.create(NOTES, defaultSelectedBitMap, MR.strings.action_notes)
}
}
return flags
}
/** Returns a bit map of selected flags. */
fun getSelectedFlagsBitMap(
selectedFlags: List<Boolean>,
flags: List<MigrationFlag>,
): Int {
return selectedFlags
.zip(flags)
.filter { (isSelected, _) -> isSelected }
.map { (_, flag) -> flag.flag }
.reduceOrNull { acc, mask -> acc or mask } ?: 0
}
}

View File

@ -1,312 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
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.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.model.StateScreenModel
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.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 eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
import kotlinx.coroutines.flow.update
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
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 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
import java.time.Instant
@Composable
internal fun MigrateDialog(
oldManga: Manga,
newManga: Manga,
screenModel: MigrateDialogScreenModel,
onDismissRequest: () -> Unit,
onClickTitle: () -> Unit,
onPopScreen: () -> Unit,
) {
val scope = rememberCoroutineScope()
val state by screenModel.state.collectAsState()
val flags = remember { MigrationFlags.getFlags(oldManga, screenModel.migrateFlags.get()) }
val selectedFlags = remember { flags.map { it.isDefaultSelected }.toMutableStateList() }
if (state.isMigrating) {
LoadingScreen(
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
)
} else {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.migration_dialog_what_to_include))
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
flags.forEachIndexed { index, flag ->
LabeledCheckbox(
label = stringResource(flag.titleId),
checked = selectedFlags[index],
onCheckedChange = { selectedFlags[index] = it },
)
}
}
},
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(
oldManga,
newManga,
false,
MigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
)
withUIContext { onPopScreen() }
}
},
) {
Text(text = stringResource(MR.strings.copy))
}
TextButton(
onClick = {
scope.launchIO {
screenModel.migrateManga(
oldManga,
newManga,
true,
MigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
)
withUIContext { onPopScreen() }
}
},
) {
Text(text = stringResource(MR.strings.migrate))
}
}
},
)
}
}
internal class MigrateDialogScreenModel(
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(),
private val insertTrack: InsertTrack = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val preferenceStore: PreferenceStore = Injekt.get(),
) : StateScreenModel<MigrateDialogScreenModel.State>(State()) {
val migrateFlags: Preference<Int> by lazy {
preferenceStore.getInt("migrate_flags", Int.MAX_VALUE)
}
private val enhancedServices by lazy {
Injekt.get<TrackerManager>().trackers.filterIsInstance<EnhancedTracker>()
}
suspend fun migrateManga(
oldManga: Manga,
newManga: Manga,
replace: Boolean,
flags: Int,
) {
migrateFlags.set(flags)
val source = sourceManager.get(newManga.source) ?: return
val prevSource = sourceManager.get(oldManga.source)
mutableState.update { it.copy(isMigrating = true) }
try {
val chapters = source.getChapterList(newManga.toSManga())
migrateMangaInternal(
oldSource = prevSource,
newSource = source,
oldManga = oldManga,
newManga = newManga,
sourceChapters = chapters,
replace = replace,
flags = flags,
)
} catch (_: Throwable) {
// Explicitly stop if an error occurred; the dialog normally gets popped at the end
// anyway
mutableState.update { it.copy(isMigrating = false) }
}
}
private suspend fun migrateMangaInternal(
oldSource: Source?,
newSource: Source,
oldManga: Manga,
newManga: Manga,
sourceChapters: List<SChapter>,
replace: Boolean,
flags: Int,
) {
val migrateChapters = MigrationFlags.hasChapters(flags)
val migrateCategories = MigrationFlags.hasCategories(flags)
val migrateCustomCover = MigrationFlags.hasCustomCover(flags)
val deleteDownloaded = MigrationFlags.hasDeleteDownloaded(flags)
val migrateNotes = MigrationFlags.hasNotes(flags)
try {
syncChaptersWithSource.await(sourceChapters, newManga, newSource)
} catch (_: Exception) {
// Worst case, chapters won't be synced
}
// Update chapters read, bookmark and dateFetch
if (migrateChapters) {
val prevMangaChapters = getChaptersByMangaId.await(oldManga.id)
val mangaChapters = getChaptersByMangaId.await(newManga.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 (migrateCategories) {
val categoryIds = getCategories.await(oldManga.id).map { it.id }
setMangaCategories.await(newManga.id, categoryIds)
}
// Update track
getTracks.await(oldManga.id).mapNotNull { track ->
val updatedTrack = track.copy(mangaId = newManga.id)
val service = enhancedServices
.firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) }
if (service != null) {
service.migrateTrack(updatedTrack, newManga, newSource)
} else {
updatedTrack
}
}
.takeIf { it.isNotEmpty() }
?.let { insertTrack.awaitAll(it) }
// Delete downloaded
if (deleteDownloaded) {
if (oldSource != null) {
downloadManager.deleteManga(oldManga, oldSource)
}
}
if (replace) {
updateManga.awaitUpdateFavorite(oldManga.id, favorite = false)
}
// Update custom cover (recheck if custom cover exists)
if (migrateCustomCover && oldManga.hasCustomCover()) {
coverCache.setCustomCoverToCache(newManga, coverCache.getCustomCoverFile(oldManga.id).inputStream())
}
updateManga.await(
MangaUpdate(
id = newManga.id,
favorite = true,
chapterFlags = oldManga.chapterFlags,
viewerFlags = oldManga.viewerFlags,
dateAdded = if (replace) oldManga.dateAdded else Instant.now().toEpochMilli(),
notes = if (migrateNotes) oldManga.notes else null,
),
)
}
@Immutable
data class State(
val isMigrating: Boolean = false,
)
}

View File

@ -8,7 +8,9 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.MigrateSearchScreen import eu.kanade.presentation.browse.MigrateSearchScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
class MigrateSearchScreen(private val mangaId: Long) : Screen() { class MigrateSearchScreen(private val mangaId: Long) : Screen() {
@ -19,42 +21,35 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) } val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) }
val state by screenModel.state.collectAsState() val state by screenModel.state.collectAsState()
val dialogScreenModel = rememberScreenModel { MigrateSearchScreenDialogScreenModel(mangaId = mangaId) }
val dialogState by dialogScreenModel.state.collectAsState()
MigrateSearchScreen( MigrateSearchScreen(
state = state, state = state,
fromSourceId = dialogState.manga?.source, fromSourceId = state.from?.source,
navigateUp = navigator::pop, navigateUp = navigator::pop,
onChangeSearchQuery = screenModel::updateSearchQuery, onChangeSearchQuery = screenModel::updateSearchQuery,
onSearch = { screenModel.search() }, onSearch = { screenModel.search() },
getManga = { screenModel.getManga(it) }, getManga = { screenModel.getManga(it) },
onChangeSearchFilter = screenModel::setSourceFilter, onChangeSearchFilter = screenModel::setSourceFilter,
onToggleResults = screenModel::toggleFilterResults, onToggleResults = screenModel::toggleFilterResults,
onClickSource = { onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) },
navigator.push(SourceSearchScreen(dialogState.manga!!, it.id, state.searchQuery)) onClickItem = { screenModel.setMigrateDialog(mangaId, it) },
},
onClickItem = { dialogScreenModel.setDialog(MigrateSearchScreenDialogScreenModel.Dialog.Migrate(it)) },
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) }, onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
) )
when (val dialog = dialogState.dialog) { when (val dialog = state.dialog) {
is MigrateSearchScreenDialogScreenModel.Dialog.Migrate -> { is SearchScreenModel.Dialog.Migrate -> {
MigrateDialog( MigrateMangaDialog(
oldManga = dialogState.manga!!, current = dialog.current,
newManga = dialog.manga, target = dialog.target,
screenModel = rememberScreenModel { MigrateDialogScreenModel() }, // Initiated from the context of [dialog.current] so we show [dialog.target].
onDismissRequest = { dialogScreenModel.setDialog(null) }, onClickTitle = { navigator.push(MangaScreen(dialog.target.id, true)) },
onClickTitle = { onDismissRequest = { screenModel.clearDialog() },
navigator.push(MangaScreen(dialog.manga.id, true)) onComplete = {
},
onPopScreen = {
if (navigator.lastItem is MangaScreen) { if (navigator.lastItem is MangaScreen) {
val lastItem = navigator.lastItem val lastItem = navigator.lastItem
navigator.popUntil { navigator.items.contains(lastItem) } navigator.popUntil { navigator.items.contains(lastItem) }
navigator.push(MangaScreen(dialog.manga.id)) navigator.push(MangaScreen(dialog.target.id))
} else { } else {
navigator.replace(MangaScreen(dialog.manga.id)) navigator.replace(MangaScreen(dialog.target.id))
} }
}, },
) )

View File

@ -1,43 +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)
}
}
}
fun setDialog(dialog: Dialog?) {
mutableState.update {
it.copy(dialog = dialog)
}
}
@Immutable
data class State(
val manga: Manga? = null,
val dialog: Dialog? = null,
)
sealed interface Dialog {
data class Migrate(val manga: Manga) : Dialog
}
}

View File

@ -33,7 +33,7 @@ class MigrateSearchScreenModel(
val manga = getManga.await(mangaId)!! val manga = getManga.await(mangaId)!!
mutableState.update { mutableState.update {
it.copy( it.copy(
fromSourceId = manga.source, from = manga,
searchQuery = manga.title, searchQuery = manga.title,
) )
} }

View File

@ -28,6 +28,7 @@ import eu.kanade.tachiyomi.ui.home.HomeScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.presentation.core.util.collectAsLazyPagingItems import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@ -38,8 +39,8 @@ import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.source.local.LocalSource import tachiyomi.source.local.LocalSource
data class SourceSearchScreen( data class MigrateSourceSearchScreen(
private val oldManga: Manga, private val currentManga: Manga,
private val sourceId: Long, private val sourceId: Long,
private val query: String?, private val query: String?,
) : Screen() { ) : Screen() {
@ -82,7 +83,7 @@ data class SourceSearchScreen(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues -> ) { paddingValues ->
val openMigrateDialog: (Manga) -> Unit = { val openMigrateDialog: (Manga) -> Unit = {
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(newManga = it, oldManga = oldManga)) screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga))
} }
BrowseSourceContent( BrowseSourceContent(
source = screenModel.source, source = screenModel.source,
@ -120,17 +121,17 @@ data class SourceSearchScreen(
) )
} }
is BrowseSourceScreenModel.Dialog.Migrate -> { is BrowseSourceScreenModel.Dialog.Migrate -> {
MigrateDialog( MigrateMangaDialog(
oldManga = oldManga, current = currentManga,
newManga = dialog.newManga, target = dialog.target,
screenModel = rememberScreenModel { MigrateDialogScreenModel() }, // Initiated from the context of [currentManga] so we show [dialog.target].
onClickTitle = { navigator.push(MangaScreen(dialog.target.id)) },
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onClickTitle = { navigator.push(MangaScreen(dialog.newManga.id)) }, onComplete = {
onPopScreen = {
scope.launch { scope.launch {
navigator.popUntilRoot() navigator.popUntilRoot()
HomeScreen.openTab(HomeScreen.Tab.Browse()) HomeScreen.openTab(HomeScreen.Tab.Browse())
navigator.push(MangaScreen(dialog.newManga.id)) navigator.push(MangaScreen(dialog.target.id))
} }
}, },
) )

View File

@ -47,8 +47,6 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
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.browse.BrowseSourceScreenModel.Listing import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
@ -56,6 +54,7 @@ import eu.kanade.tachiyomi.ui.webview.WebViewScreen
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.presentation.core.util.collectAsLazyPagingItems import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
@ -260,15 +259,12 @@ data class BrowseSourceScreen(
} }
is BrowseSourceScreenModel.Dialog.Migrate -> { is BrowseSourceScreenModel.Dialog.Migrate -> {
MigrateDialog( MigrateMangaDialog(
oldManga = dialog.oldManga, current = dialog.current,
newManga = dialog.newManga, target = dialog.target,
screenModel = MigrateDialogScreenModel(), // Initiated from the context of [dialog.target] so we show [dialog.current].
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
onPopScreen = {
onDismissRequest()
},
) )
} }
is BrowseSourceScreenModel.Dialog.RemoveManga -> { is BrowseSourceScreenModel.Dialog.RemoveManga -> {

View File

@ -341,7 +341,7 @@ class BrowseSourceScreenModel(
val manga: Manga, val manga: Manga,
val initialSelection: ImmutableList<CheckboxState.State<Category>>, val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog ) : Dialog
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog data class Migrate(val target: Manga, val current: Manga) : Dialog
} }
@Immutable @Immutable

View File

@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mihon.domain.manga.model.toDomainManga import mihon.domain.manga.model.toDomainManga
import tachiyomi.core.common.preference.toggle import tachiyomi.core.common.preference.toggle
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@ -201,18 +202,34 @@ abstract class SearchScreenModel(
updateItems(newItems) 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 @Immutable
data class State( data class State(
val fromSourceId: Long? = null, val from: Manga? = null,
val searchQuery: String? = null, val searchQuery: String? = null,
val sourceFilter: SourceFilter = SourceFilter.PinnedOnly, val sourceFilter: SourceFilter = SourceFilter.PinnedOnly,
val onlyShowHasResults: Boolean = false, val onlyShowHasResults: Boolean = false,
val items: PersistentMap<CatalogueSource, SearchItemResult> = persistentMapOf(), val items: PersistentMap<CatalogueSource, SearchItemResult> = persistentMapOf(),
val dialog: Dialog? = null,
) { ) {
val progress: Int = items.count { it.value !is SearchItemResult.Loading } val progress: Int = items.count { it.value !is SearchItemResult.Loading }
val total: Int = items.size val total: Int = items.size
val filteredItems = items.filter { (_, result) -> result.isVisible(onlyShowHasResults) } val filteredItems = items.filter { (_, result) -> result.isVisible(onlyShowHasResults) }
} }
sealed interface Dialog {
data class Migrate(val target: Manga, val current: Manga) : Dialog
}
} }
enum class SourceFilter { enum class SourceFilter {

View File

@ -216,9 +216,9 @@ class HistoryScreenModel(
} }
} }
fun showMigrateDialog(currentManga: Manga, duplicate: Manga) { fun showMigrateDialog(target: Manga, current: Manga) {
mutableState.update { currentState -> mutableState.update { currentState ->
currentState.copy(dialog = Dialog.Migrate(newManga = currentManga, oldManga = duplicate)) currentState.copy(dialog = Dialog.Migrate(target = target, current = current))
} }
} }
@ -252,7 +252,7 @@ class HistoryScreenModel(
val manga: Manga, val manga: Manga,
val initialSelection: ImmutableList<CheckboxState<Category>>, val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog ) : Dialog
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog data class Migrate(val target: Manga, val current: Manga) : Dialog
} }
sealed interface Event { sealed interface Event {

View File

@ -23,8 +23,6 @@ import eu.kanade.presentation.history.components.HistoryDeleteDialog
import eu.kanade.presentation.manga.DuplicateMangaDialog import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.presentation.util.Tab import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
@ -32,6 +30,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import mihon.feature.migration.dialog.MigrateMangaDialog
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@ -100,9 +99,7 @@ data object HistoryTab : Tab {
DuplicateMangaDialog( DuplicateMangaDialog(
duplicates = dialog.duplicates, duplicates = dialog.duplicates,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onConfirm = { onConfirm = { screenModel.addFavorite(dialog.manga) },
screenModel.addFavorite(dialog.manga)
},
onOpenManga = { navigator.push(MangaScreen(it.id)) }, onOpenManga = { navigator.push(MangaScreen(it.id)) },
onMigrate = { screenModel.showMigrateDialog(dialog.manga, it) }, onMigrate = { screenModel.showMigrateDialog(dialog.manga, it) },
) )
@ -118,13 +115,12 @@ data object HistoryTab : Tab {
) )
} }
is HistoryScreenModel.Dialog.Migrate -> { is HistoryScreenModel.Dialog.Migrate -> {
MigrateDialog( MigrateMangaDialog(
oldManga = dialog.oldManga, current = dialog.current,
newManga = dialog.newManga, target = dialog.target,
screenModel = MigrateDialogScreenModel(), // Initiated from the context of [dialog.target] so we show [dialog.current].
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
onPopScreen = onDismissRequest,
) )
} }
null -> {} null -> {}

View File

@ -43,8 +43,6 @@ import eu.kanade.presentation.util.isTabletUi
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.source.isLocalOrStub
import eu.kanade.tachiyomi.source.online.HttpSource 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.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
@ -60,6 +58,7 @@ import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import mihon.feature.migration.config.MigrationConfigScreen import mihon.feature.migration.config.MigrationConfigScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@ -212,13 +211,12 @@ class MangaScreen(
} }
is MangaScreenModel.Dialog.Migrate -> { is MangaScreenModel.Dialog.Migrate -> {
MigrateDialog( MigrateMangaDialog(
oldManga = dialog.oldManga, current = dialog.current,
newManga = dialog.newManga, target = dialog.target,
screenModel = MigrateDialogScreenModel(), // Initiated from the context of [dialog.target] so we show [dialog.current].
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
onPopScreen = onDismissRequest,
) )
} }
MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog( MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog(

View File

@ -1071,7 +1071,7 @@ class MangaScreenModel(
) : Dialog ) : Dialog
data class DeleteChapters(val chapters: List<Chapter>) : Dialog data class DeleteChapters(val chapters: List<Chapter>) : Dialog
data class DuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog data class DuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog data class Migrate(val target: Manga, val current: Manga) : Dialog
data class SetFetchInterval(val manga: Manga) : Dialog data class SetFetchInterval(val manga: Manga) : Dialog
data object SettingsSheet : Dialog data object SettingsSheet : Dialog
data object TrackSheet : Dialog data object TrackSheet : Dialog
@ -1100,7 +1100,7 @@ class MangaScreenModel(
fun showMigrateDialog(duplicate: Manga) { fun showMigrateDialog(duplicate: Manga) {
val manga = successState?.manga ?: return 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)) }
} }
fun setExcludedScanlators(excludedScanlators: Set<String>) { fun setExcludedScanlators(excludedScanlators: Set<String>) {

View File

@ -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
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}

View File

@ -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,
)
}

View File

@ -171,13 +171,13 @@ sealed class AndroidPreference<T>(
} }
} }
class Object<T>( class ObjectAsString<T>(
preferences: SharedPreferences, preferences: SharedPreferences,
keyFlow: Flow<String?>, keyFlow: Flow<String?>,
key: String, key: String,
defaultValue: T, defaultValue: T,
val serializer: (T) -> String, private val serializer: (T) -> String,
val deserializer: (String) -> T, private val deserializer: (String) -> T,
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) { ) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T { override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
return try { return try {
@ -191,4 +191,25 @@ sealed class AndroidPreference<T>(
putString(key, serializer(value)) 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))
}
}
} }

View File

@ -9,7 +9,8 @@ import tachiyomi.core.common.preference.AndroidPreference.BooleanPrimitive
import tachiyomi.core.common.preference.AndroidPreference.FloatPrimitive import tachiyomi.core.common.preference.AndroidPreference.FloatPrimitive
import tachiyomi.core.common.preference.AndroidPreference.IntPrimitive import tachiyomi.core.common.preference.AndroidPreference.IntPrimitive
import tachiyomi.core.common.preference.AndroidPreference.LongPrimitive 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.StringPrimitive
import tachiyomi.core.common.preference.AndroidPreference.StringSetPrimitive import tachiyomi.core.common.preference.AndroidPreference.StringSetPrimitive
@ -44,13 +45,29 @@ class AndroidPreferenceStore(
return StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue) return StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
} }
override fun <T> getObject( override fun <T> getObjectFromString(
key: String, key: String,
defaultValue: T, defaultValue: T,
serializer: (T) -> String, serializer: (T) -> String,
deserializer: (String) -> T, deserializer: (String) -> T,
): Preference<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, preferences = sharedPreferences,
keyFlow = keyFlow, keyFlow = keyFlow,
key = key, key = key,

View File

@ -52,7 +52,7 @@ class InMemoryPreferenceStore(
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T> getObject( override fun <T> getObjectFromString(
key: String, key: String,
defaultValue: T, defaultValue: T,
serializer: (T) -> String, serializer: (T) -> String,
@ -63,6 +63,18 @@ class InMemoryPreferenceStore(
return if (data == null) default else InMemoryPreference(key, data, defaultValue) 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, *> { override fun getAll(): Map<String, *> {
return preferences return preferences
} }

View File

@ -14,13 +14,20 @@ interface PreferenceStore {
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>> fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
fun <T> getObject( fun <T> getObjectFromString(
key: String, key: String,
defaultValue: T, defaultValue: T,
serializer: (T) -> String, serializer: (T) -> String,
deserializer: (String) -> T, deserializer: (String) -> T,
): Preference<T> ): Preference<T>
fun <T> getObjectFromInt(
key: String,
defaultValue: T,
serializer: (T) -> Int,
deserializer: (Int) -> T,
): Preference<T>
fun getAll(): Map<String, *> fun getAll(): Map<String, *>
} }
@ -28,7 +35,7 @@ fun PreferenceStore.getLongArray(
key: String, key: String,
defaultValue: List<Long>, defaultValue: List<Long>,
): Preference<List<Long>> { ): Preference<List<Long>> {
return getObject( return getObjectFromString(
key = key, key = key,
defaultValue = defaultValue, defaultValue = defaultValue,
serializer = { it.joinToString(",") }, serializer = { it.joinToString(",") },
@ -40,7 +47,7 @@ inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
key: String, key: String,
defaultValue: T, defaultValue: T,
): Preference<T> { ): Preference<T> {
return getObject( return getObjectFromString(
key = key, key = key,
defaultValue = defaultValue, defaultValue = defaultValue,
serializer = { it.name }, serializer = { it.name },

View File

@ -12,14 +12,14 @@ class LibraryPreferences(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
) { ) {
fun displayMode() = preferenceStore.getObject( fun displayMode() = preferenceStore.getObjectFromString(
"pref_display_mode_library", "pref_display_mode_library",
LibraryDisplayMode.default, LibraryDisplayMode.default,
LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::serialize,
LibraryDisplayMode.Serializer::deserialize, LibraryDisplayMode.Serializer::deserialize,
) )
fun sortingMode() = preferenceStore.getObject( fun sortingMode() = preferenceStore.getObjectFromString(
"library_sorting_mode", "library_sorting_mode",
LibrarySort.default, LibrarySort.default,
LibrarySort.Serializer::serialize, LibrarySort.Serializer::serialize,