diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b07de3caa..d2ac7ad4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreen.kt index a355704ac..c986535e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreen.kt @@ -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 = { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt index 13de72765..721557481 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt @@ -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() - .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( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt index 22e207564..1c957c983 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt @@ -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() - .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 -> {} } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/libraryUpdateError/LibraryUpdateErrorScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/libraryUpdateError/LibraryUpdateErrorScreen.kt index 4afa514d6..0987a0b9c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/libraryUpdateError/LibraryUpdateErrorScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/libraryUpdateError/LibraryUpdateErrorScreen.kt @@ -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 = { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 4e1504fc3..93403405e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -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, diff --git a/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt b/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt index fbd62af37..b0ffaad29 100644 --- a/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt +++ b/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt @@ -19,7 +19,7 @@ class SmartSearchScreenModel( private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), sourceManager: SourceManager = Injekt.get(), ) : StateScreenModel(null) { - private val smartSearchEngine = SmartSourceSearchEngine() + private val smartSearchEngine = SmartSourceSearchEngine(null) val source = sourceManager.get(sourceId) as CatalogueSource diff --git a/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt b/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt index 2cba2ba7d..fe14278e7 100644 --- a/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt +++ b/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt @@ -93,6 +93,8 @@ import uy.kohesive.injekt.api.get */ class MigrationConfigScreen(private val mangaIds: List) : 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) : 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) { diff --git a/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt b/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt index ffe2b10f9..85053f0a8 100644 --- a/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt @@ -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, private val extraSearchQuery: String?) : Screen() { - var matchOverride: Pair? = null + private var matchOverride: Pair? = 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, 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, 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, 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() } } } diff --git a/app/src/main/java/mihon/feature/migration/list/MigrationListScreenContent.kt b/app/src/main/java/mihon/feature/migration/list/MigrationListScreenContent.kt index fea93c92c..d3a1be7c6 100644 --- a/app/src/main/java/mihon/feature/migration/list/MigrationListScreenContent.kt +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListScreenContent.kt @@ -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, 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?>( - 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() }, ) } diff --git a/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt b/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt index 79041c488..eed80229e 100644 --- a/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt @@ -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, + mangaIds: List, 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(State()) { private val smartSearchEngine = SmartSourceSearchEngine(extraSearchQuery) // SY --> private val throttleManager = ThrottleManager() // SY <-- - val migratingItems = MutableStateFlow?>(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() - - val dialog = MutableStateFlow(null) - - val migratingProgress = MutableStateFlow(Float.MAX_VALUE) + private val navigateBackChannel = Channel() + 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) { + // 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? { + ): Pair? { 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 = persistentListOf(), + val finishedCount: Int = 0, + val migrationComplete: Boolean = false, + val dialog: Dialog? = null, + ) { + val mangaIds: List = items.map { it.manga.id } + } } diff --git a/app/src/main/java/mihon/feature/migration/list/components/MigrationExitDialog.kt b/app/src/main/java/mihon/feature/migration/list/components/MigrationExitDialog.kt index cc34ce311..743efb21c 100644 --- a/app/src/main/java/mihon/feature/migration/list/components/MigrationExitDialog.kt +++ b/app/src/main/java/mihon/feature/migration/list/components/MigrationExitDialog.kt @@ -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)) } }, ) diff --git a/app/src/main/java/mihon/feature/migration/list/components/MigrationMangaDialog.kt b/app/src/main/java/mihon/feature/migration/list/components/MigrationMangaDialog.kt index 4601cacff..e280fe9e3 100644 --- a/app/src/main/java/mihon/feature/migration/list/components/MigrationMangaDialog.kt +++ b/app/src/main/java/mihon/feature/migration/list/components/MigrationMangaDialog.kt @@ -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)) } }, ) diff --git a/app/src/main/java/mihon/feature/migration/list/components/MigrationProgressDialog.kt b/app/src/main/java/mihon/feature/migration/list/components/MigrationProgressDialog.kt index c84f7389c..6eb81ae6e 100644 --- a/app/src/main/java/mihon/feature/migration/list/components/MigrationProgressDialog.kt +++ b/app/src/main/java/mihon/feature/migration/list/components/MigrationProgressDialog.kt @@ -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 = { diff --git a/app/src/main/java/mihon/feature/migration/list/models/MigratingManga.kt b/app/src/main/java/mihon/feature/migration/list/models/MigratingManga.kt index 5bfd6cc5a..f45c902dd 100644 --- a/app/src/main/java/mihon/feature/migration/list/models/MigratingManga.kt +++ b/app/src/main/java/mihon/feature/migration/list/models/MigratingManga.kt @@ -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? = null + var searchingJob: Deferred?>? = null // KMK <-- val searchResult = MutableStateFlow(SearchResult.Searching) - // - 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 } } diff --git a/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt b/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt index cedfd01d2..5df31751a 100644 --- a/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt +++ b/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt @@ -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( protected abstract fun getTitle(result: T): String + protected suspend fun regularSearch(searchAction: SearchAction, title: String): T? { + return baseSearch(searchAction, listOf(title)) { + normalizedLevenshtein.similarity(title, getTitle(it)) + } + } + protected suspend fun deepSearch(searchAction: SearchAction, 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, + queries: List, + 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, 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( } 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 { @@ -163,9 +146,9 @@ abstract class BaseSmartSearchEngine( splitCleanedTitle.take(1), ) - return searchQueries.map { - it.joinToString(" ").trim() - }.distinct() + return searchQueries + .map { it.joinToString(" ").trim() } + .distinct() } companion object { diff --git a/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt b/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt index e0207bbd2..254e07f84 100644 --- a/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt +++ b/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt @@ -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(extraSearchParams) { +class SmartSourceSearchEngine(extraSearchParams: String?) : BaseSmartSearchEngine(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 = - { query -> source.getSearchManga(1, query, source.getFilterList()).mangas } + private fun makeSearchAction(source: CatalogueSource): SearchAction = { query -> + source.getSearchManga(1, query, source.getFilterList()).mangas + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 48d612630..60c8fe121 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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] diff --git a/gradle/sy.versions.toml b/gradle/sy.versions.toml index 76ad19b56..0ac102358 100644 --- a/gradle/sy.versions.toml +++ b/gradle/sy.versions.toml @@ -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" diff --git a/i18n/src/commonMain/moko-resources/base/plurals.xml b/i18n/src/commonMain/moko-resources/base/plurals.xml index e436a8cb5..c257f6764 100644 --- a/i18n/src/commonMain/moko-resources/base/plurals.xml +++ b/i18n/src/commonMain/moko-resources/base/plurals.xml @@ -95,4 +95,18 @@ %d repo %d repos + + + + Migrate %1$d entry? + Migrate %1$d entries? + + + Copy %1$d entry? + Copy %1$d entries? + + + An entry was skipped + %1$d entries were skipped + diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 645684814..2abce5b15 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -1027,4 +1027,23 @@ Breaks down the title into keywords for a wider search Match based on chapter number If enabled, chooses the match furthest ahead. Otherwise, picks the first match by source priority. + Migration + Migration (%1$d/%2$d) + Copy + Migrate + No alternatives found + Latest: %1$s + Unknown + Search manually + Don\'t migrate + Migrate now + Copy now + Stop migrating? + Stop + Cancel + Copy + Migrate + Cancel + Cancel + No chapters found, this entry cannot be used for migration