Cleanup migrate manga dialog and related code (mihonapp/mihon#2156)

(cherry picked from commit 2b126f1ff56b63e470b48a04149e28c609f01148)

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
AntsyLich 2025-05-31 21:03:39 +06:00 committed by Cuong-Tran
parent 2196c84a0c
commit 5ab24620ec
30 changed files with 611 additions and 669 deletions

View file

@ -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
@ -138,6 +139,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(), get(), get(),
)
}
addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
addFactory { GetApplicationRelease(get(), get()) }

View file

@ -3,6 +3,7 @@ package eu.kanade.domain.source.service
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
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
@ -13,7 +14,7 @@ class SourcePreferences(
private val preferenceStore: PreferenceStore,
) {
fun sourceDisplayMode() = preferenceStore.getObject(
fun sourceDisplayMode() = preferenceStore.getObjectFromString(
"pref_display_mode_catalogue",
LibraryDisplayMode.default,
LibraryDisplayMode.Serializer::serialize,
@ -70,6 +71,13 @@ class SourcePreferences(
false,
)
fun migrationFlags() = preferenceStore.getObjectFromInt(
key = "migrate_flags",
defaultValue = MigrationFlag.entries.toSet(),
serializer = { MigrationFlag.toBit(it) },
deserializer = { value: Int -> MigrationFlag.fromBit(value) },
)
// KMK -->
fun globalSearchPinnedState() = preferenceStore.getEnum(
Preference.appStateKey("global_search_pinned_toggle_state"),
@ -112,8 +120,6 @@ class SourcePreferences(
WSRV_NL,
}
fun migrateFlags() = preferenceStore.getInt("migrate_flags", Int.MAX_VALUE)
fun smartMigration() = preferenceStore.getBoolean("smart_migrate", false)
fun useSourceWithMost() = preferenceStore.getBoolean("use_source_with_most", false)

View file

@ -9,7 +9,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
@ -18,10 +17,9 @@ import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel.Dialog
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.manga.MangaScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.i18n.stringResource
@ -89,7 +87,6 @@ fun Screen.BulkFavoriteDialogs(
dialog = dialog,
navigator = navigator,
state = bulkFavoriteState,
migrateScreenModel = rememberScreenModel { MigrateDialogScreenModel() },
onDismiss = bulkFavoriteScreenModel::dismissDialog,
stopRunning = bulkFavoriteScreenModel::stopRunning,
toggleSelection = bulkFavoriteScreenModel::toggleSelection,
@ -101,11 +98,10 @@ fun Screen.BulkFavoriteDialogs(
}
@Composable
private fun ShowMigrateDialog(
private fun Screen.ShowMigrateDialog(
dialog: Dialog.Migrate,
navigator: Navigator?,
state: BulkFavoriteScreenModel.State,
migrateScreenModel: MigrateDialogScreenModel,
onDismiss: () -> Unit,
stopRunning: () -> Unit,
toggleSelection: (Manga, toSelectedState: Boolean) -> Unit,
@ -113,14 +109,14 @@ private fun ShowMigrateDialog(
) {
stopRunning()
MigrateDialog(
oldManga = dialog.oldManga,
newManga = dialog.newManga,
screenModel = migrateScreenModel,
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 = onDismiss,
onClickTitle = { navigator?.push(MangaScreen(dialog.oldManga.id)) },
onPopScreen = {
toggleSelection(dialog.newManga, false)
onComplete = {
toggleSelection(dialog.target, false)
onDismiss()
// `selection.size` is at current value before calling above `toggleSelection`
if (state.selection.size > 1) {

View file

@ -378,7 +378,7 @@ class BulkFavoriteScreenModel(
}
internal fun showMigrateDialog(manga: Manga, duplicate: Manga) {
setDialog(Dialog.Migrate(newManga = manga, oldManga = duplicate))
setDialog(Dialog.Migrate(target = manga, current = duplicate))
}
private fun setDialog(dialog: Dialog?) {
@ -406,7 +406,7 @@ class BulkFavoriteScreenModel(
}
sealed interface Dialog {
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
data class Migrate(val target: Manga, val current: Manga) : Dialog
data class AddDuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog
data class BulkAllowDuplicate(val manga: Manga, val duplicates: List<MangaWithChapterCount>, val currentIdx: Int) : Dialog
data class RemoveManga(val manga: Manga) : Dialog

View file

@ -1,112 +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 tachiyomi.i18n.sy.SYMR
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 {
const val CHAPTERS = 0b00001
const val CATEGORIES = 0b00010
// KMK -->
const val TRACK = 0b00100
// KMK <--
const val CUSTOM_COVER = 0b01000
const val DELETE_DOWNLOADED = 0b10000
// KMK -->
const val EXTRA = 0b1000000
// KMK <--
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
}
// KMK -->
fun hasTracks(value: Int): Boolean {
return value and TRACK != 0
}
// KMK <--
fun hasCustomCover(value: Int): Boolean {
return value and CUSTOM_COVER != 0
}
fun hasDeleteDownloaded(value: Int): Boolean {
return value and DELETE_DOWNLOADED != 0
}
// KMK -->
/** Returns true if the manga has extra info, include notes. */
fun hasExtra(value: Int): Boolean {
return value and EXTRA != 0
}
// KMK <--
/** 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)
// KMK -->
flags += MigrationFlag.create(TRACK, defaultSelectedBitMap, MR.strings.track)
// KMK <--
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)
}
}
// KMK -->
flags += MigrationFlag.create(EXTRA, defaultSelectedBitMap, SYMR.strings.log_extra)
// KMK <--
return flags
}
/** Returns a bit map of selected flags. */
fun getSelectedFlagsBitMap(
selectedFlags: List<Boolean>,
flags: List<MigrationFlag>,
): Int {
return selectedFlags
.zip(flags)
.filter { (isSelected, _) -> isSelected }
.map { (_, flag) -> flag.flag }
.reduceOrNull { acc, mask -> acc or mask } ?: 0
}
}

View file

@ -21,8 +21,8 @@ import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.presentation.theme.colorscheme.AndroidViewColorScheme
import eu.kanade.tachiyomi.databinding.MigrationBottomSheetBinding
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
import eu.kanade.tachiyomi.util.system.toast
import mihon.domain.migration.models.MigrationFlag
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.util.lang.toLong
@ -116,15 +116,15 @@ class MigrationBottomSheetDialogState(
* Init general reader preferences.
*/
fun initPreferences(binding: MigrationBottomSheetBinding) {
val flags = preferences.migrateFlags().get()
val flags = preferences.migrationFlags().get()
with(binding) {
migChapters.isChecked = MigrationFlags.hasChapters(flags)
migCategories.isChecked = MigrationFlags.hasCategories(flags)
migTracking.isChecked = MigrationFlags.hasTracks(flags)
migCustomCover.isChecked = MigrationFlags.hasCustomCover(flags)
migExtra.isChecked = MigrationFlags.hasExtra(flags)
migDeleteDownloaded.isChecked = MigrationFlags.hasDeleteDownloaded(flags)
migChapters.isChecked = MigrationFlag.CHAPTER in flags
migCategories.isChecked = MigrationFlag.CATEGORY in flags
migTracking.isChecked = MigrationFlag.TRACK in flags
migCustomCover.isChecked = MigrationFlag.CUSTOM_COVER in flags
migExtra.isChecked = MigrationFlag.EXTRA in flags
migDeleteDownloaded.isChecked = MigrationFlag.REMOVE_DOWNLOAD in flags
listOf(
migChapters,
@ -183,17 +183,16 @@ class MigrationBottomSheetDialogState(
}
private fun setFlags(binding: MigrationBottomSheetBinding) {
var flags = 0
val flags = mutableSetOf<MigrationFlag>()
with(binding) {
@Suppress("KotlinConstantConditions")
if (migChapters.isChecked) flags = flags or MigrationFlags.CHAPTERS
if (migCategories.isChecked) flags = flags or MigrationFlags.CATEGORIES
if (migTracking.isChecked) flags = flags or MigrationFlags.TRACK
if (migCustomCover.isChecked) flags = flags or MigrationFlags.CUSTOM_COVER
if (migExtra.isChecked) flags = flags or MigrationFlags.EXTRA
if (migDeleteDownloaded.isChecked) flags = flags or MigrationFlags.DELETE_DOWNLOADED
if (migChapters.isChecked) flags.add(MigrationFlag.CHAPTER)
if (migCategories.isChecked) flags.add(MigrationFlag.CATEGORY)
if (migTracking.isChecked) flags.add(MigrationFlag.TRACK)
if (migCustomCover.isChecked) flags.add(MigrationFlag.CUSTOM_COVER)
if (migExtra.isChecked) flags.add(MigrationFlag.EXTRA)
if (migDeleteDownloaded.isChecked) flags.add(MigrationFlag.REMOVE_DOWNLOAD)
}
preferences.migrateFlags().set(flags)
preferences.migrationFlags().set(flags)
}
/**

View file

@ -27,7 +27,7 @@ import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.i18n.sy.SYMR
/**
* Screen showing a list of pair of source-dest manga entries being migrated.
* Screen showing a list of pair of current-target manga entries being migrated.
*/
class MigrationListScreen(private val config: MigrationProcedureConfig) : Screen() {

View file

@ -12,7 +12,6 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel.Companion.migrateMangaInternal
import eu.kanade.tachiyomi.util.system.toast
import exh.smartsearch.SmartSourceSearchEngine
import exh.source.MERGED_SOURCE_ID
@ -32,6 +31,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import logcat.LogPriority
import mihon.domain.migration.usecases.MigrateMangaUseCase
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat
@ -57,6 +57,7 @@ class MigrationListScreenModel(
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(),
private val migrateManga: MigrateMangaUseCase = Injekt.get(),
) : ScreenModel {
private val smartSearchEngine = SmartSourceSearchEngine(config.extraSearchParams)
@ -383,33 +384,18 @@ class MigrationListScreenModel(
migratingProgress.value = 0f
val items = migratingItems.value.orEmpty()
try {
items.forEachIndexed { index, oldManga ->
items.forEachIndexed { index, current ->
try {
ensureActive()
val newManga = oldManga.searchResult.value.let {
val target = current.searchResult.value.let {
if (it is SearchResult.Result) {
getManga.await(it.id)
} else {
null
}
}
if (newManga != null) {
val source = sourceManager.get(newManga.source) ?: return@launchIO
val prevSource = sourceManager.get(oldManga.manga.source)
try {
val chapters = source.getChapterList(newManga.toSManga())
migrateMangaInternal(
oldSource = prevSource,
newSource = source,
oldManga = oldManga.manga,
newManga = newManga,
sourceChapters = chapters,
replace = replace,
)
} catch (_: Throwable) {
}
if (target != null) {
migrateManga(current.manga, target, replace)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@ -438,28 +424,13 @@ class MigrationListScreenModel(
fun migrateManga(mangaId: Long, replace: Boolean) {
manualMigrations.value++
screenModelScope.launchIO {
val oldManga = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
val current = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
?: return@launchIO
val newManga = getManga.await((oldManga.searchResult.value as? SearchResult.Result)?.id ?: return@launchIO)
val target = getManga.await((current.searchResult.value as? SearchResult.Result)?.id ?: return@launchIO)
?: return@launchIO
val source = sourceManager.get(newManga.source) ?: return@launchIO
val prevSource = sourceManager.get(oldManga.manga.source)
try {
val chapters = source.getChapterList(newManga.toSManga())
migrateMangaInternal(
oldSource = prevSource,
newSource = source,
oldManga = oldManga.manga,
newManga = newManga,
sourceChapters = chapters,
replace = replace,
)
} catch (_: Throwable) {
}
migrateManga(current.manga, target, replace)
removeManga(mangaId)
}
}

View file

@ -1,382 +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.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 eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
import exh.log.LogLevel
import exh.log.xLog
import kotlinx.coroutines.flow.update
import tachiyomi.core.common.preference.Preference
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.ChapterUpdate
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.model.HistoryUpdate
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 = { 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(),
// KMK -->
sourcePreferences: SourcePreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<MigrateDialogScreenModel.State>(State()) {
val migrateFlags: Preference<Int> by lazy {
// KMK -->
sourcePreferences.migrateFlags()
// KMK <--
}
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,
presetFlags = flags,
)
} catch (e: Throwable) {
// Explicitly stop if an error occurred; the dialog normally gets popped at the end anyway
// KMK -->
xLog(LogLevel.Error, "Failed to migrate manga ${oldManga.title} to ${newManga.title}", e)
} finally {
// KMK <--
mutableState.update { it.copy(isMigrating = false) }
}
}
companion object {
suspend fun migrateMangaInternal(
oldSource: Source?,
newSource: Source,
oldManga: Manga,
newManga: Manga,
sourceChapters: List<SChapter>,
replace: Boolean,
// KMK -->
presetFlags: Int? = null,
// KMK <--
) {
// KMK -->
if (oldManga.id == newManga.id) return // Nothing to migrate
val sourcePreferences: SourcePreferences = Injekt.get()
val downloadManager: DownloadManager = Injekt.get()
val coverCache: CoverCache = Injekt.get()
val updateManga: UpdateManga = Injekt.get()
val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
val updateChapter: UpdateChapter = Injekt.get()
val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get()
val getHistory: GetHistory = Injekt.get()
val upsertHistory: UpsertHistory = Injekt.get()
val getCategories: GetCategories = Injekt.get()
val setMangaCategories: SetMangaCategories = Injekt.get()
val getTracks: GetTracks = Injekt.get()
val insertTrack: InsertTrack = Injekt.get()
val enhancedServices by lazy {
Injekt.get<TrackerManager>().trackers.filterIsInstance<EnhancedTracker>()
}
val flags = presetFlags ?: sourcePreferences.migrateFlags().get()
// KMK <--
val migrateChapters = MigrationFlags.hasChapters(flags)
val migrateCategories = MigrationFlags.hasCategories(flags)
val migrateCustomCover = MigrationFlags.hasCustomCover(flags)
val deleteDownloaded = MigrationFlags.hasDeleteDownloaded(flags)
// KMK -->
val migrateTracks = MigrationFlags.hasTracks(flags)
val migrateExtra = MigrationFlags.hasExtra(flags)
// KMK <--
// Update newManga's chapters first to ensure it has latest chapter list
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 }
// SY -->
val prevHistoryList = getHistory.await(oldManga.id)
// KMK -->
.associateBy { it.chapterId }
// KMK <--
val chapterUpdates = mutableListOf<ChapterUpdate>()
val historyUpdates = mutableListOf<HistoryUpdate>()
mangaChapters.forEach { updatedChapter ->
// SY <--
if (updatedChapter.isRecognizedNumber) {
val prevChapter = prevMangaChapters
.find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber }
if (prevChapter != null) {
// SY -->
// If chapters match then mark new manga's chapters read/unread as old one
chapterUpdates += ChapterUpdate(
id = updatedChapter.id,
// Also migrate read status
read = prevChapter.read,
// SY <--
dateFetch = prevChapter.dateFetch,
bookmark = prevChapter.bookmark,
)
// SY -->
// KMK -->
prevHistoryList[prevChapter.id]?.let { prevHistory ->
// KMK <--
historyUpdates += HistoryUpdate(
updatedChapter.id,
prevHistory.readAt ?: return@let,
prevHistory.readDuration,
)
}
// SY <--
} else if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) {
// SY -->
// If chapters which only present on new manga then mark read up to latest read chapter number
chapterUpdates += ChapterUpdate(
id = updatedChapter.id,
// SY <--
read = true,
)
}
}
}
updateChapter.awaitAll(chapterUpdates)
// SY -->
upsertHistory.awaitAll(historyUpdates)
// SY <--
}
// Update categories
if (migrateCategories) {
val categoryIds = getCategories.await(oldManga.id).map { it.id }
setMangaCategories.await(newManga.id, categoryIds)
}
// Update track
// SY -->
if (migrateTracks) {
// SY <--
getTracks.await(oldManga.id).mapNotNull { track ->
val updatedTrack = track.copy(mangaId = newManga.id)
// Kavita, Komga, Suwayomi
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
// KMK -->
.takeIf { migrateExtra },
// KMK <--
viewerFlags = oldManga.viewerFlags
// KMK -->
.takeIf { migrateExtra },
// KMK <--
dateAdded = if (replace) oldManga.dateAdded else Instant.now().toEpochMilli(),
notes = oldManga.notes
// KMK -->
.takeIf { migrateExtra },
// KMK <--
),
)
}
}
@Immutable
data class State(
val isMigrating: Boolean = false,
)
}

View file

@ -19,12 +19,9 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel =
rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) }
val state by screenModel.state.collectAsState()
val dialogScreenModel = rememberScreenModel { MigrateSearchScreenDialogScreenModel(mangaId = mangaId) }
val dialogState by dialogScreenModel.state.collectAsState()
val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) }
val state by screenModel.state.collectAsState()
// KMK -->
val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() }
@ -37,18 +34,14 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
MigrateSearchScreen(
state = state,
fromSourceId = state.fromSourceId,
fromSourceId = state.from?.source,
navigateUp = navigator::pop,
onChangeSearchQuery = screenModel::updateSearchQuery,
onSearch = { screenModel.search() },
getManga = { screenModel.getManga(it) },
onChangeSearchFilter = screenModel::setSourceFilter,
onToggleResults = screenModel::toggleFilterResults,
onClickSource = {
// SY -->
navigator.push(SourceSearchScreen(dialogState.manga!!, it.id, state.searchQuery))
// SY <--
},
onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) },
onClickItem = { manga ->
// KMK -->
if (bulkFavoriteState.selectionMode) {

View file

@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MigrateSearchScreenDialogScreenModel(
val mangaId: Long,
getManga: GetManga = Injekt.get(),
) : StateScreenModel<MigrateSearchScreenDialogScreenModel.State>(State()) {
init {
screenModelScope.launch {
val manga = getManga.await(mangaId)!!
mutableState.update {
it.copy(manga = manga)
}
}
}
@Immutable
data class State(
val manga: Manga? = null,
)
}

View file

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

View file

@ -43,8 +43,8 @@ import tachiyomi.source.local.LocalSource
/**
* Opened when click on a source in [MigrateSearchScreen] while doing manual search for migration
*/
data class SourceSearchScreen(
private val oldManga: Manga,
data class MigrateSourceSearchScreen(
private val currentManga: Manga,
private val sourceId: Long,
private val query: String?,
) : Screen() {
@ -135,7 +135,7 @@ data class SourceSearchScreen(
navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.newSelectedItem = oldManga.id to it.id
.newSelectedItem = currentManga.id to it.id
navigator.popUntil { it is MigrationListScreen }
// SY <--
}

View file

@ -56,8 +56,6 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
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.SourcesScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
import eu.kanade.tachiyomi.ui.category.CategoryScreen
@ -73,6 +71,7 @@ import exh.ui.smartsearch.SmartSearchScreen
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
@ -462,13 +461,12 @@ data class BrowseSourceScreen(
}
is BrowseSourceScreenModel.Dialog.Migrate -> {
MigrateDialog(
oldManga = dialog.oldManga,
newManga = dialog.newManga,
screenModel = rememberScreenModel { 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 -> {

View file

@ -530,7 +530,7 @@ open class BrowseSourceScreenModel(
val manga: Manga,
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
data class Migrate(val target: Manga, val current: Manga) : Dialog
// SY -->
data class DeleteSavedSearch(val idToDelete: Long, val name: String) : Dialog

View file

@ -27,6 +27,7 @@ import kotlinx.coroutines.withContext
import mihon.domain.manga.model.toDomainManga
import tachiyomi.core.common.preference.toggle
import tachiyomi.core.common.util.QuerySanitizer.sanitize
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
@ -223,19 +224,35 @@ abstract class SearchScreenModel(
updateItems(newItems)
}
fun setMigrateDialog(currentId: Long, target: Manga) {
screenModelScope.launchIO {
val current = getManga.await(currentId) ?: return@launchIO
mutableState.update { it.copy(dialog = Dialog.Migrate(target, current)) }
}
}
fun clearDialog() {
mutableState.update { it.copy(dialog = null) }
}
@Immutable
data class State(
val fromSourceId: Long? = null,
val from: Manga? = null,
val searchQuery: String? = null,
val sourceFilter: SourceFilter = SourceFilter.PinnedOnly,
val onlyShowHasResults: Boolean = false,
val items: PersistentMap<CatalogueSource, SearchItemResult> = persistentMapOf(),
val dialog: Dialog? = null,
) {
val progress: Int = items.count { it.value !is SearchItemResult.Loading }
val total: Int = items.size
val filteredItems = items.filter { (_, result) -> result.isVisible(onlyShowHasResults) }
.toImmutableMap()
}
sealed interface Dialog {
data class Migrate(val target: Manga, val current: Manga) : Dialog
}
}
enum class SourceFilter {

View file

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

View file

@ -29,8 +29,6 @@ import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService
import eu.kanade.tachiyomi.data.connections.discord.DiscordScreen
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
@ -38,6 +36,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.core.common.util.lang.launchIO
import tachiyomi.domain.chapter.model.Chapter
@ -47,6 +46,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
data object HistoryTab : Tab {
@Suppress("unused")
private fun readResolve(): Any = HistoryTab
private val snackbarHostState = SnackbarHostState()
@ -120,9 +120,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) },
// KMK -->
@ -141,13 +139,12 @@ data object HistoryTab : Tab {
)
}
is HistoryScreenModel.Dialog.Migrate -> {
MigrateDialog(
oldManga = dialog.oldManga,
newManga = dialog.newManga,
screenModel = rememberScreenModel { 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 -> {}

View file

@ -69,8 +69,6 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreen
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.SourcesScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedScreen
@ -104,6 +102,7 @@ import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.feature.migration.config.MigrationConfigScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchUI
@ -513,13 +512,12 @@ class MangaScreen(
}
is MangaScreenModel.Dialog.Migrate -> {
MigrateDialog(
oldManga = dialog.oldManga,
newManga = dialog.newManga,
screenModel = rememberScreenModel { 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(

View file

@ -1898,7 +1898,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
// SY -->
@ -1937,7 +1937,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>) {

View file

@ -24,11 +24,10 @@ import eu.kanade.presentation.components.BulkSelectionToolbar
import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
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
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.sy.SYMR
@ -167,13 +166,12 @@ class MangaDexFollowsScreen(private val sourceId: Long) : Screen() {
}
// KMK -->
is BrowseSourceScreenModel.Dialog.Migrate -> {
MigrateDialog(
oldManga = dialog.oldManga,
newManga = dialog.newManga,
screenModel = rememberScreenModel { 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() },
)
}
// KMK <--

View file

@ -0,0 +1,36 @@
package mihon.domain.migration.models
enum class MigrationFlag(val flag: Int) {
CHAPTER(0b00001),
CATEGORY(0b00010),
// KMK -->
TRACK(0b00100),
// KMK <--
// 0b00100 was used for manga trackers
CUSTOM_COVER(0b01000),
NOTES(0b100000),
REMOVE_DOWNLOAD(0b10000),
// KMK -->
EXTRA(0b1000000),
// KMK <--
;
companion object {
fun fromBit(bit: Int): Set<MigrationFlag> {
return buildSet {
entries.forEach { entry ->
if (bit and entry.flag != 0) add(entry)
}
}
}
fun toBit(flags: Set<MigrationFlag>): Int {
return flags.map { it.flag }
.reduceOrNull { acc, mask -> acc or mask }
?: 0
}
}
}

View file

@ -0,0 +1,196 @@
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.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.model.HistoryUpdate
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,
// KMK -->
private val getHistory: GetHistory,
private val upsertHistory: UpsertHistory,
// KMK <--
) {
private val enhancedServices by lazy { trackerManager.trackers.filterIsInstance<EnhancedTracker>() }
suspend operator fun invoke(
current: Manga,
target: Manga,
replace: Boolean,
// KMK -->
presetFlags: Set<MigrationFlag>? = null,
// KMK <--
) {
val targetSource = sourceManager.get(target.source) ?: return
val currentSource = sourceManager.get(current.source)
val flags = /* KMK --> */ presetFlags ?: /* KMK <-- */ 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 }
// SY -->
val historyUpdates = mutableListOf<HistoryUpdate>()
val prevHistoryList = getHistory.await(current.id)
// SY <--
// KMK -->
.associateBy { it.chapterId }
// KMK <--
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(
// SY -->
// If chapters match then mark new manga's chapters read/unread as old one
read = prevChapter.read,
// SY <--
dateFetch = prevChapter.dateFetch,
bookmark = prevChapter.bookmark,
)
// SY -->
// KMK -->
prevHistoryList[prevChapter.id]?.let { prevHistory ->
// KMK <--
historyUpdates += HistoryUpdate(
mangaChapter.id,
prevHistory.readAt ?: return@let,
prevHistory.readDuration,
)
}
// SY <--
}
// KMK -->
// If chapters which only present on new manga then mark read up to latest read chapter number
else /* KMK <-- */ if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) {
updatedChapter = updatedChapter.copy(read = true)
}
}
updatedChapter
}
val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates)
// SY -->
upsertHistory.awaitAll(historyUpdates)
// SY <--
}
// Update categories
if (MigrationFlag.CATEGORY in flags) {
val categoryIds = getCategories.await(current.id).map { it.id }
setMangaCategories.await(target.id, categoryIds)
}
// Update track
// SY -->
if (MigrationFlag.TRACK in flags) {
// SY <--
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
// KMK -->
.takeIf { MigrationFlag.EXTRA in flags },
// KMK <--
viewerFlags = current.viewerFlags
// KMK -->
.takeIf { MigrationFlag.EXTRA in flags },
// KMK <--
dateAdded = if (replace) current.dateAdded else Instant.now().toEpochMilli(),
notes = if (MigrationFlag.NOTES in flags) current.notes else null,
)
updateManga.awaitAll(listOfNotNull(currentMangaUpdate, targetMangaUpdate))
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
}
}
}

View file

@ -0,0 +1,22 @@
package mihon.feature.common.utils
import dev.icerock.moko.resources.StringResource
import mihon.domain.migration.models.MigrationFlag
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
fun MigrationFlag.getLabel(): StringResource {
return when (this) {
MigrationFlag.CHAPTER -> MR.strings.chapters
MigrationFlag.CATEGORY -> MR.strings.categories
// KMK -->
MigrationFlag.TRACK -> MR.strings.track
// KMK <--
MigrationFlag.CUSTOM_COVER -> MR.strings.custom_cover
MigrationFlag.NOTES -> MR.strings.action_notes
MigrationFlag.REMOVE_DOWNLOAD -> MR.strings.delete_downloaded
// KMK -->
MigrationFlag.EXTRA -> SYMR.strings.log_extra
// KMK <--
}
}

View file

@ -0,0 +1,178 @@
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 = {
// KMK -->
// Allow `migrate` mangas when using `Bulk-favorite`
// onDismissRequest()
// KMK <--
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,
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
// KMK -->
MigrationFlag.TRACK -> true
// KMK <--
MigrationFlag.CUSTOM_COVER -> current.hasCustomCover(coverCache)
MigrationFlag.NOTES -> current.notes.isNotBlank()
MigrationFlag.REMOVE_DOWNLOAD -> downloadManager.getDownloadCount(current) > 0
// KMK -->
MigrationFlag.EXTRA -> true
// KMK <--
}
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) {
// KMK -->
// sourcePreference.migrationFlags().set(state.value.selectedFlags)
// KMK <--
mutableState.update { it.copy(isMigrating = true) }
migrateManga(current, target, replace, /* KMK --> */ state.value.selectedFlags /* KMK <-- */)
mutableState.update { it.copy(isMigrating = false) }
}
data class State(
val applicableFlags: List<MigrationFlag> = emptyList(),
val selectedFlags: Set<MigrationFlag> = emptySet(),
val isMigrating: Boolean = false,
)
}

View file

@ -171,13 +171,13 @@ sealed class AndroidPreference<T>(
}
}
class Object<T>(
class ObjectAsString<T>(
preferences: SharedPreferences,
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))
}
}
}

View file

@ -9,7 +9,8 @@ import tachiyomi.core.common.preference.AndroidPreference.BooleanPrimitive
import tachiyomi.core.common.preference.AndroidPreference.FloatPrimitive
import tachiyomi.core.common.preference.AndroidPreference.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,

View file

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

View file

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

View file

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