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

View file

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

View file

@ -11,7 +11,9 @@ import eu.kanade.presentation.browse.MigrateSearchScreen
import eu.kanade.presentation.browse.components.BulkFavoriteDialogs
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.feature.migration.list.MigrationListScreen
class MigrateSearchScreen(private val mangaId: Long) : Screen() {
@ -48,13 +50,16 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
bulkFavoriteScreenModel.toggleSelection(it)
} else {
// KMK <--
// SY -->
navigator.items
val migrateListScreen = navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.matchOverride = mangaId to it.id
navigator.popUntil { screen -> screen is MigrationListScreen }
// SY <--
.lastOrNull()
if (migrateListScreen == null) {
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)) },
@ -64,6 +69,28 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
// 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 -->
// Bulk-favorite actions only
BulkFavoriteDialogs(

View file

@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.source.browse.BrowseSourceScreenModel
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.webview.WebViewScreen
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.feature.migration.list.MigrationListScreen
import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants
@ -66,6 +70,7 @@ data class MigrateSourceSearchScreen(
val uriHandler = LocalUriHandler.current
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId, query) }
val state by screenModel.state.collectAsState()
@ -142,13 +147,16 @@ data class MigrateSourceSearchScreen(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues ->
val openMigrateDialog: (Manga) -> Unit = {
// SY -->
navigator.items
val migrateListScreen = navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.matchOverride = currentManga.id to it.id
navigator.popUntil { it is MigrationListScreen }
// SY <--
.lastOrNull()
if (migrateListScreen == null) {
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(
source = screenModel.source,
@ -189,7 +197,7 @@ data class MigrateSourceSearchScreen(
}
val onDismissRequest = { screenModel.setDialog(null) }
when (state.dialog) {
when (val dialog = state.dialog) {
is BrowseSourceScreenModel.Dialog.Filter -> {
SourceFilterDialog(
onDismissRequest = onDismissRequest,
@ -216,6 +224,22 @@ data class MigrateSourceSearchScreen(
// 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 -> {}
}

View file

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

View file

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

View file

@ -19,7 +19,7 @@ class SmartSearchScreenModel(
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
sourceManager: SourceManager = Injekt.get(),
) : StateScreenModel<SmartSearchScreenModel.SearchResults?>(null) {
private val smartSearchEngine = SmartSourceSearchEngine()
private val smartSearchEngine = SmartSourceSearchEngine(null)
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() {
constructor(mangaId: Long) : this(listOf(mangaId))
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
@ -110,17 +112,22 @@ class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() {
var migrationSheetOpen by rememberSaveable { mutableStateOf(false) }
fun continueMigration(openSheet: Boolean, extraSearchQuery: String?) {
// KMK -->
// val mangaId = mangaIds.singleOrNull()
// if (mangaId == null && openSheet) {
if (openSheet) {
// KMK <--
migrationSheetOpen = true
return
}
navigator.replace(
// KMK -->
// MigrateSearchScreen(mangaId)
MigrationListScreen(mangaIds, extraSearchQuery),
// KMK <--
)
val screen = // KMK --> if (mangaId == null) {
MigrationListScreen(mangaIds, extraSearchQuery)
// KMK -->
// } else {
// MigrateSearchScreen(mangaId)
// }
// KMK <--
navigator.replace(screen)
}
if (state.isLoading) {

View file

@ -1,5 +1,6 @@
package mihon.feature.migration.list
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
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.manga.MangaScreen
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.MigrationConfigScreenSheet
import mihon.feature.migration.list.components.MigrationExitDialog
import mihon.feature.migration.list.components.MigrationMangaDialog
import mihon.feature.migration.list.components.MigrationProgressDialog
import mihon.feature.migration.list.models.MigratingManga
import tachiyomi.core.common.i18n.pluralStringResource
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.i18n.sy.SYMR
import tachiyomi.i18n.MR
/**
* 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() {
var matchOverride: Pair<Long, Long>? = null
private var matchOverride: Pair<Long, Long>? = null
fun addMatchOverride(current: Long, target: Long) {
matchOverride = current to target
}
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { MigrationListScreenModel(mangaIds, extraSearchQuery) }
val state by screenModel.state.collectAsState()
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) {
if (matchOverride != null) {
val (oldId, newId) = matchOverride!!
screenModel.useMangaForMigration(context, newId, oldId)
matchOverride = null
}
val (current, target) = matchOverride ?: return@LaunchedEffect
screenModel.useMangaForMigration(
current = current,
target = target,
onMissingChapters = {
context.toast(MR.strings.migrationListScreen_matchWithoutChapterToast, Toast.LENGTH_LONG)
},
)
matchOverride = null
}
LaunchedEffect(screenModel) {
screenModel.navigateOut.collect {
if (items.orEmpty().size == 1 && navigator.items.any { it is MangaScreen }) {
val mangaId = (items.orEmpty().firstOrNull()?.searchResult?.value as? MigratingManga.SearchResult.Success)?.id
withUIContext {
if (mangaId != null) {
val newStack = navigator.items.filter {
it !is MangaScreen &&
it !is MigrationListScreen &&
it !is MigrationConfigScreen
} + MangaScreen(mangaId)
navigator replaceAll newStack.first()
navigator.push(newStack.drop(1))
screenModel.navigateBackEvent.collect {
// KMK -->
/* If this screen is called from single manga migration, replace the MangaScreen in the backstack
with the newly migrated manga to reflect the changes properly.
Otherwise, just pop normally. */
if (mangaIds.size == 1 && navigator.items.any { it is MangaScreen }) {
val mangaId = (state.items.firstOrNull()?.searchResult?.value as? MigratingManga.SearchResult.Success)?.manga?.id
if (mangaId != null) {
val newStack = navigator.items.filter {
it !is MangaScreen &&
it !is MigrationListScreen &&
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
navigator.push(this@MigrationListScreen)
navigator.pop()
} else {
navigator.pop()
}
}
} else {
withUIContext {
// need to set the navigator in a pop state to dispose of everything properly
navigator.push(this@MigrationListScreen)
navigator.pop()
} else {
navigator.pop()
}
}
}
}
LaunchedEffect(items) {
if (items?.isEmpty() == true) {
val manualMigrations = screenModel.manualMigrations.value
context.toast(
context.pluralStringResource(
SYMR.plurals.entry_migrated,
manualMigrations,
manualMigrations,
),
)
if (!screenModel.hideUnmatched) {
} else {
// KMK <--
navigator.pop()
}
}
}
MigrationListScreenContent(
items = items ?: persistentListOf(),
migrationComplete = migrationComplete,
finishedCount = finishedCount,
getManga = screenModel::getManga,
getChapterInfo = screenModel::getChapterInfo,
getSourceName = screenModel::getSourceName,
items = state.items,
migrationComplete = state.migrationComplete,
finishedCount = state.finishedCount,
onItemClick = {
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)
},
onSkip = { screenModel.removeManga(it) },
onMigrate = { screenModel.migrateNow(it, true) },
onCopy = { screenModel.migrateNow(it, false) },
onMigrate = { screenModel.migrateNow(mangaId = it, replace = true) },
onCopy = { screenModel.migrateNow(mangaId = it, replace = false) },
openMigrationDialog = screenModel::showMigrateDialog,
// KMK -->
onCancel = { screenModel.cancelManga(it) },
navigateUp = { navigator.pop() },
openOptionsDialog = screenModel::openOptionsDialog,
// KMK <--
)
val onDismissRequest = { screenModel.dialog.value = null }
when (
@Suppress("NAME_SHADOWING")
val dialog = dialog
) {
when (val dialog = state.dialog) {
is MigrationListScreenModel.Dialog.Migrate -> {
MigrationMangaDialog(
onDismissRequest = onDismissRequest,
onDismissRequest = screenModel::dismissDialog,
copy = dialog.copy,
totalCount = dialog.totalCount,
skippedCount = dialog.skippedCount,
copyManga = screenModel::copyMangas,
onMigrate = screenModel::migrateMangas,
onMigrate = {
if (dialog.copy) {
screenModel.copyMangas()
} else {
screenModel.migrateMangas()
}
},
)
}
is MigrationListScreenModel.Dialog.Progress -> {
MigrationProgressDialog(
progress = dialog.progress,
exitMigration = screenModel::cancelMigrate,
)
}
MigrationListScreenModel.Dialog.Exit -> {
MigrationExitDialog(
onDismissRequest = onDismissRequest,
onDismissRequest = screenModel::dismissDialog,
exitMigration = navigator::pop,
)
}
@ -145,9 +133,9 @@ class MigrationListScreen(private val mangaIds: List<Long>, private val extraSea
MigrationListScreenModel.Dialog.Options -> {
MigrationConfigScreenSheet(
preferences = screenModel.preferences,
onDismissRequest = onDismissRequest,
onDismissRequest = screenModel::dismissDialog,
onStartMigration = { _ ->
onDismissRequest()
screenModel.dismissDialog()
screenModel.updateOptions()
},
fullSettings = false,
@ -157,15 +145,8 @@ class MigrationListScreen(private val mangaIds: List<Long>, private val extraSea
null -> Unit
}
if (!migrateProgress.isNaN() && migrateProgress overEq 0f && migrateProgress underEq 1f) {
MigrationProgressDialog(
progress = migrateProgress,
exitMigration = screenModel::cancelMigrate,
)
}
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.Shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.formatChapterNumber
import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import mihon.feature.migration.list.models.MigratingManga
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.Badge
import tachiyomi.presentation.core.components.BadgeGroup
import tachiyomi.presentation.core.components.FastScrollLazyColumn
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.i18n.stringResource
import tachiyomi.presentation.core.util.plus
@ -78,9 +76,6 @@ fun MigrationListScreenContent(
items: ImmutableList<MigratingManga>,
migrationComplete: Boolean,
finishedCount: Int,
getManga: suspend (MigratingManga.SearchResult.Success) -> Manga?,
getChapterInfo: suspend (MigratingManga.SearchResult.Success) -> MigratingManga.ChapterInfo,
getSourceName: (Manga) -> String,
onItemClick: (Manga) -> Unit,
onSearchManually: (MigratingManga) -> Unit,
onSkip: (Long) -> Unit,
@ -89,23 +84,17 @@ fun MigrationListScreenContent(
openMigrationDialog: (Boolean) -> Unit,
// KMK -->
onCancel: (Long) -> Unit,
navigateUp: () -> Unit,
openOptionsDialog: () -> Unit,
// KMK <--
) {
Scaffold(
topBar = { scrollBehavior ->
val titleString = stringResource(SYMR.strings.migration)
val title by produceState(initialValue = titleString, items, finishedCount, titleString) {
withIOContext {
value = "$titleString ($finishedCount/${items.size})"
}
}
AppBar(
title = title,
// KMK -->
navigateUp = navigateUp,
// KMK <--
title = if (items.isNotEmpty()) {
stringResource(MR.strings.migrationListScreenTitleWithProgress, finishedCount, items.size)
} else {
stringResource(MR.strings.migrationListScreenTitle)
},
actions = {
AppBarActions(
persistentListOf(
@ -117,13 +106,13 @@ fun MigrationListScreenContent(
),
// KMK <--
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,
onClick = { openMigrationDialog(true) },
enabled = migrationComplete,
),
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,
onClick = { openMigrationDialog(false) },
enabled = migrationComplete,
@ -136,52 +125,49 @@ fun MigrationListScreenContent(
},
) { contentPadding ->
FastScrollLazyColumn(contentPadding = contentPadding + topSmallPaddingValues) {
items(items, key = { "migration-list-${it.manga.id}" }) { item ->
items(items, key = { it.manga.id }) { item ->
Row(
Modifier
.fillMaxWidth()
.animateItemFastScroll()
.padding(horizontal = 16.dp)
.padding(
start = MaterialTheme.padding.medium,
end = MaterialTheme.padding.small,
)
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
MigrationListItem(
modifier = Modifier
.padding(top = 8.dp)
.weight(1f)
.align(Alignment.Top)
.fillMaxHeight(),
manga = item.manga,
source = item.source,
chapterInfo = item.chapterInfo,
chapterCount = item.chapterCount,
latestChapter = item.latestChapter,
onClick = { onItemClick(item.manga) },
)
Icon(
Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = stringResource(SYMR.strings.migrating_to),
imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = null,
modifier = Modifier.weight(0.2f),
)
val result by item.searchResult.collectAsState()
MigrationListItemResult(
modifier = Modifier
.padding(top = 8.dp)
.weight(1f)
.align(Alignment.Top)
.fillMaxHeight(),
migrationItem = item,
result = result,
getManga = getManga,
getChapterInfo = getChapterInfo,
getSourceName = getSourceName,
onItemClick = onItemClick,
)
MigrationListItemAction(
modifier = Modifier
.weight(0.2f),
modifier = Modifier.weight(0.2f),
result = result,
onSearchManually = { onSearchManually(item) },
onSkip = { onSkip(item.manga.id) },
@ -202,162 +188,140 @@ fun MigrationListItem(
modifier: Modifier,
manga: Manga,
source: String,
chapterInfo: MigratingManga.ChapterInfo,
chapterCount: Int,
latestChapter: Double?,
onClick: () -> Unit,
) {
Column(
modifier
modifier = modifier
.widthIn(max = 150.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onClick)
.padding(4.dp),
) {
val context = LocalContext.current
Box(
Modifier.Companion.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
MangaCover.Book(
modifier = Modifier.Companion
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
data = manga,
)
Box(
modifier = Modifier.Companion
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
.background(
Brush.Companion.verticalGradient(
0f to Color.Companion.Transparent,
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xAA000000),
),
)
.fillMaxHeight(0.33f)
.fillMaxWidth()
.align(Alignment.Companion.BottomCenter),
.align(Alignment.BottomCenter),
)
Text(
modifier = Modifier.Companion
modifier = Modifier
.padding(8.dp)
.align(Alignment.Companion.BottomStart),
text = manga.title.ifBlank { stringResource(MR.strings.unknown) },
fontSize = 12.sp,
lineHeight = 18.sp,
.align(Alignment.BottomStart),
text = manga.title,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
overflow = TextOverflow.Companion.Ellipsis,
style = MaterialTheme.typography.titleSmall.copy(
color = Color.Companion.White,
shadow = Shadow(
color = Color.Companion.Black,
blurRadius = 4f,
),
),
style = MaterialTheme.typography.labelMedium
.merge(shadow = Shadow(color = Color.Black, blurRadius = 4f)),
)
BadgeGroup(modifier = Modifier.Companion.padding(4.dp)) {
Badge(text = "${chapterInfo.chapterCount}")
BadgeGroup(modifier = Modifier.padding(4.dp)) {
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 = "") {
value = withIOContext {
chapterInfo.getFormattedLatestChapter(context)
Column(
modifier = Modifier
.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
fun MigrationListItemResult(
modifier: Modifier,
migrationItem: MigratingManga,
result: MigratingManga.SearchResult,
getManga: suspend (MigratingManga.SearchResult.Success) -> Manga?,
getChapterInfo: suspend (MigratingManga.SearchResult.Success) -> MigratingManga.ChapterInfo,
getSourceName: (Manga) -> String,
onItemClick: (Manga) -> Unit,
) {
Box(modifier.height(IntrinsicSize.Min)) {
when (result) {
MigratingManga.SearchResult.Searching -> Box(
modifier = Modifier.Companion
.widthIn(max = 150.dp)
.fillMaxSize()
.aspectRatio(MangaCover.Book.ratio),
contentAlignment = Alignment.Companion.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,
MigratingManga.SearchResult.Searching -> {
Box(
modifier = Modifier
.widthIn(max = 150.dp)
.fillMaxSize()
.aspectRatio(MangaCover.Book.ratio),
contentAlignment = Alignment.Center,
) {
value = withIOContext {
val manga = getManga(result) ?: return@withIOContext null
Triple(
manga,
getChapterInfo(result),
getSourceName(manga),
)
}
CircularProgressIndicator()
}
if (item != null) {
val (manga, chapterInfo, source) = item!!
MigrationListItem(
modifier = Modifier.Companion.fillMaxSize(),
manga = manga,
source = source,
chapterInfo = chapterInfo,
onClick = {
onItemClick(manga)
},
}
MigratingManga.SearchResult.NotFound -> {
Column(
Modifier
.widthIn(max = 150.dp)
.fillMaxSize()
.padding(4.dp),
) {
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
fun MigrationListItemAction(
private fun MigrationListItemAction(
modifier: Modifier,
result: MigratingManga.SearchResult,
onSearchManually: () -> Unit,
@ -368,8 +332,8 @@ fun MigrationListItemAction(
onCancel: () -> Unit,
// KMK <--
) {
var moreExpanded by remember { mutableStateOf(false) }
val closeMenu = { moreExpanded = false }
var menuExpanded by rememberSaveable { mutableStateOf(false) }
val closeMenu = { menuExpanded = false }
Box(modifier) {
when (result) {
MigratingManga.SearchResult.Searching -> {
@ -378,49 +342,49 @@ fun MigrationListItemAction(
// KMK <--
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(SYMR.strings.action_stop),
contentDescription = null,
)
}
}
MigratingManga.SearchResult.NotFound, is MigratingManga.SearchResult.Success -> {
IconButton(onClick = { moreExpanded = !moreExpanded }) {
IconButton(onClick = { menuExpanded = true }) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(MR.strings.action_menu_overflow_description),
contentDescription = null,
)
}
DropdownMenu(
expanded = moreExpanded,
expanded = menuExpanded,
onDismissRequest = closeMenu,
offset = DpOffset(8.dp, (-56).dp),
) {
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_search_manually)) },
text = { Text(stringResource(MR.strings.migrationListScreen_searchManuallyActionLabel)) },
onClick = {
onSearchManually()
closeMenu()
onSearchManually()
},
)
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_skip_entry)) },
text = { Text(stringResource(MR.strings.migrationListScreen_skipActionLabel)) },
onClick = {
onSkip()
closeMenu()
onSkip()
},
)
if (result is MigratingManga.SearchResult.Success) {
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_migrate_now)) },
text = { Text(stringResource(MR.strings.migrationListScreen_migrateNowActionLabel)) },
onClick = {
onMigrate()
closeMenu()
onMigrate()
},
)
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_copy_now)) },
text = { Text(stringResource(MR.strings.migrationListScreen_copyNowActionLabel)) },
onClick = {
onCopy()
closeMenu()
onCopy()
},
)
}

View file

@ -1,8 +1,7 @@
package mihon.feature.migration.list
import android.content.Context
import android.widget.Toast
import cafe.adriel.voyager.core.model.ScreenModel
import androidx.annotation.FloatRange
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
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.getNameForMangaInfo
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.util.system.toast
import exh.source.MERGED_SOURCE_ID
import exh.util.ThrottleManager
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Semaphore
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.model.Manga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.sy.SYMR
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.atomic.AtomicInteger
class MigrationListScreenModel(
private var mangaIds: List<Long>,
mangaIds: List<Long>,
extraSearchQuery: String?,
val preferences: SourcePreferences = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
@ -62,168 +60,149 @@ class MigrationListScreenModel(
// SY -->
private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(),
// SY <--
) : ScreenModel {
) : StateScreenModel<MigrationListScreenModel.State>(State()) {
private val smartSearchEngine = SmartSourceSearchEngine(extraSearchQuery)
// SY -->
private val throttleManager = ThrottleManager()
// SY <--
val migratingItems = MutableStateFlow<ImmutableList<MigratingManga>?>(null)
val migrationDone = MutableStateFlow(false)
val finishedCount = MutableStateFlow(0)
val items
inline get() = state.value.items
val manualMigrations = MutableStateFlow(0)
var hideUnmatched = preferences.migrationHideUnmatched().get()
private var hideUnmatched = preferences.migrationHideUnmatched().get()
private var hideWithoutUpdates = preferences.migrationHideWithoutUpdates().get()
// KMK -->
private var prioritizeByChapters = preferences.migrationPrioritizeByChapters().get()
private var deepSearchMode = preferences.migrationDeepSearchMode().get()
// KMK <--
val navigateOut = MutableSharedFlow<Unit>()
val dialog = MutableStateFlow<Dialog?>(null)
val migratingProgress = MutableStateFlow(Float.MAX_VALUE)
private val navigateBackChannel = Channel<Unit>()
val navigateBackEvent = navigateBackChannel.receiveAsFlow()
private var migrateJob: Job? = null
init {
screenModelScope.launchIO {
runMigrations(
mangaIds
.map {
async {
val manga = getManga.await(it) ?: return@async null
MigratingManga(
manga = manga,
chapterInfo = getChapterInfo(it),
source = sourceManager.getOrStub(manga.source).getNameForMangaInfo(
if (manga.source == MERGED_SOURCE_ID) {
getMergedReferencesById.await(manga.id)
.map { sourceManager.getOrStub(it.mangaSourceId) }
} else {
null
},
),
parentContext = screenModelScope.coroutineContext,
)
}
val manga = mangaIds
.map {
async {
val manga = getManga.await(it) ?: return@async null
val chapterInfo = getChapterInfo(it)
MigratingManga(
manga = manga,
chapterCount = chapterInfo.chapterCount,
latestChapter = chapterInfo.latestChapter,
source = sourceManager.getOrStub(manga.source).getNameForMangaInfo(
// KMK -->
if (manga.source == MERGED_SOURCE_ID) {
getMergedReferencesById.await(manga.id)
.map { sourceManager.getOrStub(it.mangaSourceId) }
} else {
null
},
// KMK <--
),
parentContext = screenModelScope.coroutineContext,
)
}
.awaitAll()
.filterNotNull()
.also {
migratingItems.value = it.toImmutableList()
},
)
}
.awaitAll()
.filterNotNull()
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 ->
MigratingManga.ChapterInfo(
ChapterInfo(
latestChapter = chapters.maxOfOrNull { it.chapterNumber },
chapterCount = chapters.size,
)
}
fun getSourceName(manga: Manga) = sourceManager.getOrStub(manga.source).getNameForMangaInfo()
fun getMigrationSources() = preferences.migrationSources().get().mapNotNull {
sourceManager.get(it) as? CatalogueSource
private suspend fun Manga.toSuccessSearchResult(): SearchResult.Success {
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>) {
// KMK -->
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) {
if (!currentCoroutineContext().isActive) {
break
}
// in case it was removed
if (manga.manga.id !in mangaIds) {
if (!currentCoroutineContext().isActive) break
if (manga.manga.id !in state.value.mangaIds) continue
if (manga.searchResult.value != SearchResult.Searching) continue
if (!manga.migrationScope.isActive) continue
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
}
if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) {
val mangaObj = manga.manga
val mangaSource = sourceManager.getOrStub(mangaObj.source)
val result = try {
// KMK -->
manga.searchingJob = manga.migrationScope.async {
// 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.first.thumbnailUrl == null) {
try {
val newManga = sourceManager.getOrStub(result.first.source).getMangaDetails(result.first.toSManga())
updateManga.awaitUpdateFromSource(result.first, newManga, true)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
}
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,
source: CatalogueSource,
deepSearchMode: Boolean,
): Pair<Manga, Int>? {
): Pair<Manga, ChapterInfo>? {
return try {
val searchResult = if (deepSearchMode) {
smartSearchEngine.deepSearch(source, manga.ogTitle)
smartSearchEngine.deepSearch(source, manga.title)
} else {
smartSearchEngine.regularSearch(source, manga.ogTitle)
smartSearchEngine.regularSearch(source, manga.title)
}
if (searchResult != null &&
!(searchResult.url == manga.url && source.id == manga.source)
) {
val localManga = networkToLocalManga(searchResult)
if (searchResult == null || (searchResult.url == manga.url && source.id == manga.source)) return null
val chapters = try {
val localManga = networkToLocalManga(searchResult)
try {
val chapters =
// KMK -->
if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
// KMK <--
source.getChapterList(localManga.toSManga())
}
} catch (e: Exception) {
this@MigrationListScreenModel.logcat(LogPriority.ERROR, e)
return null
}
try {
syncChaptersWithSource.await(chapters, localManga, source)
} catch (_: Exception) {
return null
}
localManga to chapters.size
} else {
null
syncChaptersWithSource.await(chapters, localManga, source)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
localManga to getChapterInfo(localManga.id)
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (_: Exception) {
null
@ -273,30 +243,32 @@ class MigrationListScreenModel(
}
private suspend fun updateMigrationProgress() {
finishedCount.value = migratingItems.value.orEmpty().count {
it.searchResult.value != SearchResult.Searching
mutableState.update { state ->
state.copy(
// KMK -->
finishedCount = state.items.count { it.searchResult.value != SearchResult.Searching },
migrationComplete = state.migrationComplete(),
// KMK <--
)
}
if (migrationComplete()) {
migrationDone.value = true
}
if (migratingItems.value?.isEmpty() == true) {
if (items.isEmpty()) {
navigateBack()
}
}
private fun migrationComplete() = migratingItems.value.orEmpty().all { it.searchResult.value != SearchResult.Searching } &&
migratingItems.value.orEmpty().any { it.searchResult.value is SearchResult.Success }
private fun mangasSkipped() = migratingItems.value.orEmpty().count { it.searchResult.value == SearchResult.NotFound }
// KMK -->
private fun State.migrationComplete() =
// KMK <--
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 */
fun useMangaForMigration(context: Context, newMangaId: Long, selectedMangaId: Long) {
val migratingManga = migratingItems.value.orEmpty().find { it.manga.id == selectedMangaId }
?: return
fun useMangaForMigration(current: Long, target: Long, onMissingChapters: () -> Unit) {
val migratingManga = items.find { it.manga.id == current } ?: return
migratingManga.searchResult.value = SearchResult.Searching
screenModelScope.launchIO {
val result = migratingManga.migrationScope.async {
val manga = getManga(newMangaId)!!
val manga = getManga.await(target) ?: return@async null
try {
val source = sourceManager.get(manga.source)!!
val chapters = source.getChapterList(manga.toSManga())
@ -305,70 +277,66 @@ class MigrationListScreenModel(
return@async null
}
manga
}.await()
}
.await()
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 {
if (result == null) {
migratingManga.searchResult.value = SearchResult.NotFound
withUIContext {
context.toast(SYMR.strings.no_chapters_found_for_migration, Toast.LENGTH_LONG)
}
withUIContext { onMissingChapters() }
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()
// KMK <--
}
}
fun migrateMangas() {
migrateMangas(true)
migrateMangas(replace = true)
}
fun copyMangas() {
migrateMangas(false)
migrateMangas(replace = false)
}
private fun migrateMangas(replace: Boolean) {
dialog.value = null
migrateJob = screenModelScope.launchIO {
migratingProgress.value = 0f
val items = migratingItems.value.orEmpty()
mutableState.update { it.copy(dialog = Dialog.Progress(0f)) }
val items = items
try {
items.forEachIndexed { index, manga ->
try {
ensureActive()
val target = manga.searchResult.value.let {
if (it is SearchResult.Success) {
getManga.await(it.id)
it.manga
} else {
null
}
}
if (target != null) {
migrateManga(manga.manga, target, replace)
migrateManga(current = manga.manga, target = target, replace = replace)
}
} catch (e: Exception) {
if (e is CancellationException) throw 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()
} finally {
migratingProgress.value = Float.MAX_VALUE
mutableState.update { it.copy(dialog = null) }
migrateJob = null
}
}
@ -380,19 +348,15 @@ class MigrationListScreenModel(
}
private suspend fun navigateBack() {
navigateOut.emit(Unit)
navigateBackChannel.send(Unit)
}
fun migrateNow(mangaId: Long, replace: Boolean) {
manualMigrations.value++
screenModelScope.launchIO {
val manga = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
?: return@launchIO
val manga = items.find { it.manga.id == mangaId } ?: 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)
}
}
@ -401,8 +365,7 @@ class MigrationListScreenModel(
/** Cancel searching without remove it from list so user can perform manual search */
fun cancelManga(mangaId: Long) {
screenModelScope.launchIO {
val item = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
?: return@launchIO
val item = items.find { it.manga.id == mangaId } ?: return@launchIO
item.searchingJob?.cancel()
item.searchingJob = null
item.searchResult.value = SearchResult.NotFound
@ -413,8 +376,7 @@ class MigrationListScreenModel(
fun removeManga(mangaId: Long) {
screenModelScope.launchIO {
val item = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
?: return@launchIO
val item = items.find { it.manga.id == mangaId } ?: return@launchIO
removeManga(item)
item.migrationScope.cancel()
updateMigrationProgress()
@ -422,34 +384,45 @@ class MigrationListScreenModel(
}
private fun removeManga(item: MigratingManga) {
val ids = mangaIds.toMutableList()
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()
}
mutableState.update { it.copy(items = items.toPersistentList().remove(item)) }
}
override fun onDispose() {
super.onDispose()
migratingItems.value.orEmpty().forEach {
items.forEach {
it.migrationScope.cancel()
}
}
fun showMigrateDialog(copy: Boolean) {
dialog.value = Dialog.Migrate(
copy,
migratingItems.value.orEmpty().size,
mangasSkipped(),
)
mutableState.update { state ->
state.copy(
dialog = Dialog.Migrate(
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 -->
fun openOptionsDialog() {
dialog.value = Dialog.Options
mutableState.update {
it.copy(dialog = Dialog.Options)
}
}
fun updateOptions() {
@ -460,12 +433,27 @@ class MigrationListScreenModel(
}
// KMK <--
sealed class Dialog {
data class Migrate(val copy: Boolean, val totalCount: Int, val skippedCount: Int) : Dialog()
data object Exit : Dialog()
data class ChapterInfo(
val latestChapter: Double?,
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 -->
data object Options : Dialog()
data object Options : Dialog
// 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.runtime.Composable
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
@ -16,16 +15,16 @@ fun MigrationExitDialog(
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(SYMR.strings.stop_migrating))
Text(text = stringResource(MR.strings.migrationListScreen_exitDialogTitle))
},
confirmButton = {
TextButton(onClick = exitMigration) {
Text(text = stringResource(SYMR.strings.action_stop))
Text(text = stringResource(MR.strings.migrationListScreen_exitDialog_stopLabel))
}
},
dismissButton = {
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.runtime.Composable
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
@ -15,37 +14,50 @@ fun MigrationMangaDialog(
copy: Boolean,
totalCount: Int,
skippedCount: Int,
copyManga: () -> Unit,
onMigrate: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
text = {
title = {
Text(
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,
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 = {
TextButton(
onClick = {
if (copy) {
copyManga()
} else {
onMigrate()
}
},
) {
Text(text = stringResource(if (copy) MR.strings.copy else MR.strings.migrate))
TextButton(onClick = onMigrate) {
Text(
text = stringResource(
resource = if (copy) {
MR.strings.migrationListScreen_migrateDialog_copyLabel
} else {
MR.strings.migrationListScreen_migrateDialog_migrateLabel
},
),
)
}
},
dismissButton = {
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 = {},
confirmButton = {
TextButton(onClick = exitMigration) {
Text(text = stringResource(MR.strings.action_cancel))
Text(text = stringResource(MR.strings.migrationListScreen_progressDialog_cancelLabel))
}
},
text = {

View file

@ -1,57 +1,37 @@
package mihon.feature.migration.list.models
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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.i18n.MR
import tachiyomi.i18n.sy.SYMR
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext
class MigratingManga(
val manga: Manga,
val chapterInfo: ChapterInfo,
val chapterCount: Int,
val latestChapter: Double?,
val source: String,
parentContext: CoroutineContext,
) {
val migrationScope = CoroutineScope(parentContext + SupervisorJob() + Dispatchers.Default)
// KMK -->
var searchingJob: Deferred<Manga?>? = null
var searchingJob: Deferred<Pair<Manga, ChapterInfo>?>? = null
// KMK <--
val searchResult = MutableStateFlow<SearchResult>(SearchResult.Searching)
// <MAX, PROGRESS>
val progress = MutableStateFlow(1 to 0)
sealed class SearchResult {
data object Searching : SearchResult()
data object NotFound : SearchResult()
data class Success(val id: Long) : 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),
)
}
}
sealed interface SearchResult {
data object Searching : SearchResult
data object NotFound : SearchResult
data class Success(
val manga: Manga,
val chapterCount: Int,
val latestChapter: Double?,
val source: String,
) : SearchResult
}
}

View file

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

View file

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

View file

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

View file

@ -95,4 +95,18 @@
<item quantity="one">%d repo</item>
<item quantity="other">%d repos</item>
</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>

View file

@ -1027,4 +1027,23 @@
<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.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>