mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-27 03:27:51 +02:00
Cleanup migrate manga dialog and related code (#2156)
This commit is contained in:
@ -35,6 +35,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
|
||||
@ -134,6 +135,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,
|
||||
@ -58,4 +59,11 @@ class SourcePreferences(
|
||||
Preference.appStateKey("has_filters_toggle_state"),
|
||||
false,
|
||||
)
|
||||
|
||||
fun migrationFlags() = preferenceStore.getObjectFromInt(
|
||||
key = "migrate_flags",
|
||||
defaultValue = MigrationFlag.entries.toSet(),
|
||||
serializer = { MigrationFlag.toBit(it) },
|
||||
deserializer = { value: Int -> MigrationFlag.fromBit(value) },
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
@ -8,7 +8,9 @@ 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.source.globalsearch.SearchScreenModel
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
|
||||
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 state by screenModel.state.collectAsState()
|
||||
|
||||
val dialogScreenModel = rememberScreenModel { MigrateSearchScreenDialogScreenModel(mangaId = mangaId) }
|
||||
val dialogState by dialogScreenModel.state.collectAsState()
|
||||
|
||||
MigrateSearchScreen(
|
||||
state = state,
|
||||
fromSourceId = dialogState.manga?.source,
|
||||
fromSourceId = state.from?.source,
|
||||
navigateUp = navigator::pop,
|
||||
onChangeSearchQuery = screenModel::updateSearchQuery,
|
||||
onSearch = { screenModel.search() },
|
||||
getManga = { screenModel.getManga(it) },
|
||||
onChangeSearchFilter = screenModel::setSourceFilter,
|
||||
onToggleResults = screenModel::toggleFilterResults,
|
||||
onClickSource = {
|
||||
navigator.push(SourceSearchScreen(dialogState.manga!!, it.id, state.searchQuery))
|
||||
},
|
||||
onClickItem = { dialogScreenModel.setDialog(MigrateSearchScreenDialogScreenModel.Dialog.Migrate(it)) },
|
||||
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 = dialogState.dialog) {
|
||||
is MigrateSearchScreenDialogScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = dialogState.manga!!,
|
||||
newManga = dialog.manga,
|
||||
screenModel = rememberScreenModel { MigrateDialogScreenModel() },
|
||||
onDismissRequest = { dialogScreenModel.setDialog(null) },
|
||||
onClickTitle = {
|
||||
navigator.push(MangaScreen(dialog.manga.id, true))
|
||||
},
|
||||
onPopScreen = {
|
||||
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.manga.id))
|
||||
navigator.push(MangaScreen(dialog.target.id))
|
||||
} else {
|
||||
navigator.replace(MangaScreen(dialog.manga.id))
|
||||
navigator.replace(MangaScreen(dialog.target.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -33,7 +33,7 @@ class MigrateSearchScreenModel(
|
||||
val manga = getManga.await(mangaId)!!
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
fromSourceId = manga.source,
|
||||
from = manga,
|
||||
searchQuery = manga.title,
|
||||
)
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import eu.kanade.tachiyomi.ui.home.HomeScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
||||
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
|
||||
@ -38,8 +39,8 @@ 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() {
|
||||
@ -82,7 +83,7 @@ data class SourceSearchScreen(
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
val openMigrateDialog: (Manga) -> Unit = {
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(newManga = it, oldManga = oldManga))
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga))
|
||||
}
|
||||
BrowseSourceContent(
|
||||
source = screenModel.source,
|
||||
@ -120,17 +121,17 @@ data class SourceSearchScreen(
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = oldManga,
|
||||
newManga = dialog.newManga,
|
||||
screenModel = rememberScreenModel { MigrateDialogScreenModel() },
|
||||
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,
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.newManga.id)) },
|
||||
onPopScreen = {
|
||||
onComplete = {
|
||||
scope.launch {
|
||||
navigator.popUntilRoot()
|
||||
HomeScreen.openTab(HomeScreen.Tab.Browse())
|
||||
navigator.push(MangaScreen(dialog.newManga.id))
|
||||
navigator.push(MangaScreen(dialog.target.id))
|
||||
}
|
||||
},
|
||||
)
|
@ -47,8 +47,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.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.category.CategoryScreen
|
||||
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.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
|
||||
@ -260,15 +259,12 @@ data class BrowseSourceScreen(
|
||||
}
|
||||
|
||||
is BrowseSourceScreenModel.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()
|
||||
},
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.RemoveManga -> {
|
||||
|
@ -341,7 +341,7 @@ 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
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
@ -25,6 +25,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
|
||||
@ -201,18 +202,34 @@ 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) }
|
||||
}
|
||||
|
||||
sealed interface Dialog {
|
||||
data class Migrate(val target: Manga, val current: Manga) : Dialog
|
||||
}
|
||||
}
|
||||
|
||||
enum class SourceFilter {
|
||||
|
@ -216,9 +216,9 @@ class HistoryScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun showMigrateDialog(currentManga: Manga, duplicate: Manga) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,7 +252,7 @@ class HistoryScreenModel(
|
||||
val manga: Manga,
|
||||
val initialSelection: ImmutableList<CheckboxState<Category>>,
|
||||
) : Dialog
|
||||
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
|
||||
data class Migrate(val target: Manga, val current: Manga) : Dialog
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
|
@ -23,8 +23,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.search.MigrateDialog
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
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.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.i18n.MR
|
||||
@ -100,9 +99,7 @@ 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 = { screenModel.showMigrateDialog(dialog.manga, it) },
|
||||
)
|
||||
@ -118,13 +115,12 @@ data object HistoryTab : Tab {
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
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.browse.BrowseSourceScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
@ -60,6 +58,7 @@ import eu.kanade.tachiyomi.util.system.toast
|
||||
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.withIOContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
@ -212,13 +211,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(
|
||||
|
@ -1071,7 +1071,7 @@ class MangaScreenModel(
|
||||
) : Dialog
|
||||
data class DeleteChapters(val chapters: List<Chapter>) : 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 object SettingsSheet : Dialog
|
||||
data object TrackSheet : Dialog
|
||||
@ -1100,7 +1100,7 @@ class MangaScreenModel(
|
||||
|
||||
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)) }
|
||||
}
|
||||
|
||||
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 },
|
||||
|
@ -12,14 +12,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,
|
||||
|
Reference in New Issue
Block a user