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