Mass migration implementation (mihonapp/mihon#2110)

There is no way to trigger mass migration at the moment. The functionality will be added in a follow up PR.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit ee19050cc00b0a787af65b641624d07bb194e155)

* Fix background crash in mass migration screen

(cherry picked from commit 63943debc2fd4efa1a0418bbfefaea93a24b49fd)

* Fix same manga check logic in mass migration

(cherry picked from commit f1193866f4306384a2a466c6353446bfed2bd9aa)

* Fix migration progress not updating and category flag mischeck (mihonapp/mihon#2484)

- Fixed an issue where migration progress wasn't updated after a manual source search
- Fixed incorrect logic where the category migration flag was ignored due to checking the chapter flag instead

(cherry picked from commit 16b5317b90b3064d12aa38f687cc30110fd8cdb3)

* Fix mass migration advanced search query building (mihonapp/mihon#2629)

(cherry picked from commit 7c08b75555a5444ede4912dc5e32607fac2b9678)

* Fix mass migration not using the same search queries as individual migration (mihonapp/mihon#2736)

(cherry picked from commit 7161bc2e825bdfd66a1829f7dce42bd0570b1008)

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
jobobby04 2025-06-13 07:15:29 -04:00 committed by Cuong-Tran
parent bc581e1d4b
commit 935f447fbb
21 changed files with 628 additions and 628 deletions

View file

@ -305,6 +305,9 @@ dependencies {
// Shizuku // Shizuku
implementation(libs.bundles.shizuku) implementation(libs.bundles.shizuku)
// String similarity
implementation(libs.stringSimilarity)
// Tests // Tests
testImplementation(libs.bundles.test) testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)
@ -316,9 +319,6 @@ dependencies {
testImplementation(kotlinx.coroutines.test) testImplementation(kotlinx.coroutines.test)
// SY --> // SY -->
// Text distance (EH)
implementation(sylibs.similarity)
// Better logging (EH) // Better logging (EH)
implementation(sylibs.xlog) implementation(sylibs.xlog)

View file

@ -38,7 +38,7 @@ data class MigrateMangaScreen(
navigateUp = navigator::pop, navigateUp = navigator::pop,
title = state.source?.name ?: "???", title = state.source?.name ?: "???",
state = state, state = state,
onClickItem = { navigator.push(MigrationConfigScreen(listOf(it.id))) }, onClickItem = { navigator.push(MigrationConfigScreen(it.id)) },
onClickCover = { navigator.push(MangaScreen(it.id)) }, onClickCover = { navigator.push(MangaScreen(it.id)) },
// KMK --> // KMK -->
onMultiMigrateClicked = { onMultiMigrateClicked = {

View file

@ -11,7 +11,9 @@ import eu.kanade.presentation.browse.MigrateSearchScreen
import eu.kanade.presentation.browse.components.BulkFavoriteDialogs import eu.kanade.presentation.browse.components.BulkFavoriteDialogs
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.feature.migration.list.MigrationListScreen import mihon.feature.migration.list.MigrationListScreen
class MigrateSearchScreen(private val mangaId: Long) : Screen() { class MigrateSearchScreen(private val mangaId: Long) : Screen() {
@ -48,13 +50,16 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
bulkFavoriteScreenModel.toggleSelection(it) bulkFavoriteScreenModel.toggleSelection(it)
} else { } else {
// KMK <-- // KMK <--
// SY --> val migrateListScreen = navigator.items
navigator.items
.filterIsInstance<MigrationListScreen>() .filterIsInstance<MigrationListScreen>()
.last() .lastOrNull()
.matchOverride = mangaId to it.id
navigator.popUntil { screen -> screen is MigrationListScreen } if (migrateListScreen == null) {
// SY <-- screenModel.setMigrateDialog(mangaId, it)
} else {
migrateListScreen.addMatchOverride(current = mangaId, target = it.id)
navigator.popUntil { screen -> screen is MigrationListScreen }
}
} }
}, },
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) }, onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
@ -64,6 +69,28 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
// KMK <-- // KMK <--
) )
when (val dialog = state.dialog) {
is SearchScreenModel.Dialog.Migrate -> {
MigrateMangaDialog(
current = dialog.current,
target = dialog.target,
// Initiated from the context of [dialog.current] so we show [dialog.target].
onClickTitle = { navigator.push(MangaScreen(dialog.target.id, true)) },
onDismissRequest = { screenModel.clearDialog() },
onComplete = {
if (navigator.lastItem is MangaScreen) {
val lastItem = navigator.lastItem
navigator.popUntil { navigator.items.contains(lastItem) }
navigator.push(MangaScreen(dialog.target.id))
} else {
navigator.replace(MangaScreen(dialog.target.id))
}
},
)
}
else -> {}
}
// KMK --> // KMK -->
// Bulk-favorite actions only // Bulk-favorite actions only
BulkFavoriteDialogs( BulkFavoriteDialogs(

View file

@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@ -33,10 +34,13 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog
import eu.kanade.tachiyomi.ui.home.HomeScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.feature.migration.list.MigrationListScreen import mihon.feature.migration.list.MigrationListScreen
import mihon.presentation.core.util.collectAsLazyPagingItems import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
@ -66,6 +70,7 @@ data class MigrateSourceSearchScreen(
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId, query) } val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId, query) }
val state by screenModel.state.collectAsState() val state by screenModel.state.collectAsState()
@ -142,13 +147,16 @@ data class MigrateSourceSearchScreen(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues -> ) { paddingValues ->
val openMigrateDialog: (Manga) -> Unit = { val openMigrateDialog: (Manga) -> Unit = {
// SY --> val migrateListScreen = navigator.items
navigator.items
.filterIsInstance<MigrationListScreen>() .filterIsInstance<MigrationListScreen>()
.last() .lastOrNull()
.matchOverride = currentManga.id to it.id
navigator.popUntil { it is MigrationListScreen } if (migrateListScreen == null) {
// SY <-- screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga))
} else {
migrateListScreen.addMatchOverride(current = currentManga.id, target = it.id)
navigator.popUntil { screen -> screen is MigrationListScreen }
}
} }
BrowseSourceContent( BrowseSourceContent(
source = screenModel.source, source = screenModel.source,
@ -189,7 +197,7 @@ data class MigrateSourceSearchScreen(
} }
val onDismissRequest = { screenModel.setDialog(null) } val onDismissRequest = { screenModel.setDialog(null) }
when (state.dialog) { when (val dialog = state.dialog) {
is BrowseSourceScreenModel.Dialog.Filter -> { is BrowseSourceScreenModel.Dialog.Filter -> {
SourceFilterDialog( SourceFilterDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -216,6 +224,22 @@ data class MigrateSourceSearchScreen(
// SY <-- // SY <--
) )
} }
is BrowseSourceScreenModel.Dialog.Migrate -> {
MigrateMangaDialog(
current = currentManga,
target = dialog.target,
// Initiated from the context of [currentManga] so we show [dialog.target].
onClickTitle = { navigator.push(MangaScreen(dialog.target.id)) },
onDismissRequest = onDismissRequest,
onComplete = {
scope.launch {
navigator.popUntilRoot()
HomeScreen.openTab(HomeScreen.Tab.Browse())
navigator.push(MangaScreen(dialog.target.id))
}
},
)
}
else -> {} else -> {}
} }

View file

@ -22,7 +22,7 @@ class LibraryUpdateErrorScreen : Screen() {
LibraryUpdateErrorScreen( LibraryUpdateErrorScreen(
state = state, state = state,
onClick = { item -> onClick = { item ->
navigator.push(MigrationConfigScreen(listOf(item.error.mangaId))) navigator.push(MigrationConfigScreen(item.error.mangaId))
}, },
onClickCover = { item -> navigator.push(MangaScreen(item.error.mangaId)) }, onClickCover = { item -> navigator.push(MangaScreen(item.error.mangaId)) },
onMultiMigrateClicked = { onMultiMigrateClicked = {

View file

@ -365,7 +365,7 @@ class MangaScreen(
successState.manga.favorite successState.manga.favorite
}, },
onMigrateClicked = { onMigrateClicked = {
navigator.push(MigrationConfigScreen(listOf(successState.manga.id))) navigator.push(MigrationConfigScreen(successState.manga.id))
}.takeIf { successState.manga.favorite }, }.takeIf { successState.manga.favorite },
// SY --> // SY -->
previewsRowCount = successState.previewsRowCount, previewsRowCount = successState.previewsRowCount,

View file

@ -19,7 +19,7 @@ class SmartSearchScreenModel(
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
sourceManager: SourceManager = Injekt.get(), sourceManager: SourceManager = Injekt.get(),
) : StateScreenModel<SmartSearchScreenModel.SearchResults?>(null) { ) : StateScreenModel<SmartSearchScreenModel.SearchResults?>(null) {
private val smartSearchEngine = SmartSourceSearchEngine() private val smartSearchEngine = SmartSourceSearchEngine(null)
val source = sourceManager.get(sourceId) as CatalogueSource val source = sourceManager.get(sourceId) as CatalogueSource

View file

@ -93,6 +93,8 @@ import uy.kohesive.injekt.api.get
*/ */
class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() { class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() {
constructor(mangaId: Long) : this(listOf(mangaId))
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
@ -110,17 +112,22 @@ class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() {
var migrationSheetOpen by rememberSaveable { mutableStateOf(false) } var migrationSheetOpen by rememberSaveable { mutableStateOf(false) }
fun continueMigration(openSheet: Boolean, extraSearchQuery: String?) { fun continueMigration(openSheet: Boolean, extraSearchQuery: String?) {
// KMK -->
// val mangaId = mangaIds.singleOrNull()
// if (mangaId == null && openSheet) {
if (openSheet) { if (openSheet) {
// KMK <--
migrationSheetOpen = true migrationSheetOpen = true
return return
} }
val screen = // KMK --> if (mangaId == null) {
navigator.replace( MigrationListScreen(mangaIds, extraSearchQuery)
// KMK --> // KMK -->
// MigrateSearchScreen(mangaId) // } else {
MigrationListScreen(mangaIds, extraSearchQuery), // MigrateSearchScreen(mangaId)
// KMK <-- // }
) // KMK <--
navigator.replace(screen)
} }
if (state.isLoading) { if (state.isLoading) {

View file

@ -1,5 +1,6 @@
package mihon.feature.migration.list package mihon.feature.migration.list
import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -13,96 +14,77 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import exh.util.overEq
import exh.util.underEq
import kotlinx.collections.immutable.persistentListOf
import mihon.feature.migration.config.MigrationConfigScreen import mihon.feature.migration.config.MigrationConfigScreen
import mihon.feature.migration.config.MigrationConfigScreenSheet import mihon.feature.migration.config.MigrationConfigScreenSheet
import mihon.feature.migration.list.components.MigrationExitDialog import mihon.feature.migration.list.components.MigrationExitDialog
import mihon.feature.migration.list.components.MigrationMangaDialog import mihon.feature.migration.list.components.MigrationMangaDialog
import mihon.feature.migration.list.components.MigrationProgressDialog import mihon.feature.migration.list.components.MigrationProgressDialog
import mihon.feature.migration.list.models.MigratingManga import mihon.feature.migration.list.models.MigratingManga
import tachiyomi.core.common.i18n.pluralStringResource import tachiyomi.i18n.MR
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.i18n.sy.SYMR
/** /**
* Screen showing a list of pair of current-target manga entries being migrated. * Screen showing a list of pair of current-target manga entries being migrated.
*/ */
class MigrationListScreen(private val mangaIds: List<Long>, private val extraSearchQuery: String?) : Screen() { class MigrationListScreen(private val mangaIds: List<Long>, private val extraSearchQuery: String?) : Screen() {
var matchOverride: Pair<Long, Long>? = null private var matchOverride: Pair<Long, Long>? = null
fun addMatchOverride(current: Long, target: Long) {
matchOverride = current to target
}
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { MigrationListScreenModel(mangaIds, extraSearchQuery) } val screenModel = rememberScreenModel { MigrationListScreenModel(mangaIds, extraSearchQuery) }
val state by screenModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val items by screenModel.migratingItems.collectAsState()
val migrationComplete by screenModel.migrationDone.collectAsState()
val finishedCount by screenModel.finishedCount.collectAsState()
val dialog by screenModel.dialog.collectAsState()
val migrateProgress by screenModel.migratingProgress.collectAsState()
LaunchedEffect(matchOverride) { LaunchedEffect(matchOverride) {
if (matchOverride != null) { val (current, target) = matchOverride ?: return@LaunchedEffect
val (oldId, newId) = matchOverride!! screenModel.useMangaForMigration(
screenModel.useMangaForMigration(context, newId, oldId) current = current,
matchOverride = null target = target,
} onMissingChapters = {
context.toast(MR.strings.migrationListScreen_matchWithoutChapterToast, Toast.LENGTH_LONG)
},
)
matchOverride = null
} }
LaunchedEffect(screenModel) { LaunchedEffect(screenModel) {
screenModel.navigateOut.collect { screenModel.navigateBackEvent.collect {
if (items.orEmpty().size == 1 && navigator.items.any { it is MangaScreen }) { // KMK -->
val mangaId = (items.orEmpty().firstOrNull()?.searchResult?.value as? MigratingManga.SearchResult.Success)?.id /* If this screen is called from single manga migration, replace the MangaScreen in the backstack
withUIContext { with the newly migrated manga to reflect the changes properly.
if (mangaId != null) { Otherwise, just pop normally. */
val newStack = navigator.items.filter { if (mangaIds.size == 1 && navigator.items.any { it is MangaScreen }) {
it !is MangaScreen && val mangaId = (state.items.firstOrNull()?.searchResult?.value as? MigratingManga.SearchResult.Success)?.manga?.id
it !is MigrationListScreen && if (mangaId != null) {
it !is MigrationConfigScreen val newStack = navigator.items.filter {
} + MangaScreen(mangaId) it !is MangaScreen &&
navigator replaceAll newStack.first() it !is MigrationListScreen &&
navigator.push(newStack.drop(1)) it !is MigrationConfigScreen
} + MangaScreen(mangaId)
navigator replaceAll newStack.first()
navigator.push(newStack.drop(1))
// need to set the navigator in a pop state to dispose of everything properly // need to set the navigator in a pop state to dispose of everything properly
navigator.push(this@MigrationListScreen) navigator.push(this@MigrationListScreen)
navigator.pop() navigator.pop()
} else { } else {
navigator.pop()
}
}
} else {
withUIContext {
navigator.pop() navigator.pop()
} }
} } else {
} // KMK <--
}
LaunchedEffect(items) {
if (items?.isEmpty() == true) {
val manualMigrations = screenModel.manualMigrations.value
context.toast(
context.pluralStringResource(
SYMR.plurals.entry_migrated,
manualMigrations,
manualMigrations,
),
)
if (!screenModel.hideUnmatched) {
navigator.pop() navigator.pop()
} }
} }
} }
MigrationListScreenContent( MigrationListScreenContent(
items = items ?: persistentListOf(), items = state.items,
migrationComplete = migrationComplete, migrationComplete = state.migrationComplete,
finishedCount = finishedCount, finishedCount = state.finishedCount,
getManga = screenModel::getManga,
getChapterInfo = screenModel::getChapterInfo,
getSourceName = screenModel::getSourceName,
onItemClick = { onItemClick = {
navigator.push(MangaScreen(it.id, true)) navigator.push(MangaScreen(it.id, true))
}, },
@ -110,34 +92,40 @@ class MigrationListScreen(private val mangaIds: List<Long>, private val extraSea
navigator push MigrateSearchScreen(migrationItem.manga.id) navigator push MigrateSearchScreen(migrationItem.manga.id)
}, },
onSkip = { screenModel.removeManga(it) }, onSkip = { screenModel.removeManga(it) },
onMigrate = { screenModel.migrateNow(it, true) }, onMigrate = { screenModel.migrateNow(mangaId = it, replace = true) },
onCopy = { screenModel.migrateNow(it, false) }, onCopy = { screenModel.migrateNow(mangaId = it, replace = false) },
openMigrationDialog = screenModel::showMigrateDialog, openMigrationDialog = screenModel::showMigrateDialog,
// KMK --> // KMK -->
onCancel = { screenModel.cancelManga(it) }, onCancel = { screenModel.cancelManga(it) },
navigateUp = { navigator.pop() },
openOptionsDialog = screenModel::openOptionsDialog, openOptionsDialog = screenModel::openOptionsDialog,
// KMK <-- // KMK <--
) )
val onDismissRequest = { screenModel.dialog.value = null } when (val dialog = state.dialog) {
when (
@Suppress("NAME_SHADOWING")
val dialog = dialog
) {
is MigrationListScreenModel.Dialog.Migrate -> { is MigrationListScreenModel.Dialog.Migrate -> {
MigrationMangaDialog( MigrationMangaDialog(
onDismissRequest = onDismissRequest, onDismissRequest = screenModel::dismissDialog,
copy = dialog.copy, copy = dialog.copy,
totalCount = dialog.totalCount, totalCount = dialog.totalCount,
skippedCount = dialog.skippedCount, skippedCount = dialog.skippedCount,
copyManga = screenModel::copyMangas, onMigrate = {
onMigrate = screenModel::migrateMangas, if (dialog.copy) {
screenModel.copyMangas()
} else {
screenModel.migrateMangas()
}
},
)
}
is MigrationListScreenModel.Dialog.Progress -> {
MigrationProgressDialog(
progress = dialog.progress,
exitMigration = screenModel::cancelMigrate,
) )
} }
MigrationListScreenModel.Dialog.Exit -> { MigrationListScreenModel.Dialog.Exit -> {
MigrationExitDialog( MigrationExitDialog(
onDismissRequest = onDismissRequest, onDismissRequest = screenModel::dismissDialog,
exitMigration = navigator::pop, exitMigration = navigator::pop,
) )
} }
@ -145,9 +133,9 @@ class MigrationListScreen(private val mangaIds: List<Long>, private val extraSea
MigrationListScreenModel.Dialog.Options -> { MigrationListScreenModel.Dialog.Options -> {
MigrationConfigScreenSheet( MigrationConfigScreenSheet(
preferences = screenModel.preferences, preferences = screenModel.preferences,
onDismissRequest = onDismissRequest, onDismissRequest = screenModel::dismissDialog,
onStartMigration = { _ -> onStartMigration = { _ ->
onDismissRequest() screenModel.dismissDialog()
screenModel.updateOptions() screenModel.updateOptions()
}, },
fullSettings = false, fullSettings = false,
@ -157,15 +145,8 @@ class MigrationListScreen(private val mangaIds: List<Long>, private val extraSea
null -> Unit null -> Unit
} }
if (!migrateProgress.isNaN() && migrateProgress overEq 0f && migrateProgress underEq 1f) {
MigrationProgressDialog(
progress = migrateProgress,
exitMigration = screenModel::cancelMigrate,
)
}
BackHandler(true) { BackHandler(true) {
screenModel.dialog.value = MigrationListScreenModel.Dialog.Exit screenModel.showExitDialog()
} }
} }
} }

View file

@ -37,8 +37,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -47,28 +47,26 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.manga.components.MangaCover import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.presentation.util.animateItemFastScroll import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.formatChapterNumber
import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import mihon.feature.migration.list.models.MigratingManga import mihon.feature.migration.list.models.MigratingManga
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.Badge import tachiyomi.presentation.core.components.Badge
import tachiyomi.presentation.core.components.BadgeGroup import tachiyomi.presentation.core.components.BadgeGroup
import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.components.material.topSmallPaddingValues import tachiyomi.presentation.core.components.material.topSmallPaddingValues
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus import tachiyomi.presentation.core.util.plus
@ -78,9 +76,6 @@ fun MigrationListScreenContent(
items: ImmutableList<MigratingManga>, items: ImmutableList<MigratingManga>,
migrationComplete: Boolean, migrationComplete: Boolean,
finishedCount: Int, finishedCount: Int,
getManga: suspend (MigratingManga.SearchResult.Success) -> Manga?,
getChapterInfo: suspend (MigratingManga.SearchResult.Success) -> MigratingManga.ChapterInfo,
getSourceName: (Manga) -> String,
onItemClick: (Manga) -> Unit, onItemClick: (Manga) -> Unit,
onSearchManually: (MigratingManga) -> Unit, onSearchManually: (MigratingManga) -> Unit,
onSkip: (Long) -> Unit, onSkip: (Long) -> Unit,
@ -89,23 +84,17 @@ fun MigrationListScreenContent(
openMigrationDialog: (Boolean) -> Unit, openMigrationDialog: (Boolean) -> Unit,
// KMK --> // KMK -->
onCancel: (Long) -> Unit, onCancel: (Long) -> Unit,
navigateUp: () -> Unit,
openOptionsDialog: () -> Unit, openOptionsDialog: () -> Unit,
// KMK <-- // KMK <--
) { ) {
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
val titleString = stringResource(SYMR.strings.migration)
val title by produceState(initialValue = titleString, items, finishedCount, titleString) {
withIOContext {
value = "$titleString ($finishedCount/${items.size})"
}
}
AppBar( AppBar(
title = title, title = if (items.isNotEmpty()) {
// KMK --> stringResource(MR.strings.migrationListScreenTitleWithProgress, finishedCount, items.size)
navigateUp = navigateUp, } else {
// KMK <-- stringResource(MR.strings.migrationListScreenTitle)
},
actions = { actions = {
AppBarActions( AppBarActions(
persistentListOf( persistentListOf(
@ -117,13 +106,13 @@ fun MigrationListScreenContent(
), ),
// KMK <-- // KMK <--
AppBar.Action( AppBar.Action(
title = stringResource(MR.strings.copy), title = stringResource(MR.strings.migrationListScreen_copyActionLabel),
icon = if (items.size == 1) Icons.Outlined.ContentCopy else Icons.Outlined.CopyAll, icon = if (items.size == 1) Icons.Outlined.ContentCopy else Icons.Outlined.CopyAll,
onClick = { openMigrationDialog(true) }, onClick = { openMigrationDialog(true) },
enabled = migrationComplete, enabled = migrationComplete,
), ),
AppBar.Action( AppBar.Action(
title = stringResource(MR.strings.migrate), title = stringResource(MR.strings.migrationListScreen_migrateActionLabel),
icon = if (items.size == 1) Icons.Outlined.Done else Icons.Outlined.DoneAll, icon = if (items.size == 1) Icons.Outlined.Done else Icons.Outlined.DoneAll,
onClick = { openMigrationDialog(false) }, onClick = { openMigrationDialog(false) },
enabled = migrationComplete, enabled = migrationComplete,
@ -136,52 +125,49 @@ fun MigrationListScreenContent(
}, },
) { contentPadding -> ) { contentPadding ->
FastScrollLazyColumn(contentPadding = contentPadding + topSmallPaddingValues) { FastScrollLazyColumn(contentPadding = contentPadding + topSmallPaddingValues) {
items(items, key = { "migration-list-${it.manga.id}" }) { item -> items(items, key = { it.manga.id }) { item ->
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.animateItemFastScroll() .animateItemFastScroll()
.padding(horizontal = 16.dp) .padding(
start = MaterialTheme.padding.medium,
end = MaterialTheme.padding.small,
)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MigrationListItem( MigrationListItem(
modifier = Modifier modifier = Modifier
.padding(top = 8.dp)
.weight(1f) .weight(1f)
.align(Alignment.Top) .align(Alignment.Top)
.fillMaxHeight(), .fillMaxHeight(),
manga = item.manga, manga = item.manga,
source = item.source, source = item.source,
chapterInfo = item.chapterInfo, chapterCount = item.chapterCount,
latestChapter = item.latestChapter,
onClick = { onItemClick(item.manga) }, onClick = { onItemClick(item.manga) },
) )
Icon( Icon(
Icons.AutoMirrored.Outlined.ArrowForward, imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = stringResource(SYMR.strings.migrating_to), contentDescription = null,
modifier = Modifier.weight(0.2f), modifier = Modifier.weight(0.2f),
) )
val result by item.searchResult.collectAsState() val result by item.searchResult.collectAsState()
MigrationListItemResult( MigrationListItemResult(
modifier = Modifier modifier = Modifier
.padding(top = 8.dp)
.weight(1f) .weight(1f)
.align(Alignment.Top) .align(Alignment.Top)
.fillMaxHeight(), .fillMaxHeight(),
migrationItem = item,
result = result, result = result,
getManga = getManga,
getChapterInfo = getChapterInfo,
getSourceName = getSourceName,
onItemClick = onItemClick, onItemClick = onItemClick,
) )
MigrationListItemAction( MigrationListItemAction(
modifier = Modifier modifier = Modifier.weight(0.2f),
.weight(0.2f),
result = result, result = result,
onSearchManually = { onSearchManually(item) }, onSearchManually = { onSearchManually(item) },
onSkip = { onSkip(item.manga.id) }, onSkip = { onSkip(item.manga.id) },
@ -202,162 +188,140 @@ fun MigrationListItem(
modifier: Modifier, modifier: Modifier,
manga: Manga, manga: Manga,
source: String, source: String,
chapterInfo: MigratingManga.ChapterInfo, chapterCount: Int,
latestChapter: Double?,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Column( Column(
modifier modifier = modifier
.widthIn(max = 150.dp) .widthIn(max = 150.dp)
.fillMaxWidth() .fillMaxWidth()
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(4.dp), .padding(4.dp),
) { ) {
val context = LocalContext.current
Box( Box(
Modifier.Companion.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio), .aspectRatio(MangaCover.Book.ratio),
) { ) {
MangaCover.Book( MangaCover.Book(
modifier = Modifier.Companion modifier = Modifier.fillMaxWidth(),
.fillMaxWidth(),
data = manga, data = manga,
) )
Box( Box(
modifier = Modifier.Companion modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp)) .clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
.background( .background(
Brush.Companion.verticalGradient( Brush.verticalGradient(
0f to Color.Companion.Transparent, 0f to Color.Transparent,
1f to Color(0xAA000000), 1f to Color(0xAA000000),
), ),
) )
.fillMaxHeight(0.33f) .fillMaxHeight(0.33f)
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.Companion.BottomCenter), .align(Alignment.BottomCenter),
) )
Text( Text(
modifier = Modifier.Companion modifier = Modifier
.padding(8.dp) .padding(8.dp)
.align(Alignment.Companion.BottomStart), .align(Alignment.BottomStart),
text = manga.title.ifBlank { stringResource(MR.strings.unknown) }, text = manga.title,
fontSize = 12.sp, overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Companion.Ellipsis, style = MaterialTheme.typography.labelMedium
style = MaterialTheme.typography.titleSmall.copy( .merge(shadow = Shadow(color = Color.Black, blurRadius = 4f)),
color = Color.Companion.White,
shadow = Shadow(
color = Color.Companion.Black,
blurRadius = 4f,
),
),
) )
BadgeGroup(modifier = Modifier.Companion.padding(4.dp)) { BadgeGroup(modifier = Modifier.padding(4.dp)) {
Badge(text = "${chapterInfo.chapterCount}") Badge(text = "$chapterCount")
} }
} }
Text(
text = source,
modifier = Modifier.Companion.padding(top = 4.dp, bottom = 1.dp, start = 8.dp),
overflow = TextOverflow.Companion.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.titleSmall,
)
val formattedLatestChapter by produceState(initialValue = "") { Column(
value = withIOContext { modifier = Modifier
chapterInfo.getFormattedLatestChapter(context) .padding(MaterialTheme.padding.extraSmall),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = source,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.titleSmall,
)
val formattedLatestChapters = remember(latestChapter) {
latestChapter?.let(::formatChapterNumber)
} }
Text(
text = stringResource(
MR.strings.migrationListScreen_latestChapterLabel,
formattedLatestChapters ?: stringResource(MR.strings.migrationListScreen_unknownLatestChapter),
),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
)
} }
Text(
text = formattedLatestChapter,
modifier = Modifier.Companion.padding(top = 1.dp, bottom = 4.dp, start = 8.dp),
overflow = TextOverflow.Companion.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
)
} }
} }
@Composable @Composable
fun MigrationListItemResult( fun MigrationListItemResult(
modifier: Modifier, modifier: Modifier,
migrationItem: MigratingManga,
result: MigratingManga.SearchResult, result: MigratingManga.SearchResult,
getManga: suspend (MigratingManga.SearchResult.Success) -> Manga?,
getChapterInfo: suspend (MigratingManga.SearchResult.Success) -> MigratingManga.ChapterInfo,
getSourceName: (Manga) -> String,
onItemClick: (Manga) -> Unit, onItemClick: (Manga) -> Unit,
) { ) {
Box(modifier.height(IntrinsicSize.Min)) { Box(modifier.height(IntrinsicSize.Min)) {
when (result) { when (result) {
MigratingManga.SearchResult.Searching -> Box( MigratingManga.SearchResult.Searching -> {
modifier = Modifier.Companion Box(
.widthIn(max = 150.dp) modifier = Modifier
.fillMaxSize() .widthIn(max = 150.dp)
.aspectRatio(MangaCover.Book.ratio), .fillMaxSize()
contentAlignment = Alignment.Companion.Center, .aspectRatio(MangaCover.Book.ratio),
) { contentAlignment = Alignment.Center,
CircularProgressIndicator()
}
MigratingManga.SearchResult.NotFound -> Column(
Modifier.Companion
.widthIn(max = 150.dp)
.fillMaxSize()
.padding(top = 4.dp),
) {
Image(
painter = rememberResourceBitmapPainter(id = R.drawable.cover_error),
contentDescription = null,
modifier = Modifier.Companion
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio)
.clip(MaterialTheme.shapes.extraSmall),
contentScale = ContentScale.Companion.Crop,
)
Text(
text = stringResource(SYMR.strings.no_alternatives_found),
modifier = Modifier.Companion.padding(top = 4.dp, bottom = 1.dp, start = 8.dp),
style = MaterialTheme.typography.titleSmall,
)
}
is MigratingManga.SearchResult.Success -> {
val item by produceState<Triple<Manga, MigratingManga.ChapterInfo, String>?>(
initialValue = null,
migrationItem,
result,
) { ) {
value = withIOContext { CircularProgressIndicator()
val manga = getManga(result) ?: return@withIOContext null
Triple(
manga,
getChapterInfo(result),
getSourceName(manga),
)
}
} }
if (item != null) { }
val (manga, chapterInfo, source) = item!! MigratingManga.SearchResult.NotFound -> {
MigrationListItem( Column(
modifier = Modifier.Companion.fillMaxSize(), Modifier
manga = manga, .widthIn(max = 150.dp)
source = source, .fillMaxSize()
chapterInfo = chapterInfo, .padding(4.dp),
onClick = { ) {
onItemClick(manga) Image(
}, painter = rememberResourceBitmapPainter(id = R.drawable.cover_error),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio)
.clip(MaterialTheme.shapes.extraSmall),
contentScale = ContentScale.Crop,
)
Text(
text = stringResource(MR.strings.migrationListScreen_noMatchFoundText),
modifier = Modifier.padding(MaterialTheme.padding.extraSmall),
style = MaterialTheme.typography.titleSmall,
) )
} }
} }
is MigratingManga.SearchResult.Success -> {
MigrationListItem(
modifier = Modifier.fillMaxSize(),
manga = result.manga,
source = result.source,
chapterCount = result.chapterCount,
latestChapter = result.latestChapter,
onClick = { onItemClick(result.manga) },
)
}
} }
} }
} }
@Composable @Composable
fun MigrationListItemAction( private fun MigrationListItemAction(
modifier: Modifier, modifier: Modifier,
result: MigratingManga.SearchResult, result: MigratingManga.SearchResult,
onSearchManually: () -> Unit, onSearchManually: () -> Unit,
@ -368,8 +332,8 @@ fun MigrationListItemAction(
onCancel: () -> Unit, onCancel: () -> Unit,
// KMK <-- // KMK <--
) { ) {
var moreExpanded by remember { mutableStateOf(false) } var menuExpanded by rememberSaveable { mutableStateOf(false) }
val closeMenu = { moreExpanded = false } val closeMenu = { menuExpanded = false }
Box(modifier) { Box(modifier) {
when (result) { when (result) {
MigratingManga.SearchResult.Searching -> { MigratingManga.SearchResult.Searching -> {
@ -378,49 +342,49 @@ fun MigrationListItemAction(
// KMK <-- // KMK <--
Icon( Icon(
imageVector = Icons.Outlined.Close, imageVector = Icons.Outlined.Close,
contentDescription = stringResource(SYMR.strings.action_stop), contentDescription = null,
) )
} }
} }
MigratingManga.SearchResult.NotFound, is MigratingManga.SearchResult.Success -> { MigratingManga.SearchResult.NotFound, is MigratingManga.SearchResult.Success -> {
IconButton(onClick = { moreExpanded = !moreExpanded }) { IconButton(onClick = { menuExpanded = true }) {
Icon( Icon(
imageVector = Icons.Outlined.MoreVert, imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(MR.strings.action_menu_overflow_description), contentDescription = null,
) )
} }
DropdownMenu( DropdownMenu(
expanded = moreExpanded, expanded = menuExpanded,
onDismissRequest = closeMenu, onDismissRequest = closeMenu,
offset = DpOffset(8.dp, (-56).dp), offset = DpOffset(8.dp, (-56).dp),
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_search_manually)) }, text = { Text(stringResource(MR.strings.migrationListScreen_searchManuallyActionLabel)) },
onClick = { onClick = {
onSearchManually()
closeMenu() closeMenu()
onSearchManually()
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_skip_entry)) }, text = { Text(stringResource(MR.strings.migrationListScreen_skipActionLabel)) },
onClick = { onClick = {
onSkip()
closeMenu() closeMenu()
onSkip()
}, },
) )
if (result is MigratingManga.SearchResult.Success) { if (result is MigratingManga.SearchResult.Success) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_migrate_now)) }, text = { Text(stringResource(MR.strings.migrationListScreen_migrateNowActionLabel)) },
onClick = { onClick = {
onMigrate()
closeMenu() closeMenu()
onMigrate()
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_copy_now)) }, text = { Text(stringResource(MR.strings.migrationListScreen_copyNowActionLabel)) },
onClick = { onClick = {
onCopy()
closeMenu() closeMenu()
onCopy()
}, },
) )
} }

View file

@ -1,8 +1,7 @@
package mihon.feature.migration.list package mihon.feature.migration.list
import android.content.Context import androidx.annotation.FloatRange
import android.widget.Toast import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.interactor.UpdateManga
@ -11,20 +10,22 @@ import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.getNameForMangaInfo import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.online.all.EHentai import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.util.system.toast
import exh.source.MERGED_SOURCE_ID import exh.source.MERGED_SOURCE_ID
import exh.util.ThrottleManager import exh.util.ThrottleManager
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@ -42,14 +43,11 @@ import tachiyomi.domain.manga.interactor.GetMergedReferencesById
import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.sy.SYMR
import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.concurrent.atomic.AtomicInteger
class MigrationListScreenModel( class MigrationListScreenModel(
private var mangaIds: List<Long>, mangaIds: List<Long>,
extraSearchQuery: String?, extraSearchQuery: String?,
val preferences: SourcePreferences = Injekt.get(), val preferences: SourcePreferences = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
@ -62,168 +60,149 @@ class MigrationListScreenModel(
// SY --> // SY -->
private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(), private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(),
// SY <-- // SY <--
) : ScreenModel { ) : StateScreenModel<MigrationListScreenModel.State>(State()) {
private val smartSearchEngine = SmartSourceSearchEngine(extraSearchQuery) private val smartSearchEngine = SmartSourceSearchEngine(extraSearchQuery)
// SY --> // SY -->
private val throttleManager = ThrottleManager() private val throttleManager = ThrottleManager()
// SY <-- // SY <--
val migratingItems = MutableStateFlow<ImmutableList<MigratingManga>?>(null) val items
val migrationDone = MutableStateFlow(false) inline get() = state.value.items
val finishedCount = MutableStateFlow(0)
val manualMigrations = MutableStateFlow(0) private var hideUnmatched = preferences.migrationHideUnmatched().get()
var hideUnmatched = preferences.migrationHideUnmatched().get()
private var hideWithoutUpdates = preferences.migrationHideWithoutUpdates().get() private var hideWithoutUpdates = preferences.migrationHideWithoutUpdates().get()
// KMK -->
private var prioritizeByChapters = preferences.migrationPrioritizeByChapters().get() private var prioritizeByChapters = preferences.migrationPrioritizeByChapters().get()
private var deepSearchMode = preferences.migrationDeepSearchMode().get() private var deepSearchMode = preferences.migrationDeepSearchMode().get()
// KMK <--
val navigateOut = MutableSharedFlow<Unit>() private val navigateBackChannel = Channel<Unit>()
val navigateBackEvent = navigateBackChannel.receiveAsFlow()
val dialog = MutableStateFlow<Dialog?>(null)
val migratingProgress = MutableStateFlow(Float.MAX_VALUE)
private var migrateJob: Job? = null private var migrateJob: Job? = null
init { init {
screenModelScope.launchIO { screenModelScope.launchIO {
runMigrations( val manga = mangaIds
mangaIds .map {
.map { async {
async { val manga = getManga.await(it) ?: return@async null
val manga = getManga.await(it) ?: return@async null val chapterInfo = getChapterInfo(it)
MigratingManga( MigratingManga(
manga = manga, manga = manga,
chapterInfo = getChapterInfo(it), chapterCount = chapterInfo.chapterCount,
source = sourceManager.getOrStub(manga.source).getNameForMangaInfo( latestChapter = chapterInfo.latestChapter,
if (manga.source == MERGED_SOURCE_ID) { source = sourceManager.getOrStub(manga.source).getNameForMangaInfo(
getMergedReferencesById.await(manga.id) // KMK -->
.map { sourceManager.getOrStub(it.mangaSourceId) } if (manga.source == MERGED_SOURCE_ID) {
} else { getMergedReferencesById.await(manga.id)
null .map { sourceManager.getOrStub(it.mangaSourceId) }
}, } else {
), null
parentContext = screenModelScope.coroutineContext, },
) // KMK <--
} ),
parentContext = screenModelScope.coroutineContext,
)
} }
.awaitAll() }
.filterNotNull() .awaitAll()
.also { .filterNotNull()
migratingItems.value = it.toImmutableList() mutableState.update { it.copy(items = manga.toImmutableList()) }
}, runMigrations(manga)
)
} }
} }
suspend fun getManga(result: SearchResult.Success) = getManga(result.id)
suspend fun getManga(id: Long) = getManga.await(id)
suspend fun getChapterInfo(result: SearchResult.Success) = getChapterInfo(result.id)
private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters -> private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters ->
MigratingManga.ChapterInfo( ChapterInfo(
latestChapter = chapters.maxOfOrNull { it.chapterNumber }, latestChapter = chapters.maxOfOrNull { it.chapterNumber },
chapterCount = chapters.size, chapterCount = chapters.size,
) )
} }
fun getSourceName(manga: Manga) = sourceManager.getOrStub(manga.source).getNameForMangaInfo()
fun getMigrationSources() = preferences.migrationSources().get().mapNotNull { private suspend fun Manga.toSuccessSearchResult(): SearchResult.Success {
sourceManager.get(it) as? CatalogueSource val chapterInfo = getChapterInfo(id)
val source = sourceManager.getOrStub(source).getNameForMangaInfo()
return SearchResult.Success(
manga = this,
chapterCount = chapterInfo.chapterCount,
latestChapter = chapterInfo.latestChapter,
source = source,
)
} }
private suspend fun runMigrations(mangas: List<MigratingManga>) { private suspend fun runMigrations(mangas: List<MigratingManga>) {
// KMK -->
throttleManager.resetThrottle() throttleManager.resetThrottle()
// KMK: finishedCount.value = mangas.size // val prioritizeByChapters = preferences.migrationPrioritizeByChapters().get()
// val deepSearchMode = preferences.migrationDeepSearchMode().get()
// KMK <--
val sources = preferences.migrationSources().get()
.mapNotNull { sourceManager.get(it) as? CatalogueSource }
val sources = getMigrationSources()
for (manga in mangas) { for (manga in mangas) {
if (!currentCoroutineContext().isActive) { if (!currentCoroutineContext().isActive) break
break if (manga.manga.id !in state.value.mangaIds) continue
} if (manga.searchResult.value != SearchResult.Searching) continue
// in case it was removed if (!manga.migrationScope.isActive) continue
if (manga.manga.id !in mangaIds) {
val result = try {
// KMK -->
manga.searchingJob = manga.migrationScope.async {
// KMK <--
if (prioritizeByChapters) {
val sourceSemaphore = Semaphore(5)
sources.map { source ->
async innerAsync@{
sourceSemaphore.withPermit {
val result = searchSource(manga.manga, source, deepSearchMode)
if (result == null || result.second.chapterCount == 0) return@innerAsync null
result
}
}
}
.mapNotNull { it.await() }
.maxByOrNull { it.second.latestChapter ?: 0.0 }
} else {
sources.forEach { source ->
val result = searchSource(manga.manga, source, deepSearchMode)
if (result != null) return@async result
}
null
}
}
// KMK -->
manga.searchingJob?.await()
// KMK <--
} catch (_: CancellationException) {
continue continue
} }
if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) { if (result != null && result.first.thumbnailUrl == null) {
val mangaObj = manga.manga try {
val mangaSource = sourceManager.getOrStub(mangaObj.source) val newManga = sourceManager.getOrStub(result.first.source).getMangaDetails(result.first.toSManga())
updateManga.awaitUpdateFromSource(result.first, newManga, true)
val result = try { } catch (e: CancellationException) {
// KMK --> throw e
manga.searchingJob = manga.migrationScope.async { } catch (_: Exception) {
// KMK <--
val validSources = if (sources.size == 1) {
sources
} else {
sources.filter { it.id != mangaSource.id }
}
if (prioritizeByChapters) {
val sourceSemaphore = Semaphore(3)
val processedSources = AtomicInteger()
validSources.map { source ->
async async2@{
sourceSemaphore.withPermit {
val result = searchSource(manga.manga, source, deepSearchMode)
if (result != null) {
manga.progress.value =
validSources.size to processedSources.incrementAndGet()
}
result
}
}
}.mapNotNull { it.await() }.maxByOrNull { it.second }?.first
} else {
validSources.forEachIndexed { index, source ->
val result = searchSource(manga.manga, source, deepSearchMode)
manga.progress.value = validSources.size to (index + 1)
if (result != null) return@async result.first
}
null
}
}
// KMK -->
manga.searchingJob?.await()
// KMK <--
} catch (_: CancellationException) {
// Ignore canceled migrations
continue
} }
if (result != null && result.thumbnailUrl == null) {
try {
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
updateManga.awaitUpdateFromSource(result, newManga, true)
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
Timber.tag("MigrationListScreenModel").e(e, "Error updating manga from source")
}
}
manga.searchResult.value = if (result == null) {
SearchResult.NotFound
} else {
SearchResult.Success(result.id)
}
if (result == null && hideUnmatched) {
removeManga(manga)
}
if (result != null &&
hideWithoutUpdates &&
(getChapterInfo(result.id).latestChapter ?: 0.0) <= (manga.chapterInfo.latestChapter ?: 0.0)
) {
removeManga(manga)
}
updateMigrationProgress()
} }
manga.searchResult.value = result?.first?.toSuccessSearchResult() ?: SearchResult.NotFound
if (result == null && hideUnmatched) {
removeManga(manga)
}
if (result != null &&
hideWithoutUpdates &&
(result.second.latestChapter ?: 0.0) <= (manga.latestChapter ?: 0.0)
) {
removeManga(manga)
}
updateMigrationProgress()
} }
} }
@ -231,41 +210,32 @@ class MigrationListScreenModel(
manga: Manga, manga: Manga,
source: CatalogueSource, source: CatalogueSource,
deepSearchMode: Boolean, deepSearchMode: Boolean,
): Pair<Manga, Int>? { ): Pair<Manga, ChapterInfo>? {
return try { return try {
val searchResult = if (deepSearchMode) { val searchResult = if (deepSearchMode) {
smartSearchEngine.deepSearch(source, manga.ogTitle) smartSearchEngine.deepSearch(source, manga.title)
} else { } else {
smartSearchEngine.regularSearch(source, manga.ogTitle) smartSearchEngine.regularSearch(source, manga.title)
} }
if (searchResult != null && if (searchResult == null || (searchResult.url == manga.url && source.id == manga.source)) return null
!(searchResult.url == manga.url && source.id == manga.source)
) {
val localManga = networkToLocalManga(searchResult)
val chapters = try { val localManga = networkToLocalManga(searchResult)
try {
val chapters =
// KMK -->
if (source is EHentai) { if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle) source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else { } else {
// KMK <--
source.getChapterList(localManga.toSManga()) source.getChapterList(localManga.toSManga())
} }
} catch (e: Exception) { syncChaptersWithSource.await(chapters, localManga, source)
this@MigrationListScreenModel.logcat(LogPriority.ERROR, e) } catch (e: Exception) {
return null logcat(LogPriority.ERROR, e)
}
try {
syncChaptersWithSource.await(chapters, localManga, source)
} catch (_: Exception) {
return null
}
localManga to chapters.size
} else {
null
} }
localManga to getChapterInfo(localManga.id)
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Ignore cancellations
throw e throw e
} catch (_: Exception) { } catch (_: Exception) {
null null
@ -273,30 +243,32 @@ class MigrationListScreenModel(
} }
private suspend fun updateMigrationProgress() { private suspend fun updateMigrationProgress() {
finishedCount.value = migratingItems.value.orEmpty().count { mutableState.update { state ->
it.searchResult.value != SearchResult.Searching state.copy(
// KMK -->
finishedCount = state.items.count { it.searchResult.value != SearchResult.Searching },
migrationComplete = state.migrationComplete(),
// KMK <--
)
} }
if (migrationComplete()) { if (items.isEmpty()) {
migrationDone.value = true
}
if (migratingItems.value?.isEmpty() == true) {
navigateBack() navigateBack()
} }
} }
private fun migrationComplete() = migratingItems.value.orEmpty().all { it.searchResult.value != SearchResult.Searching } && // KMK -->
migratingItems.value.orEmpty().any { it.searchResult.value is SearchResult.Success } private fun State.migrationComplete() =
// KMK <--
private fun mangasSkipped() = migratingItems.value.orEmpty().count { it.searchResult.value == SearchResult.NotFound } items.all { it.searchResult.value != SearchResult.Searching } &&
items.any { it.searchResult.value is SearchResult.Success }
/** Set a manga picked from manual search to be used as migration target */ /** Set a manga picked from manual search to be used as migration target */
fun useMangaForMigration(context: Context, newMangaId: Long, selectedMangaId: Long) { fun useMangaForMigration(current: Long, target: Long, onMissingChapters: () -> Unit) {
val migratingManga = migratingItems.value.orEmpty().find { it.manga.id == selectedMangaId } val migratingManga = items.find { it.manga.id == current } ?: return
?: return
migratingManga.searchResult.value = SearchResult.Searching migratingManga.searchResult.value = SearchResult.Searching
screenModelScope.launchIO { screenModelScope.launchIO {
val result = migratingManga.migrationScope.async { val result = migratingManga.migrationScope.async {
val manga = getManga(newMangaId)!! val manga = getManga.await(target) ?: return@async null
try { try {
val source = sourceManager.get(manga.source)!! val source = sourceManager.get(manga.source)!!
val chapters = source.getChapterList(manga.toSManga()) val chapters = source.getChapterList(manga.toSManga())
@ -305,70 +277,66 @@ class MigrationListScreenModel(
return@async null return@async null
} }
manga manga
}.await() }
.await()
if (result != null) { if (result == null) {
try {
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
updateManga.awaitUpdateFromSource(result, newManga, true)
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
Timber.tag("MigrationListScreenModel").e(e, "Error updating manga from source")
}
migratingManga.searchResult.value = SearchResult.Success(result.id)
} else {
migratingManga.searchResult.value = SearchResult.NotFound migratingManga.searchResult.value = SearchResult.NotFound
withUIContext { withUIContext { onMissingChapters() }
context.toast(SYMR.strings.no_chapters_found_for_migration, Toast.LENGTH_LONG) return@launchIO
}
} }
// KMK --> try {
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
updateManga.awaitUpdateFromSource(result, newManga, true)
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (_: Exception) {
}
migratingManga.searchResult.value = result.toSuccessSearchResult()
updateMigrationProgress() updateMigrationProgress()
// KMK <--
} }
} }
fun migrateMangas() { fun migrateMangas() {
migrateMangas(true) migrateMangas(replace = true)
} }
fun copyMangas() { fun copyMangas() {
migrateMangas(false) migrateMangas(replace = false)
} }
private fun migrateMangas(replace: Boolean) { private fun migrateMangas(replace: Boolean) {
dialog.value = null
migrateJob = screenModelScope.launchIO { migrateJob = screenModelScope.launchIO {
migratingProgress.value = 0f mutableState.update { it.copy(dialog = Dialog.Progress(0f)) }
val items = migratingItems.value.orEmpty() val items = items
try { try {
items.forEachIndexed { index, manga -> items.forEachIndexed { index, manga ->
try { try {
ensureActive() ensureActive()
val target = manga.searchResult.value.let { val target = manga.searchResult.value.let {
if (it is SearchResult.Success) { if (it is SearchResult.Success) {
getManga.await(it.id) it.manga
} else { } else {
null null
} }
} }
if (target != null) { if (target != null) {
migrateManga(manga.manga, target, replace) migrateManga(current = manga.manga, target = target, replace = replace)
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
logcat(LogPriority.WARN, throwable = e) logcat(LogPriority.WARN, throwable = e)
} }
migratingProgress.value = index.toFloat() / items.size mutableState.update {
it.copy(dialog = Dialog.Progress((index.toFloat() / items.size).coerceAtMost(1f)))
}
} }
navigateBack() navigateBack()
} finally { } finally {
migratingProgress.value = Float.MAX_VALUE mutableState.update { it.copy(dialog = null) }
migrateJob = null migrateJob = null
} }
} }
@ -380,19 +348,15 @@ class MigrationListScreenModel(
} }
private suspend fun navigateBack() { private suspend fun navigateBack() {
navigateOut.emit(Unit) navigateBackChannel.send(Unit)
} }
fun migrateNow(mangaId: Long, replace: Boolean) { fun migrateNow(mangaId: Long, replace: Boolean) {
manualMigrations.value++
screenModelScope.launchIO { screenModelScope.launchIO {
val manga = migratingItems.value.orEmpty().find { it.manga.id == mangaId } val manga = items.find { it.manga.id == mangaId } ?: return@launchIO
?: return@launchIO val target = (manga.searchResult.value as? SearchResult.Success)?.manga ?: return@launchIO
migrateManga(current = manga.manga, target = target, replace = replace)
val target = getManga.await((manga.searchResult.value as? SearchResult.Success)?.id ?: return@launchIO)
?: return@launchIO
migrateManga(manga.manga, target, replace)
removeManga(mangaId) removeManga(mangaId)
} }
} }
@ -401,8 +365,7 @@ class MigrationListScreenModel(
/** Cancel searching without remove it from list so user can perform manual search */ /** Cancel searching without remove it from list so user can perform manual search */
fun cancelManga(mangaId: Long) { fun cancelManga(mangaId: Long) {
screenModelScope.launchIO { screenModelScope.launchIO {
val item = migratingItems.value.orEmpty().find { it.manga.id == mangaId } val item = items.find { it.manga.id == mangaId } ?: return@launchIO
?: return@launchIO
item.searchingJob?.cancel() item.searchingJob?.cancel()
item.searchingJob = null item.searchingJob = null
item.searchResult.value = SearchResult.NotFound item.searchResult.value = SearchResult.NotFound
@ -413,8 +376,7 @@ class MigrationListScreenModel(
fun removeManga(mangaId: Long) { fun removeManga(mangaId: Long) {
screenModelScope.launchIO { screenModelScope.launchIO {
val item = migratingItems.value.orEmpty().find { it.manga.id == mangaId } val item = items.find { it.manga.id == mangaId } ?: return@launchIO
?: return@launchIO
removeManga(item) removeManga(item)
item.migrationScope.cancel() item.migrationScope.cancel()
updateMigrationProgress() updateMigrationProgress()
@ -422,34 +384,45 @@ class MigrationListScreenModel(
} }
private fun removeManga(item: MigratingManga) { private fun removeManga(item: MigratingManga) {
val ids = mangaIds.toMutableList() mutableState.update { it.copy(items = items.toPersistentList().remove(item)) }
val index = ids.indexOf(item.manga.id)
if (index > -1) {
ids.removeAt(index)
mangaIds = ids
val index2 = migratingItems.value.orEmpty().indexOf(item)
if (index2 > -1) migratingItems.value = (migratingItems.value.orEmpty() - item).toImmutableList()
}
} }
override fun onDispose() { override fun onDispose() {
super.onDispose() super.onDispose()
migratingItems.value.orEmpty().forEach { items.forEach {
it.migrationScope.cancel() it.migrationScope.cancel()
} }
} }
fun showMigrateDialog(copy: Boolean) { fun showMigrateDialog(copy: Boolean) {
dialog.value = Dialog.Migrate( mutableState.update { state ->
copy, state.copy(
migratingItems.value.orEmpty().size, dialog = Dialog.Migrate(
mangasSkipped(), copy = copy,
) // KMK -->
totalCount = state.items.size,
skippedCount = state.items.count { it.searchResult.value == SearchResult.NotFound },
// KMK <--
),
)
}
}
fun showExitDialog() {
mutableState.update {
it.copy(dialog = Dialog.Exit)
}
}
fun dismissDialog() {
mutableState.update { it.copy(dialog = null) }
} }
// KMK --> // KMK -->
fun openOptionsDialog() { fun openOptionsDialog() {
dialog.value = Dialog.Options mutableState.update {
it.copy(dialog = Dialog.Options)
}
} }
fun updateOptions() { fun updateOptions() {
@ -460,12 +433,27 @@ class MigrationListScreenModel(
} }
// KMK <-- // KMK <--
sealed class Dialog { data class ChapterInfo(
data class Migrate(val copy: Boolean, val totalCount: Int, val skippedCount: Int) : Dialog() val latestChapter: Double?,
data object Exit : Dialog() val chapterCount: Int,
)
sealed interface Dialog {
data class Migrate(val copy: Boolean, val totalCount: Int, val skippedCount: Int) : Dialog
data class Progress(@FloatRange(0.0, 1.0) val progress: Float) : Dialog
data object Exit : Dialog
// KMK --> // KMK -->
data object Options : Dialog() data object Options : Dialog
// KMK <-- // KMK <--
} }
data class State(
val items: ImmutableList<MigratingManga> = persistentListOf(),
val finishedCount: Int = 0,
val migrationComplete: Boolean = false,
val dialog: Dialog? = null,
) {
val mangaIds: List<Long> = items.map { it.manga.id }
}
} }

View file

@ -5,7 +5,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@Composable @Composable
@ -16,16 +15,16 @@ fun MigrationExitDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
title = { title = {
Text(text = stringResource(SYMR.strings.stop_migrating)) Text(text = stringResource(MR.strings.migrationListScreen_exitDialogTitle))
}, },
confirmButton = { confirmButton = {
TextButton(onClick = exitMigration) { TextButton(onClick = exitMigration) {
Text(text = stringResource(SYMR.strings.action_stop)) Text(text = stringResource(MR.strings.migrationListScreen_exitDialog_stopLabel))
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismissRequest) { TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel)) Text(text = stringResource(MR.strings.migrationListScreen_exitDialog_cancelLabel))
} }
}, },
) )

View file

@ -5,7 +5,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@ -15,37 +14,50 @@ fun MigrationMangaDialog(
copy: Boolean, copy: Boolean,
totalCount: Int, totalCount: Int,
skippedCount: Int, skippedCount: Int,
copyManga: () -> Unit,
onMigrate: () -> Unit, onMigrate: () -> Unit,
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
text = { title = {
Text( Text(
text = pluralStringResource( text = pluralStringResource(
if (copy) SYMR.plurals.copy_entry else SYMR.plurals.migrate_entry, resource = if (copy) {
MR.plurals.migrationListScreen_migrateDialog_copyTitle
} else {
MR.plurals.migrationListScreen_migrateDialog_migrateTitle
},
count = totalCount, count = totalCount,
totalCount, totalCount,
(if (skippedCount > 0) " " + stringResource(SYMR.strings.skipping_, skippedCount) else ""),
), ),
) )
}, },
text = {
if (skippedCount > 0) {
Text(
text = pluralStringResource(
resource = MR.plurals.migrationListScreen_migrateDialog_skipText,
count = skippedCount,
skippedCount,
),
)
}
},
confirmButton = { confirmButton = {
TextButton( TextButton(onClick = onMigrate) {
onClick = { Text(
if (copy) { text = stringResource(
copyManga() resource = if (copy) {
} else { MR.strings.migrationListScreen_migrateDialog_copyLabel
onMigrate() } else {
} MR.strings.migrationListScreen_migrateDialog_migrateLabel
}, },
) { ),
Text(text = stringResource(if (copy) MR.strings.copy else MR.strings.migrate)) )
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismissRequest) { TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel)) Text(text = stringResource(MR.strings.migrationListScreen_migrateDialog_cancelLabel))
} }
}, },
) )

View file

@ -21,7 +21,7 @@ fun MigrationProgressDialog(
onDismissRequest = {}, onDismissRequest = {},
confirmButton = { confirmButton = {
TextButton(onClick = exitMigration) { TextButton(onClick = exitMigration) {
Text(text = stringResource(MR.strings.action_cancel)) Text(text = stringResource(MR.strings.migrationListScreen_progressDialog_cancelLabel))
} }
}, },
text = { text = {

View file

@ -1,57 +1,37 @@
package mihon.feature.migration.list.models package mihon.feature.migration.list.models
import android.content.Context
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import tachiyomi.core.common.i18n.stringResource import mihon.feature.migration.list.MigrationListScreenModel.ChapterInfo
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class MigratingManga( class MigratingManga(
val manga: Manga, val manga: Manga,
val chapterInfo: ChapterInfo, val chapterCount: Int,
val latestChapter: Double?,
val source: String, val source: String,
parentContext: CoroutineContext, parentContext: CoroutineContext,
) { ) {
val migrationScope = CoroutineScope(parentContext + SupervisorJob() + Dispatchers.Default) val migrationScope = CoroutineScope(parentContext + SupervisorJob() + Dispatchers.Default)
// KMK --> // KMK -->
var searchingJob: Deferred<Manga?>? = null var searchingJob: Deferred<Pair<Manga, ChapterInfo>?>? = null
// KMK <-- // KMK <--
val searchResult = MutableStateFlow<SearchResult>(SearchResult.Searching) val searchResult = MutableStateFlow<SearchResult>(SearchResult.Searching)
// <MAX, PROGRESS> sealed interface SearchResult {
val progress = MutableStateFlow(1 to 0) data object Searching : SearchResult
data object NotFound : SearchResult
sealed class SearchResult { data class Success(
data object Searching : SearchResult() val manga: Manga,
data object NotFound : SearchResult() val chapterCount: Int,
data class Success(val id: Long) : SearchResult() val latestChapter: Double?,
} val source: String,
) : SearchResult
data class ChapterInfo(
val latestChapter: Double?,
val chapterCount: Int,
) {
fun getFormattedLatestChapter(context: Context): String {
return if (latestChapter != null && latestChapter > 0.0) {
context.stringResource(
SYMR.strings.latest_,
DecimalFormat("#.#").format(latestChapter),
)
} else {
context.stringResource(
SYMR.strings.latest_,
context.stringResource(MR.strings.unknown),
)
}
}
} }
} }

View file

@ -1,6 +1,6 @@
package mihon.feature.migration.list.search package mihon.feature.migration.list.search
import info.debatty.java.stringsimilarity.NormalizedLevenshtein import com.aallam.similarity.NormalizedLevenshtein
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
@ -17,59 +17,62 @@ abstract class BaseSmartSearchEngine<T>(
protected abstract fun getTitle(result: T): String protected abstract fun getTitle(result: T): String
protected suspend fun regularSearch(searchAction: SearchAction<T>, title: String): T? {
return baseSearch(searchAction, listOf(title)) {
normalizedLevenshtein.similarity(title, getTitle(it))
}
}
protected suspend fun deepSearch(searchAction: SearchAction<T>, title: String): T? { protected suspend fun deepSearch(searchAction: SearchAction<T>, title: String): T? {
val cleanedTitle = cleanSmartSearchTitle(title) val cleanedTitle = cleanDeepSearchTitle(title)
val queries = getDeepSearchQueries(cleanedTitle) val queries = getDeepSearchQueries(cleanedTitle)
return baseSearch(searchAction, queries) {
val cleanedMangaTitle = cleanDeepSearchTitle(getTitle(it))
normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle)
}
}
private suspend fun baseSearch(
searchAction: SearchAction<T>,
queries: List<String>,
calculateDistance: (T) -> Double,
): T? {
val eligibleManga = supervisorScope { val eligibleManga = supervisorScope {
queries.map { query -> queries.map { query ->
async(Dispatchers.Default) { async(Dispatchers.Default) {
val builtQuery = if (extraSearchParams != null) { val builtQuery = if (!extraSearchParams.isNullOrBlank()) {
"$query ${extraSearchParams.trim()}" "$query ${extraSearchParams.trim()}"
} else { } else {
query query
} }
searchAction(builtQuery.sanitize()).map { val candidates = searchAction(
val cleanedMangaTitle = cleanSmartSearchTitle(getTitle(it)) builtQuery
val normalizedDistance = normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle) // KMK -->
SearchEntry(it, normalizedDistance) .sanitize(),
}.filter { (_, normalizedDistance) -> // KMK <--
normalizedDistance >= eligibleThreshold )
} candidates
.map {
val distance = if (queries.size > 1 || candidates.size > 1) {
calculateDistance(it)
} else {
1.0
}
SearchEntry(it, distance)
}
.filter { it.distance >= eligibleThreshold }
} }
}.flatMap { it.await() } }
.flatMap { it.await() }
} }
return eligibleManga.maxByOrNull { it.distance }?.entry return eligibleManga.maxByOrNull { it.distance }?.entry
} }
protected suspend fun regularSearch(searchAction: SearchAction<T>, title: String): T? { private fun cleanDeepSearchTitle(title: String): String {
val eligibleManga = supervisorScope {
val searchQuery = if (extraSearchParams != null) {
"$title ${extraSearchParams.trim()}"
} else {
title
}
val searchResults = searchAction(searchQuery.sanitize())
if (searchResults.size == 1) {
return@supervisorScope listOf(SearchEntry(searchResults.first(), 0.0))
}
searchResults.map {
val normalizedDistance = normalizedLevenshtein.similarity(title, getTitle(it))
SearchEntry(it, normalizedDistance)
}.filter { (_, normalizedDistance) ->
normalizedDistance >= eligibleThreshold
}
}
return eligibleManga.maxByOrNull { it.distance }?.entry
}
private fun cleanSmartSearchTitle(title: String): String {
val preTitle = title.lowercase(Locale.getDefault()) val preTitle = title.lowercase(Locale.getDefault())
// Remove text in brackets // Remove text in brackets
@ -98,48 +101,28 @@ abstract class BaseSmartSearchEngine<T>(
} }
private fun removeTextInBrackets(text: String, readForward: Boolean): String { private fun removeTextInBrackets(text: String, readForward: Boolean): String {
val bracketPairs = listOf( val openingChars = if (readForward) "([<{" else ")]}>"
'(' to ')', val closingChars = if (readForward) ")]}>" else "([<{"
'[' to ']', var depth = 0
'<' to '>',
'{' to '}',
)
var openingBracketPairs = bracketPairs.mapIndexed { index, (opening, _) ->
opening to index
}.toMap()
var closingBracketPairs = bracketPairs.mapIndexed { index, (_, closing) ->
closing to index
}.toMap()
// Reverse pairs if reading backwards // KMK -->
if (!readForward) { val result = buildString {
val tmp = openingBracketPairs for (char in (if (readForward) text else text.reversed())) {
openingBracketPairs = closingBracketPairs when {
closingBracketPairs = tmp char in openingChars -> depth++
} char in closingChars && depth > 0 -> {
depth-- // Avoid depth going negative on mismatched closing
val depthPairs = bracketPairs.map { 0 }.toMutableList() // Unmatched closing bracket (depth = 0) -> treat as normal text
}
val result = StringBuilder() else -> if (depth == 0) {
for (c in if (readForward) text else text.reversed()) { append(char)
val openingBracketDepthIndex = openingBracketPairs[c]
if (openingBracketDepthIndex != null) {
depthPairs[openingBracketDepthIndex]++
} else {
val closingBracketDepthIndex = closingBracketPairs[c]
if (closingBracketDepthIndex != null) {
depthPairs[closingBracketDepthIndex]--
} else {
if (depthPairs.all { it <= 0 }) {
result.append(c)
} else {
// In brackets, do not append to result
} }
} }
} }
} }
// If reading backward, the result is reversed
return result.toString() return if (readForward) result else result.reversed()
// KMK <--
} }
private fun getDeepSearchQueries(cleanedTitle: String): List<String> { private fun getDeepSearchQueries(cleanedTitle: String): List<String> {
@ -163,9 +146,9 @@ abstract class BaseSmartSearchEngine<T>(
splitCleanedTitle.take(1), splitCleanedTitle.take(1),
) )
return searchQueries.map { return searchQueries
it.joinToString(" ").trim() .map { it.joinToString(" ").trim() }
}.distinct() .distinct()
} }
companion object { companion object {

View file

@ -5,22 +5,23 @@ import eu.kanade.tachiyomi.source.model.SManga
import mihon.domain.manga.model.toDomainManga import mihon.domain.manga.model.toDomainManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
class SmartSourceSearchEngine( class SmartSourceSearchEngine(extraSearchParams: String?) : BaseSmartSearchEngine<SManga>(extraSearchParams) {
extraSearchParams: String? = null,
) : BaseSmartSearchEngine<SManga>(extraSearchParams) {
override fun getTitle(result: SManga) = result.originalTitle override fun getTitle(result: SManga) = result.originalTitle
suspend fun regularSearch(source: CatalogueSource, title: String): Manga? = suspend fun regularSearch(source: CatalogueSource, title: String): Manga? {
regularSearch(makeSearchAction(source), title).let { return regularSearch(makeSearchAction(source), title).let {
it?.toDomainManga(source.id) it?.toDomainManga(source.id)
} }
}
suspend fun deepSearch(source: CatalogueSource, title: String): Manga? = suspend fun deepSearch(source: CatalogueSource, title: String): Manga? {
deepSearch(makeSearchAction(source), title).let { return deepSearch(makeSearchAction(source), title).let {
it?.toDomainManga(source.id) it?.toDomainManga(source.id)
} }
}
private fun makeSearchAction(source: CatalogueSource): SearchAction<SManga> = private fun makeSearchAction(source: CatalogueSource): SearchAction<SManga> = { query ->
{ query -> source.getSearchManga(1, query, source.getFilterList()).mangas } source.getSearchManga(1, query, source.getFilterList()).mangas
}
} }

View file

@ -107,6 +107,8 @@ ktlint-core = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlin
markdown-core = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdown" } markdown-core = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdown" }
markdown-coil = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdown" } markdown-coil = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdown" }
stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version = "0.1.0" }
materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
[plugins] [plugins]

View file

@ -1,7 +1,6 @@
[versions] [versions]
[libraries] [libraries]
similarity = "info.debatty:java-string-similarity:2.0.0"
xlog = "com.elvishew:xlog:1.11.1" xlog = "com.elvishew:xlog:1.11.1"
ratingbar = "me.zhanghai.android.materialratingbar:library:1.4.0" ratingbar = "me.zhanghai.android.materialratingbar:library:1.4.0"

View file

@ -95,4 +95,18 @@
<item quantity="one">%d repo</item> <item quantity="one">%d repo</item>
<item quantity="other">%d repos</item> <item quantity="other">%d repos</item>
</plurals> </plurals>
<!--Migration-->
<plurals name="migrationListScreen.migrateDialog.migrateTitle">
<item quantity="one">Migrate %1$d entry?</item>
<item quantity="other">Migrate %1$d entries?</item>
</plurals>
<plurals name="migrationListScreen.migrateDialog.copyTitle">
<item quantity="one">Copy %1$d entry?</item>
<item quantity="other">Copy %1$d entries?</item>
</plurals>
<plurals name="migrationListScreen.migrateDialog.skipText">
<item quantity="one">An entry was skipped</item>
<item quantity="other">%1$d entries were skipped</item>
</plurals>
</resources> </resources>

View file

@ -1027,4 +1027,23 @@
<string name="migrationConfigScreen.deepSearchModeSubtitle">Breaks down the title into keywords for a wider search</string> <string name="migrationConfigScreen.deepSearchModeSubtitle">Breaks down the title into keywords for a wider search</string>
<string name="migrationConfigScreen.prioritizeByChaptersTitle">Match based on chapter number</string> <string name="migrationConfigScreen.prioritizeByChaptersTitle">Match based on chapter number</string>
<string name="migrationConfigScreen.prioritizeByChaptersSubtitle">If enabled, chooses the match furthest ahead. Otherwise, picks the first match by source priority.</string> <string name="migrationConfigScreen.prioritizeByChaptersSubtitle">If enabled, chooses the match furthest ahead. Otherwise, picks the first match by source priority.</string>
<string name="migrationListScreenTitle">Migration</string>
<string name="migrationListScreenTitleWithProgress">Migration (%1$d/%2$d)</string>
<string name="migrationListScreen.copyActionLabel">Copy</string>
<string name="migrationListScreen.migrateActionLabel">Migrate</string>
<string name="migrationListScreen.noMatchFoundText">No alternatives found</string>
<string name="migrationListScreen.latestChapterLabel">Latest: %1$s</string>
<string name="migrationListScreen.unknownLatestChapter">Unknown</string>
<string name="migrationListScreen.searchManuallyActionLabel">Search manually</string>
<string name="migrationListScreen.skipActionLabel">Don\'t migrate</string>
<string name="migrationListScreen.migrateNowActionLabel">Migrate now</string>
<string name="migrationListScreen.copyNowActionLabel">Copy now</string>
<string name="migrationListScreen.exitDialogTitle">Stop migrating?</string>
<string name="migrationListScreen.exitDialog.stopLabel">Stop</string>
<string name="migrationListScreen.exitDialog.cancelLabel">Cancel</string>
<string name="migrationListScreen.migrateDialog.copyLabel">Copy</string>
<string name="migrationListScreen.migrateDialog.migrateLabel">Migrate</string>
<string name="migrationListScreen.migrateDialog.cancelLabel">Cancel</string>
<string name="migrationListScreen.progressDialog.cancelLabel">Cancel</string>
<string name="migrationListScreen.matchWithoutChapterToast">No chapters found, this entry cannot be used for migration</string>
</resources> </resources>