mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 06:17:57 +01: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, | ||||
|     ) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user