refactor(migration): clean up migration list logic and UI components (#1403)

*   Merged `MigrationItem`, `MigrationItemResult`, and `MigrationActionIcon` into `MigrationListItem` variants within `MigrationListScreenContent.kt` for better cohesion.
*   Simplified `MigrationListScreen` and `MigrationListScreenModel` by removing `MigrationProcedureConfig` and `MigrationType` in favor of direct parameters (`mangaIds`, `extraSearchQuery`).
*   Renamed `newSelectedItem` to `matchOverride` in migration screens.
*   Updated `SmartSourceSearchEngine` and `BaseSmartSearchEngine` method names for clarity: `smartSearch` -> `regularSearch` and `normalSearch` -> `deepSearch`.
*   Refactored search result states: `SearchResult.Result` is now `SearchResult.Success`.
*   Improved `MigrationMangaDialog` and `MigrationExitDialog` by standardizing parameter names and structure.
*   Inlined `BrowseSourceFloatingActionButton` logic into `SourceFeedScreen` and `MigrateSourceSearchScreen` using standard `SmallExtendedFloatingActionButton`.
*   Cleaned up various internal field names and logic related to "unmatched" vs "not found" entries.
This commit is contained in:
Cuong-Tran 2025-12-14 12:00:00 +07:00
parent e2a92b9220
commit bc581e1d4b
19 changed files with 538 additions and 667 deletions

View file

@ -4,7 +4,11 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
@ -12,7 +16,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.browse.components.BrowseSourceFloatingActionButton
import eu.kanade.presentation.browse.components.GlobalSearchCardRow
import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
@ -31,6 +34,7 @@ import tachiyomi.domain.source.model.FeedSavedSearch
import tachiyomi.domain.source.model.SavedSearch
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.topSmallPaddingValues
@ -156,11 +160,22 @@ fun SourceFeedScreen(
}
},
floatingActionButton = {
BrowseSourceFloatingActionButton(
isVisible = hasFilters,
onFabClick = onFabClick,
// KMK -->
SmallExtendedFloatingActionButton(
text = {
Text(
text = if (hasFilters) {
stringResource(MR.strings.action_filter)
} else {
stringResource(SYMR.strings.saved_searches)
},
)
},
icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
onClick = onFabClick,
)
// KMK <--
},
) { paddingValues ->
Crossfade(targetState = isLoading, label = "source_feed") { state ->
when (state) {

View file

@ -1,34 +0,0 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun BrowseSourceFloatingActionButton(
isVisible: Boolean,
onFabClick: () -> Unit,
modifier: Modifier = Modifier,
) {
SmallExtendedFloatingActionButton(
modifier = modifier,
text = {
Text(
text = if (isVisible) {
stringResource(MR.strings.action_filter)
} else {
stringResource(SYMR.strings.saved_searches)
},
)
},
icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
onClick = onFabClick,
)
}

View file

@ -1,95 +0,0 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import mihon.feature.migration.list.models.MigratingManga
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun MigrationActionIcon(
modifier: Modifier,
result: MigratingManga.SearchResult,
skipManga: () -> Unit,
// KMK -->
cancelManga: () -> Unit,
// KMK <--
searchManually: () -> Unit,
migrateNow: () -> Unit,
copyNow: () -> Unit,
) {
var moreExpanded by remember { mutableStateOf(false) }
val closeMenu = { moreExpanded = false }
Box(modifier) {
if (result is MigratingManga.SearchResult.Searching) {
// KMK -->
IconButton(onClick = cancelManga) {
// KMK <--
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(SYMR.strings.action_stop),
)
}
} else if (result is MigratingManga.SearchResult.Result || result is MigratingManga.SearchResult.NotFound) {
IconButton(onClick = { moreExpanded = !moreExpanded }) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(MR.strings.action_menu_overflow_description),
)
}
DropdownMenu(
expanded = moreExpanded,
onDismissRequest = closeMenu,
offset = DpOffset(8.dp, (-56).dp),
) {
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_search_manually)) },
onClick = {
searchManually()
closeMenu()
},
)
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_skip_entry)) },
onClick = {
skipManga()
closeMenu()
},
)
if (result is MigratingManga.SearchResult.Result) {
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_migrate_now)) },
onClick = {
migrateNow()
closeMenu()
},
)
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_copy_now)) },
onClick = {
copyNow()
closeMenu()
},
)
}
}
}
}
}

View file

@ -1,118 +0,0 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
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.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.manga.components.MangaCover
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.presentation.core.components.Badge
import tachiyomi.presentation.core.components.BadgeGroup
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun MigrationItem(
modifier: Modifier,
manga: Manga,
sourcesString: String,
chapterInfo: MigratingManga.ChapterInfo,
onClick: () -> Unit,
) {
Column(
modifier
.widthIn(max = 150.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onClick)
.padding(4.dp),
) {
val context = LocalContext.current
Box(
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.ifBlank { stringResource(MR.strings.unknown) },
fontSize = 12.sp,
lineHeight = 18.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall.copy(
color = Color.White,
shadow = Shadow(
color = Color.Black,
blurRadius = 4f,
),
),
)
BadgeGroup(modifier = Modifier.padding(4.dp)) {
Badge(text = "${chapterInfo.chapterCount}")
}
}
Text(
text = sourcesString,
modifier = Modifier.padding(top = 4.dp, bottom = 1.dp, start = 8.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.titleSmall,
)
val formattedLatestChapter by produceState(initialValue = "") {
value = withIOContext {
chapterInfo.getFormattedLatestChapter(context)
}
}
Text(
text = formattedLatestChapter,
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp, start = 8.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
)
}
}

View file

