diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 599f3723c..1fc53fcc6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -278,6 +278,9 @@ dependencies { // Shizuku implementation(libs.bundles.shizuku) + // String similarity + implementation(libs.stringSimilarity) + // Tests testImplementation(libs.bundles.test) testRuntimeOnly(libs.junit.platform.launcher) 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 2115574e5..909b5e33c 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,6 +11,7 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel import eu.kanade.tachiyomi.ui.manga.MangaScreen import mihon.feature.migration.dialog.MigrateMangaDialog +import mihon.feature.migration.list.MigrationListScreen class MigrateSearchScreen(private val mangaId: Long) : Screen() { @@ -31,7 +32,18 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() { onChangeSearchFilter = screenModel::setSourceFilter, onToggleResults = screenModel::toggleFilterResults, onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) }, - onClickItem = { screenModel.setMigrateDialog(mangaId, it) }, + onClickItem = { + val migrateListScreen = navigator.items + .filterIsInstance() + .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)) }, ) 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 18edbe6db..dc606ea1e 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 @@ -29,6 +29,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen import kotlinx.coroutines.launch import mihon.feature.migration.dialog.MigrateMangaDialog +import mihon.feature.migration.list.MigrationListScreen import mihon.presentation.core.util.collectAsLazyPagingItems import tachiyomi.core.common.Constants import tachiyomi.domain.manga.model.Manga @@ -83,7 +84,16 @@ data class MigrateSourceSearchScreen( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { paddingValues -> val openMigrateDialog: (Manga) -> Unit = { - screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga)) + val migrateListScreen = navigator.items + .filterIsInstance() + .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, 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 8fb2daf19..499a33397 100644 --- a/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt +++ b/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt @@ -50,6 +50,7 @@ import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen import eu.kanade.tachiyomi.util.system.LocaleHelper import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.update +import mihon.feature.migration.list.MigrationListScreen import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyListState @@ -69,7 +70,9 @@ import tachiyomi.presentation.core.util.shouldExpandFAB import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class MigrationConfigScreen(private val mangaId: Long) : Screen() { +class MigrationConfigScreen(private val mangaIds: List) : Screen() { + + constructor(mangaId: Long) : this(listOf(mangaId)) @Composable override fun Content() { @@ -81,7 +84,17 @@ class MigrationConfigScreen(private val mangaId: Long) : Screen() { var migrationSheetOpen by rememberSaveable { mutableStateOf(false) } fun continueMigration(openSheet: Boolean, extraSearchQuery: String?) { - navigator.replace(MigrateSearchScreen(mangaId)) + val mangaId = mangaIds.singleOrNull() + if (mangaId == null && openSheet) { + migrationSheetOpen = true + return + } + val screen = if (mangaId == null) { + MigrationListScreen(mangaIds, extraSearchQuery) + } else { + MigrateSearchScreen(mangaId) + } + 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 new file mode 100644 index 000000000..3d40dd898 --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt @@ -0,0 +1,107 @@ +package mihon.feature.migration.list + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +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 mihon.feature.migration.list.components.MigrationExitDialog +import mihon.feature.migration.list.components.MigrationMangaDialog +import mihon.feature.migration.list.components.MigrationProgressDialog +import tachiyomi.i18n.MR + +class MigrationListScreen(private val mangaIds: List, private val extraSearchQuery: String?) : Screen() { + + private var matchOverride: Pair? by mutableStateOf(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 + + LaunchedEffect(matchOverride) { + 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.navigateBackEvent.collect { + navigator.pop() + } + } + MigrationListScreenContent( + items = state.items, + migrationComplete = state.migrationComplete, + finishedCount = state.finishedCount, + onItemClick = { + navigator.push(MangaScreen(it.id, true)) + }, + onSearchManually = { migrationItem -> + navigator push MigrateSearchScreen(migrationItem.manga.id) + }, + onSkip = { screenModel.removeManga(it) }, + onMigrate = { screenModel.migrateNow(mangaId = it, replace = true) }, + onCopy = { screenModel.migrateNow(mangaId = it, replace = false) }, + openMigrationDialog = screenModel::showMigrateDialog, + ) + + when (val dialog = state.dialog) { + is MigrationListScreenModel.Dialog.Migrate -> { + MigrationMangaDialog( + onDismissRequest = screenModel::dismissDialog, + copy = dialog.copy, + totalCount = dialog.totalCount, + skippedCount = dialog.skippedCount, + 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 = screenModel::dismissDialog, + exitMigration = navigator::pop, + ) + } + null -> Unit + } + + BackHandler(true) { + 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 new file mode 100644 index 000000000..11655ebb7 --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListScreenContent.kt @@ -0,0 +1,375 @@ +package mihon.feature.migration.list + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.CopyAll +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 +import androidx.compose.ui.draw.clip +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.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +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.domain.manga.model.Manga +import tachiyomi.i18n.MR +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 + +@Composable +fun MigrationListScreenContent( + items: ImmutableList, + migrationComplete: Boolean, + finishedCount: Int, + onItemClick: (Manga) -> Unit, + onSearchManually: (MigratingManga) -> Unit, + onSkip: (Long) -> Unit, + onMigrate: (Long) -> Unit, + onCopy: (Long) -> Unit, + openMigrationDialog: (Boolean) -> Unit, +) { + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = if (items.isNotEmpty()) { + stringResource(MR.strings.migrationListScreenTitleWithProgress, finishedCount, items.size) + } else { + stringResource(MR.strings.migrationListScreenTitle) + }, + actions = { + AppBarActions( + persistentListOf( + AppBar.Action( + 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.migrationListScreen_migrateActionLabel), + icon = if (items.size == 1) Icons.Outlined.Done else Icons.Outlined.DoneAll, + onClick = { openMigrationDialog(false) }, + enabled = migrationComplete, + ), + ), + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + FastScrollLazyColumn(contentPadding = contentPadding + topSmallPaddingValues) { + items(items, key = { it.manga.id }) { item -> + Row( + Modifier + .fillMaxWidth() + .animateItemFastScroll() + .padding( + start = MaterialTheme.padding.medium, + end = MaterialTheme.padding.small, + ) + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MigrationListItem( + modifier = Modifier + .weight(1f) + .align(Alignment.Top) + .fillMaxHeight(), + manga = item.manga, + source = item.source, + chapterCount = item.chapterCount, + latestChapter = item.latestChapter, + onClick = { onItemClick(item.manga) }, + ) + + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = null, + modifier = Modifier.weight(0.2f), + ) + + val result by item.searchResult.collectAsState() + MigrationListItemResult( + modifier = Modifier + .weight(1f) + .align(Alignment.Top) + .fillMaxHeight(), + result = result, + onItemClick = onItemClick, + ) + + MigrationListItemAction( + modifier = Modifier.weight(0.2f), + result = result, + onSearchManually = { onSearchManually(item) }, + onSkip = { onSkip(item.manga.id) }, + onMigrate = { onMigrate(item.manga.id) }, + onCopy = { onCopy(item.manga.id) }, + ) + } + } + } + } +} + +@Composable +fun MigrationListItem( + modifier: Modifier, + manga: Manga, + source: String, + chapterCount: Int, + latestChapter: Double?, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .widthIn(max = 150.dp) + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .padding(4.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(MangaCover.Book.ratio), + ) { + MangaCover.Book( + modifier = Modifier.fillMaxWidth(), + data = manga, + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp)) + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 1f to Color(0xAA000000), + ), + ) + .fillMaxHeight(0.33f) + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + Text( + modifier = Modifier + .padding(8.dp) + .align(Alignment.BottomStart), + text = manga.title, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = MaterialTheme.typography.labelMedium + .merge(shadow = Shadow(color = Color.Black, blurRadius = 4f)), + ) + BadgeGroup(modifier = Modifier.padding(4.dp)) { + Badge(text = "$chapterCount") + } + } + + 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, + ) + } + } +} + +@Composable +fun MigrationListItemResult( + modifier: Modifier, + result: MigratingManga.SearchResult, + onItemClick: (Manga) -> Unit, +) { + Box(modifier.height(IntrinsicSize.Min)) { + when (result) { + MigratingManga.SearchResult.Searching -> { + Box( + modifier = Modifier + .widthIn(max = 150.dp) + .fillMaxSize() + .aspectRatio(MangaCover.Book.ratio), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + 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 +private fun MigrationListItemAction( + modifier: Modifier, + result: MigratingManga.SearchResult, + onSearchManually: () -> Unit, + onSkip: () -> Unit, + onMigrate: () -> Unit, + onCopy: () -> Unit, +) { + var menuExpanded by rememberSaveable { mutableStateOf(false) } + val closeMenu = { menuExpanded = false } + Box(modifier) { + when (result) { + MigratingManga.SearchResult.Searching -> { + IconButton(onClick = onSkip) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + ) + } + } + MigratingManga.SearchResult.NotFound, is MigratingManga.SearchResult.Success -> { + IconButton(onClick = { menuExpanded = true }) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = null, + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = closeMenu, + offset = DpOffset(8.dp, (-56).dp), + ) { + DropdownMenuItem( + text = { Text(stringResource(MR.strings.migrationListScreen_searchManuallyActionLabel)) }, + onClick = { + closeMenu() + onSearchManually() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(MR.strings.migrationListScreen_skipActionLabel)) }, + onClick = { + closeMenu() + onSkip() + }, + ) + if (result is MigratingManga.SearchResult.Success) { + DropdownMenuItem( + text = { Text(stringResource(MR.strings.migrationListScreen_migrateNowActionLabel)) }, + onClick = { + closeMenu() + onMigrate() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(MR.strings.migrationListScreen_copyNowActionLabel)) }, + onClick = { + 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 new file mode 100644 index 000000000..2bc216e8b --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt @@ -0,0 +1,380 @@ +package mihon.feature.migration.list + +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 +import eu.kanade.domain.manga.model.toSManga +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.getNameForMangaInfo +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.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import logcat.LogPriority +import mihon.domain.migration.usecases.MigrateMangaUseCase +import mihon.feature.migration.list.models.MigratingManga +import mihon.feature.migration.list.models.MigratingManga.SearchResult +import mihon.feature.migration.list.search.SmartSourceSearchEngine +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.lang.withUIContext +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId +import tachiyomi.domain.manga.interactor.GetManga +import tachiyomi.domain.manga.interactor.NetworkToLocalManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.source.service.SourceManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class MigrationListScreenModel( + mangaIds: List, + extraSearchQuery: String?, + private val preferences: SourcePreferences = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), + private val getManga: GetManga = Injekt.get(), + private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), + private val updateManga: UpdateManga = Injekt.get(), + private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(), + private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(), + private val migrateManga: MigrateMangaUseCase = Injekt.get(), +) : StateScreenModel(State()) { + + private val smartSearchEngine = SmartSourceSearchEngine(extraSearchQuery) + + val items + inline get() = state.value.items + + private val hideUnmatched = preferences.migrationHideUnmatched().get() + private val hideWithoutUpdates = preferences.migrationHideWithoutUpdates().get() + + private val navigateBackChannel = Channel() + val navigateBackEvent = navigateBackChannel.receiveAsFlow() + + private var migrateJob: Job? = null + + init { + screenModelScope.launchIO { + 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(), + parentContext = screenModelScope.coroutineContext, + ) + } + } + .awaitAll() + .filterNotNull() + mutableState.update { it.copy(items = manga.toImmutableList()) } + runMigrations(manga) + } + } + + private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters -> + ChapterInfo( + latestChapter = chapters.maxOfOrNull { it.chapterNumber }, + chapterCount = chapters.size, + ) + } + + 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) { + val prioritizeByChapters = preferences.migrationPrioritizeByChapters().get() + val deepSearchMode = preferences.migrationDeepSearchMode().get() + + val sources = preferences.migrationSources().get() + .mapNotNull { sourceManager.get(it) as? CatalogueSource } + + for (manga in mangas) { + 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 { + manga.migrationScope.async { + 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 + } + } + .await() + } catch (e: CancellationException) { + 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) { + } + } + + 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() + } + } + + private suspend fun searchSource( + manga: Manga, + source: CatalogueSource, + deepSearchMode: Boolean, + ): Pair? { + return try { + val searchResult = if (deepSearchMode) { + smartSearchEngine.deepSearch(source, manga.title) + } else { + smartSearchEngine.regularSearch(source, manga.title) + } + + if (searchResult == null || !(searchResult.url == manga.url && source.id == manga.source)) return null + + val localManga = networkToLocalManga(searchResult) + try { + val chapters = source.getChapterList(localManga.toSManga()) + syncChaptersWithSource.await(chapters, localManga, source) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + } + localManga to getChapterInfo(localManga.id) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } + + private suspend fun updateMigrationProgress() { + mutableState.update { state -> + state.copy( + finishedCount = items.count { it.searchResult.value != SearchResult.Searching }, + migrationComplete = migrationComplete(), + ) + } + if (items.isEmpty()) { + navigateBack() + } + } + + private fun migrationComplete() = items.all { it.searchResult.value != SearchResult.Searching } && + items.any { it.searchResult.value is SearchResult.Success } + + 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.await(target) ?: return@async null + try { + val source = sourceManager.get(manga.source)!! + val chapters = source.getChapterList(manga.toSManga()) + syncChaptersWithSource.await(chapters, manga, source) + } catch (_: Exception) { + return@async null + } + manga + } + .await() + + if (result == null) { + migratingManga.searchResult.value = SearchResult.NotFound + withUIContext { onMissingChapters() } + return@launchIO + } + + try { + val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga()) + updateManga.awaitUpdateFromSource(result, newManga, true) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + } + migratingManga.searchResult.value = result.toSuccessSearchResult() + } + } + + fun migrateMangas() { + migrateMangas(replace = true) + } + + fun copyMangas() { + migrateMangas(replace = false) + } + + private fun migrateMangas(replace: Boolean) { + migrateJob = screenModelScope.launchIO { + 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) { + it.manga + } else { + null + } + } + if (target != null) { + migrateManga(current = manga.manga, target = target, replace = replace) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + logcat(LogPriority.WARN, throwable = e) + } + mutableState.update { + it.copy(dialog = Dialog.Progress((index.toFloat() / items.size).coerceAtMost(1f))) + } + } + + navigateBack() + } finally { + mutableState.update { it.copy(dialog = null) } + migrateJob = null + } + } + } + + fun cancelMigrate() { + migrateJob?.cancel() + migrateJob = null + } + + private suspend fun navigateBack() { + navigateBackChannel.send(Unit) + } + + fun migrateNow(mangaId: Long, replace: Boolean) { + screenModelScope.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) + + removeManga(mangaId) + } + } + + fun removeManga(mangaId: Long) { + screenModelScope.launchIO { + val item = items.find { it.manga.id == mangaId } ?: return@launchIO + removeManga(item) + item.migrationScope.cancel() + updateMigrationProgress() + } + } + + private fun removeManga(item: MigratingManga) { + mutableState.update { it.copy(items = items.toPersistentList().remove(item)) } + } + + override fun onDispose() { + super.onDispose() + items.forEach { + it.migrationScope.cancel() + } + } + + fun showMigrateDialog(copy: Boolean) { + mutableState.update { state -> + state.copy( + dialog = Dialog.Migrate( + copy = copy, + totalCount = items.size, + skippedCount = items.count { it.searchResult.value == SearchResult.NotFound }, + ), + ) + } + } + + fun showExitDialog() { + mutableState.update { + it.copy(dialog = Dialog.Exit) + } + } + + fun dismissDialog() { + mutableState.update { it.copy(dialog = null) } + } + + 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 + } + + 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 new file mode 100644 index 000000000..743efb21c --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/components/MigrationExitDialog.kt @@ -0,0 +1,31 @@ +package mihon.feature.migration.list.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun MigrationExitDialog( + onDismissRequest: () -> Unit, + exitMigration: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(MR.strings.migrationListScreen_exitDialogTitle)) + }, + confirmButton = { + TextButton(onClick = exitMigration) { + Text(text = stringResource(MR.strings.migrationListScreen_exitDialog_stopLabel)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + 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 new file mode 100644 index 000000000..e280fe9e3 --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/components/MigrationMangaDialog.kt @@ -0,0 +1,64 @@ +package mihon.feature.migration.list.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.pluralStringResource +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun MigrationMangaDialog( + onDismissRequest: () -> Unit, + copy: Boolean, + totalCount: Int, + skippedCount: Int, + onMigrate: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text( + text = pluralStringResource( + resource = if (copy) { + MR.plurals.migrationListScreen_migrateDialog_copyTitle + } else { + MR.plurals.migrationListScreen_migrateDialog_migrateTitle + }, + count = totalCount, + totalCount, + ), + ) + }, + text = { + if (skippedCount > 0) { + Text( + text = pluralStringResource( + resource = MR.plurals.migrationListScreen_migrateDialog_skipText, + count = skippedCount, + skippedCount, + ), + ) + } + }, + confirmButton = { + 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.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 new file mode 100644 index 000000000..6eb81ae6e --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/components/MigrationProgressDialog.kt @@ -0,0 +1,44 @@ +package mihon.feature.migration.list.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.window.DialogProperties +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun MigrationProgressDialog( + progress: Float, + exitMigration: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = exitMigration) { + Text(text = stringResource(MR.strings.migrationListScreen_progressDialog_cancelLabel)) + } + }, + text = { + if (!progress.isNaN()) { + val progressAnimated by animateFloatAsState( + targetValue = progress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "migration_progress", + ) + LinearProgressIndicator( + progress = { progressAnimated }, + ) + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) +} 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 new file mode 100644 index 000000000..4764067b9 --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/models/MigratingManga.kt @@ -0,0 +1,31 @@ +package mihon.feature.migration.list.models + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import tachiyomi.domain.manga.model.Manga +import kotlin.coroutines.CoroutineContext + +class MigratingManga( + val manga: Manga, + val chapterCount: Int, + val latestChapter: Double?, + val source: String, + parentContext: CoroutineContext, +) { + val migrationScope = CoroutineScope(parentContext + SupervisorJob() + Dispatchers.Default) + + val searchResult = MutableStateFlow(SearchResult.Searching) + + 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 new file mode 100644 index 000000000..9de2c5447 --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt @@ -0,0 +1,152 @@ +package mihon.feature.migration.list.search + +import com.aallam.similarity.NormalizedLevenshtein +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.supervisorScope +import java.util.Locale + +typealias SearchAction = suspend (String) -> List + +abstract class BaseSmartSearchEngine( + private val extraSearchParams: String? = null, + private val eligibleThreshold: Double = MIN_ELIGIBLE_THRESHOLD, +) { + private val normalizedLevenshtein = NormalizedLevenshtein() + + 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 = 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) { + "$query ${extraSearchParams.trim()}" + } else { + query + } + + val candidates = searchAction(builtQuery) + 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() } + } + + return eligibleManga.maxByOrNull { it.distance }?.entry + } + + private fun cleanDeepSearchTitle(title: String): String { + val preTitle = title.lowercase(Locale.getDefault()) + + // Remove text in brackets + var cleanedTitle = removeTextInBrackets(preTitle, true) + if (cleanedTitle.length <= 5) { // Title is suspiciously short, try parsing it backwards + cleanedTitle = removeTextInBrackets(preTitle, false) + } + + // Strip chapter reference RU + cleanedTitle = cleanedTitle.replace(chapterRefCyrillicRegexp, " ").trim() + + // Strip non-special characters + val cleanedTitleEng = cleanedTitle.replace(titleRegex, " ") + + // Do not strip foreign language letters if cleanedTitle is too short + cleanedTitle = if (cleanedTitleEng.length <= 5) { + cleanedTitle.replace(titleCyrillicRegex, " ") + } else { + cleanedTitleEng + } + + // Strip splitters and consecutive spaces + cleanedTitle = cleanedTitle.trim().replace(" - ", " ").replace(consecutiveSpacesRegex, " ").trim() + + return cleanedTitle + } + + private fun removeTextInBrackets(text: String, readForward: Boolean): String { + val openingChars = if (readForward) "([<{ " else ")]}>" + val closingChars = if (readForward) ")]}>" else "([<{ " + var depth = 0 + + return buildString { + for (char in (if (readForward) text else text.reversed())) { + when (char) { + in openingChars -> depth++ + in closingChars -> if (depth > 0) depth-- // Avoid depth going negative on mismatched closing + else -> if (depth == 0) { + // If reading backward, the result is reversed, so prepend + if (readForward) append(char) else insert(0, char) + } + } + } + } + } + + private fun getDeepSearchQueries(cleanedTitle: String): List { + val splitCleanedTitle = cleanedTitle.split(" ") + val splitSortedByLargest = splitCleanedTitle.sortedByDescending { it.length } + + if (splitCleanedTitle.isEmpty()) { + return emptyList() + } + + // Search cleaned title + // Search two largest words + // Search largest word + // Search first two words + // Search first word + val searchQueries = listOf( + listOf(cleanedTitle), + splitSortedByLargest.take(2), + splitSortedByLargest.take(1), + splitCleanedTitle.take(2), + splitCleanedTitle.take(1), + ) + + return searchQueries + .map { it.joinToString(" ").trim() } + .distinct() + } + + companion object { + const val MIN_ELIGIBLE_THRESHOLD = 0.4 + + private val titleRegex = Regex("[^a-zA-Z0-9- ]") + private val titleCyrillicRegex = Regex("[^\\p{L}0-9- ]") + private val consecutiveSpacesRegex = Regex(" +") + private val chapterRefCyrillicRegexp = Regex("""((- часть|- глава) \d*)""") + } +} + +data class SearchEntry(val entry: T, val distance: Double) 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 new file mode 100644 index 000000000..82ee6254e --- /dev/null +++ b/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt @@ -0,0 +1,28 @@ +package mihon.feature.migration.list.search + +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import mihon.domain.manga.model.toDomainManga +import tachiyomi.domain.manga.model.Manga + +class SmartSourceSearchEngine(extraSearchParams: String?) : BaseSmartSearchEngine(extraSearchParams) { + + override fun getTitle(result: SManga) = result.title + + 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? { + return deepSearch(makeSearchAction(source), title).let { + it?.toDomainManga(source.id) + } + } + + private fun makeSearchAction(source: CatalogueSource): SearchAction = { query -> + source.getSearchManga(1, query, FilterList()).mangas + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc5a17d1b..cc24815ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,6 +106,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" } + [plugins] google-services = { id = "com.google.gms.google-services", version = "4.4.2" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlib_version" } 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 9105e64de..93877a1fc 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -1016,4 +1016,24 @@ 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 + Copy now + Stop migrating? + Stop + Cancel + Copy + Migrate + Cancel + Cancel + No chapters found, this entry cannot be used for migration