Implemented J2K Auto Source Migration
(cherry picked from commit 8ba75831e6f51f6472d85f813405ede3f679cfd8)
This commit is contained in:
@@ -16,7 +16,7 @@ import eu.kanade.tachiyomi.data.database.tables.CategoryTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
import exh.metadata.sql.tables.SearchMetadataTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable
|
||||
|
||||
interface MangaQueries : DbProvider {
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package eu.kanade.tachiyomi.smartsearch
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.ui.smartsearch.SmartSearchPresenter
|
||||
import eu.kanade.tachiyomi.util.await
|
||||
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class SmartSearchEngine(
|
||||
parentContext: CoroutineContext,
|
||||
val extraSearchParams: String? = null
|
||||
) : CoroutineScope {
|
||||
override val coroutineContext: CoroutineContext = parentContext + Job() + Dispatchers.Default
|
||||
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
private val normalizedLevenshtein = NormalizedLevenshtein()
|
||||
|
||||
suspend fun smartSearch(source: CatalogueSource, title: String): SManga? {
|
||||
val cleanedTitle = cleanSmartSearchTitle(title)
|
||||
|
||||
val queries = getSmartSearchQueries(cleanedTitle)
|
||||
|
||||
val eligibleManga = supervisorScope {
|
||||
queries.map { query ->
|
||||
async(Dispatchers.Default) {
|
||||
val builtQuery = if (extraSearchParams != null) {
|
||||
"$query ${extraSearchParams.trim()}"
|
||||
} else query
|
||||
|
||||
val searchResults = source.fetchSearchManga(1, builtQuery, FilterList())
|
||||
.toSingle().await(Schedulers.io())
|
||||
|
||||
searchResults.mangas.map {
|
||||
val cleanedMangaTitle = cleanSmartSearchTitle(it.title)
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle)
|
||||
SmartSearchPresenter.SearchEntry(it, normalizedDistance)
|
||||
}.filter { (_, normalizedDistance) ->
|
||||
normalizedDistance >= MIN_SMART_ELIGIBLE_THRESHOLD
|
||||
}
|
||||
}
|
||||
}.flatMap { it.await() }
|
||||
}
|
||||
|
||||
return eligibleManga.maxBy { it.dist }?.manga
|
||||
}
|
||||
|
||||
suspend fun normalSearch(source: CatalogueSource, title: String): SManga? {
|
||||
val eligibleManga = supervisorScope {
|
||||
val searchQuery = if (extraSearchParams != null) {
|
||||
"$title ${extraSearchParams.trim()}"
|
||||
} else title
|
||||
val searchResults = source.fetchSearchManga(1, searchQuery, FilterList()).toSingle().await(Schedulers.io())
|
||||
|
||||
searchResults.mangas.map {
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(title, it.title)
|
||||
SmartSearchPresenter.SearchEntry(it, normalizedDistance)
|
||||
}.filter { (_, normalizedDistance) ->
|
||||
normalizedDistance >= MIN_NORMAL_ELIGIBLE_THRESHOLD
|
||||
}
|
||||
}
|
||||
|
||||
return eligibleManga.maxBy { it.dist }?.manga
|
||||
}
|
||||
|
||||
private fun getSmartSearchQueries(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()
|
||||
}
|
||||
|
||||
private fun cleanSmartSearchTitle(title: String): String {
|
||||
val preTitle = title.toLowerCase()
|
||||
|
||||
// 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 non-special characters
|
||||
cleanedTitle = cleanedTitle.replace(titleRegex, " ")
|
||||
|
||||
// Strip splitters and consecutive spaces
|
||||
cleanedTitle = cleanedTitle.trim().replace(" - ", " ").replace(consecutiveSpacesRegex, " ").trim()
|
||||
|
||||
return cleanedTitle
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga from the database for the given manga from network. It creates a new entry
|
||||
* if the manga is not yet in the database.
|
||||
*
|
||||
* @param sManga the manga from the source.
|
||||
* @return a manga from the database.
|
||||
*/
|
||||
suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
|
||||
var localManga = db.getManga(sManga.url, sourceId).executeAsBlocking()
|
||||
if (localManga == null) {
|
||||
val newManga = Manga.create(sManga.url, sManga.title, sourceId)
|
||||
newManga.copyFrom(sManga)
|
||||
val result = db.insertManga(newManga).executeAsBlocking()
|
||||
newManga.id = result.insertedId()
|
||||
localManga = newManga
|
||||
}
|
||||
return localManga
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_SMART_ELIGIBLE_THRESHOLD = 0.4
|
||||
const val MIN_NORMAL_ELIGIBLE_THRESHOLD = 0.4
|
||||
|
||||
private val titleRegex = Regex("[^a-zA-Z0-9- ]")
|
||||
private val consecutiveSpacesRegex = Regex(" +")
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController
|
||||
import eu.kanade.tachiyomi.ui.smartsearch.SmartSearchController
|
||||
import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.source.global_search.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.source.latest.LatestUpdatesController
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -56,16 +61,22 @@ class SourceController :
|
||||
*/
|
||||
private var adapter: SourceAdapter? = null
|
||||
|
||||
// EXH -->
|
||||
private val mode = if (smartSearchConfig == null) Mode.CATALOGUE else Mode.SMART_SEARCH
|
||||
// EXH <--
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
// Enable the option menu
|
||||
setHasOptionsMenu(mode == Mode.CATALOGUE)
|
||||
}
|
||||
|
||||
override fun getTitle(): String? {
|
||||
return applicationContext?.getString(R.string.label_sources)
|
||||
}
|
||||
|
||||
returnwhen (mode) {
|
||||
Mode.CATALOGUE -> applicationContext?.getString(R.string.label_sources)
|
||||
Mode.SMART_SEARCH -> "Find in another source"
|
||||
}
|
||||
override fun createPresenter(): SourcePresenter {
|
||||
return SourcePresenter()
|
||||
return SourcePresenter(controllerMode = mode)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,7 +126,16 @@ class SourceController :
|
||||
override fun onItemClick(view: View, position: Int): Boolean {
|
||||
val item = adapter?.getItem(position) as? SourceItem ?: return false
|
||||
val source = item.source
|
||||
openCatalogue(source, BrowseSourceController(source))
|
||||
when (mode) {
|
||||
Mode.CATALOGUE -> {
|
||||
// Open the catalogue view.
|
||||
openCatalogue(source, BrowseSourceController(source))
|
||||
}
|
||||
Mode.SMART_SEARCH -> router.pushController(SmartSearchController(Bundle().apply {
|
||||
putLong(SmartSearchController.ARG_SOURCE_ID, source.id)
|
||||
putParcelable(SmartSearchController.ARG_SMART_SEARCH_CONFIG, smartSearchConfig)
|
||||
}).withFadeTransaction())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -250,4 +270,16 @@ class SourceController :
|
||||
adapter?.addScrollableHeader(LangItem(SourcePresenter.LAST_USED_KEY))
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class SmartSearchConfig(val origTitle: String, val origMangaId: Long) : Parcelable
|
||||
|
||||
enum class Mode {
|
||||
CATALOGUE,
|
||||
SMART_SEARCH
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SMART_SEARCH_CONFIG = "SMART_SEARCH_CONFIG"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -614,9 +614,8 @@ open class BrowseSourceController(bundle: Bundle) :
|
||||
|
||||
protected companion object {
|
||||
const val SOURCE_ID_KEY = "sourceId"
|
||||
|
||||
const val SEARCH_QUERY_KEY = "searchQuery"
|
||||
// EXH -->
|
||||
const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig"
|
||||
// EXH <--
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryAdapter
|
||||
import exh.isLewdSource
|
||||
import exh.metadata.sql.tables.SearchMetadataTable
|
||||
import exh.search.SearchEngine
|
||||
import exh.util.await
|
||||
import exh.util.cancellable
|
||||
|
||||
@@ -34,9 +34,17 @@ import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.ui.main.offsetFabAppbarHeight
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.ui.migration.MigrationController
|
||||
import eu.kanade.tachiyomi.ui.migration.MigrationInterface
|
||||
import eu.kanade.tachiyomi.ui.migration.SearchController
|
||||
import eu.kanade.tachiyomi.ui.migration.manga.design.MigrationDesignController
|
||||
import eu.kanade.tachiyomi.util.system.getResourceColor
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import eu.kanade.tachiyomi.util.view.inflate
|
||||
import eu.kanade.tachiyomi.util.view.visible
|
||||
import exh.favorites.FavoritesIntroDialog
|
||||
import exh.favorites.FavoritesSyncStatus
|
||||
import exh.ui.LoaderManager
|
||||
import java.io.IOException
|
||||
import kotlinx.android.synthetic.main.main_activity.tabs
|
||||
import kotlinx.coroutines.flow.filter
|
||||
@@ -57,7 +65,8 @@ class LibraryController(
|
||||
TabbedController,
|
||||
ActionMode.Callback,
|
||||
ChangeMangaCategoriesDialog.Listener,
|
||||
DeleteLibraryMangasDialog.Listener {
|
||||
DeleteLibraryMangasDialog.Listener,
|
||||
MigrationInterface {
|
||||
|
||||
/**
|
||||
* Position of the active category.
|
||||
@@ -86,6 +95,11 @@ class LibraryController(
|
||||
*/
|
||||
val selectionRelay: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
|
||||
|
||||
/**
|
||||
* Current mangas to move.
|
||||
*/
|
||||
private var migratingMangas = mutableSetOf<Manga>()
|
||||
|
||||
/**
|
||||
* Relay to notify search query changes.
|
||||
*/
|
||||
@@ -468,10 +482,11 @@ class LibraryController(
|
||||
R.id.action_delete -> showDeleteMangaDialog()
|
||||
R.id.action_select_all -> selectAllCategoryManga()
|
||||
R.id.action_select_inverse -> selectInverseCategoryManga()
|
||||
R.id.action_auto_source_migration -> {
|
||||
router.pushController(MigrationDesignController.create(
|
||||
R.id.action_migrate -> {
|
||||
router.pushController(
|
||||
MigrationDesignController.create(
|
||||
selectedMangas.mapNotNull { it.id }
|
||||
).withFadeTransaction())
|
||||
).withFadeTransaction())
|
||||
destroyActionModeIfNeeded()
|
||||
}
|
||||
else -> return false
|
||||
@@ -479,6 +494,27 @@ class LibraryController(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean): Manga? {
|
||||
if (manga.id != prevManga.id) {
|
||||
presenter.migrateManga(prevManga, manga, replace = replace)
|
||||
}
|
||||
val nextManga = migratingMangas.firstOrNull() ?: return null
|
||||
migratingMangas.remove(nextManga)
|
||||
return nextManga
|
||||
}
|
||||
|
||||
private fun startMangaMigration() {
|
||||
migratingMangas.clear()
|
||||
migratingMangas.addAll(selectedMangas)
|
||||
destroyActionModeIfNeeded()
|
||||
val manga = migratingMangas.firstOrNull() ?: return
|
||||
val searchController = SearchController(manga)
|
||||
searchController.totalProgress = migratingMangas.size
|
||||
searchController.targetController = this
|
||||
router.pushController(searchController.withFadeTransaction())
|
||||
migratingMangas.remove(manga)
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
// Clear all the manga selections and notify child views.
|
||||
selectedMangas.clear()
|
||||
|
||||
@@ -10,10 +10,14 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.ui.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
||||
import eu.kanade.tachiyomi.util.lang.combineLatest
|
||||
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
|
||||
import java.io.IOException
|
||||
@@ -367,6 +371,84 @@ class LibraryPresenter(
|
||||
db.setMangaCategories(mc, mangas)
|
||||
}
|
||||
|
||||
fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean) {
|
||||
val source = sourceManager.get(manga.source) ?: return
|
||||
|
||||
// state = state.copy(isReplacingManga = true)
|
||||
|
||||
Observable.defer { source.fetchChapterList(manga) }
|
||||
.onErrorReturn { emptyList() }
|
||||
.doOnNext { migrateMangaInternal(source, it, prevManga, manga, replace) }
|
||||
.onErrorReturn { emptyList() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// .doOnUnsubscribe { state = state.copy(isReplacingManga = false) }
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
private fun migrateMangaInternal(
|
||||
source: Source,
|
||||
sourceChapters: List<SChapter>,
|
||||
prevManga: Manga,
|
||||
manga: Manga,
|
||||
replace: Boolean
|
||||
) {
|
||||
|
||||
val flags = preferences.migrateFlags().getOrDefault()
|
||||
val migrateChapters = MigrationFlags.hasChapters(flags)
|
||||
val migrateCategories = MigrationFlags.hasCategories(flags)
|
||||
val migrateTracks = MigrationFlags.hasTracks(flags)
|
||||
|
||||
db.inTransaction {
|
||||
// Update chapters read
|
||||
if (migrateChapters) {
|
||||
try {
|
||||
syncChaptersWithSource(db, sourceChapters, manga, source)
|
||||
} catch (e: Exception) {
|
||||
// Worst case, chapters won't be synced
|
||||
}
|
||||
|
||||
val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking()
|
||||
val maxChapterRead =
|
||||
prevMangaChapters.filter { it.read }.maxBy { it.chapter_number }?.chapter_number
|
||||
if (maxChapterRead != null) {
|
||||
val dbChapters = db.getChapters(manga).executeAsBlocking()
|
||||
for (chapter in dbChapters) {
|
||||
if (chapter.isRecognizedNumber && chapter.chapter_number <= maxChapterRead) {
|
||||
chapter.read = true
|
||||
}
|
||||
}
|
||||
db.insertChapters(dbChapters).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
// Update categories
|
||||
if (migrateCategories) {
|
||||
val categories = db.getCategoriesForManga(prevManga).executeAsBlocking()
|
||||
val mangaCategories = categories.map { MangaCategory.create(manga, it) }
|
||||
db.setMangaCategories(mangaCategories, listOf(manga))
|
||||
}
|
||||
// Update track
|
||||
if (migrateTracks) {
|
||||
val tracks = db.getTracks(prevManga).executeAsBlocking()
|
||||
for (track in tracks) {
|
||||
track.id = null
|
||||
track.manga_id = manga.id!!
|
||||
}
|
||||
db.insertTracks(tracks).executeAsBlocking()
|
||||
}
|
||||
// Update favorite status
|
||||
if (replace) {
|
||||
prevManga.favorite = false
|
||||
db.updateMangaFavorite(prevManga).executeAsBlocking()
|
||||
}
|
||||
manga.favorite = true
|
||||
db.updateMangaFavorite(manga).executeAsBlocking()
|
||||
|
||||
// SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title
|
||||
db.updateMangaTitle(manga).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cover with local file.
|
||||
*
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
package eu.kanade.tachiyomi.ui.migration
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.databinding.MigrationControllerBinding
|
||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import exh.ui.migration.manga.design.MigrationDesignController
|
||||
import eu.kanade.tachiyomi.ui.migration.manga.design.PreMigrationController
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import exh.util.RecyclerWindowInsetsListener
|
||||
import exh.util.applyWindowInsetsForController
|
||||
import exh.util.await
|
||||
import kotlinx.android.synthetic.main.migration_controller.migration_recycler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class MigrationController : NucleusController<MigrationPresenter>(),
|
||||
FlexibleAdapter.OnItemClickListener,
|
||||
SourceAdapter.OnSelectClickListener,
|
||||
SourceAdapter.OnAutoClickListener {
|
||||
class MigrationController :
|
||||
NucleusController<MigrationControllerBinding, MigrationPresenter>(),
|
||||
FlexibleAdapter.OnItemClickListener,
|
||||
SourceAdapter.OnSelectClickListener,
|
||||
SourceAdapter.OnAutoClickListener,
|
||||
MigrationInterface {
|
||||
|
||||
private var adapter: FlexibleAdapter<IFlexible<*>>? = null
|
||||
|
||||
@@ -44,15 +42,26 @@ class MigrationController : NucleusController<MigrationPresenter>(),
|
||||
}
|
||||
|
||||
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
|
||||
return inflater.inflate(R.layout.migration_controller, container, false)
|
||||
binding = MigrationControllerBinding.inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
fun searchController(manga: Manga): SearchController {
|
||||
val controller = SearchController(manga)
|
||||
controller.targetController = this
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
view.applyWindowInsetsForController()
|
||||
|
||||
adapter = FlexibleAdapter(null, this)
|
||||
migration_recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context)
|
||||
migration_recycler.adapter = adapter
|
||||
binding.migrationRecycler.layoutManager =
|
||||
androidx.recyclerview.widget.LinearLayoutManager(view.context)
|
||||
binding.migrationRecycler.adapter = adapter
|
||||
binding.migrationRecycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener)
|
||||
}
|
||||
|
||||
override fun onDestroyView(view: View) {
|
||||
@@ -75,29 +84,24 @@ class MigrationController : NucleusController<MigrationPresenter>(),
|
||||
|
||||
fun render(state: ViewState) {
|
||||
if (state.selectedSource == null) {
|
||||
title = resources?.getString(R.string.label_migration)
|
||||
title = resources?.getString(R.string.source_migration)
|
||||
if (adapter !is SourceAdapter) {
|
||||
adapter = SourceAdapter(this)
|
||||
binding.migrationRecycler.adapter = adapter
|
||||
}
|
||||
adapter?.updateDataSet(state.sourcesWithManga)
|
||||
} else {
|
||||
// val switching = title == resources?.getString(R.string.source_migration)
|
||||
title = state.selectedSource.toString()
|
||||
if (adapter !is MangaAdapter) {
|
||||
adapter = MangaAdapter(this)
|
||||
binding.migrationRecycler.adapter = adapter
|
||||
}
|
||||
adapter?.updateDataSet(state.mangaForSource)
|
||||
}
|
||||
}
|
||||
|
||||
fun renderIsReplacingManga(state: ViewState) {
|
||||
if (state.isReplacingManga) {
|
||||
if (router.getControllerWithTag(LOADING_DIALOG_TAG) == null) {
|
||||
LoadingController().showDialog(router, LOADING_DIALOG_TAG)
|
||||
}
|
||||
} else {
|
||||
router.popControllerWithTag(LOADING_DIALOG_TAG)
|
||||
adapter?.updateDataSet(state.mangaForSource, true)
|
||||
/*if (switching) launchUI {
|
||||
migration_recycler.alpha = 0f
|
||||
migration_recycler.animate().alpha(1f).setStartDelay(100).setDuration(200).start()
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +109,11 @@ class MigrationController : NucleusController<MigrationPresenter>(),
|
||||
val item = adapter?.getItem(position) ?: return false
|
||||
|
||||
if (item is MangaItem) {
|
||||
val controller = SearchController(item.manga)
|
||||
controller.targetController = this
|
||||
|
||||
router.pushController(controller.withFadeTransaction())
|
||||
PreMigrationController.navigateToMigration(
|
||||
Injekt.get<PreferencesHelper>().skipPreMigration().get(),
|
||||
router,
|
||||
listOf(item.manga.id!!)
|
||||
)
|
||||
} else if (item is SourceItem) {
|
||||
presenter.setSelectedSource(item.source)
|
||||
}
|
||||
@@ -116,41 +121,34 @@ class MigrationController : NucleusController<MigrationPresenter>(),
|
||||
}
|
||||
|
||||
override fun onSelectClick(position: Int) {
|
||||
onItemClick(null, position)
|
||||
onItemClick(view, position)
|
||||
}
|
||||
|
||||
override fun onAutoClick(position: Int) {
|
||||
val item = adapter?.getItem(position) as? SourceItem ?: return
|
||||
|
||||
GlobalScope.launch {
|
||||
val manga = Injekt.get<DatabaseHelper>().getFavoriteMangas().asRxSingle().await(Schedulers.io())
|
||||
val sourceMangas = manga.asSequence().filter { it.source == item.source.id }.map { it.id!! }.toList()
|
||||
launchUI {
|
||||
val manga = Injekt.get<DatabaseHelper>().getFavoriteMangas().asRxSingle().await(
|
||||
Schedulers.io()
|
||||
)
|
||||
val sourceMangas =
|
||||
manga.asSequence().filter { it.source == item.source.id }.map { it.id!! }.toList()
|
||||
withContext(Dispatchers.Main) {
|
||||
router.pushController(MigrationDesignController.create(sourceMangas).withFadeTransaction())
|
||||
PreMigrationController.navigateToMigration(
|
||||
Injekt.get<PreferencesHelper>().skipPreMigration().get(),
|
||||
router,
|
||||
sourceMangas
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateManga(prevManga: Manga, manga: Manga) {
|
||||
presenter.migrateManga(prevManga, manga, replace = true)
|
||||
}
|
||||
|
||||
fun copyManga(prevManga: Manga, manga: Manga) {
|
||||
presenter.migrateManga(prevManga, manga, replace = false)
|
||||
}
|
||||
|
||||
class LoadingController : DialogController() {
|
||||
|
||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
||||
return MaterialDialog.Builder(activity!!)
|
||||
.progress(true, 0)
|
||||
.content(R.string.migrating)
|
||||
.cancelable(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LOADING_DIALOG_TAG = "LoadingDialog"
|
||||
override fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean): Manga? {
|
||||
presenter.migrateManga(prevManga, manga, replace)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface MigrationInterface {
|
||||
fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean): Manga?
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ import android.os.Bundle
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
||||
import eu.kanade.tachiyomi.util.lang.combineLatest
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
@@ -16,7 +21,8 @@ import uy.kohesive.injekt.api.get
|
||||
|
||||
class MigrationPresenter(
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val db: DatabaseHelper = Injekt.get()
|
||||
private val db: DatabaseHelper = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
) : BasePresenter<MigrationController>() {
|
||||
|
||||
var state = ViewState()
|
||||
@@ -45,8 +51,10 @@ class MigrationPresenter(
|
||||
.doOnNext { state = state.copy(mangaForSource = it) }
|
||||
.subscribe()
|
||||
|
||||
// Render the view when any field changes
|
||||
stateRelay.subscribeLatestCache(MigrationController::render)
|
||||
stateRelay
|
||||
// Render the view when any field other than isReplacingManga changes
|
||||
.distinctUntilChanged { t1, t2 -> t1.isReplacingManga != t2.isReplacingManga }
|
||||
.subscribeLatestCache(MigrationController::render)
|
||||
}
|
||||
|
||||
fun setSelectedSource(source: Source) {
|
||||
@@ -67,4 +75,78 @@ class MigrationPresenter(
|
||||
private fun libraryToMigrationItem(library: List<Manga>, sourceId: Long): List<MangaItem> {
|
||||
return library.filter { it.source == sourceId }.map(::MangaItem)
|
||||
}
|
||||
|
||||
fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean) {
|
||||
val source = sourceManager.get(manga.source) ?: return
|
||||
|
||||
state = state.copy(isReplacingManga = true)
|
||||
|
||||
Observable.defer { source.fetchChapterList(manga) }.onErrorReturn { emptyList() }
|
||||
.doOnNext { migrateMangaInternal(source, it, prevManga, manga, replace) }
|
||||
.onErrorReturn { emptyList() }.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnUnsubscribe { state = state.copy(isReplacingManga = false) }.subscribe()
|
||||
}
|
||||
|
||||
private fun migrateMangaInternal(
|
||||
source: Source,
|
||||
sourceChapters: List<SChapter>,
|
||||
prevManga: Manga,
|
||||
manga: Manga,
|
||||
replace: Boolean
|
||||
) {
|
||||
val flags = preferences.migrateFlags().get()
|
||||
val migrateChapters = MigrationFlags.hasChapters(flags)
|
||||
val migrateCategories = MigrationFlags.hasCategories(flags)
|
||||
val migrateTracks = MigrationFlags.hasTracks(flags)
|
||||
|
||||
db.inTransaction {
|
||||
// Update chapters read
|
||||
if (migrateChapters) {
|
||||
try {
|
||||
syncChaptersWithSource(db, sourceChapters, manga, source)
|
||||
} catch (e: Exception) {
|
||||
// Worst case, chapters won't be synced
|
||||
}
|
||||
|
||||
val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking()
|
||||
val maxChapterRead =
|
||||
prevMangaChapters.filter { it.read }.maxBy { it.chapter_number }?.chapter_number
|
||||
if (maxChapterRead != null) {
|
||||
val dbChapters = db.getChapters(manga).executeAsBlocking()
|
||||
for (chapter in dbChapters) {
|
||||
if (chapter.isRecognizedNumber && chapter.chapter_number <= maxChapterRead) {
|
||||
chapter.read = true
|
||||
}
|
||||
}
|
||||
db.insertChapters(dbChapters).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
// Update categories
|
||||
if (migrateCategories) {
|
||||
val categories = db.getCategoriesForManga(prevManga).executeAsBlocking()
|
||||
val mangaCategories = categories.map { MangaCategory.create(manga, it) }
|
||||
db.setMangaCategories(mangaCategories, listOf(manga))
|
||||
}
|
||||
// Update track
|
||||
if (migrateTracks) {
|
||||
val tracks = db.getTracks(prevManga).executeAsBlocking()
|
||||
for (track in tracks) {
|
||||
track.id = null
|
||||
track.manga_id = manga.id!!
|
||||
}
|
||||
db.insertTracks(tracks).executeAsBlocking()
|
||||
}
|
||||
// Update favorite status
|
||||
if (replace) {
|
||||
prevManga.favorite = false
|
||||
db.updateMangaFavorite(prevManga).executeAsBlocking()
|
||||
}
|
||||
manga.favorite = true
|
||||
db.updateMangaFavorite(manga).executeAsBlocking()
|
||||
|
||||
// SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title
|
||||
db.updateMangaTitle(manga).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package eu.kanade.tachiyomi.ui.migration
|
||||
|
||||
class MigrationStatus {
|
||||
companion object {
|
||||
val NOT_INITIALIZED = -1
|
||||
val COMPLETED = 0
|
||||
|
||||
// Migration process
|
||||
val NOTIFY_USER = 1
|
||||
val OPEN_BACKUP_MENU = 2
|
||||
val PERFORM_BACKUP = 3
|
||||
val FINALIZE_MIGRATION = 4
|
||||
|
||||
val MAX_MIGRATION_STEPS = 2
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,26 @@ package eu.kanade.tachiyomi.ui.migration
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.list.listItemsMultiChoice
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter
|
||||
import eu.kanade.tachiyomi.util.view.gone
|
||||
import eu.kanade.tachiyomi.util.view.visible
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationListController
|
||||
import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchController
|
||||
import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchPresenter
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import reactivecircus.flowbinding.appcompat.QueryTextEvent
|
||||
import reactivecircus.flowbinding.appcompat.queryTextEvents
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class SearchController(
|
||||
@@ -19,6 +29,23 @@ class SearchController(
|
||||
) : GlobalSearchController(manga?.title) {
|
||||
|
||||
private var newManga: Manga? = null
|
||||
private var progress = 1
|
||||
var totalProgress = 0
|
||||
|
||||
/**
|
||||
* Called when controller is initialized.
|
||||
*/
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun getTitle(): String? {
|
||||
if (totalProgress > 1) {
|
||||
return "($progress/$totalProgress) ${super.getTitle()}"
|
||||
} else {
|
||||
return super.getTitle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun createPresenter(): GlobalSearchPresenter {
|
||||
return SearchPresenter(initialQuery, manga!!)
|
||||
@@ -36,21 +63,62 @@ class SearchController(
|
||||
newManga = savedInstanceState.getSerializable(::newManga.name) as? Manga
|
||||
}
|
||||
|
||||
/*override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
if (totalProgress > 1) {
|
||||
val menuItem = menu.add(Menu.NONE, 1, Menu.NONE, R.string.action_skip_manga)
|
||||
menuItem.icon = VectorDrawableCompat.create(resources!!, R.drawable
|
||||
.baseline_skip_next_white_24, null)
|
||||
menuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
}
|
||||
}
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
1 -> {
|
||||
newManga = manga
|
||||
migrateManga()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}*/
|
||||
|
||||
fun migrateManga() {
|
||||
val target = targetController as? MigrationInterface ?: return
|
||||
val manga = manga ?: return
|
||||
val newManga = newManga ?: return
|
||||
|
||||
(presenter as? SearchPresenter)?.migrateManga(manga, newManga, true)
|
||||
val nextManga = target.migrateManga(manga, newManga, true)
|
||||
replaceWithNewSearchController(nextManga)
|
||||
}
|
||||
|
||||
fun copyManga() {
|
||||
val target = targetController as? MigrationInterface ?: return
|
||||
val manga = manga ?: return
|
||||
val newManga = newManga ?: return
|
||||
|
||||
(presenter as? SearchPresenter)?.migrateManga(manga, newManga, false)
|
||||
val nextManga = target.migrateManga(manga, newManga, false)
|
||||
replaceWithNewSearchController(nextManga)
|
||||
}
|
||||
|
||||
private fun replaceWithNewSearchController(manga: Manga?) {
|
||||
if (manga != null) {
|
||||
// router.popCurrentController()
|
||||
val searchController = SearchController(manga)
|
||||
searchController.targetController = targetController
|
||||
searchController.progress = progress + 1
|
||||
searchController.totalProgress = totalProgress
|
||||
router.replaceTopController(searchController.withFadeTransaction())
|
||||
} else router.popController(this)
|
||||
}
|
||||
|
||||
override fun onMangaClick(manga: Manga) {
|
||||
if (targetController is MigrationListController) {
|
||||
val migrationListController = targetController as? MigrationListController
|
||||
val sourceManager: SourceManager by injectLazy()
|
||||
val source = sourceManager.get(manga.source) ?: return
|
||||
migrationListController?.useMangaForMigration(manga, source)
|
||||
router.popCurrentController()
|
||||
return
|
||||
}
|
||||
newManga = manga
|
||||
val dialog = MigrationDialog()
|
||||
dialog.targetController = this
|
||||
@@ -62,15 +130,6 @@ class SearchController(
|
||||
super.onMangaClick(manga)
|
||||
}
|
||||
|
||||
fun renderIsReplacingManga(isReplacingManga: Boolean) {
|
||||
if (isReplacingManga) {
|
||||
binding.progress.visible()
|
||||
} else {
|
||||
binding.progress.gone()
|
||||
router.popController(this)
|
||||
}
|
||||
}
|
||||
|
||||
class MigrationDialog : DialogController() {
|
||||
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
@@ -81,7 +140,7 @@ class SearchController(
|
||||
val preselected = MigrationFlags.getEnabledFlagsPositions(prefValue)
|
||||
|
||||
return MaterialDialog(activity!!)
|
||||
.message(R.string.migration_dialog_what_to_include)
|
||||
.message(R.string.data_to_include_in_migration)
|
||||
.listItemsMultiChoice(
|
||||
items = MigrationFlags.titles.map { resources?.getString(it) as CharSequence },
|
||||
initialSelection = preselected.toIntArray()
|
||||
@@ -99,4 +158,40 @@ class SearchController(
|
||||
.neutralButton(android.R.string.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds items to the options menu.
|
||||
*
|
||||
* @param menu menu containing options.
|
||||
* @param inflater used to load the menu xml.
|
||||
*/
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
// Inflate menu.
|
||||
inflater.inflate(R.menu.source_browse, menu)
|
||||
|
||||
// Initialize search menu
|
||||
val searchItem = menu.findItem(R.id.action_search)
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
|
||||
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
|
||||
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
|
||||
searchView.onActionViewExpanded() // Required to show the query in the view
|
||||
searchView.setQuery(presenter.query, false)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
searchView.queryTextEvents()
|
||||
.filter { it is QueryTextEvent.QuerySubmitted }
|
||||
.onEach {
|
||||
presenter.search(it.queryText.toString())
|
||||
searchItem.collapseActionView()
|
||||
setTitle() // Update toolbar title
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,20 +21,6 @@ class SourceAdapter(val controller: MigrationController) :
|
||||
setDisplayHeadersAtStartUp(true)
|
||||
}
|
||||
|
||||
// EXH -->
|
||||
/**
|
||||
* Listener for auto item clicks.
|
||||
*/
|
||||
val autoClickListener: OnAutoClickListener? = controller
|
||||
|
||||
/**
|
||||
* Listener which should be called when user clicks select.
|
||||
*/
|
||||
interface OnAutoClickListener {
|
||||
fun onAutoClick(position: Int)
|
||||
}
|
||||
// EXH <--
|
||||
|
||||
/**
|
||||
* Listener for browse item clicks.
|
||||
*/
|
||||
@@ -47,6 +33,18 @@ class SourceAdapter(val controller: MigrationController) :
|
||||
fun onSelectClick(position: Int)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for auto item clicks.
|
||||
*/
|
||||
val autoClickListener: OnAutoClickListener? = controller
|
||||
|
||||
/**
|
||||
* Listener which should be called when user clicks select.
|
||||
*/
|
||||
interface OnAutoClickListener {
|
||||
fun onAutoClick(position: Int)
|
||||
}
|
||||
|
||||
override fun updateDataSet(items: MutableList<IFlexible<*>>?) {
|
||||
if (this.items !== items) {
|
||||
this.items = items
|
||||
|
||||
@@ -26,13 +26,13 @@ class SourceHolder(view: View, override val adapter: SourceAdapter) :
|
||||
|
||||
init {
|
||||
source_latest.text = "Auto"
|
||||
source_latest.setOnClickListener {
|
||||
adapter.autoClickListener?.onAutoClick(adapterPosition)
|
||||
}
|
||||
source_browse.setText(R.string.select)
|
||||
source_browse.setOnClickListener {
|
||||
adapter.selectClickListener?.onSelectClick(bindingAdapterPosition)
|
||||
}
|
||||
source_latest.setOnClickListener {
|
||||
adapter.autoClickListener?.onAutoClick(adapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(item: SourceItem) {
|
||||
|
||||
@@ -5,5 +5,6 @@ import eu.kanade.tachiyomi.source.Source
|
||||
data class ViewState(
|
||||
val selectedSource: Source? = null,
|
||||
val mangaForSource: List<MangaItem> = emptyList(),
|
||||
val sourcesWithManga: List<SourceItem> = emptyList()
|
||||
val sourcesWithManga: List<SourceItem> = emptyList(),
|
||||
val isReplacingManga: Boolean = false
|
||||
)
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.design
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.BaseController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationProcedureConfig
|
||||
import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationProcedureController
|
||||
import eu.kanade.tachiyomi.util.view.gone
|
||||
import eu.kanade.tachiyomi.util.view.visible
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.begin_migration_btn
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.copy_manga
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.copy_manga_desc
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.extra_search_param
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.extra_search_param_desc
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.extra_search_param_text
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.fuzzy_search
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.mig_categories
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.mig_chapters
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.migration_mode
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.options_group
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.prioritize_chapter_count
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.recycler
|
||||
import kotlinx.android.synthetic.main.migration_design_controller.use_smart_search
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class MigrationDesignController(bundle: Bundle? = null) : BaseController(bundle), FlexibleAdapter
|
||||
.OnItemClickListener {
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val prefs: PreferencesHelper by injectLazy()
|
||||
|
||||
private var adapter: MigrationSourceAdapter? = null
|
||||
|
||||
private val config: LongArray = args.getLongArray(MANGA_IDS_EXTRA) ?: LongArray(0)
|
||||
|
||||
private var showingOptions = false
|
||||
|
||||
override fun getTitle() = "Select target sources"
|
||||
|
||||
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
|
||||
return inflater.inflate(R.layout.migration_design_controller, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
|
||||
val ourAdapter = adapter ?: MigrationSourceAdapter(
|
||||
getEnabledSources().map { MigrationSourceItem(it, true) },
|
||||
this
|
||||
)
|
||||
adapter = ourAdapter
|
||||
recycler.layoutManager = LinearLayoutManager(view.context)
|
||||
recycler.setHasFixedSize(true)
|
||||
recycler.adapter = ourAdapter
|
||||
ourAdapter.itemTouchHelperCallback = null // Reset adapter touch adapter to fix drag after rotation
|
||||
ourAdapter.isHandleDragEnabled = true
|
||||
|
||||
migration_mode.setOnClickListener {
|
||||
prioritize_chapter_count.toggle()
|
||||
}
|
||||
|
||||
fuzzy_search.setOnClickListener {
|
||||
use_smart_search.toggle()
|
||||
}
|
||||
|
||||
copy_manga_desc.setOnClickListener {
|
||||
copy_manga.toggle()
|
||||
}
|
||||
|
||||
extra_search_param_desc.setOnClickListener {
|
||||
extra_search_param.toggle()
|
||||
}
|
||||
|
||||
prioritize_chapter_count.setOnCheckedChangeListener { _, b ->
|
||||
updatePrioritizeChapterCount(b)
|
||||
}
|
||||
|
||||
extra_search_param.setOnCheckedChangeListener { _, b ->
|
||||
updateOptionsState()
|
||||
}
|
||||
|
||||
updatePrioritizeChapterCount(prioritize_chapter_count.isChecked)
|
||||
|
||||
updateOptionsState()
|
||||
|
||||
begin_migration_btn.setOnClickListener {
|
||||
if (!showingOptions) {
|
||||
showingOptions = true
|
||||
updateOptionsState()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
var flags = 0
|
||||
if (mig_chapters.isChecked) flags = flags or MigrationFlags.CHAPTERS
|
||||
if (mig_categories.isChecked) flags = flags or MigrationFlags.CATEGORIES
|
||||
if (mig_categories.isChecked) flags = flags or MigrationFlags.TRACK
|
||||
|
||||
router.replaceTopController(
|
||||
MigrationProcedureController.create(
|
||||
MigrationProcedureConfig(
|
||||
config.toList(),
|
||||
ourAdapter.items.filter {
|
||||
it.sourceEnabled
|
||||
}.map { it.source.id },
|
||||
useSourceWithMostChapters = prioritize_chapter_count.isChecked,
|
||||
enableLenientSearch = use_smart_search.isChecked,
|
||||
migrationFlags = flags,
|
||||
copy = copy_manga.isChecked,
|
||||
extraSearchParams = if (extra_search_param.isChecked && extra_search_param_text.text.isNotBlank()) {
|
||||
extra_search_param_text.text.toString()
|
||||
} else null
|
||||
)
|
||||
).withFadeTransaction())
|
||||
}
|
||||
}
|
||||
|
||||
fun updateOptionsState() {
|
||||
if (showingOptions) {
|
||||
begin_migration_btn.text = "Begin migration"
|
||||
options_group.visible()
|
||||
if (extra_search_param.isChecked) {
|
||||
extra_search_param_text.visible()
|
||||
} else {
|
||||
extra_search_param_text.gone()
|
||||
}
|
||||
} else {
|
||||
begin_migration_btn.text = "Next step"
|
||||
options_group.gone()
|
||||
extra_search_param_text.gone()
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleBack(): Boolean {
|
||||
if (showingOptions) {
|
||||
showingOptions = false
|
||||
updateOptionsState()
|
||||
return true
|
||||
}
|
||||
return super.handleBack()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
adapter?.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
// TODO Still incorrect, why is this called before onViewCreated?
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
adapter?.onRestoreInstanceState(savedInstanceState)
|
||||
}
|
||||
|
||||
private fun updatePrioritizeChapterCount(migrationMode: Boolean) {
|
||||
migration_mode.text = if (migrationMode) {
|
||||
"Currently using the source with the most chapters and the above list to break ties (slow with many sources or smart search)"
|
||||
} else {
|
||||
"Currently using the first source in the list that has the manga"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(view: View, position: Int): Boolean {
|
||||
adapter?.getItem(position)?.let {
|
||||
it.sourceEnabled = !it.sourceEnabled
|
||||
}
|
||||
adapter?.notifyItemChanged(position)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of enabled sources ordered by language and name.
|
||||
*
|
||||
* @return list containing enabled sources.
|
||||
*/
|
||||
private fun getEnabledSources(): List<HttpSource> {
|
||||
val languages = prefs.enabledLanguages().getOrDefault()
|
||||
val hiddenCatalogues = prefs.hiddenCatalogues().getOrDefault()
|
||||
|
||||
return sourceManager.getVisibleCatalogueSources()
|
||||
.filterIsInstance<HttpSource>()
|
||||
.filter { it.lang in languages }
|
||||
.filterNot { it.id.toString() in hiddenCatalogues }
|
||||
.sortedBy { "(${it.lang}) ${it.name}" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MANGA_IDS_EXTRA = "manga_ids"
|
||||
|
||||
fun create(mangaIds: List<Long>): MigrationDesignController {
|
||||
return MigrationDesignController(Bundle().apply {
|
||||
putLongArray(MANGA_IDS_EXTRA, mangaIds.toLongArray())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.design
|
||||
|
||||
import android.os.Bundle
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class MigrationSourceAdapter(
|
||||
val items: List<MigrationSourceItem>,
|
||||
val controller: MigrationDesignController
|
||||
) : FlexibleAdapter<MigrationSourceItem>(
|
||||
items,
|
||||
controller,
|
||||
true
|
||||
) {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
outState.putParcelableArrayList(SELECTED_SOURCES_KEY, ArrayList(currentItems.map {
|
||||
it.asParcelable()
|
||||
}))
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
val sourceManager: SourceManager by injectLazy()
|
||||
savedInstanceState.getParcelableArrayList<MigrationSourceItem.ParcelableSI>(
|
||||
SELECTED_SOURCES_KEY
|
||||
)?.let {
|
||||
updateDataSet(it.map { MigrationSourceItem.fromParcelable(sourceManager, it) })
|
||||
}
|
||||
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SELECTED_SOURCES_KEY = "selected_sources"
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.design
|
||||
|
||||
import android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.icon
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
|
||||
import kotlinx.android.synthetic.main.migration_source_item.image
|
||||
import kotlinx.android.synthetic.main.migration_source_item.reorder
|
||||
import kotlinx.android.synthetic.main.migration_source_item.title
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class MigrationSourceHolder(view: View, val adapter: MigrationSourceAdapter) :
|
||||
BaseFlexibleViewHolder(view, adapter) {
|
||||
init {
|
||||
setDragHandleView(reorder)
|
||||
}
|
||||
|
||||
fun bind(source: HttpSource, sourceEnabled: Boolean) {
|
||||
val preferences by injectLazy<PreferencesHelper>()
|
||||
val isMultiLanguage = preferences.enabledLanguages().get().size > 1
|
||||
// Set capitalized title.
|
||||
val sourceName = if (isMultiLanguage) source.toString() else source.name.capitalize()
|
||||
title.text = sourceName
|
||||
// Update circle letter image.
|
||||
itemView.post {
|
||||
val icon = source.icon()
|
||||
if (icon != null) {
|
||||
image.setImageDrawable(icon)
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceEnabled) {
|
||||
title.alpha = 1.0f
|
||||
image.alpha = 1.0f
|
||||
title.paintFlags = title.paintFlags and STRIKE_THRU_TEXT_FLAG.inv()
|
||||
} else {
|
||||
title.alpha = DISABLED_ALPHA
|
||||
image.alpha = DISABLED_ALPHA
|
||||
title.paintFlags = title.paintFlags or STRIKE_THRU_TEXT_FLAG
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an item is released.
|
||||
*
|
||||
* @param position The position of the released item.
|
||||
*/
|
||||
override fun onItemReleased(position: Int) {
|
||||
super.onItemReleased(position)
|
||||
adapter.updateItems()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DISABLED_ALPHA = 0.3f
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.design
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
class MigrationSourceItem(val source: HttpSource, var sourceEnabled: Boolean) : AbstractFlexibleItem<MigrationSourceHolder>() {
|
||||
override fun getLayoutRes() = R.layout.migration_source_item
|
||||
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): MigrationSourceHolder {
|
||||
return MigrationSourceHolder(view, adapter as MigrationSourceAdapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the given view holder with this item.
|
||||
*
|
||||
* @param adapter The adapter of this item.
|
||||
* @param holder The holder to bind.
|
||||
* @param position The position of this item in the adapter.
|
||||
* @param payloads List of partial changes.
|
||||
*/
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
holder: MigrationSourceHolder,
|
||||
position: Int,
|
||||
payloads: List<Any?>?
|
||||
) {
|
||||
holder.bind(source, sourceEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this item is draggable.
|
||||
*/
|
||||
override fun isDraggable(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other is MigrationSourceItem) {
|
||||
return source.id == other.source.id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return source.id.hashCode()
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableSI(val sourceId: Long, val sourceEnabled: Boolean) : Parcelable
|
||||
|
||||
fun asParcelable(): ParcelableSI {
|
||||
return ParcelableSI(source.id, sourceEnabled)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromParcelable(sourceManager: SourceManager, si: ParcelableSI): MigrationSourceItem? {
|
||||
val source = sourceManager.get(si.sourceId) as? HttpSource ?: return null
|
||||
|
||||
return MigrationSourceItem(
|
||||
source,
|
||||
si.sourceEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.process
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
|
||||
class DeactivatableViewPager : ViewPager {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return !isEnabled || super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
||||
return isEnabled && super.onInterceptTouchEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.process
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.util.DeferredField
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||
|
||||
class MigratingManga(
|
||||
private val db: DatabaseHelper,
|
||||
private val sourceManager: SourceManager,
|
||||
val mangaId: Long,
|
||||
parentContext: CoroutineContext
|
||||
) {
|
||||
val searchResult = DeferredField<Long?>()
|
||||
|
||||
// <MAX, PROGRESS>
|
||||
val progress = ConflatedBroadcastChannel(1 to 0)
|
||||
|
||||
val migrationJob = parentContext + SupervisorJob() + Dispatchers.Default
|
||||
|
||||
@Volatile
|
||||
private var manga: Manga? = null
|
||||
suspend fun manga(): Manga? {
|
||||
if (manga == null) manga = db.getManga(mangaId).executeAsBlocking()
|
||||
return manga
|
||||
}
|
||||
|
||||
suspend fun mangaSource(): Source {
|
||||
return sourceManager.getOrStub(manga()?.source ?: -1)
|
||||
}
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.process
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.viewpager.widget.PagerAdapter
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.glide.GlideApp
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.all.MergedSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.ui.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.util.view.gone
|
||||
import eu.kanade.tachiyomi.util.view.inflate
|
||||
import eu.kanade.tachiyomi.util.view.visible
|
||||
import exh.MERGED_SOURCE_ID
|
||||
import java.text.DateFormat
|
||||
import java.text.DecimalFormat
|
||||
import java.util.Date
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.loading_group
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_artist
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_author
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_chapters
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_cover
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_full_title
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_last_chapter
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_last_update
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_source
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_source_label
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.manga_status
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.search_progress
|
||||
import kotlinx.android.synthetic.main.migration_manga_card.view.search_status
|
||||
import kotlinx.android.synthetic.main.migration_process_item.view.accept_migration
|
||||
import kotlinx.android.synthetic.main.migration_process_item.view.migrating_frame
|
||||
import kotlinx.android.synthetic.main.migration_process_item.view.migration_manga_card_from
|
||||
import kotlinx.android.synthetic.main.migration_process_item.view.migration_manga_card_to
|
||||
import kotlinx.android.synthetic.main.migration_process_item.view.skip_migration
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class MigrationProcedureAdapter(
|
||||
val controller: MigrationProcedureController,
|
||||
val migratingManga: List<MigratingManga>,
|
||||
override val coroutineContext: CoroutineContext
|
||||
) : PagerAdapter(), CoroutineScope {
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
private val gson: Gson by injectLazy()
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
override fun isViewFromObject(p0: View, p1: Any): Boolean {
|
||||
return p0 == p1
|
||||
}
|
||||
|
||||
override fun getCount() = migratingManga.size
|
||||
|
||||
override fun instantiateItem(container: ViewGroup, position: Int): Any {
|
||||
val item = migratingManga[position]
|
||||
val view = container.inflate(R.layout.migration_process_item)
|
||||
container.addView(view)
|
||||
|
||||
view.skip_migration.setOnClickListener {
|
||||
controller.nextMigration()
|
||||
}
|
||||
|
||||
val viewTag = ViewTag(coroutineContext)
|
||||
view.tag = viewTag
|
||||
view.setupView(viewTag, item)
|
||||
|
||||
view.accept_migration.setOnClickListener {
|
||||
viewTag.launch(Dispatchers.Main) {
|
||||
view.migrating_frame.visible()
|
||||
try {
|
||||
withContext(Dispatchers.Default) {
|
||||
performMigration(item)
|
||||
}
|
||||
controller.nextMigration()
|
||||
} catch (e: Exception) {
|
||||
controller.migrationFailure()
|
||||
}
|
||||
view.migrating_frame.gone()
|
||||
}
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
suspend fun performMigration(manga: MigratingManga) {
|
||||
if (!manga.searchResult.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
val toMangaObj = db.getManga(manga.searchResult.get() ?: return).executeAsBlocking() ?: return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
migrateMangaInternal(
|
||||
manga.manga() ?: return@withContext,
|
||||
toMangaObj,
|
||||
!(controller.config?.copy ?: false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateMangaInternal(
|
||||
prevManga: Manga,
|
||||
manga: Manga,
|
||||
replace: Boolean
|
||||
) {
|
||||
val config = controller.config ?: return
|
||||
db.inTransaction {
|
||||
// Update chapters read
|
||||
if (MigrationFlags.hasChapters(controller.config.migrationFlags)) {
|
||||
val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking()
|
||||
val maxChapterRead = prevMangaChapters.filter { it.read }
|
||||
.maxBy { it.chapter_number }?.chapter_number
|
||||
if (maxChapterRead != null) {
|
||||
val dbChapters = db.getChapters(manga).executeAsBlocking()
|
||||
for (chapter in dbChapters) {
|
||||
if (chapter.isRecognizedNumber && chapter.chapter_number <= maxChapterRead) {
|
||||
chapter.read = true
|
||||
}
|
||||
}
|
||||
db.insertChapters(dbChapters).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
// Update categories
|
||||
if (MigrationFlags.hasCategories(controller.config.migrationFlags)) {
|
||||
val categories = db.getCategoriesForManga(prevManga).executeAsBlocking()
|
||||
val mangaCategories = categories.map { MangaCategory.create(manga, it) }
|
||||
db.setMangaCategories(mangaCategories, listOf(manga))
|
||||
}
|
||||
// Update track
|
||||
if (MigrationFlags.hasTracks(controller.config.migrationFlags)) {
|
||||
val tracks = db.getTracks(prevManga).executeAsBlocking()
|
||||
for (track in tracks) {
|
||||
track.id = null
|
||||
track.manga_id = manga.id!!
|
||||
}
|
||||
db.insertTracks(tracks).executeAsBlocking()
|
||||
}
|
||||
// Update favorite status
|
||||
if (replace) {
|
||||
prevManga.favorite = false
|
||||
db.updateMangaFavorite(prevManga).executeAsBlocking()
|
||||
}
|
||||
manga.favorite = true
|
||||
db.updateMangaFavorite(manga).executeAsBlocking()
|
||||
|
||||
// SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title
|
||||
db.updateMangaTitle(manga).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setupView(tag: ViewTag, migratingManga: MigratingManga) {
|
||||
tag.launch {
|
||||
val manga = migratingManga.manga()
|
||||
val source = migratingManga.mangaSource()
|
||||
if (manga != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
migration_manga_card_from.loading_group.gone()
|
||||
migration_manga_card_from.attachManga(tag, manga, source)
|
||||
migration_manga_card_from.setOnClickListener {
|
||||
controller.router.pushController(MangaController(manga, true).withFadeTransaction())
|
||||
}
|
||||
}
|
||||
|
||||
tag.launch {
|
||||
migratingManga.progress.asFlow().collect { (max, progress) ->
|
||||
withContext(Dispatchers.Main) {
|
||||
migration_manga_card_to.search_progress.let { progressBar ->
|
||||
progressBar.max = max
|
||||
progressBar.progress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val searchResult = migratingManga.searchResult.get()?.let {
|
||||
db.getManga(it).executeAsBlocking()
|
||||
}
|
||||
val resultSource = searchResult?.source?.let {
|
||||
sourceManager.get(it)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (searchResult != null && resultSource != null) {
|
||||
migration_manga_card_to.loading_group.gone()
|
||||
migration_manga_card_to.attachManga(tag, searchResult, resultSource)
|
||||
migration_manga_card_to.setOnClickListener {
|
||||
controller.router.pushController(MangaController(searchResult, true).withFadeTransaction())
|
||||
}
|
||||
accept_migration.isEnabled = true
|
||||
accept_migration.alpha = 1.0f
|
||||
} else {
|
||||
migration_manga_card_to.search_progress.gone()
|
||||
migration_manga_card_to.search_status.text = "Found no manga"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun View.attachManga(tag: ViewTag, manga: Manga, source: Source) {
|
||||
// TODO Duplicated in MangaInfoController
|
||||
|
||||
GlideApp.with(context)
|
||||
.load(manga)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.centerCrop()
|
||||
.into(manga_cover)
|
||||
|
||||
manga_full_title.text = if (manga.title.isBlank()) {
|
||||
context.getString(R.string.unknown)
|
||||
} else {
|
||||
manga.title
|
||||
}
|
||||
|
||||
manga_artist.text = if (manga.artist.isNullOrBlank()) {
|
||||
context.getString(R.string.unknown)
|
||||
} else {
|
||||
manga.artist
|
||||
}
|
||||
|
||||
manga_author.text = if (manga.author.isNullOrBlank()) {
|
||||
context.getString(R.string.unknown)
|
||||
} else {
|
||||
manga.author
|
||||
}
|
||||
|
||||
manga_source.text = if (source.id == MERGED_SOURCE_ID) {
|
||||
MergedSource.MangaConfig.readFromUrl(gson, manga.url).children.map {
|
||||
sourceManager.getOrStub(it.source).toString()
|
||||
}.distinct().joinToString()
|
||||
} else {
|
||||
source.toString()
|
||||
}
|
||||
|
||||
if (source.id == MERGED_SOURCE_ID) {
|
||||
manga_source_label.text = "Sources"
|
||||
} else {
|
||||
manga_source_label.setText(R.string.manga_info_source_label)
|
||||
}
|
||||
|
||||
manga_status.setText(when (manga.status) {
|
||||
SManga.ONGOING -> R.string.ongoing
|
||||
SManga.COMPLETED -> R.string.completed
|
||||
SManga.LICENSED -> R.string.licensed
|
||||
else -> R.string.unknown
|
||||
})
|
||||
|
||||
val mangaChapters = db.getChapters(manga).executeAsBlocking()
|
||||
manga_chapters.text = mangaChapters.size.toString()
|
||||
val latestChapter = mangaChapters.maxBy { it.chapter_number }?.chapter_number ?: -1f
|
||||
val lastUpdate = Date(mangaChapters.maxBy { it.date_upload }?.date_upload ?: 0)
|
||||
|
||||
if (latestChapter > 0f) {
|
||||
manga_last_chapter.text = DecimalFormat("#.#").format(latestChapter)
|
||||
} else {
|
||||
manga_last_chapter.setText(R.string.unknown)
|
||||
}
|
||||
|
||||
if (lastUpdate.time != 0L) {
|
||||
manga_last_update.text = DateFormat.getDateInstance(DateFormat.SHORT).format(lastUpdate)
|
||||
} else {
|
||||
manga_last_update.setText(R.string.unknown)
|
||||
}
|
||||
}
|
||||
|
||||
override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {
|
||||
val objectAsView = `object` as View
|
||||
container.removeView(objectAsView)
|
||||
(objectAsView.tag as? ViewTag)?.destroy()
|
||||
}
|
||||
|
||||
class ViewTag(parent: CoroutineContext) : CoroutineScope {
|
||||
/**
|
||||
* The context of this scope.
|
||||
* Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope.
|
||||
* Accessing this property in general code is not recommended for any purposes except accessing the [Job] instance for advanced usages.
|
||||
*
|
||||
* By convention, should contain an instance of a [job][Job] to enforce structured concurrency.
|
||||
*/
|
||||
override val coroutineContext = parent + Job() + Dispatchers.Default
|
||||
|
||||
fun destroy() {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.process
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class MigrationProcedureConfig(
|
||||
val mangaIds: List<Long>,
|
||||
val targetSourceIds: List<Long>,
|
||||
val useSourceWithMostChapters: Boolean,
|
||||
val enableLenientSearch: Boolean,
|
||||
val migrationFlags: Int,
|
||||
val copy: Boolean,
|
||||
val extraSearchParams: String?
|
||||
) : Parcelable
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package eu.kanade.tachiyomi.ui.migration.manga.process
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.smartsearch.SmartSearchEngine
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.ui.base.controller.BaseController
|
||||
import eu.kanade.tachiyomi.util.await
|
||||
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.android.synthetic.main.migration_process.pager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
// TODO Will probably implode if activity is fully destroyed
|
||||
class MigrationProcedureController(bundle: Bundle? = null) : BaseController(bundle), CoroutineScope {
|
||||
|
||||
private var titleText = "Migrate manga"
|
||||
|
||||
private var adapter: MigrationProcedureAdapter? = null
|
||||
|
||||
override val coroutineContext: CoroutineContext = Job() + Dispatchers.Default
|
||||
|
||||
val config: MigrationProcedureConfig? = args.getParcelable(CONFIG_EXTRA)
|
||||
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
private val smartSearchEngine = SmartSearchEngine(coroutineContext, config?.extraSearchParams)
|
||||
|
||||
private var migrationsJob: Job? = null
|
||||
private var migratingManga: List<MigratingManga>? = null
|
||||
|
||||
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
|
||||
return inflater.inflate(R.layout.migration_process, container, false)
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return titleText
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
setTitle()
|
||||
val config = this.config ?: return
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
|
||||
|
||||
val newMigratingManga = migratingManga ?: run {
|
||||
val new = config.mangaIds.map {
|
||||
MigratingManga(db, sourceManager, it, coroutineContext)
|
||||
}
|
||||
migratingManga = new
|
||||
new
|
||||
}
|
||||
|
||||
adapter = MigrationProcedureAdapter(this, newMigratingManga, coroutineContext)
|
||||
|
||||
pager.adapter = adapter
|
||||
pager.isEnabled = false
|
||||
|
||||
if (migrationsJob == null) {
|
||||
migrationsJob = launch {
|
||||
runMigrations(newMigratingManga)
|
||||
}
|
||||
}
|
||||
|
||||
pager.post {
|
||||
// pager.currentItem doesn't appear to be valid if we don't do this in a post
|
||||
updateTitle()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTitle() {
|
||||
titleText = "Migrate manga (${pager.currentItem + 1}/${adapter?.count ?: 0})"
|
||||
setTitle()
|
||||
}
|
||||
|
||||
fun nextMigration() {
|
||||
adapter?.let { adapter ->
|
||||
if (pager.currentItem >= adapter.count - 1) {
|
||||
applicationContext?.toast("All migrations complete!")
|
||||
router.popCurrentController()
|
||||
} else {
|
||||
adapter.migratingManga[pager.currentItem].migrationJob.cancel()
|
||||
pager.setCurrentItem(pager.currentItem + 1, true)
|
||||
launch(Dispatchers.Main) {
|
||||
updateTitle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun migrationFailure() {
|
||||
activity?.let {
|
||||
MaterialDialog.Builder(it)
|
||||
.title("Migration failure")
|
||||
.content("An unknown error occured while migrating this manga!")
|
||||
.positiveText("Ok")
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runMigrations(mangas: List<MigratingManga>) {
|
||||
val sources = config?.targetSourceIds?.mapNotNull { sourceManager.get(it) as?
|
||||
CatalogueSource } ?: return
|
||||
|
||||
for (manga in mangas) {
|
||||
if (!manga.searchResult.initialized && manga.migrationJob.isActive) {
|
||||
val mangaObj = manga.manga()
|
||||
|
||||
if (mangaObj == null) {
|
||||
manga.searchResult.initialize(null)
|
||||
continue
|
||||
}
|
||||
|
||||
val mangaSource = manga.mangaSource()
|
||||
|
||||
val result = try {
|
||||
CoroutineScope(manga.migrationJob).async {
|
||||
val validSources = sources.filter {
|
||||
it.id != mangaSource.id
|
||||
}
|
||||
if (config.useSourceWithMostChapters) {
|
||||
val sourceSemaphore = Semaphore(3)
|
||||
val processedSources = AtomicInteger()
|
||||
|
||||
validSources.map { source ->
|
||||
async {
|
||||
sourceSemaphore.withPermit {
|
||||
try {
|
||||
val searchResult = if (config?.enableLenientSearch ==
|
||||
true) {
|
||||
smartSearchEngine.smartSearch(source, mangaObj.title)
|
||||
} else {
|
||||
smartSearchEngine.normalSearch(source, mangaObj.title)
|
||||
}
|
||||
|
||||
if (searchResult != null) {
|
||||
val localManga = smartSearchEngine.networkToLocalManga(searchResult, source.id)
|
||||
val chapters = source.fetchChapterList(localManga).toSingle().await(Schedulers.io())
|
||||
withContext(Dispatchers.IO) {
|
||||
syncChaptersWithSource(db, chapters, localManga, source)
|
||||
}
|
||||
manga.progress.send(validSources.size to processedSources.incrementAndGet())
|
||||
localManga to chapters.size
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}.mapNotNull { it.await() }.maxBy { it.second }?.first
|
||||
} else {
|
||||
validSources.forEachIndexed { index, source ->
|
||||
val searchResult = try {
|
||||
val searchResult = if (config.enableLenientSearch) {
|
||||
smartSearchEngine.smartSearch(source, mangaObj.title)
|
||||
} else {
|
||||
smartSearchEngine.normalSearch(source, mangaObj.title)
|
||||
}
|
||||
|
||||
if (searchResult != null) {
|
||||
val localManga = smartSearchEngine.networkToLocalManga(searchResult, source.id)
|
||||
val chapters = source.fetchChapterList(localManga).toSingle().await(Schedulers.io())
|
||||
withContext(Dispatchers.IO) {
|
||||
syncChaptersWithSource(db, chapters, localManga, source)
|
||||
}
|
||||
localManga
|
||||
} else null
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
manga.progress.send(validSources.size to (index + 1))
|
||||
|
||||
if (searchResult != null) return@async searchResult
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
}.await()
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore canceled migrations
|
||||
continue
|
||||
}
|
||||
|
||||
if (result != null && result.thumbnail_url == null) {
|
||||
try {
|
||||
val newManga = sourceManager.getOrStub(result.source)
|
||||
.fetchMangaDetails(result)
|
||||
.toSingle()
|
||||
.await()
|
||||
result.copyFrom(newManga)
|
||||
|
||||
db.insertManga(result).executeAsBlocking()
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
manga.searchResult.initialize(result?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CONFIG_EXTRA = "config_extra"
|
||||
|
||||
fun create(config: MigrationProcedureConfig): MigrationProcedureController {
|
||||
return MigrationProcedureController(Bundle().apply {
|
||||
putParcelable(CONFIG_EXTRA, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryController
|
||||
import eu.kanade.tachiyomi.ui.migration.MetadataFetchDialog
|
||||
import eu.kanade.tachiyomi.util.preference.defaultValue
|
||||
import eu.kanade.tachiyomi.util.preference.onClick
|
||||
import eu.kanade.tachiyomi.util.preference.preference
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package eu.kanade.tachiyomi.ui.smartsearch
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.ui.source.SourceController
|
||||
import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.android.synthetic.main.smart_search.appbar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class SmartSearchController(bundle: Bundle? = null) : NucleusController<SmartSearchPresenter>(), CoroutineScope {
|
||||
override val coroutineContext = Job() + Dispatchers.Main
|
||||
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
private val source = sourceManager.get(bundle?.getLong(ARG_SOURCE_ID, -1) ?: -1) as? CatalogueSource
|
||||
private val smartSearchConfig: SourceController.SmartSearchConfig? = bundle?.getParcelable(
|
||||
ARG_SMART_SEARCH_CONFIG
|
||||
)
|
||||
|
||||
override fun inflateView(inflater: LayoutInflater, container: ViewGroup) =
|
||||
inflater.inflate(R.layout.smart_search, container, false)!!
|
||||
|
||||
override fun getTitle() = source?.name ?: ""
|
||||
|
||||
override fun createPresenter() = SmartSearchPresenter(source, smartSearchConfig)
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
|
||||
appbar.bringToFront()
|
||||
|
||||
if (source == null || smartSearchConfig == null) {
|
||||
router.popCurrentController()
|
||||
applicationContext?.toast("Missing data!")
|
||||
return
|
||||
}
|
||||
|
||||
// Init presenter now to resolve threading issues
|
||||
presenter
|
||||
|
||||
launch(Dispatchers.Default) {
|
||||
for (event in presenter.smartSearchChannel) {
|
||||
withContext(NonCancellable) {
|
||||
if (event is SmartSearchPresenter.SearchResults.Found) {
|
||||
val transaction = MangaController(event.manga, true, smartSearchConfig).withFadeTransaction()
|
||||
withContext(Dispatchers.Main) {
|
||||
router.replaceTopController(transaction)
|
||||
}
|
||||
} else {
|
||||
if (event is SmartSearchPresenter.SearchResults.NotFound) {
|
||||
applicationContext?.toast("Couldn't find the manga in the source!")
|
||||
} else {
|
||||
applicationContext?.toast("Error performing automatic search!")
|
||||
}
|
||||
|
||||
val transaction = BrowseSourceController(source, smartSearchConfig.origTitle, smartSearchConfig).withFadeTransaction()
|
||||
withContext(Dispatchers.Main) {
|
||||
router.replaceTopController(transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_SOURCE_ID = "SOURCE_ID"
|
||||
const val ARG_SMART_SEARCH_CONFIG = "SMART_SEARCH_CONFIG"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.kanade.tachiyomi.ui.smartsearch
|
||||
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.smartsearch.SmartSearchEngine
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.ui.source.SourceController
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SmartSearchPresenter(private val source: CatalogueSource?, private val config: SourceController.SmartSearchConfig?) :
|
||||
BasePresenter<SmartSearchController>(), CoroutineScope {
|
||||
|
||||
override val coroutineContext = Job() + Dispatchers.Main
|
||||
|
||||
val smartSearchChannel = Channel<SearchResults>()
|
||||
|
||||
private val smartSearchEngine = SmartSearchEngine(coroutineContext)
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
if (source != null && config != null) {
|
||||
launch(Dispatchers.Default) {
|
||||
val result = try {
|
||||
val resultManga = smartSearchEngine.smartSearch(source, config.origTitle)
|
||||
if (resultManga != null) {
|
||||
val localManga = smartSearchEngine.networkToLocalManga(resultManga, source.id)
|
||||
SearchResults.Found(localManga)
|
||||
} else {
|
||||
SearchResults.NotFound
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) {
|
||||
throw e
|
||||
} else {
|
||||
SearchResults.Error
|
||||
}
|
||||
}
|
||||
|
||||
smartSearchChannel.send(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
data class SearchEntry(val manga: SManga, val dist: Double)
|
||||
|
||||
sealed class SearchResults {
|
||||
data class Found(val manga: Manga) : SearchResults()
|
||||
object NotFound : SearchResults()
|
||||
object Error : SearchResults()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package eu.kanade.tachiyomi.util
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Field that can be initialized later. Users can suspend while waiting for the field to initialize.
|
||||
*
|
||||
* @author nulldev
|
||||
*/
|
||||
class DeferredField<T> {
|
||||
|
||||
@Volatile
|
||||
private var content: T? = null
|
||||
|
||||
@Volatile
|
||||
var initialized = false
|
||||
private set
|
||||
|
||||
private val mutex = Mutex(true)
|
||||
|
||||
/**
|
||||
* Initialize the field
|
||||
*/
|
||||
fun initialize(content: T) {
|
||||
// Fast-path new listeners
|
||||
this.content = content
|
||||
initialized = true
|
||||
|
||||
// Notify current listeners
|
||||
mutex.unlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Will only suspend if !initialized.
|
||||
*/
|
||||
suspend fun get(): T {
|
||||
// Check if field is initialized and return immediately if it is
|
||||
if (initialized) return content as T
|
||||
|
||||
// Wait for field to initialize
|
||||
mutex.withLock {}
|
||||
|
||||
// Field is initialized, return value
|
||||
return content as T
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user