@ -1,105 +0,0 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.aspectRatio
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.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R
import mihon.feature.migration.list.models.MigratingManga
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun MigrationItemResult(
modifier: Modifier,
migrationItem: MigratingManga,
result: MigratingManga.SearchResult,
getManga: suspend (MigratingManga.SearchResult.Result) -> Manga?,
getChapterInfo: suspend (MigratingManga.SearchResult.Result) -> MigratingManga.ChapterInfo,
getSourceName: (Manga) -> String,
onMigrationItemClick: (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(top = 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(SYMR.strings.no_alternatives_found),
modifier = Modifier.padding(top = 4.dp, bottom = 1.dp, start = 8.dp),
style = MaterialTheme.typography.titleSmall,
)
}
is MigratingManga.SearchResult.Result -> {
val item by produceState<Triple<Manga, MigratingManga.ChapterInfo, String>?>(
initialValue = null,
migrationItem,
result,
) {
value = withIOContext {
val manga = getManga(result) ?: return@withIOContext null
Triple(
manga,
getChapterInfo(result),
getSourceName(manga),
)
}
}
if (item != null) {
val (manga, chapterInfo, source) = item!!
MigrationItem(
modifier = Modifier.fillMaxSize(),
manga = manga,
sourcesString = source,
chapterInfo = chapterInfo,
onClick = {
onMigrationItemClick(manga)
},
)
}
}
}
}
}

View file

@ -42,18 +42,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 = { manga ->
onClickItem = {
// KMK -->
if (bulkFavoriteState.selectionMode) {
bulkFavoriteScreenModel.toggleSelection(manga)
bulkFavoriteScreenModel.toggleSelection(it)
} else {
// KMK <--
// SY -->
navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.newSelectedItem = mangaId to manga.id
navigator.popUntil { it is MigrationListScreen }
.matchOverride = mangaId to it.id
navigator.popUntil { screen -> screen is MigrationListScreen }
// SY <--
}
},

View file

@ -1,12 +1,20 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import androidx.activity.compose.BackHandler
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@ -15,7 +23,6 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.browse.components.BrowseSourceFloatingActionButton
import eu.kanade.presentation.browse.components.BulkFavoriteDialogs
import eu.kanade.presentation.browse.components.bulkSelectionButton
import eu.kanade.presentation.components.AppBarActions
@ -34,6 +41,7 @@ import mihon.feature.migration.list.MigrationListScreen
import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
@ -121,12 +129,15 @@ data class MigrateSourceSearchScreen(
}
},
floatingActionButton = {
// SY -->
BrowseSourceFloatingActionButton(
isVisible = state.filters.isNotEmpty(),
onFabClick = screenModel::openFilterSheet,
SmallExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_filter)) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = null) },
onClick = screenModel::openFilterSheet,
modifier = Modifier.animateFloatingActionButton(
visible = state.filters.isNotEmpty(),
alignment = Alignment.BottomEnd,
),
)
// SY <--
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues ->
@ -135,7 +146,7 @@ data class MigrateSourceSearchScreen(
navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.newSelectedItem = currentManga.id to it.id
.matchOverride = currentManga.id to it.id
navigator.popUntil { it is MigrationListScreen }
// SY <--
}

View file

