Mass migration implementation (mihonapp/mihon#2110)
There is no way to trigger mass migration at the moment. The functionality will be added in a follow up PR. Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com> (cherry picked from commit ee19050cc00b0a787af65b641624d07bb194e155) * Fix background crash in mass migration screen (cherry picked from commit 63943debc2fd4efa1a0418bbfefaea93a24b49fd) * Fix same manga check logic in mass migration (cherry picked from commit f1193866f4306384a2a466c6353446bfed2bd9aa) * Fix migration progress not updating and category flag mischeck (mihonapp/mihon#2484) - Fixed an issue where migration progress wasn't updated after a manual source search - Fixed incorrect logic where the category migration flag was ignored due to checking the chapter flag instead (cherry picked from commit 16b5317b90b3064d12aa38f687cc30110fd8cdb3) * Fix mass migration advanced search query building (mihonapp/mihon#2629) (cherry picked from commit 7c08b75555a5444ede4912dc5e32607fac2b9678) * Fix mass migration not using the same search queries as individual migration (mihonapp/mihon#2736) (cherry picked from commit 7161bc2e825bdfd66a1829f7dce42bd0570b1008) Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
parent
bc581e1d4b
commit
935f447fbb
21 changed files with 628 additions and 628 deletions
|
|
@ -305,6 +305,9 @@ dependencies {
|
|||
// Shizuku
|
||||
implementation(libs.bundles.shizuku)
|
||||
|
||||
// String similarity
|
||||
implementation(libs.stringSimilarity)
|
||||
|
||||
// Tests
|
||||
testImplementation(libs.bundles.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
|
|
@ -316,9 +319,6 @@ dependencies {
|
|||
testImplementation(kotlinx.coroutines.test)
|
||||
|
||||
// SY -->
|
||||
// Text distance (EH)
|
||||
implementation(sylibs.similarity)
|
||||
|
||||
// Better logging (EH)
|
||||
implementation(sylibs.xlog)
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ data class MigrateMangaScreen(
|
|||
navigateUp = navigator::pop,
|
||||
title = state.source?.name ?: "???",
|
||||
state = state,
|
||||
onClickItem = { navigator.push(MigrationConfigScreen(listOf(it.id))) },
|
||||
onClickItem = { navigator.push(MigrationConfigScreen(it.id)) },
|
||||
onClickCover = { navigator.push(MangaScreen(it.id)) },
|
||||
// KMK -->
|
||||
onMultiMigrateClicked = {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import eu.kanade.presentation.browse.MigrateSearchScreen
|
|||
import eu.kanade.presentation.browse.components.BulkFavoriteDialogs
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
import mihon.feature.migration.list.MigrationListScreen
|
||||
|
||||
class MigrateSearchScreen(private val mangaId: Long) : Screen() {
|
||||
|
|
@ -48,13 +50,16 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
|
|||
bulkFavoriteScreenModel.toggleSelection(it)
|
||||
} else {
|
||||
// KMK <--
|
||||
// SY -->
|
||||
navigator.items
|
||||
val migrateListScreen = navigator.items
|
||||
.filterIsInstance<MigrationListScreen>()
|
||||
.last()
|
||||
.matchOverride = mangaId to it.id
|
||||
navigator.popUntil { screen -> screen is MigrationListScreen }
|
||||
// SY <--
|
||||
.lastOrNull()
|
||||
|
||||
if (migrateListScreen == null) {
|
||||
screenModel.setMigrateDialog(mangaId, it)
|
||||
} else {
|
||||
migrateListScreen.addMatchOverride(current = mangaId, target = it.id)
|
||||
navigator.popUntil { screen -> screen is MigrationListScreen }
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
|
||||
|
|
@ -64,6 +69,28 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
|
|||
// KMK <--
|
||||
)
|
||||
|
||||
when (val dialog = state.dialog) {
|
||||
is SearchScreenModel.Dialog.Migrate -> {
|
||||
MigrateMangaDialog(
|
||||
current = dialog.current,
|
||||
target = dialog.target,
|
||||
// Initiated from the context of [dialog.current] so we show [dialog.target].
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.target.id, true)) },
|
||||
onDismissRequest = { screenModel.clearDialog() },
|
||||
onComplete = {
|
||||
if (navigator.lastItem is MangaScreen) {
|
||||
val lastItem = navigator.lastItem
|
||||
navigator.popUntil { navigator.items.contains(lastItem) }
|
||||
navigator.push(MangaScreen(dialog.target.id))
|
||||
} else {
|
||||
navigator.replace(MangaScreen(dialog.target.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
// Bulk-favorite actions only
|
||||
BulkFavoriteDialogs(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
|
|
@ -33,10 +34,13 @@ import eu.kanade.tachiyomi.source.online.HttpSource
|
|||
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog
|
||||
import eu.kanade.tachiyomi.ui.home.HomeScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.launch
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
import mihon.feature.migration.list.MigrationListScreen
|
||||
import mihon.presentation.core.util.collectAsLazyPagingItems
|
||||
import tachiyomi.core.common.Constants
|
||||
|
|
@ -66,6 +70,7 @@ data class MigrateSourceSearchScreen(
|
|||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId, query) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
|
@ -142,13 +147,16 @@ data class MigrateSourceSearchScreen(
|
|||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
val openMigrateDialog: (Manga) -> Unit = {
|
||||
// SY -->
|
||||
navigator.items
|
||||
val migrateListScreen = navigator.items
|
||||
.filterIsInstance<MigrationListScreen>()
|
||||
.last()
|
||||
.matchOverride = currentManga.id to it.id
|
||||
navigator.popUntil { it is MigrationListScreen }
|
||||
// SY <--
|
||||
.lastOrNull()
|
||||
|
||||
if (migrateListScreen == null) {
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga))
|
||||
} else {
|
||||
migrateListScreen.addMatchOverride(current = currentManga.id, target = it.id)
|
||||
navigator.popUntil { screen -> screen is MigrationListScreen }
|
||||
}
|
||||
}
|
||||
BrowseSourceContent(
|
||||
source = screenModel.source,
|
||||
|
|
@ -189,7 +197,7 @@ data class MigrateSourceSearchScreen(
|
|||
}
|
||||
|
||||
val onDismissRequest = { screenModel.setDialog(null) }
|
||||
when (state.dialog) {
|
||||
when (val dialog = state.dialog) {
|
||||
is BrowseSourceScreenModel.Dialog.Filter -> {
|
||||
SourceFilterDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
|
|
@ -216,6 +224,22 @@ data class MigrateSourceSearchScreen(
|
|||
// SY <--
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.Migrate -> {
|
||||
MigrateMangaDialog(
|
||||
current = currentManga,
|
||||
target = dialog.target,
|
||||
// Initiated from the context of [currentManga] so we show [dialog.target].
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.target.id)) },
|
||||
onDismissRequest = onDismissRequest,
|
||||
onComplete = {
|
||||
scope.launch {
|
||||
navigator.popUntilRoot()
|
||||
HomeScreen.openTab(HomeScreen.Tab.Browse())
|
||||
navigator.push(MangaScreen(dialog.target.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class LibraryUpdateErrorScreen : Screen() {
|
|||
LibraryUpdateErrorScreen(
|
||||
state = state,
|
||||
onClick = { item ->
|
||||
navigator.push(MigrationConfigScreen(listOf(item.error.mangaId)))
|
||||
navigator.push(MigrationConfigScreen(item.error.mangaId))
|
||||
},
|
||||
onClickCover = { item -> navigator.push(MangaScreen(item.error.mangaId)) },
|
||||
onMultiMigrateClicked = {
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ class MangaScreen(
|
|||
successState.manga.favorite
|
||||
},
|
||||
onMigrateClicked = {
|
||||
navigator.push(MigrationConfigScreen(listOf(successState.manga.id)))
|
||||
navigator.push(MigrationConfigScreen(successState.manga.id))
|
||||
}.takeIf { successState.manga.favorite },
|
||||
// SY -->
|
||||
previewsRowCount = successState.previewsRowCount,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class SmartSearchScreenModel(
|
|||
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
|
||||
sourceManager: SourceManager = Injekt.get(),
|
||||
) : StateScreenModel<SmartSearchScreenModel.SearchResults?>(null) {
|
||||
private val smartSearchEngine = SmartSourceSearchEngine()
|
||||
private val smartSearchEngine = SmartSourceSearchEngine(null)
|
||||
|
||||
val source = sourceManager.get(sourceId) as CatalogueSource
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ import uy.kohesive.injekt.api.get
|
|||
*/
|
||||
class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() {
|
||||
|
||||
constructor(mangaId: Long) : this(listOf(mangaId))
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
|
@ -110,17 +112,22 @@ class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() {
|
|||
var migrationSheetOpen by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
fun continueMigration(openSheet: Boolean, extraSearchQuery: String?) {
|
||||
// KMK -->
|
||||
// val mangaId = mangaIds.singleOrNull()
|
||||
// if (mangaId == null && openSheet) {
|
||||
if (openSheet) {
|
||||
// KMK <--
|
||||
migrationSheetOpen = true
|
||||
return
|
||||
}
|
||||
|
||||
navigator.replace(
|
||||
// KMK -->
|
||||
// MigrateSearchScreen(mangaId)
|
||||
MigrationListScreen(mangaIds, extraSearchQuery),
|
||||
// KMK <--
|
||||
)
|
||||
val screen = // KMK --> if (mangaId == null) {
|
||||
MigrationListScreen(mangaIds, extraSearchQuery)
|
||||
// KMK -->
|
||||
// } else {
|
||||
// MigrateSearchScreen(mangaId)
|
||||
// }
|
||||
// KMK <--
|
||||
navigator.replace(screen)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package mihon.feature.migration.list
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -13,96 +14,77 @@ import eu.kanade.presentation.util.Screen
|
|||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import exh.util.overEq
|
||||
import exh.util.underEq
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import mihon.feature.migration.config.MigrationConfigScreen
|
||||
import mihon.feature.migration.config.MigrationConfigScreenSheet
|
||||
import mihon.feature.migration.list.components.MigrationExitDialog
|
||||
import mihon.feature.migration.list.components.MigrationMangaDialog
|
||||
import mihon.feature.migration.list.components.MigrationProgressDialog
|
||||
import mihon.feature.migration.list.models.MigratingManga
|
||||
import tachiyomi.core.common.i18n.pluralStringResource
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
/**
|
||||
* Screen showing a list of pair of current-target manga entries being migrated.
|
||||
*/
|
||||
class MigrationListScreen(private val mangaIds: List<Long>, private val extraSearchQuery: String?) : Screen() {
|
||||
|
||||
var matchOverride: Pair<Long, Long>? = null
|
||||
private var matchOverride: Pair<Long, Long>? = null
|
||||
|
||||
fun addMatchOverride(current: Long, target: Long) {
|
||||
matchOverride = current to target
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val screenModel = rememberScreenModel { MigrationListScreenModel(mangaIds, extraSearchQuery) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val items by screenModel.migratingItems.collectAsState()
|
||||
val migrationComplete by screenModel.migrationDone.collectAsState()
|
||||
val finishedCount by screenModel.finishedCount.collectAsState()
|
||||
val dialog by screenModel.dialog.collectAsState()
|
||||
val migrateProgress by screenModel.migratingProgress.collectAsState()
|
||||
|
||||
LaunchedEffect(matchOverride) {
|
||||
if (matchOverride != null) {
|
||||
val (oldId, newId) = matchOverride!!
|
||||
screenModel.useMangaForMigration(context, newId, oldId)
|
||||
matchOverride = null
|
||||
}
|
||||
val (current, target) = matchOverride ?: return@LaunchedEffect
|
||||
screenModel.useMangaForMigration(
|
||||
current = current,
|
||||
target = target,
|
||||
onMissingChapters = {
|
||||
context.toast(MR.strings.migrationListScreen_matchWithoutChapterToast, Toast.LENGTH_LONG)
|
||||
},
|
||||
)
|
||||
matchOverride = null
|
||||
}
|
||||
|
||||
LaunchedEffect(screenModel) {
|
||||
screenModel.navigateOut.collect {
|
||||
if (items.orEmpty().size == 1 && navigator.items.any { it is MangaScreen }) {
|
||||
val mangaId = (items.orEmpty().firstOrNull()?.searchResult?.value as? MigratingManga.SearchResult.Success)?.id
|
||||
withUIContext {
|
||||
if (mangaId != null) {
|
||||
val newStack = navigator.items.filter {
|
||||
it !is MangaScreen &&
|
||||
it !is MigrationListScreen &&
|
||||
it !is MigrationConfigScreen
|
||||
} + MangaScreen(mangaId)
|
||||
navigator replaceAll newStack.first()
|
||||
navigator.push(newStack.drop(1))
|
||||
screenModel.navigateBackEvent.collect {
|
||||
// KMK -->
|
||||
/* If this screen is called from single manga migration, replace the MangaScreen in the backstack
|
||||
with the newly migrated manga to reflect the changes properly.
|
||||
Otherwise, just pop normally. */
|
||||
if (mangaIds.size == 1 && navigator.items.any { it is MangaScreen }) {
|
||||
val mangaId = (state.items.firstOrNull()?.searchResult?.value as? MigratingManga.SearchResult.Success)?.manga?.id
|
||||
if (mangaId != null) {
|
||||
val newStack = navigator.items.filter {
|
||||
it !is MangaScreen &&
|
||||
it !is MigrationListScreen &&
|
||||
it !is MigrationConfigScreen
|
||||
} + MangaScreen(mangaId)
|
||||
navigator replaceAll newStack.first()
|
||||
navigator.push(newStack.drop(1))
|
||||
|
||||
// need to set the navigator in a pop state to dispose of everything properly
|
||||
navigator.push(this@MigrationListScreen)
|
||||
navigator.pop()
|
||||
} else {
|
||||
navigator.pop()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withUIContext {
|
||||
// need to set the navigator in a pop state to dispose of everything properly
|
||||
navigator.push(this@MigrationListScreen)
|
||||
navigator.pop()
|
||||
} else {
|
||||
navigator.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(items) {
|
||||
if (items?.isEmpty() == true) {
|
||||
val manualMigrations = screenModel.manualMigrations.value
|
||||
context.toast(
|
||||
context.pluralStringResource(
|
||||
SYMR.plurals.entry_migrated,
|
||||
manualMigrations,
|
||||
manualMigrations,
|
||||
),
|
||||
)
|
||||
if (!screenModel.hideUnmatched) {
|
||||
} else {
|
||||
// KMK <--
|
||||
navigator.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
MigrationListScreenContent(
|
||||
items = items ?: persistentListOf(),
|
||||
migrationComplete = migrationComplete,
|
||||
finishedCount = finishedCount,
|
||||
getManga = screenModel::getManga,
|
||||
getChapterInfo = screenModel::getChapterInfo,
|
||||
getSourceName = screenModel::getSourceName,
|
||||
items = state.items,
|
||||
migrationComplete = state.migrationComplete,
|
||||
finishedCount = state.finishedCount,
|
||||
onItemClick = {
|
||||
navigator.push(MangaScreen(it.id, true))
|
||||
},
|
||||
|
|
@ -110,34 +92,40 @@ class MigrationListScreen(private val mangaIds: List<Long>, private val extraSea
|
|||
navigator push MigrateSearchScreen(migrationItem.manga.id)
|
||||
},
|
||||
onSkip = { screenModel.removeManga(it) },
|
||||
onMigrate = { screenModel.migrateNow(it, true) },
|
||||
onCopy = { screenModel.migrateNow(it, false) },
|
||||
onMigrate = { screenModel.migrateNow(mangaId = it, replace = true) },
|
||||
onCopy = { screenModel.migrateNow(mangaId = it, replace = false) },
|
||||
openMigrationDialog = screenModel::showMigrateDialog,
|
||||
// KMK -->
|
||||
onCancel = { screenModel.cancelManga(it) },
|
||||
navigateUp = { navigator.pop() },
|
||||
openOptionsDialog = screenModel::openOptionsDialog,
|
||||
// KMK <--
|
||||
)
|
||||
|
||||
val onDismissRequest = { screenModel.dialog.value = null }
|
||||
when (
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val dialog = dialog
|
||||
) {
|
||||
when (val dialog = state.dialog) {
|
||||
is MigrationListScreenModel.Dialog.Migrate -> {
|
||||
MigrationMangaDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
copy = dialog.copy,
|
||||
totalCount = dialog.totalCount,
|
||||
skippedCount = dialog.skippedCount,
|
||||
copyManga = screenModel::copyMangas,
|
||||
onMigrate = screenModel::migrateMangas,
|
||||
onMigrate = {
|
||||
if (dialog.copy) {
|
||||
screenModel.copyMangas()
|
||||
} else {
|
||||
screenModel.migrateMangas()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is MigrationListScreenModel.Dialog.Progress -> {
|
||||
MigrationProgressDialog(
|
||||
progress = dialog.progress,
|
||||
exitMigration = screenModel::cancelMigrate,
|
||||
)
|
||||
}
|
||||
MigrationListScreenModel.Dialog.Exit -> {
|
||||
MigrationExitDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
exitMigration = navigator::pop,
|
||||
)
|
||||
}
|
||||
|
|
@ -145,9 +133,9 @@ class MigrationListScreen(private val mangaIds: List<Long>, private val extraSea
|
|||
MigrationListScreenModel.Dialog.Options -> {
|
||||
MigrationConfigScreenSheet(
|
||||
preferences = screenModel.preferences,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
onStartMigration = { _ ->
|
||||
onDismissRequest()
|
||||
screenModel.dismissDialog()
|
||||
screenModel.updateOptions()
|
||||
},
|
||||
fullSettings = false,
|
||||
|
|
@ -157,15 +145,8 @@ class MigrationListScreen(private val mangaIds: List<Long>, private val extraSea
|
|||
null -> Unit
|
||||
}
|
||||
|
||||
if (!migrateProgress.isNaN() && migrateProgress overEq 0f && migrateProgress underEq 1f) {
|
||||
MigrationProgressDialog(
|
||||
progress = migrateProgress,
|
||||
exitMigration = screenModel::cancelMigrate,
|
||||
)
|
||||
}
|
||||
|
||||
BackHandler(true) {
|
||||
screenModel.dialog.value = MigrationListScreenModel.Dialog.Exit
|
||||
screenModel.showExitDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -47,28 +47,26 @@ import androidx.compose.ui.graphics.Brush
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarActions
|
||||
import eu.kanade.presentation.manga.components.MangaCover
|
||||
import eu.kanade.presentation.util.animateItemFastScroll
|
||||
import eu.kanade.presentation.util.formatChapterNumber
|
||||
import eu.kanade.presentation.util.rememberResourceBitmapPainter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import mihon.feature.migration.list.models.MigratingManga
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.components.Badge
|
||||
import tachiyomi.presentation.core.components.BadgeGroup
|
||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.components.material.topSmallPaddingValues
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.plus
|
||||
|
|
@ -78,9 +76,6 @@ fun MigrationListScreenContent(
|
|||
items: ImmutableList<MigratingManga>,
|
||||
migrationComplete: Boolean,
|
||||
finishedCount: Int,
|
||||
getManga: suspend (MigratingManga.SearchResult.Success) -> Manga?,
|
||||
getChapterInfo: suspend (MigratingManga.SearchResult.Success) -> MigratingManga.ChapterInfo,
|
||||
getSourceName: (Manga) -> String,
|
||||
onItemClick: (Manga) -> Unit,
|
||||
onSearchManually: (MigratingManga) -> Unit,
|
||||
onSkip: (Long) -> Unit,
|
||||
|
|
@ -89,23 +84,17 @@ fun MigrationListScreenContent(
|
|||
openMigrationDialog: (Boolean) -> Unit,
|
||||
// KMK -->
|
||||
onCancel: (Long) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
openOptionsDialog: () -> Unit,
|
||||
// KMK <--
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { scrollBehavior ->
|
||||
val titleString = stringResource(SYMR.strings.migration)
|
||||
val title by produceState(initialValue = titleString, items, finishedCount, titleString) {
|
||||
withIOContext {
|
||||
value = "$titleString ($finishedCount/${items.size})"
|
||||
}
|
||||
}
|
||||
AppBar(
|
||||
title = title,
|
||||
// KMK -->
|
||||
navigateUp = navigateUp,
|
||||
// KMK <--
|
||||
title = if (items.isNotEmpty()) {
|
||||
stringResource(MR.strings.migrationListScreenTitleWithProgress, finishedCount, items.size)
|
||||
} else {
|
||||
stringResource(MR.strings.migrationListScreenTitle)
|
||||
},
|
||||
actions = {
|
||||
AppBarActions(
|
||||
persistentListOf(
|
||||
|
|
@ -117,13 +106,13 @@ fun MigrationListScreenContent(
|
|||
),
|
||||
// KMK <--
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.copy),
|
||||
title = stringResource(MR.strings.migrationListScreen_copyActionLabel),
|
||||
icon = if (items.size == 1) Icons.Outlined.ContentCopy else Icons.Outlined.CopyAll,
|
||||
onClick = { openMigrationDialog(true) },
|
||||
enabled = migrationComplete,
|
||||
),
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.migrate),
|
||||
title = stringResource(MR.strings.migrationListScreen_migrateActionLabel),
|
||||
icon = if (items.size == 1) Icons.Outlined.Done else Icons.Outlined.DoneAll,
|
||||
onClick = { openMigrationDialog(false) },
|
||||
enabled = migrationComplete,
|
||||
|
|
@ -136,52 +125,49 @@ fun MigrationListScreenContent(
|
|||
},
|
||||
) { contentPadding ->
|
||||
FastScrollLazyColumn(contentPadding = contentPadding + topSmallPaddingValues) {
|
||||
items(items, key = { "migration-list-${it.manga.id}" }) { item ->
|
||||
items(items, key = { it.manga.id }) { item ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItemFastScroll()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(
|
||||
start = MaterialTheme.padding.medium,
|
||||
end = MaterialTheme.padding.small,
|
||||
)
|
||||
.height(IntrinsicSize.Min),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MigrationListItem(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.weight(1f)
|
||||
.align(Alignment.Top)
|
||||
.fillMaxHeight(),
|
||||
manga = item.manga,
|
||||
source = item.source,
|
||||
chapterInfo = item.chapterInfo,
|
||||
chapterCount = item.chapterCount,
|
||||
latestChapter = item.latestChapter,
|
||||
onClick = { onItemClick(item.manga) },
|
||||
)
|
||||
|
||||
Icon(
|
||||
Icons.AutoMirrored.Outlined.ArrowForward,
|
||||
contentDescription = stringResource(SYMR.strings.migrating_to),
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.weight(0.2f),
|
||||
)
|
||||
|
||||
val result by item.searchResult.collectAsState()
|
||||
MigrationListItemResult(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.weight(1f)
|
||||
.align(Alignment.Top)
|
||||
.fillMaxHeight(),
|
||||
migrationItem = item,
|
||||
result = result,
|
||||
getManga = getManga,
|
||||
getChapterInfo = getChapterInfo,
|
||||
getSourceName = getSourceName,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
|
||||
MigrationListItemAction(
|
||||
modifier = Modifier
|
||||
.weight(0.2f),
|
||||
modifier = Modifier.weight(0.2f),
|
||||
result = result,
|
||||
onSearchManually = { onSearchManually(item) },
|
||||
onSkip = { onSkip(item.manga.id) },
|
||||
|
|
@ -202,162 +188,140 @@ fun MigrationListItem(
|
|||
modifier: Modifier,
|
||||
manga: Manga,
|
||||
source: String,
|
||||
chapterInfo: MigratingManga.ChapterInfo,
|
||||
chapterCount: Int,
|
||||
latestChapter: Double?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
modifier = modifier
|
||||
.widthIn(max = 150.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(4.dp),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Box(
|
||||
Modifier.Companion.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(MangaCover.Book.ratio),
|
||||
) {
|
||||
MangaCover.Book(
|
||||
modifier = Modifier.Companion
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
data = manga,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.Companion
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
|
||||
.background(
|
||||
Brush.Companion.verticalGradient(
|
||||
0f to Color.Companion.Transparent,
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color(0xAA000000),
|
||||
),
|
||||
)
|
||||
.fillMaxHeight(0.33f)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Companion.BottomCenter),
|
||||
.align(Alignment.BottomCenter),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.Companion
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.Companion.BottomStart),
|
||||
text = manga.title.ifBlank { stringResource(MR.strings.unknown) },
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 18.sp,
|
||||
.align(Alignment.BottomStart),
|
||||
text = manga.title,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Companion.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
color = Color.Companion.White,
|
||||
shadow = Shadow(
|
||||
color = Color.Companion.Black,
|
||||
blurRadius = 4f,
|
||||
),
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
.merge(shadow = Shadow(color = Color.Black, blurRadius = 4f)),
|
||||
)
|
||||
BadgeGroup(modifier = Modifier.Companion.padding(4.dp)) {
|
||||
Badge(text = "${chapterInfo.chapterCount}")
|
||||
BadgeGroup(modifier = Modifier.padding(4.dp)) {
|
||||
Badge(text = "$chapterCount")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = source,
|
||||
modifier = Modifier.Companion.padding(top = 4.dp, bottom = 1.dp, start = 8.dp),
|
||||
overflow = TextOverflow.Companion.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
|
||||
val formattedLatestChapter by produceState(initialValue = "") {
|
||||
value = withIOContext {
|
||||
chapterInfo.getFormattedLatestChapter(context)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(MaterialTheme.padding.extraSmall),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = source,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
val formattedLatestChapters = remember(latestChapter) {
|
||||
latestChapter?.let(::formatChapterNumber)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
MR.strings.migrationListScreen_latestChapterLabel,
|
||||
formattedLatestChapters ?: stringResource(MR.strings.migrationListScreen_unknownLatestChapter),
|
||||
),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formattedLatestChapter,
|
||||
modifier = Modifier.Companion.padding(top = 1.dp, bottom = 4.dp, start = 8.dp),
|
||||
overflow = TextOverflow.Companion.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MigrationListItemResult(
|
||||
modifier: Modifier,
|
||||
migrationItem: MigratingManga,
|
||||
result: MigratingManga.SearchResult,
|
||||
getManga: suspend (MigratingManga.SearchResult.Success) -> Manga?,
|
||||
getChapterInfo: suspend (MigratingManga.SearchResult.Success) -> MigratingManga.ChapterInfo,
|
||||
getSourceName: (Manga) -> String,
|
||||
onItemClick: (Manga) -> Unit,
|
||||
) {
|
||||
Box(modifier.height(IntrinsicSize.Min)) {
|
||||
when (result) {
|
||||
MigratingManga.SearchResult.Searching -> Box(
|
||||
modifier = Modifier.Companion
|
||||
.widthIn(max = 150.dp)
|
||||
.fillMaxSize()
|
||||
.aspectRatio(MangaCover.Book.ratio),
|
||||
contentAlignment = Alignment.Companion.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
MigratingManga.SearchResult.NotFound -> Column(
|
||||
Modifier.Companion
|
||||
.widthIn(max = 150.dp)
|
||||
.fillMaxSize()
|
||||
.padding(top = 4.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = rememberResourceBitmapPainter(id = R.drawable.cover_error),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.Companion
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(MangaCover.Book.ratio)
|
||||
.clip(MaterialTheme.shapes.extraSmall),
|
||||
contentScale = ContentScale.Companion.Crop,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(SYMR.strings.no_alternatives_found),
|
||||
modifier = Modifier.Companion.padding(top = 4.dp, bottom = 1.dp, start = 8.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
|
||||
is MigratingManga.SearchResult.Success -> {
|
||||
val item by produceState<Triple<Manga, MigratingManga.ChapterInfo, String>?>(
|
||||
initialValue = null,
|
||||
migrationItem,
|
||||
result,
|
||||
MigratingManga.SearchResult.Searching -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 150.dp)
|
||||
.fillMaxSize()
|
||||
.aspectRatio(MangaCover.Book.ratio),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
value = withIOContext {
|
||||
val manga = getManga(result) ?: return@withIOContext null
|
||||
Triple(
|
||||
manga,
|
||||
getChapterInfo(result),
|
||||
getSourceName(manga),
|
||||
)
|
||||
}
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
if (item != null) {
|
||||
val (manga, chapterInfo, source) = item!!
|
||||
MigrationListItem(
|
||||
modifier = Modifier.Companion.fillMaxSize(),
|
||||
manga = manga,
|
||||
source = source,
|
||||
chapterInfo = chapterInfo,
|
||||
onClick = {
|
||||
onItemClick(manga)
|
||||
},
|
||||
}
|
||||
MigratingManga.SearchResult.NotFound -> {
|
||||
Column(
|
||||
Modifier
|
||||
.widthIn(max = 150.dp)
|
||||
.fillMaxSize()
|
||||
.padding(4.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = rememberResourceBitmapPainter(id = R.drawable.cover_error),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(MangaCover.Book.ratio)
|
||||
.clip(MaterialTheme.shapes.extraSmall),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(MR.strings.migrationListScreen_noMatchFoundText),
|
||||
modifier = Modifier.padding(MaterialTheme.padding.extraSmall),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MigratingManga.SearchResult.Success -> {
|
||||
MigrationListItem(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
manga = result.manga,
|
||||
source = result.source,
|
||||
chapterCount = result.chapterCount,
|
||||
latestChapter = result.latestChapter,
|
||||
onClick = { onItemClick(result.manga) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MigrationListItemAction(
|
||||
private fun MigrationListItemAction(
|
||||
modifier: Modifier,
|
||||
result: MigratingManga.SearchResult,
|
||||
onSearchManually: () -> Unit,
|
||||
|
|
@ -368,8 +332,8 @@ fun MigrationListItemAction(
|
|||
onCancel: () -> Unit,
|
||||
// KMK <--
|
||||
) {
|
||||
var moreExpanded by remember { mutableStateOf(false) }
|
||||
val closeMenu = { moreExpanded = false }
|
||||
var menuExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
val closeMenu = { menuExpanded = false }
|
||||
Box(modifier) {
|
||||
when (result) {
|
||||
MigratingManga.SearchResult.Searching -> {
|
||||
|
|
@ -378,49 +342,49 @@ fun MigrationListItemAction(
|
|||
// KMK <--
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = stringResource(SYMR.strings.action_stop),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
MigratingManga.SearchResult.NotFound, is MigratingManga.SearchResult.Success -> {
|
||||
IconButton(onClick = { moreExpanded = !moreExpanded }) {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MoreVert,
|
||||
contentDescription = stringResource(MR.strings.action_menu_overflow_description),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = moreExpanded,
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = closeMenu,
|
||||
offset = DpOffset(8.dp, (-56).dp),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(SYMR.strings.action_search_manually)) },
|
||||
text = { Text(stringResource(MR.strings.migrationListScreen_searchManuallyActionLabel)) },
|
||||
onClick = {
|
||||
onSearchManually()
|
||||
closeMenu()
|
||||
onSearchManually()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(SYMR.strings.action_skip_entry)) },
|
||||
text = { Text(stringResource(MR.strings.migrationListScreen_skipActionLabel)) },
|
||||
onClick = {
|
||||
onSkip()
|
||||
closeMenu()
|
||||
onSkip()
|
||||
},
|
||||
)
|
||||
if (result is MigratingManga.SearchResult.Success) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(SYMR.strings.action_migrate_now)) },
|
||||
text = { Text(stringResource(MR.strings.migrationListScreen_migrateNowActionLabel)) },
|
||||
onClick = {
|
||||
onMigrate()
|
||||
closeMenu()
|
||||
onMigrate()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(SYMR.strings.action_copy_now)) },
|
||||
text = { Text(stringResource(MR.strings.migrationListScreen_copyNowActionLabel)) },
|
||||
onClick = {
|
||||
onCopy()
|
||||
closeMenu()
|
||||
onCopy()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
package mihon.feature.migration.list
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import androidx.annotation.FloatRange
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
|
|
@ -11,20 +10,22 @@ import eu.kanade.domain.source.service.SourcePreferences
|
|||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.getNameForMangaInfo
|
||||
import eu.kanade.tachiyomi.source.online.all.EHentai
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import exh.source.MERGED_SOURCE_ID
|
||||
import exh.util.ThrottleManager
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
|
@ -42,14 +43,11 @@ import tachiyomi.domain.manga.interactor.GetMergedReferencesById
|
|||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class MigrationListScreenModel(
|
||||
private var mangaIds: List<Long>,
|
||||
mangaIds: List<Long>,
|
||||
extraSearchQuery: String?,
|
||||
val preferences: SourcePreferences = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
|
|
@ -62,168 +60,149 @@ class MigrationListScreenModel(
|
|||
// SY -->
|
||||
private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(),
|
||||
// SY <--
|
||||
) : ScreenModel {
|
||||
) : StateScreenModel<MigrationListScreenModel.State>(State()) {
|
||||
|
||||
private val smartSearchEngine = SmartSourceSearchEngine(extraSearchQuery)
|
||||
// SY -->
|
||||
private val throttleManager = ThrottleManager()
|
||||
// SY <--
|
||||
|
||||
val migratingItems = MutableStateFlow<ImmutableList<MigratingManga>?>(null)
|
||||
val migrationDone = MutableStateFlow(false)
|
||||
val finishedCount = MutableStateFlow(0)
|
||||
val items
|
||||
inline get() = state.value.items
|
||||
|
||||
val manualMigrations = MutableStateFlow(0)
|
||||
|
||||
var hideUnmatched = preferences.migrationHideUnmatched().get()
|
||||
private var hideUnmatched = preferences.migrationHideUnmatched().get()
|
||||
private var hideWithoutUpdates = preferences.migrationHideWithoutUpdates().get()
|
||||
// KMK -->
|
||||
private var prioritizeByChapters = preferences.migrationPrioritizeByChapters().get()
|
||||
private var deepSearchMode = preferences.migrationDeepSearchMode().get()
|
||||
// KMK <--
|
||||
|
||||
val navigateOut = MutableSharedFlow<Unit>()
|
||||
|
||||
val dialog = MutableStateFlow<Dialog?>(null)
|
||||
|
||||
val migratingProgress = MutableStateFlow(Float.MAX_VALUE)
|
||||
private val navigateBackChannel = Channel<Unit>()
|
||||
val navigateBackEvent = navigateBackChannel.receiveAsFlow()
|
||||
|
||||
private var migrateJob: Job? = null
|
||||
|
||||
init {
|
||||
screenModelScope.launchIO {
|
||||
runMigrations(
|
||||
mangaIds
|
||||
.map {
|
||||
async {
|
||||
val manga = getManga.await(it) ?: return@async null
|
||||
MigratingManga(
|
||||
manga = manga,
|
||||
chapterInfo = getChapterInfo(it),
|
||||
source = sourceManager.getOrStub(manga.source).getNameForMangaInfo(
|
||||
if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedReferencesById.await(manga.id)
|
||||
.map { sourceManager.getOrStub(it.mangaSourceId) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
),
|
||||
parentContext = screenModelScope.coroutineContext,
|
||||
)
|
||||
}
|
||||
val manga = mangaIds
|
||||
.map {
|
||||
async {
|
||||
val manga = getManga.await(it) ?: return@async null
|
||||
val chapterInfo = getChapterInfo(it)
|
||||
MigratingManga(
|
||||
manga = manga,
|
||||
chapterCount = chapterInfo.chapterCount,
|
||||
latestChapter = chapterInfo.latestChapter,
|
||||
source = sourceManager.getOrStub(manga.source).getNameForMangaInfo(
|
||||
// KMK -->
|
||||
if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedReferencesById.await(manga.id)
|
||||
.map { sourceManager.getOrStub(it.mangaSourceId) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// KMK <--
|
||||
),
|
||||
parentContext = screenModelScope.coroutineContext,
|
||||
)
|
||||
}
|
||||
.awaitAll()
|
||||
.filterNotNull()
|
||||
.also {
|
||||
migratingItems.value = it.toImmutableList()
|
||||
},
|
||||
)
|
||||
}
|
||||
.awaitAll()
|
||||
.filterNotNull()
|
||||
mutableState.update { it.copy(items = manga.toImmutableList()) }
|
||||
runMigrations(manga)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getManga(result: SearchResult.Success) = getManga(result.id)
|
||||
suspend fun getManga(id: Long) = getManga.await(id)
|
||||
suspend fun getChapterInfo(result: SearchResult.Success) = getChapterInfo(result.id)
|
||||
private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters ->
|
||||
MigratingManga.ChapterInfo(
|
||||
ChapterInfo(
|
||||
latestChapter = chapters.maxOfOrNull { it.chapterNumber },
|
||||
chapterCount = chapters.size,
|
||||
)
|
||||
}
|
||||
fun getSourceName(manga: Manga) = sourceManager.getOrStub(manga.source).getNameForMangaInfo()
|
||||
|
||||
fun getMigrationSources() = preferences.migrationSources().get().mapNotNull {
|
||||
sourceManager.get(it) as? CatalogueSource
|
||||
private suspend fun Manga.toSuccessSearchResult(): SearchResult.Success {
|
||||
val chapterInfo = getChapterInfo(id)
|
||||
val source = sourceManager.getOrStub(source).getNameForMangaInfo()
|
||||
return SearchResult.Success(
|
||||
manga = this,
|
||||
chapterCount = chapterInfo.chapterCount,
|
||||
latestChapter = chapterInfo.latestChapter,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun runMigrations(mangas: List<MigratingManga>) {
|
||||
// KMK -->
|
||||
throttleManager.resetThrottle()
|
||||
// KMK: finishedCount.value = mangas.size
|
||||
// val prioritizeByChapters = preferences.migrationPrioritizeByChapters().get()
|
||||
// val deepSearchMode = preferences.migrationDeepSearchMode().get()
|
||||
// KMK <--
|
||||
|
||||
val sources = preferences.migrationSources().get()
|
||||
.mapNotNull { sourceManager.get(it) as? CatalogueSource }
|
||||
|
||||
val sources = getMigrationSources()
|
||||
for (manga in mangas) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
break
|
||||
}
|
||||
// in case it was removed
|
||||
if (manga.manga.id !in mangaIds) {
|
||||
if (!currentCoroutineContext().isActive) break
|
||||
if (manga.manga.id !in state.value.mangaIds) continue
|
||||
if (manga.searchResult.value != SearchResult.Searching) continue
|
||||
if (!manga.migrationScope.isActive) continue
|
||||
|
||||
val result = try {
|
||||
// KMK -->
|
||||
manga.searchingJob = manga.migrationScope.async {
|
||||
// KMK <--
|
||||
if (prioritizeByChapters) {
|
||||
val sourceSemaphore = Semaphore(5)
|
||||
sources.map { source ->
|
||||
async innerAsync@{
|
||||
sourceSemaphore.withPermit {
|
||||
val result = searchSource(manga.manga, source, deepSearchMode)
|
||||
if (result == null || result.second.chapterCount == 0) return@innerAsync null
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
.mapNotNull { it.await() }
|
||||
.maxByOrNull { it.second.latestChapter ?: 0.0 }
|
||||
} else {
|
||||
sources.forEach { source ->
|
||||
val result = searchSource(manga.manga, source, deepSearchMode)
|
||||
if (result != null) return@async result
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
// KMK -->
|
||||
manga.searchingJob?.await()
|
||||
// KMK <--
|
||||
} catch (_: CancellationException) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) {
|
||||
val mangaObj = manga.manga
|
||||
val mangaSource = sourceManager.getOrStub(mangaObj.source)
|
||||
|
||||
val result = try {
|
||||
// KMK -->
|
||||
manga.searchingJob = manga.migrationScope.async {
|
||||
// KMK <--
|
||||
val validSources = if (sources.size == 1) {
|
||||
sources
|
||||
} else {
|
||||
sources.filter { it.id != mangaSource.id }
|
||||
}
|
||||
if (prioritizeByChapters) {
|
||||
val sourceSemaphore = Semaphore(3)
|
||||
val processedSources = AtomicInteger()
|
||||
|
||||
validSources.map { source ->
|
||||
async async2@{
|
||||
sourceSemaphore.withPermit {
|
||||
val result = searchSource(manga.manga, source, deepSearchMode)
|
||||
if (result != null) {
|
||||
manga.progress.value =
|
||||
validSources.size to processedSources.incrementAndGet()
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}.mapNotNull { it.await() }.maxByOrNull { it.second }?.first
|
||||
} else {
|
||||
validSources.forEachIndexed { index, source ->
|
||||
val result = searchSource(manga.manga, source, deepSearchMode)
|
||||
manga.progress.value = validSources.size to (index + 1)
|
||||
if (result != null) return@async result.first
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
// KMK -->
|
||||
manga.searchingJob?.await()
|
||||
// KMK <--
|
||||
} catch (_: CancellationException) {
|
||||
// Ignore canceled migrations
|
||||
continue
|
||||
if (result != null && result.first.thumbnailUrl == null) {
|
||||
try {
|
||||
val newManga = sourceManager.getOrStub(result.first.source).getMangaDetails(result.first.toSManga())
|
||||
updateManga.awaitUpdateFromSource(result.first, newManga, true)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
if (result != null && result.thumbnailUrl == null) {
|
||||
try {
|
||||
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
|
||||
updateManga.awaitUpdateFromSource(result, newManga, true)
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MigrationListScreenModel").e(e, "Error updating manga from source")
|
||||
}
|
||||
}
|
||||
|
||||
manga.searchResult.value = if (result == null) {
|
||||
SearchResult.NotFound
|
||||
} else {
|
||||
SearchResult.Success(result.id)
|
||||
}
|
||||
if (result == null && hideUnmatched) {
|
||||
removeManga(manga)
|
||||
}
|
||||
if (result != null &&
|
||||
hideWithoutUpdates &&
|
||||
(getChapterInfo(result.id).latestChapter ?: 0.0) <= (manga.chapterInfo.latestChapter ?: 0.0)
|
||||
) {
|
||||
removeManga(manga)
|
||||
}
|
||||
|
||||
updateMigrationProgress()
|
||||
}
|
||||
|
||||
manga.searchResult.value = result?.first?.toSuccessSearchResult() ?: SearchResult.NotFound
|
||||
|
||||
if (result == null && hideUnmatched) {
|
||||
removeManga(manga)
|
||||
}
|
||||
if (result != null &&
|
||||
hideWithoutUpdates &&
|
||||
(result.second.latestChapter ?: 0.0) <= (manga.latestChapter ?: 0.0)
|
||||
) {
|
||||
removeManga(manga)
|
||||
}
|
||||
|
||||
updateMigrationProgress()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,41 +210,32 @@ class MigrationListScreenModel(
|
|||
manga: Manga,
|
||||
source: CatalogueSource,
|
||||
deepSearchMode: Boolean,
|
||||
): Pair<Manga, Int>? {
|
||||
): Pair<Manga, ChapterInfo>? {
|
||||
return try {
|
||||
val searchResult = if (deepSearchMode) {
|
||||
smartSearchEngine.deepSearch(source, manga.ogTitle)
|
||||
smartSearchEngine.deepSearch(source, manga.title)
|
||||
} else {
|
||||
smartSearchEngine.regularSearch(source, manga.ogTitle)
|
||||
smartSearchEngine.regularSearch(source, manga.title)
|
||||
}
|
||||
|
||||
if (searchResult != null &&
|
||||
!(searchResult.url == manga.url && source.id == manga.source)
|
||||
) {
|
||||
val localManga = networkToLocalManga(searchResult)
|
||||
if (searchResult == null || (searchResult.url == manga.url && source.id == manga.source)) return null
|
||||
|
||||
val chapters = try {
|
||||
val localManga = networkToLocalManga(searchResult)
|
||||
try {
|
||||
val chapters =
|
||||
// KMK -->
|
||||
if (source is EHentai) {
|
||||
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
|
||||
} else {
|
||||
// KMK <--
|
||||
source.getChapterList(localManga.toSManga())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
this@MigrationListScreenModel.logcat(LogPriority.ERROR, e)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
syncChaptersWithSource.await(chapters, localManga, source)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
localManga to chapters.size
|
||||
} else {
|
||||
null
|
||||
syncChaptersWithSource.await(chapters, localManga, source)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
localManga to getChapterInfo(localManga.id)
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
|
|
@ -273,30 +243,32 @@ class MigrationListScreenModel(
|
|||
}
|
||||
|
||||
private suspend fun updateMigrationProgress() {
|
||||
finishedCount.value = migratingItems.value.orEmpty().count {
|
||||
it.searchResult.value != SearchResult.Searching
|
||||
mutableState.update { state ->
|
||||
state.copy(
|
||||
// KMK -->
|
||||
finishedCount = state.items.count { it.searchResult.value != SearchResult.Searching },
|
||||
migrationComplete = state.migrationComplete(),
|
||||
// KMK <--
|
||||
)
|
||||
}
|
||||
if (migrationComplete()) {
|
||||
migrationDone.value = true
|
||||
}
|
||||
if (migratingItems.value?.isEmpty() == true) {
|
||||
if (items.isEmpty()) {
|
||||
navigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrationComplete() = migratingItems.value.orEmpty().all { it.searchResult.value != SearchResult.Searching } &&
|
||||
migratingItems.value.orEmpty().any { it.searchResult.value is SearchResult.Success }
|
||||
|
||||
private fun mangasSkipped() = migratingItems.value.orEmpty().count { it.searchResult.value == SearchResult.NotFound }
|
||||
// KMK -->
|
||||
private fun State.migrationComplete() =
|
||||
// KMK <--
|
||||
items.all { it.searchResult.value != SearchResult.Searching } &&
|
||||
items.any { it.searchResult.value is SearchResult.Success }
|
||||
|
||||
/** Set a manga picked from manual search to be used as migration target */
|
||||
fun useMangaForMigration(context: Context, newMangaId: Long, selectedMangaId: Long) {
|
||||
val migratingManga = migratingItems.value.orEmpty().find { it.manga.id == selectedMangaId }
|
||||
?: return
|
||||
fun useMangaForMigration(current: Long, target: Long, onMissingChapters: () -> Unit) {
|
||||
val migratingManga = items.find { it.manga.id == current } ?: return
|
||||
migratingManga.searchResult.value = SearchResult.Searching
|
||||
screenModelScope.launchIO {
|
||||
val result = migratingManga.migrationScope.async {
|
||||
val manga = getManga(newMangaId)!!
|
||||
val manga = getManga.await(target) ?: return@async null
|
||||
try {
|
||||
val source = sourceManager.get(manga.source)!!
|
||||
val chapters = source.getChapterList(manga.toSManga())
|
||||
|
|
@ -305,70 +277,66 @@ class MigrationListScreenModel(
|
|||
return@async null
|
||||
}
|
||||
manga
|
||||
}.await()
|
||||
}
|
||||
.await()
|
||||
|
||||
if (result != null) {
|
||||
try {
|
||||
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
|
||||
updateManga.awaitUpdateFromSource(result, newManga, true)
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MigrationListScreenModel").e(e, "Error updating manga from source")
|
||||
}
|
||||
|
||||
migratingManga.searchResult.value = SearchResult.Success(result.id)
|
||||
} else {
|
||||
if (result == null) {
|
||||
migratingManga.searchResult.value = SearchResult.NotFound
|
||||
withUIContext {
|
||||
context.toast(SYMR.strings.no_chapters_found_for_migration, Toast.LENGTH_LONG)
|
||||
}
|
||||
withUIContext { onMissingChapters() }
|
||||
return@launchIO
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
try {
|
||||
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
|
||||
updateManga.awaitUpdateFromSource(result, newManga, true)
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
migratingManga.searchResult.value = result.toSuccessSearchResult()
|
||||
updateMigrationProgress()
|
||||
// KMK <--
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateMangas() {
|
||||
migrateMangas(true)
|
||||
migrateMangas(replace = true)
|
||||
}
|
||||
|
||||
fun copyMangas() {
|
||||
migrateMangas(false)
|
||||
migrateMangas(replace = false)
|
||||
}
|
||||
|
||||
private fun migrateMangas(replace: Boolean) {
|
||||
dialog.value = null
|
||||
migrateJob = screenModelScope.launchIO {
|
||||
migratingProgress.value = 0f
|
||||
val items = migratingItems.value.orEmpty()
|
||||
mutableState.update { it.copy(dialog = Dialog.Progress(0f)) }
|
||||
val items = items
|
||||
try {
|
||||
items.forEachIndexed { index, manga ->
|
||||
try {
|
||||
ensureActive()
|
||||
val target = manga.searchResult.value.let {
|
||||
if (it is SearchResult.Success) {
|
||||
getManga.await(it.id)
|
||||
it.manga
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (target != null) {
|
||||
migrateManga(manga.manga, target, replace)
|
||||
migrateManga(current = manga.manga, target = target, replace = replace)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
logcat(LogPriority.WARN, throwable = e)
|
||||
}
|
||||
migratingProgress.value = index.toFloat() / items.size
|
||||
mutableState.update {
|
||||
it.copy(dialog = Dialog.Progress((index.toFloat() / items.size).coerceAtMost(1f)))
|
||||
}
|
||||
}
|
||||
|
||||
navigateBack()
|
||||
} finally {
|
||||
migratingProgress.value = Float.MAX_VALUE
|
||||
mutableState.update { it.copy(dialog = null) }
|
||||
migrateJob = null
|
||||
}
|
||||
}
|
||||
|
|
@ -380,19 +348,15 @@ class MigrationListScreenModel(
|
|||
}
|
||||
|
||||
private suspend fun navigateBack() {
|
||||
navigateOut.emit(Unit)
|
||||
navigateBackChannel.send(Unit)
|
||||
}
|
||||
|
||||
fun migrateNow(mangaId: Long, replace: Boolean) {
|
||||
manualMigrations.value++
|
||||
screenModelScope.launchIO {
|
||||
val manga = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
|
||||
?: return@launchIO
|
||||
val manga = items.find { it.manga.id == mangaId } ?: return@launchIO
|
||||
val target = (manga.searchResult.value as? SearchResult.Success)?.manga ?: return@launchIO
|
||||
migrateManga(current = manga.manga, target = target, replace = replace)
|
||||
|
||||
val target = getManga.await((manga.searchResult.value as? SearchResult.Success)?.id ?: return@launchIO)
|
||||
?: return@launchIO
|
||||
|
||||
migrateManga(manga.manga, target, replace)
|
||||
removeManga(mangaId)
|
||||
}
|
||||
}
|
||||
|
|
@ -401,8 +365,7 @@ class MigrationListScreenModel(
|
|||
/** Cancel searching without remove it from list so user can perform manual search */
|
||||
fun cancelManga(mangaId: Long) {
|
||||
screenModelScope.launchIO {
|
||||
val item = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
|
||||
?: return@launchIO
|
||||
val item = items.find { it.manga.id == mangaId } ?: return@launchIO
|
||||
item.searchingJob?.cancel()
|
||||
item.searchingJob = null
|
||||
item.searchResult.value = SearchResult.NotFound
|
||||
|
|
@ -413,8 +376,7 @@ class MigrationListScreenModel(
|
|||
|
||||
fun removeManga(mangaId: Long) {
|
||||
screenModelScope.launchIO {
|
||||
val item = migratingItems.value.orEmpty().find { it.manga.id == mangaId }
|
||||
?: return@launchIO
|
||||
val item = items.find { it.manga.id == mangaId } ?: return@launchIO
|
||||
removeManga(item)
|
||||
item.migrationScope.cancel()
|
||||
updateMigrationProgress()
|
||||
|
|
@ -422,34 +384,45 @@ class MigrationListScreenModel(
|
|||
}
|
||||
|
||||
private fun removeManga(item: MigratingManga) {
|
||||
val ids = mangaIds.toMutableList()
|
||||
val index = ids.indexOf(item.manga.id)
|
||||
if (index > -1) {
|
||||
ids.removeAt(index)
|
||||
mangaIds = ids
|
||||
val index2 = migratingItems.value.orEmpty().indexOf(item)
|
||||
if (index2 > -1) migratingItems.value = (migratingItems.value.orEmpty() - item).toImmutableList()
|
||||
}
|
||||
mutableState.update { it.copy(items = items.toPersistentList().remove(item)) }
|
||||
}
|
||||
|
||||
override fun onDispose() {
|
||||
super.onDispose()
|
||||
migratingItems.value.orEmpty().forEach {
|
||||
items.forEach {
|
||||
it.migrationScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun showMigrateDialog(copy: Boolean) {
|
||||
dialog.value = Dialog.Migrate(
|
||||
copy,
|
||||
migratingItems.value.orEmpty().size,
|
||||
mangasSkipped(),
|
||||
)
|
||||
mutableState.update { state ->
|
||||
state.copy(
|
||||
dialog = Dialog.Migrate(
|
||||
copy = copy,
|
||||
// KMK -->
|
||||
totalCount = state.items.size,
|
||||
skippedCount = state.items.count { it.searchResult.value == SearchResult.NotFound },
|
||||
// KMK <--
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showExitDialog() {
|
||||
mutableState.update {
|
||||
it.copy(dialog = Dialog.Exit)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissDialog() {
|
||||
mutableState.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
fun openOptionsDialog() {
|
||||
dialog.value = Dialog.Options
|
||||
mutableState.update {
|
||||
it.copy(dialog = Dialog.Options)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateOptions() {
|
||||
|
|
@ -460,12 +433,27 @@ class MigrationListScreenModel(
|
|||
}
|
||||
// KMK <--
|
||||
|
||||
sealed class Dialog {
|
||||
data class Migrate(val copy: Boolean, val totalCount: Int, val skippedCount: Int) : Dialog()
|
||||
data object Exit : Dialog()
|
||||
data class ChapterInfo(
|
||||
val latestChapter: Double?,
|
||||
val chapterCount: Int,
|
||||
)
|
||||
|
||||
sealed interface Dialog {
|
||||
data class Migrate(val copy: Boolean, val totalCount: Int, val skippedCount: Int) : Dialog
|
||||
data class Progress(@FloatRange(0.0, 1.0) val progress: Float) : Dialog
|
||||
data object Exit : Dialog
|
||||
|
||||
// KMK -->
|
||||
data object Options : Dialog()
|
||||
data object Options : Dialog
|
||||
// KMK <--
|
||||
}
|
||||
|
||||
data class State(
|
||||
val items: ImmutableList<MigratingManga> = persistentListOf(),
|
||||
val finishedCount: Int = 0,
|
||||
val migrationComplete: Boolean = false,
|
||||
val dialog: Dialog? = null,
|
||||
) {
|
||||
val mangaIds: List<Long> = items.map { it.manga.id }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
|
|
@ -16,16 +15,16 @@ fun MigrationExitDialog(
|
|||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(text = stringResource(SYMR.strings.stop_migrating))
|
||||
Text(text = stringResource(MR.strings.migrationListScreen_exitDialogTitle))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = exitMigration) {
|
||||
Text(text = stringResource(SYMR.strings.action_stop))
|
||||
Text(text = stringResource(MR.strings.migrationListScreen_exitDialog_stopLabel))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
Text(text = stringResource(MR.strings.migrationListScreen_exitDialog_cancelLabel))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.i18n.pluralStringResource
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
|
|
@ -15,37 +14,50 @@ fun MigrationMangaDialog(
|
|||
copy: Boolean,
|
||||
totalCount: Int,
|
||||
skippedCount: Int,
|
||||
copyManga: () -> Unit,
|
||||
onMigrate: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
text = {
|
||||
title = {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
if (copy) SYMR.plurals.copy_entry else SYMR.plurals.migrate_entry,
|
||||
resource = if (copy) {
|
||||
MR.plurals.migrationListScreen_migrateDialog_copyTitle
|
||||
} else {
|
||||
MR.plurals.migrationListScreen_migrateDialog_migrateTitle
|
||||
},
|
||||
count = totalCount,
|
||||
totalCount,
|
||||
(if (skippedCount > 0) " " + stringResource(SYMR.strings.skipping_, skippedCount) else ""),
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (skippedCount > 0) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
resource = MR.plurals.migrationListScreen_migrateDialog_skipText,
|
||||
count = skippedCount,
|
||||
skippedCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (copy) {
|
||||
copyManga()
|
||||
} else {
|
||||
onMigrate()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(if (copy) MR.strings.copy else MR.strings.migrate))
|
||||
TextButton(onClick = onMigrate) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
resource = if (copy) {
|
||||
MR.strings.migrationListScreen_migrateDialog_copyLabel
|
||||
} else {
|
||||
MR.strings.migrationListScreen_migrateDialog_migrateLabel
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
Text(text = stringResource(MR.strings.migrationListScreen_migrateDialog_cancelLabel))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ fun MigrationProgressDialog(
|
|||
onDismissRequest = {},
|
||||
confirmButton = {
|
||||
TextButton(onClick = exitMigration) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
Text(text = stringResource(MR.strings.migrationListScreen_progressDialog_cancelLabel))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
|
|
|
|||
|
|
@ -1,57 +1,37 @@
|
|||
package mihon.feature.migration.list.models
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import mihon.feature.migration.list.MigrationListScreenModel.ChapterInfo
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import java.text.DecimalFormat
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class MigratingManga(
|
||||
val manga: Manga,
|
||||
val chapterInfo: ChapterInfo,
|
||||
val chapterCount: Int,
|
||||
val latestChapter: Double?,
|
||||
val source: String,
|
||||
parentContext: CoroutineContext,
|
||||
) {
|
||||
val migrationScope = CoroutineScope(parentContext + SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
// KMK -->
|
||||
var searchingJob: Deferred<Manga?>? = null
|
||||
var searchingJob: Deferred<Pair<Manga, ChapterInfo>?>? = null
|
||||
// KMK <--
|
||||
|
||||
val searchResult = MutableStateFlow<SearchResult>(SearchResult.Searching)
|
||||
|
||||
// <MAX, PROGRESS>
|
||||
val progress = MutableStateFlow(1 to 0)
|
||||
|
||||
sealed class SearchResult {
|
||||
data object Searching : SearchResult()
|
||||
data object NotFound : SearchResult()
|
||||
data class Success(val id: Long) : SearchResult()
|
||||
}
|
||||
|
||||
data class ChapterInfo(
|
||||
val latestChapter: Double?,
|
||||
val chapterCount: Int,
|
||||
) {
|
||||
fun getFormattedLatestChapter(context: Context): String {
|
||||
return if (latestChapter != null && latestChapter > 0.0) {
|
||||
context.stringResource(
|
||||
SYMR.strings.latest_,
|
||||
DecimalFormat("#.#").format(latestChapter),
|
||||
)
|
||||
} else {
|
||||
context.stringResource(
|
||||
SYMR.strings.latest_,
|
||||
context.stringResource(MR.strings.unknown),
|
||||
)
|
||||
}
|
||||
}
|
||||
sealed interface SearchResult {
|
||||
data object Searching : SearchResult
|
||||
data object NotFound : SearchResult
|
||||
data class Success(
|
||||
val manga: Manga,
|
||||
val chapterCount: Int,
|
||||
val latestChapter: Double?,
|
||||
val source: String,
|
||||
) : SearchResult
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package mihon.feature.migration.list.search
|
||||
|
||||
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
|
||||
import com.aallam.similarity.NormalizedLevenshtein
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
|
|
@ -17,59 +17,62 @@ abstract class BaseSmartSearchEngine<T>(
|
|||
|
||||
protected abstract fun getTitle(result: T): String
|
||||
|
||||
protected suspend fun regularSearch(searchAction: SearchAction<T>, title: String): T? {
|
||||
return baseSearch(searchAction, listOf(title)) {
|
||||
normalizedLevenshtein.similarity(title, getTitle(it))
|
||||
}
|
||||
}
|
||||
|
||||
protected suspend fun deepSearch(searchAction: SearchAction<T>, title: String): T? {
|
||||
val cleanedTitle = cleanSmartSearchTitle(title)
|
||||
val cleanedTitle = cleanDeepSearchTitle(title)
|
||||
|
||||
val queries = getDeepSearchQueries(cleanedTitle)
|
||||
|
||||
return baseSearch(searchAction, queries) {
|
||||
val cleanedMangaTitle = cleanDeepSearchTitle(getTitle(it))
|
||||
normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun baseSearch(
|
||||
searchAction: SearchAction<T>,
|
||||
queries: List<String>,
|
||||
calculateDistance: (T) -> Double,
|
||||
): T? {
|
||||
val eligibleManga = supervisorScope {
|
||||
queries.map { query ->
|
||||
async(Dispatchers.Default) {
|
||||
val builtQuery = if (extraSearchParams != null) {
|
||||
val builtQuery = if (!extraSearchParams.isNullOrBlank()) {
|
||||
"$query ${extraSearchParams.trim()}"
|
||||
} else {
|
||||
query
|
||||
}
|
||||
|
||||
searchAction(builtQuery.sanitize()).map {
|
||||
val cleanedMangaTitle = cleanSmartSearchTitle(getTitle(it))
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle)
|
||||
SearchEntry(it, normalizedDistance)
|
||||
}.filter { (_, normalizedDistance) ->
|
||||
normalizedDistance >= eligibleThreshold
|
||||
}
|
||||
val candidates = searchAction(
|
||||
builtQuery
|
||||
// KMK -->
|
||||
.sanitize(),
|
||||
// KMK <--
|
||||
)
|
||||
candidates
|
||||
.map {
|
||||
val distance = if (queries.size > 1 || candidates.size > 1) {
|
||||
calculateDistance(it)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
SearchEntry(it, distance)
|
||||
}
|
||||
.filter { it.distance >= eligibleThreshold }
|
||||
}
|
||||
}.flatMap { it.await() }
|
||||
}
|
||||
.flatMap { it.await() }
|
||||
}
|
||||
|
||||
return eligibleManga.maxByOrNull { it.distance }?.entry
|
||||
}
|
||||
|
||||
protected suspend fun regularSearch(searchAction: SearchAction<T>, title: String): T? {
|
||||
val eligibleManga = supervisorScope {
|
||||
val searchQuery = if (extraSearchParams != null) {
|
||||
"$title ${extraSearchParams.trim()}"
|
||||
} else {
|
||||
title
|
||||
}
|
||||
val searchResults = searchAction(searchQuery.sanitize())
|
||||
|
||||
if (searchResults.size == 1) {
|
||||
return@supervisorScope listOf(SearchEntry(searchResults.first(), 0.0))
|
||||
}
|
||||
|
||||
searchResults.map {
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(title, getTitle(it))
|
||||
SearchEntry(it, normalizedDistance)
|
||||
}.filter { (_, normalizedDistance) ->
|
||||
normalizedDistance >= eligibleThreshold
|
||||
}
|
||||
}
|
||||
|
||||
return eligibleManga.maxByOrNull { it.distance }?.entry
|
||||
}
|
||||
|
||||
private fun cleanSmartSearchTitle(title: String): String {
|
||||
private fun cleanDeepSearchTitle(title: String): String {
|
||||
val preTitle = title.lowercase(Locale.getDefault())
|
||||
|
||||
// Remove text in brackets
|
||||
|
|
@ -98,48 +101,28 @@ abstract class BaseSmartSearchEngine<T>(
|
|||
}
|
||||
|
||||
private fun removeTextInBrackets(text: String, readForward: Boolean): String {
|
||||
val bracketPairs = listOf(
|
||||
'(' to ')',
|
||||
'[' to ']',
|
||||
'<' to '>',
|
||||
'{' to '}',
|
||||
)
|
||||
var openingBracketPairs = bracketPairs.mapIndexed { index, (opening, _) ->
|
||||
opening to index
|
||||
}.toMap()
|
||||
var closingBracketPairs = bracketPairs.mapIndexed { index, (_, closing) ->
|
||||
closing to index
|
||||
}.toMap()
|
||||
val openingChars = if (readForward) "([<{" else ")]}>"
|
||||
val closingChars = if (readForward) ")]}>" else "([<{"
|
||||
var depth = 0
|
||||
|
||||
// Reverse pairs if reading backwards
|
||||
if (!readForward) {
|
||||
val tmp = openingBracketPairs
|
||||
openingBracketPairs = closingBracketPairs
|
||||
closingBracketPairs = tmp
|
||||
}
|
||||
|
||||
val depthPairs = bracketPairs.map { 0 }.toMutableList()
|
||||
|
||||
val result = StringBuilder()
|
||||
for (c in if (readForward) text else text.reversed()) {
|
||||
val openingBracketDepthIndex = openingBracketPairs[c]
|
||||
if (openingBracketDepthIndex != null) {
|
||||
depthPairs[openingBracketDepthIndex]++
|
||||
} else {
|
||||
val closingBracketDepthIndex = closingBracketPairs[c]
|
||||
if (closingBracketDepthIndex != null) {
|
||||
depthPairs[closingBracketDepthIndex]--
|
||||
} else {
|
||||
if (depthPairs.all { it <= 0 }) {
|
||||
result.append(c)
|
||||
} else {
|
||||
// In brackets, do not append to result
|
||||
// KMK -->
|
||||
val result = buildString {
|
||||
for (char in (if (readForward) text else text.reversed())) {
|
||||
when {
|
||||
char in openingChars -> depth++
|
||||
char in closingChars && depth > 0 -> {
|
||||
depth-- // Avoid depth going negative on mismatched closing
|
||||
// Unmatched closing bracket (depth = 0) -> treat as normal text
|
||||
}
|
||||
else -> if (depth == 0) {
|
||||
append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString()
|
||||
// If reading backward, the result is reversed
|
||||
return if (readForward) result else result.reversed()
|
||||
// KMK <--
|
||||
}
|
||||
|
||||
private fun getDeepSearchQueries(cleanedTitle: String): List<String> {
|
||||
|
|
@ -163,9 +146,9 @@ abstract class BaseSmartSearchEngine<T>(
|
|||
splitCleanedTitle.take(1),
|
||||
)
|
||||
|
||||
return searchQueries.map {
|
||||
it.joinToString(" ").trim()
|
||||
}.distinct()
|
||||
return searchQueries
|
||||
.map { it.joinToString(" ").trim() }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -5,22 +5,23 @@ import eu.kanade.tachiyomi.source.model.SManga
|
|||
import mihon.domain.manga.model.toDomainManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
|
||||
class SmartSourceSearchEngine(
|
||||
extraSearchParams: String? = null,
|
||||
) : BaseSmartSearchEngine<SManga>(extraSearchParams) {
|
||||
class SmartSourceSearchEngine(extraSearchParams: String?) : BaseSmartSearchEngine<SManga>(extraSearchParams) {
|
||||
|
||||
override fun getTitle(result: SManga) = result.originalTitle
|
||||
|
||||
suspend fun regularSearch(source: CatalogueSource, title: String): Manga? =
|
||||
regularSearch(makeSearchAction(source), title).let {
|
||||
suspend fun regularSearch(source: CatalogueSource, title: String): Manga? {
|
||||
return regularSearch(makeSearchAction(source), title).let {
|
||||
it?.toDomainManga(source.id)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deepSearch(source: CatalogueSource, title: String): Manga? =
|
||||
deepSearch(makeSearchAction(source), title).let {
|
||||
suspend fun deepSearch(source: CatalogueSource, title: String): Manga? {
|
||||
return deepSearch(makeSearchAction(source), title).let {
|
||||
it?.toDomainManga(source.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeSearchAction(source: CatalogueSource): SearchAction<SManga> =
|
||||
{ query -> source.getSearchManga(1, query, source.getFilterList()).mangas }
|
||||
private fun makeSearchAction(source: CatalogueSource): SearchAction<SManga> = { query ->
|
||||
source.getSearchManga(1, query, source.getFilterList()).mangas
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ ktlint-core = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlin
|
|||
markdown-core = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdown" }
|
||||
markdown-coil = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdown" }
|
||||
|
||||
stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version = "0.1.0" }
|
||||
|
||||
materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
||||
|
||||
[plugins]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
[versions]
|
||||
|
||||
[libraries]
|
||||
similarity = "info.debatty:java-string-similarity:2.0.0"
|
||||
xlog = "com.elvishew:xlog:1.11.1"
|
||||
|
||||
ratingbar = "me.zhanghai.android.materialratingbar:library:1.4.0"
|
||||
|
|
|
|||
|
|
@ -95,4 +95,18 @@
|
|||
<item quantity="one">%d repo</item>
|
||||
<item quantity="other">%d repos</item>
|
||||
</plurals>
|
||||
|
||||
<!--Migration-->
|
||||
<plurals name="migrationListScreen.migrateDialog.migrateTitle">
|
||||
<item quantity="one">Migrate %1$d entry?</item>
|
||||
<item quantity="other">Migrate %1$d entries?</item>
|
||||
</plurals>
|
||||
<plurals name="migrationListScreen.migrateDialog.copyTitle">
|
||||
<item quantity="one">Copy %1$d entry?</item>
|
||||
<item quantity="other">Copy %1$d entries?</item>
|
||||
</plurals>
|
||||
<plurals name="migrationListScreen.migrateDialog.skipText">
|
||||
<item quantity="one">An entry was skipped</item>
|
||||
<item quantity="other">%1$d entries were skipped</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1027,4 +1027,23 @@
|
|||
<string name="migrationConfigScreen.deepSearchModeSubtitle">Breaks down the title into keywords for a wider search</string>
|
||||
<string name="migrationConfigScreen.prioritizeByChaptersTitle">Match based on chapter number</string>
|
||||
<string name="migrationConfigScreen.prioritizeByChaptersSubtitle">If enabled, chooses the match furthest ahead. Otherwise, picks the first match by source priority.</string>
|
||||
<string name="migrationListScreenTitle">Migration</string>
|
||||
<string name="migrationListScreenTitleWithProgress">Migration (%1$d/%2$d)</string>
|
||||
<string name="migrationListScreen.copyActionLabel">Copy</string>
|
||||
<string name="migrationListScreen.migrateActionLabel">Migrate</string>
|
||||
<string name="migrationListScreen.noMatchFoundText">No alternatives found</string>
|
||||
<string name="migrationListScreen.latestChapterLabel">Latest: %1$s</string>
|
||||
<string name="migrationListScreen.unknownLatestChapter">Unknown</string>
|
||||
<string name="migrationListScreen.searchManuallyActionLabel">Search manually</string>
|
||||
<string name="migrationListScreen.skipActionLabel">Don\'t migrate</string>
|
||||
<string name="migrationListScreen.migrateNowActionLabel">Migrate now</string>
|
||||
<string name="migrationListScreen.copyNowActionLabel">Copy now</string>
|
||||
<string name="migrationListScreen.exitDialogTitle">Stop migrating?</string>
|
||||
<string name="migrationListScreen.exitDialog.stopLabel">Stop</string>
|
||||
<string name="migrationListScreen.exitDialog.cancelLabel">Cancel</string>
|
||||
<string name="migrationListScreen.migrateDialog.copyLabel">Copy</string>
|
||||
<string name="migrationListScreen.migrateDialog.migrateLabel">Migrate</string>
|
||||
<string name="migrationListScreen.migrateDialog.cancelLabel">Cancel</string>
|
||||
<string name="migrationListScreen.progressDialog.cancelLabel">Cancel</string>
|
||||
<string name="migrationListScreen.matchWithoutChapterToast">No chapters found, this entry cannot be used for migration</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Reference in a new issue