@ -10,7 +10,7 @@ class SmartLibrarySearchEngine(
override fun getTitle(result: LibraryManga) = result.manga.ogTitle
suspend fun smartSearch(library: List<LibraryManga>, title: String): LibraryManga? =
smartSearch(
deepSearch(
{ query ->
library.filter { it.manga.ogTitle.contains(query, true) }
},

View file

@ -26,7 +26,7 @@ class SmartSearchScreenModel(
init {
screenModelScope.launchIO {
val result = try {
val resultManga = smartSearchEngine.smartSearch(source, config.origTitle)
val resultManga = smartSearchEngine.deepSearch(source, config.origTitle)
if (resultManga != null) {
val localManga = networkToLocalManga(resultManga)
SearchResults.Found(localManga)

View file

@ -62,8 +62,6 @@ import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.update
import mihon.feature.migration.list.MigrationListScreen
import mihon.feature.migration.list.MigrationProcedureConfig
import mihon.feature.migration.list.MigrationType
import sh.calvin.reorderable.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.ReorderableLazyListState
@ -100,13 +98,14 @@ class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ScreenModel() }
val state by screenModel.state.collectAsState()
// KMK -->
var searchQuery by remember { mutableStateOf("") }
BackHandler(enabled = searchQuery.isNotBlank()) {
searchQuery = ""
}
// KMK <--
val state by screenModel.state.collectAsState()
var migrationSheetOpen by rememberSaveable { mutableStateOf(false) }
@ -119,9 +118,7 @@ class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() {
navigator.replace(
// KMK -->
// MigrateSearchScreen(mangaId)
MigrationListScreen(
MigrationProcedureConfig(MigrationType.MangaList(mangaIds), extraSearchQuery),
),
MigrationListScreen(mangaIds, extraSearchQuery),
// KMK <--
)
}

View file

@ -29,48 +29,33 @@ import tachiyomi.i18n.sy.SYMR
/**
* Screen showing a list of pair of current-target manga entries being migrated.
*/
class MigrationListScreen(private val config: MigrationProcedureConfig) : Screen() {
class MigrationListScreen(private val mangaIds: List<Long>, private val extraSearchQuery: String?) : Screen() {
var newSelectedItem: Pair<Long, Long>? = null
var matchOverride: Pair<Long, Long>? = null
@Composable
override fun Content() {
val screenModel = rememberScreenModel { MigrationListScreenModel(config) }
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { MigrationListScreenModel(mangaIds, extraSearchQuery) }
val context = LocalContext.current
val items by screenModel.migratingItems.collectAsState()
val migrationDone by screenModel.migrationDone.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()
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
LaunchedEffect(items) {
if (items?.isEmpty() == true) {
val manualMigrations = screenModel.manualMigrations.value
context.toast(
context.pluralStringResource(
SYMR.plurals.entry_migrated,
manualMigrations,
manualMigrations,
),
)
if (!screenModel.hideNotFound) {
navigator.pop()
}
}
}
LaunchedEffect(newSelectedItem) {
if (newSelectedItem != null) {
val (oldId, newId) = newSelectedItem!!
LaunchedEffect(matchOverride) {
if (matchOverride != null) {
val (oldId, newId) = matchOverride!!
screenModel.useMangaForMigration(context, newId, oldId)
newSelectedItem = null
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.Result)?.id
val mangaId = (items.orEmpty().firstOrNull()?.searchResult?.value as? MigratingManga.SearchResult.Success)?.id
withUIContext {
if (mangaId != null) {
val newStack = navigator.items.filter {
@ -95,28 +80,44 @@ class MigrationListScreen(private val config: MigrationProcedureConfig) : Screen
}
}
}
LaunchedEffect(items) {
if (items?.isEmpty() == true) {
val manualMigrations = screenModel.manualMigrations.value
context.toast(
context.pluralStringResource(
SYMR.plurals.entry_migrated,
manualMigrations,
manualMigrations,
),
)
if (!screenModel.hideUnmatched) {
navigator.pop()
}
}
}
MigrationListScreenContent(
items = items ?: persistentListOf(),
migrationDone = migrationDone,
migrationComplete = migrationComplete,
finishedCount = finishedCount,
getManga = screenModel::getManga,
getChapterInfo = screenModel::getChapterInfo,
getSourceName = screenModel::getSourceName,
onMigrationItemClick = {
onItemClick = {
navigator.push(MangaScreen(it.id, true))
},
openMigrationDialog = screenModel::openMigrateDialog,
skipManga = { screenModel.removeManga(it) },
// KMK -->
cancelManga = { screenModel.cancelManga(it) },
navigateUp = { navigator.pop() },
openMigrationOptionsDialog = screenModel::openMigrationOptionsDialog,
// KMK <--
searchManually = { migrationItem ->
onSearchManually = { migrationItem ->
navigator push MigrateSearchScreen(migrationItem.manga.id)
},
migrateNow = { screenModel.migrateManga(it, true) },
copyNow = { screenModel.migrateManga(it, false) },
onSkip = { screenModel.removeManga(it) },
onMigrate = { screenModel.migrateNow(it, true) },
onCopy = { screenModel.migrateNow(it, false) },
openMigrationDialog = screenModel::showMigrateDialog,
// KMK -->
onCancel = { screenModel.cancelManga(it) },
navigateUp = { navigator.pop() },
openOptionsDialog = screenModel::openOptionsDialog,
// KMK <--
)
val onDismissRequest = { screenModel.dialog.value = null }
@ -124,24 +125,24 @@ class MigrationListScreen(private val config: MigrationProcedureConfig) : Screen
@Suppress("NAME_SHADOWING")
val dialog = dialog
) {
is MigrationListScreenModel.Dialog.MigrateMangaDialog -> {
is MigrationListScreenModel.Dialog.Migrate -> {
MigrationMangaDialog(
onDismissRequest = onDismissRequest,
copy = dialog.copy,
mangaSet = dialog.mangaSet,
mangaSkipped = dialog.mangaSkipped,
totalCount = dialog.totalCount,
skippedCount = dialog.skippedCount,
copyManga = screenModel::copyMangas,
migrateManga = screenModel::migrateMangas,
onMigrate = screenModel::migrateMangas,
)
}
MigrationListScreenModel.Dialog.MigrationExitDialog -> {
MigrationListScreenModel.Dialog.Exit -> {
MigrationExitDialog(
onDismissRequest = onDismissRequest,
exitMigration = navigator::pop,
)
}
// KMK -->
MigrationListScreenModel.Dialog.MigrationOptionsDialog -> {
MigrationListScreenModel.Dialog.Options -> {
MigrationConfigScreenSheet(
preferences = screenModel.preferences,
onDismissRequest = onDismissRequest,
@ -164,7 +165,7 @@ class MigrationListScreen(private val config: MigrationProcedureConfig) : Screen
}
BackHandler(true) {
screenModel.dialog.value = MigrationListScreenModel.Dialog.MigrationExitDialog
screenModel.dialog.value = MigrationListScreenModel.Dialog.Exit
}
}
}

View file

@ -1,33 +1,63 @@
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.material.icons.outlined.Settings
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.produceState
import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.browse.components.MigrationActionIcon
import eu.kanade.presentation.browse.components.MigrationItem
import eu.kanade.presentation.browse.components.MigrationItemResult
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.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import mihon.feature.migration.list.models.MigratingManga
@ -35,7 +65,9 @@ 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.ScrollbarLazyColumn
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.topSmallPaddingValues
import tachiyomi.presentation.core.i18n.stringResource
@ -44,22 +76,22 @@ import tachiyomi.presentation.core.util.plus
@Composable
fun MigrationListScreenContent(
items: ImmutableList<MigratingManga>,
migrationDone: Boolean,
migrationComplete: Boolean,
finishedCount: Int,
getManga: suspend (MigratingManga.SearchResult.Result) -> Manga?,
getChapterInfo: suspend (MigratingManga.SearchResult.Result) -> MigratingManga.ChapterInfo,
getManga: suspend (MigratingManga.SearchResult.Success) -> Manga?,
getChapterInfo: suspend (MigratingManga.SearchResult.Success) -> MigratingManga.ChapterInfo,
getSourceName: (Manga) -> String,
onMigrationItemClick: (Manga) -> Unit,
onItemClick: (Manga) -> Unit,
onSearchManually: (MigratingManga) -> Unit,
onSkip: (Long) -> Unit,
onMigrate: (Long) -> Unit,
onCopy: (Long) -> Unit,
openMigrationDialog: (Boolean) -> Unit,
skipManga: (Long) -> Unit,
// KMK -->
cancelManga: (Long) -> Unit,
onCancel: (Long) -> Unit,
navigateUp: () -> Unit,
openMigrationOptionsDialog: () -> Unit,
openOptionsDialog: () -> Unit,
// KMK <--
searchManually: (MigratingManga) -> Unit,
migrateNow: (Long) -> Unit,
copyNow: (Long) -> Unit,
) {
Scaffold(
topBar = { scrollBehavior ->
@ -81,20 +113,20 @@ fun MigrationListScreenContent(
AppBar.Action(
title = stringResource(MR.strings.action_settings),
icon = Icons.Outlined.Settings,
onClick = openMigrationOptionsDialog,
onClick = openOptionsDialog,
),
// KMK <--
AppBar.Action(
title = stringResource(MR.strings.copy),
icon = if (items.size == 1) Icons.Outlined.ContentCopy else Icons.Outlined.CopyAll,
onClick = { openMigrationDialog(true) },
enabled = migrationDone,
enabled = migrationComplete,
),
AppBar.Action(
title = stringResource(MR.strings.migrate),
icon = if (items.size == 1) Icons.Outlined.Done else Icons.Outlined.DoneAll,
onClick = { openMigrationDialog(false) },
enabled = migrationDone,
enabled = migrationComplete,
),
),
)
@ -103,30 +135,27 @@ fun MigrationListScreenContent(
)
},
) { contentPadding ->
ScrollbarLazyColumn(
contentPadding = contentPadding + topSmallPaddingValues,
) {
items(items, key = { "migration-list-${it.manga.id}" }) { migrationItem ->
FastScrollLazyColumn(contentPadding = contentPadding + topSmallPaddingValues) {
items(items, key = { "migration-list-${it.manga.id}" }) { item ->
Row(
Modifier
.fillMaxWidth()
.animateItem()
.animateItemFastScroll()
.padding(horizontal = 16.dp)
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val result by migrationItem.searchResult.collectAsState()
MigrationItem(
MigrationListItem(
modifier = Modifier
.padding(top = 8.dp)
.weight(1f)
.align(Alignment.Top)
.fillMaxHeight(),
manga = migrationItem.manga,
sourcesString = migrationItem.sourcesString,
chapterInfo = migrationItem.chapterInfo,
onClick = { onMigrationItemClick(migrationItem.manga) },
manga = item.manga,
source = item.source,
chapterInfo = item.chapterInfo,
onClick = { onItemClick(item.manga) },
)
Icon(
@ -135,34 +164,190 @@ fun MigrationListScreenContent(
modifier = Modifier.weight(0.2f),
)
MigrationItemResult(
val result by item.searchResult.collectAsState()
MigrationListItemResult(
modifier = Modifier
.padding(top = 8.dp)
.weight(1f)
.align(Alignment.Top)
.fillMaxHeight(),
migrationItem = migrationItem,
migrationItem = item,
result = result,
getManga = getManga,
getChapterInfo = getChapterInfo,
getSourceName = getSourceName,
onMigrationItemClick = onMigrationItemClick,
onItemClick = onItemClick,
)
MigrationActionIcon(
MigrationListItemAction(
modifier = Modifier
.weight(0.2f),
result = result,
skipManga = { skipManga(migrationItem.manga.id) },
onSearchManually = { onSearchManually(item) },
onSkip = { onSkip(item.manga.id) },
onMigrate = { onMigrate(item.manga.id) },
onCopy = { onCopy(item.manga.id) },
// KMK -->
cancelManga = { cancelManga(migrationItem.manga.id) },
onCancel = { onCancel(item.manga.id) },
// KMK <--
searchManually = { searchManually(migrationItem) },
migrateNow = {
migrateNow(migrationItem.manga.id)
},
copyNow = {
copyNow(migrationItem.manga.id)
)
}
}
}
}
}
@Composable
fun MigrationListItem(
modifier: Modifier,
manga: Manga,
source: String,
chapterInfo: MigratingManga.ChapterInfo,
onClick: () -> Unit,
) {
Column(
modifier
.widthIn(max = 150.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onClick)
.padding(4.dp),
) {
val context = LocalContext.current
Box(
Modifier.Companion.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
MangaCover.Book(
modifier = Modifier.Companion
.fillMaxWidth(),
data = manga,
)
Box(
modifier = Modifier.Companion
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
.background(
Brush.Companion.verticalGradient(
0f to Color.Companion.Transparent,
1f to Color(0xAA000000),
),
)
.fillMaxHeight(0.33f)
.fillMaxWidth()
.align(Alignment.Companion.BottomCenter),
)
Text(
modifier = Modifier.Companion
.padding(8.dp)
.align(Alignment.Companion.BottomStart),
text = manga.title.ifBlank { stringResource(MR.strings.unknown) },
fontSize = 12.sp,
lineHeight = 18.sp,
maxLines = 2,
overflow = TextOverflow.Companion.Ellipsis,
style = MaterialTheme.typography.titleSmall.copy(
color = Color.Companion.White,
shadow = Shadow(
color = Color.Companion.Black,
blurRadius = 4f,
),
),
)
BadgeGroup(modifier = Modifier.Companion.padding(4.dp)) {
Badge(text = "${chapterInfo.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)
}
}
Text(
text = formattedLatestChapter,
modifier = Modifier.Companion.padding(top = 1.dp, bottom = 4.dp, start = 8.dp),
overflow = TextOverflow.Companion.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
fun MigrationListItemResult(
modifier: Modifier,
migrationItem: MigratingManga,
result: MigratingManga.SearchResult,
getManga: suspend (MigratingManga.SearchResult.Success) -> Manga?,
getChapterInfo: suspend (MigratingManga.SearchResult.Success) -> MigratingManga.ChapterInfo,
getSourceName: (Manga) -> String,
onItemClick: (Manga) -> Unit,
) {
Box(modifier.height(IntrinsicSize.Min)) {
when (result) {
MigratingManga.SearchResult.Searching -> Box(
modifier = Modifier.Companion
.widthIn(max = 150.dp)
.fillMaxSize()
.aspectRatio(MangaCover.Book.ratio),
contentAlignment = Alignment.Companion.Center,
) {
CircularProgressIndicator()
}
MigratingManga.SearchResult.NotFound -> Column(
Modifier.Companion
.widthIn(max = 150.dp)
.fillMaxSize()
.padding(top = 4.dp),
) {
Image(
painter = rememberResourceBitmapPainter(id = R.drawable.cover_error),
contentDescription = null,
modifier = Modifier.Companion
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio)
.clip(MaterialTheme.shapes.extraSmall),
contentScale = ContentScale.Companion.Crop,
)
Text(
text = stringResource(SYMR.strings.no_alternatives_found),
modifier = Modifier.Companion.padding(top = 4.dp, bottom = 1.dp, start = 8.dp),
style = MaterialTheme.typography.titleSmall,
)
}
is MigratingManga.SearchResult.Success -> {
val item by produceState<Triple<Manga, MigratingManga.ChapterInfo, String>?>(
initialValue = null,
migrationItem,
result,
) {
value = withIOContext {
val manga = getManga(result) ?: return@withIOContext null
Triple(
manga,
getChapterInfo(result),
getSourceName(manga),
)
}
}
if (item != null) {
val (manga, chapterInfo, source) = item!!
MigrationListItem(
modifier = Modifier.Companion.fillMaxSize(),
manga = manga,
source = source,
chapterInfo = chapterInfo,
onClick = {
onItemClick(manga)
},
)
}
@ -170,3 +355,77 @@ fun MigrationListScreenContent(
}
}
}
@Composable
fun MigrationListItemAction(
modifier: Modifier,
result: MigratingManga.SearchResult,
onSearchManually: () -> Unit,
onSkip: () -> Unit,
onMigrate: () -> Unit,
onCopy: () -> Unit,
// KMK -->
onCancel: () -> Unit,
// KMK <--
) {
var moreExpanded by remember { mutableStateOf(false) }
val closeMenu = { moreExpanded = false }
Box(modifier) {
when (result) {
MigratingManga.SearchResult.Searching -> {
// KMK -->
IconButton(onClick = onCancel) {
// KMK <--
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(SYMR.strings.action_stop),
)
}
}
MigratingManga.SearchResult.NotFound, is MigratingManga.SearchResult.Success -> {
IconButton(onClick = { moreExpanded = !moreExpanded }) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(MR.strings.action_menu_overflow_description),
)
}
DropdownMenu(
expanded = moreExpanded,
onDismissRequest = closeMenu,
offset = DpOffset(8.dp, (-56).dp),
) {
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_search_manually)) },
onClick = {
onSearchManually()
closeMenu()
},
)
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_skip_entry)) },
onClick = {
onSkip()
closeMenu()
},
)
if (result is MigratingManga.SearchResult.Success) {
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_migrate_now)) },
onClick = {
onMigrate()
closeMenu()
},
)
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_copy_now)) },
onClick = {
onCopy()
closeMenu()
},
)
}
}
}
}
}
}

View file

@ -49,7 +49,8 @@ import uy.kohesive.injekt.api.get
import java.util.concurrent.atomic.AtomicInteger
class MigrationListScreenModel(
private val config: MigrationProcedureConfig,
private var mangaIds: List<Long>,
extraSearchQuery: String?,
val preferences: SourcePreferences = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
@ -57,12 +58,16 @@ class MigrationListScreenModel(
private val updateManga: UpdateManga = Injekt.get(),
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(),
private val migrateManga: MigrateMangaUseCase = Injekt.get(),
// SY -->
private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(),
// SY <--
) : ScreenModel {
private val smartSearchEngine = SmartSourceSearchEngine(config.extraSearchParams)
private val smartSearchEngine = SmartSourceSearchEngine(extraSearchQuery)
// SY -->
private val throttleManager = ThrottleManager()
// SY <--
val migratingItems = MutableStateFlow<ImmutableList<MigratingManga>?>(null)
val migrationDone = MutableStateFlow(false)
@ -70,10 +75,10 @@ class MigrationListScreenModel(
val manualMigrations = MutableStateFlow(0)
var hideNotFound = preferences.migrationHideUnmatched().get()
private var showOnlyUpdates = preferences.migrationHideWithoutUpdates().get()
private var useSourceWithMost = preferences.migrationPrioritizeByChapters().get()
private var useSmartSearch = preferences.migrationDeepSearchMode().get()
var hideUnmatched = preferences.migrationHideUnmatched().get()
private var hideWithoutUpdates = preferences.migrationHideWithoutUpdates().get()
private var prioritizeByChapters = preferences.migrationPrioritizeByChapters().get()
private var deepSearchMode = preferences.migrationDeepSearchMode().get()
val navigateOut = MutableSharedFlow<Unit>()
@ -85,12 +90,6 @@ class MigrationListScreenModel(
init {
screenModelScope.launchIO {
val mangaIds = when (val migration = config.migration) {
is MigrationType.MangaList -> {
migration.mangaIds
}
is MigrationType.MangaSingle -> listOf(migration.fromMangaId)
}
runMigrations(
mangaIds
.map {
@ -99,7 +98,7 @@ class MigrationListScreenModel(
MigratingManga(
manga = manga,
chapterInfo = getChapterInfo(it),
sourcesString = sourceManager.getOrStub(manga.source).getNameForMangaInfo(
source = sourceManager.getOrStub(manga.source).getNameForMangaInfo(
if (manga.source == MERGED_SOURCE_ID) {
getMergedReferencesById.await(manga.id)
.map { sourceManager.getOrStub(it.mangaSourceId) }
@ -120,9 +119,9 @@ class MigrationListScreenModel(
}
}
suspend fun getManga(result: SearchResult.Result) = getManga(result.id)
suspend fun getManga(result: SearchResult.Success) = getManga(result.id)
suspend fun getManga(id: Long) = getManga.await(id)
suspend fun getChapterInfo(result: SearchResult.Result) = getChapterInfo(result.id)
suspend fun getChapterInfo(result: SearchResult.Success) = getChapterInfo(result.id)
private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters ->
MigratingManga.ChapterInfo(
latestChapter = chapters.maxOfOrNull { it.chapterNumber },
@ -145,12 +144,9 @@ class MigrationListScreenModel(
break
}
// in case it was removed
when (val migration = config.migration) {
is MigrationType.MangaList -> if (manga.manga.id !in migration.mangaIds) {
if (manga.manga.id !in mangaIds) {
continue
}
else -> Unit
}
if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) {
val mangaObj = manga.manga
@ -165,107 +161,27 @@ class MigrationListScreenModel(
} else {
sources.filter { it.id != mangaSource.id }
}
when (val migration = config.migration) {
is MigrationType.MangaSingle -> if (migration.toManga != null) {
val localManga = getManga.await(migration.toManga)
if (localManga != null) {
val source = sourceManager.get(localManga.source) as? CatalogueSource
if (source != null) {
try {
val chapters = if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
source.getChapterList(localManga.toSManga())
}
syncChaptersWithSource.await(chapters, localManga, source)
} catch (_: Exception) {
}
manga.progress.value = validSources.size to validSources.size
return@async localManga
}
}
}
else -> Unit
}
if (useSourceWithMost) {
if (prioritizeByChapters) {
val sourceSemaphore = Semaphore(3)
val processedSources = AtomicInteger()
validSources.map { source ->
async async2@{
sourceSemaphore.withPermit {
try {
val searchResult = if (useSmartSearch) {
smartSearchEngine.smartSearch(source, mangaObj.ogTitle)
} else {
smartSearchEngine.normalSearch(source, mangaObj.ogTitle)
}
if (searchResult != null &&
!(searchResult.url == mangaObj.url && source.id == mangaObj.source)
) {
val localManga = networkToLocalManga(searchResult)
val chapters = if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
source.getChapterList(localManga.toSManga())
}
try {
syncChaptersWithSource.await(chapters, localManga, source)
} catch (_: Exception) {
return@async2 null
}
val result = searchSource(manga.manga, source, deepSearchMode)
if (result != null) {
manga.progress.value =
validSources.size to processedSources.incrementAndGet()
localManga to chapters.size
} else {
null
}
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (_: Exception) {
null
}
result
}
}
}.mapNotNull { it.await() }.maxByOrNull { it.second }?.first
} else {
validSources.forEachIndexed { index, source ->
val searchResult = try {
val searchResult = if (useSmartSearch) {
smartSearchEngine.smartSearch(source, mangaObj.ogTitle)
} else {
smartSearchEngine.normalSearch(source, mangaObj.ogTitle)
}
if (searchResult != null) {
val localManga = networkToLocalManga(searchResult)
val chapters = try {
if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
source.getChapterList(localManga.toSManga())
}
} catch (e: Exception) {
this@MigrationListScreenModel.logcat(LogPriority.ERROR, e)
emptyList()
}
syncChaptersWithSource.await(chapters, localManga, source)
localManga
} else {
null
}
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (_: Exception) {
null
}
val result = searchSource(manga.manga, source, deepSearchMode)
manga.progress.value = validSources.size to (index + 1)
if (searchResult != null) return@async searchResult
if (result != null) return@async result.first
}
null
@ -294,37 +210,82 @@ class MigrationListScreenModel(
manga.searchResult.value = if (result == null) {
SearchResult.NotFound
} else {
SearchResult.Result(result.id)
SearchResult.Success(result.id)
}
if (result == null && hideNotFound) {
if (result == null && hideUnmatched) {
removeManga(manga)
}
if (result != null &&
showOnlyUpdates &&
hideWithoutUpdates &&
(getChapterInfo(result.id).latestChapter ?: 0.0) <= (manga.chapterInfo.latestChapter ?: 0.0)
) {
removeManga(manga)
}
sourceFinished()
updateMigrationProgress()
}
}
}
private suspend fun sourceFinished() {
private suspend fun searchSource(
manga: Manga,
source: CatalogueSource,
deepSearchMode: Boolean,
): Pair<Manga, Int>? {
return try {
val searchResult = if (deepSearchMode) {
smartSearchEngine.deepSearch(source, manga.ogTitle)
} else {
smartSearchEngine.regularSearch(source, manga.ogTitle)
}
if (searchResult != null &&
!(searchResult.url == manga.url && source.id == manga.source)
) {
val localManga = networkToLocalManga(searchResult)
val chapters = try {
if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
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
}
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (_: Exception) {
null
}
}
private suspend fun updateMigrationProgress() {
finishedCount.value = migratingItems.value.orEmpty().count {
it.searchResult.value != SearchResult.Searching
}
if (allMangasDone()) {
if (migrationComplete()) {
migrationDone.value = true
}
if (migratingItems.value?.isEmpty() == true) {
navigateOut()
navigateBack()
}
}
private fun allMangasDone() = migratingItems.value.orEmpty().all { it.searchResult.value != SearchResult.Searching } &&
migratingItems.value.orEmpty().any { it.searchResult.value is SearchResult.Result }
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 }
@ -357,7 +318,7 @@ class MigrationListScreenModel(
Timber.tag("MigrationListScreenModel").e(e, "Error updating manga from source")
}
migratingManga.searchResult.value = SearchResult.Result(result.id)
migratingManga.searchResult.value = SearchResult.Success(result.id)
} else {
migratingManga.searchResult.value = SearchResult.NotFound
withUIContext {
@ -366,7 +327,7 @@ class MigrationListScreenModel(
}
// KMK -->
sourceFinished()
updateMigrationProgress()
// KMK <--
}
}
@ -385,18 +346,18 @@ class MigrationListScreenModel(
migratingProgress.value = 0f
val items = migratingItems.value.orEmpty()
try {
items.forEachIndexed { index, current ->
items.forEachIndexed { index, manga ->
try {
ensureActive()
val target = current.searchResult.value.let {
if (it is SearchResult.Result) {
val target = manga.searchResult.value.let {
if (it is SearchResult.Success) {
getManga.await(it.id)
} else {
null
}
}
if (target != null) {
migrateManga(current.manga, target, replace)
migrateManga(manga.manga, target, replace)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@ -405,7 +366,7 @@ class MigrationListScreenModel(
migratingProgress.value = index.toFloat() / items.size
}
navigateOut()
navigateBack()
} finally {
migratingProgress.value = Float.MAX_VALUE
migrateJob = null
@ -418,20 +379,20 @@ class MigrationListScreenModel(
migrateJob = null
}
private suspend fun navigateOut() {
private suspend fun navigateBack() {
navigateOut.emit(Unit)
}
fun migrateManga(mangaId: Long, replace: Boolean) {
fun migrateNow(mangaId: Long, replace: Boolean) {
manualMigrations.value++
screenModelScope.launchIO {
val current = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
val manga = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
?: return@launchIO
val target = getManga.await((current.searchResult.value as? SearchResult.Result)?.id ?: return@launchIO)
val target = getManga.await((manga.searchResult.value as? SearchResult.Success)?.id ?: return@launchIO)
?: return@launchIO
migrateManga(current.manga, target, replace)
migrateManga(manga.manga, target, replace)
removeManga(mangaId)
}
}
@ -445,7 +406,7 @@ class MigrationListScreenModel(
item.searchingJob?.cancel()
item.searchingJob = null
item.searchResult.value = SearchResult.NotFound
sourceFinished()
updateMigrationProgress()
}
}
// KMK <--
@ -456,25 +417,20 @@ class MigrationListScreenModel(
?: return@launchIO
removeManga(item)
item.migrationScope.cancel()
sourceFinished()
updateMigrationProgress()
}
}
private fun removeManga(item: MigratingManga) {
when (val migration = config.migration) {
is MigrationType.MangaList -> {
val ids = migration.mangaIds.toMutableList()
val ids = mangaIds.toMutableList()
val index = ids.indexOf(item.manga.id)
if (index > -1) {
ids.removeAt(index)
config.migration = MigrationType.MangaList(ids)
mangaIds = ids
val index2 = migratingItems.value.orEmpty().indexOf(item)
if (index2 > -1) migratingItems.value = (migratingItems.value.orEmpty() - item).toImmutableList()
}
}
is MigrationType.MangaSingle -> Unit
}
}
override fun onDispose() {
super.onDispose()
@ -483,10 +439,8 @@ class MigrationListScreenModel(
}
}
fun openMigrateDialog(
copy: Boolean,
) {
dialog.value = Dialog.MigrateMangaDialog(
fun showMigrateDialog(copy: Boolean) {
dialog.value = Dialog.Migrate(
copy,
migratingItems.value.orEmpty().size,
mangasSkipped(),
@ -494,24 +448,24 @@ class MigrationListScreenModel(
}
// KMK -->
fun openMigrationOptionsDialog() {
dialog.value = Dialog.MigrationOptionsDialog
fun openOptionsDialog() {
dialog.value = Dialog.Options
}
fun updateOptions() {
hideNotFound = preferences.migrationHideUnmatched().get()
showOnlyUpdates = preferences.migrationHideWithoutUpdates().get()
useSourceWithMost = preferences.migrationPrioritizeByChapters().get()
useSmartSearch = preferences.migrationDeepSearchMode().get()
hideUnmatched = preferences.migrationHideUnmatched().get()
hideWithoutUpdates = preferences.migrationHideWithoutUpdates().get()
prioritizeByChapters = preferences.migrationPrioritizeByChapters().get()
deepSearchMode = preferences.migrationDeepSearchMode().get()
}
// KMK <--
sealed class Dialog {
data class MigrateMangaDialog(val copy: Boolean, val mangaSet: Int, val mangaSkipped: Int) : Dialog()
data object MigrationExitDialog : Dialog()
data class Migrate(val copy: Boolean, val totalCount: Int, val skippedCount: Int) : Dialog()
data object Exit : Dialog()
// KMK -->
data object MigrationOptionsDialog : Dialog()
data object Options : Dialog()
// KMK <--
}
}

View file

@ -1,13 +0,0 @@
package mihon.feature.migration.list
import java.io.Serializable
sealed class MigrationType : Serializable {
data class MangaList(val mangaIds: List<Long>) : MigrationType()
data class MangaSingle(val fromMangaId: Long, val toManga: Long?) : MigrationType()
}
data class MigrationProcedureConfig(
var migration: MigrationType,
val extraSearchParams: String?,
) : Serializable

View file

@ -15,6 +15,9 @@ fun MigrationExitDialog(
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(SYMR.strings.stop_migrating))
},
confirmButton = {
TextButton(onClick = exitMigration) {
Text(text = stringResource(SYMR.strings.action_stop))
@ -25,8 +28,5 @@ fun MigrationExitDialog(
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(SYMR.strings.stop_migrating))
},
)
}

View file

@ -13,20 +13,30 @@ import tachiyomi.presentation.core.i18n.stringResource
fun MigrationMangaDialog(
onDismissRequest: () -> Unit,
copy: Boolean,
mangaSet: Int,
mangaSkipped: Int,
totalCount: Int,
skippedCount: Int,
copyManga: () -> Unit,
migrateManga: () -> Unit,
onMigrate: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
text = {
Text(
text = pluralStringResource(
if (copy) SYMR.plurals.copy_entry else SYMR.plurals.migrate_entry,
count = totalCount,
totalCount,
(if (skippedCount > 0) " " + stringResource(SYMR.strings.skipping_, skippedCount) else ""),
),
)
},
confirmButton = {
TextButton(
onClick = {
if (copy) {
copyManga()
} else {
migrateManga()
onMigrate()
}
},
) {
@ -38,15 +48,5 @@ fun MigrationMangaDialog(
Text(text = stringResource(MR.strings.action_cancel))
}
},
text = {
Text(
text = pluralStringResource(
if (copy) SYMR.plurals.copy_entry else SYMR.plurals.migrate_entry,
count = mangaSet,
mangaSet,
(if (mangaSkipped > 0) " " + stringResource(SYMR.strings.skipping_, mangaSkipped) else ""),
),
)
},
)
}

View file

@ -16,7 +16,7 @@ import kotlin.coroutines.CoroutineContext
class MigratingManga(
val manga: Manga,
val chapterInfo: ChapterInfo,
val sourcesString: String,
val source: String,
parentContext: CoroutineContext,
) {
val migrationScope = CoroutineScope(parentContext + SupervisorJob() + Dispatchers.Default)
@ -33,7 +33,7 @@ class MigratingManga(
sealed class SearchResult {
data object Searching : SearchResult()
data object NotFound : SearchResult()
data class Result(val id: Long) : SearchResult()
data class Success(val id: Long) : SearchResult()
}
data class ChapterInfo(

View file

@ -17,10 +17,10 @@ abstract class BaseSmartSearchEngine<T>(
protected abstract fun getTitle(result: T): String
protected suspend fun smartSearch(searchAction: SearchAction<T>, title: String): T? {
protected suspend fun deepSearch(searchAction: SearchAction<T>, title: String): T? {
val cleanedTitle = cleanSmartSearchTitle(title)
val queries = getSmartSearchQueries(cleanedTitle)
val queries = getDeepSearchQueries(cleanedTitle)
val eligibleManga = supervisorScope {
queries.map { query ->
@ -42,10 +42,10 @@ abstract class BaseSmartSearchEngine<T>(
}.flatMap { it.await() }
}
return eligibleManga.maxByOrNull { it.dist }?.manga
return eligibleManga.maxByOrNull { it.distance }?.entry
}
protected suspend fun normalSearch(searchAction: SearchAction<T>, title: String): T? {
protected suspend fun regularSearch(searchAction: SearchAction<T>, title: String): T? {
val eligibleManga = supervisorScope {
val searchQuery = if (extraSearchParams != null) {
"$title ${extraSearchParams.trim()}"
@ -66,7 +66,7 @@ abstract class BaseSmartSearchEngine<T>(
}
}
return eligibleManga.maxByOrNull { it.dist }?.manga
return eligibleManga.maxByOrNull { it.distance }?.entry
}
private fun cleanSmartSearchTitle(title: String): String {
@ -142,7 +142,7 @@ abstract class BaseSmartSearchEngine<T>(
return result.toString()
}
private fun getSmartSearchQueries(cleanedTitle: String): List<String> {
private fun getDeepSearchQueries(cleanedTitle: String): List<String> {
val splitCleanedTitle = cleanedTitle.split(" ")
val splitSortedByLargest = splitCleanedTitle.sortedByDescending { it.length }
@ -155,7 +155,6 @@ abstract class BaseSmartSearchEngine<T>(
// Search largest word
// Search first two words
// Search first word
val searchQueries = listOf(
listOf(cleanedTitle),
splitSortedByLargest.take(2),
@ -179,4 +178,4 @@ abstract class BaseSmartSearchEngine<T>(
}
}
data class SearchEntry<T>(val manga: T, val dist: Double)
data class SearchEntry<T>(val entry: T, val distance: Double)

View file

@ -11,13 +11,13 @@ class SmartSourceSearchEngine(
override fun getTitle(result: SManga) = result.originalTitle
suspend fun smartSearch(source: CatalogueSource, title: String): Manga? =
smartSearch(makeSearchAction(source), title).let {
suspend fun regularSearch(source: CatalogueSource, title: String): Manga? =
regularSearch(makeSearchAction(source), title).let {
it?.toDomainManga(source.id)
}
suspend fun normalSearch(source: CatalogueSource, title: String): Manga? =
normalSearch(makeSearchAction(source), title).let {
suspend fun deepSearch(source: CatalogueSource, title: String): Manga? =
deepSearch(makeSearchAction(source), title).let {
it?.toDomainManga(source.id)
}