Completed most of auto-migration UI
This commit is contained in:
parent
f811cc5c87
commit
5b3e72db54
@ -437,8 +437,10 @@ class LibraryController(
|
|||||||
R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
|
R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
|
||||||
R.id.action_delete -> showDeleteMangaDialog()
|
R.id.action_delete -> showDeleteMangaDialog()
|
||||||
R.id.action_auto_source_migration -> {
|
R.id.action_auto_source_migration -> {
|
||||||
|
router.pushController(MigrationDesignController.create(
|
||||||
|
selectedMangas.mapNotNull { it.id }
|
||||||
|
).withFadeTransaction())
|
||||||
destroyActionModeIfNeeded()
|
destroyActionModeIfNeeded()
|
||||||
router.pushController(MigrationDesignController().withFadeTransaction())
|
|
||||||
}
|
}
|
||||||
else -> return false
|
else -> return false
|
||||||
}
|
}
|
||||||
|
@ -252,6 +252,8 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
|
|||||||
private fun setMangaInfo(manga: Manga, source: Source?) {
|
private fun setMangaInfo(manga: Manga, source: Source?) {
|
||||||
val view = view ?: return
|
val view = view ?: return
|
||||||
|
|
||||||
|
// TODO Duplicated in MigrationProcedureAdapter
|
||||||
|
|
||||||
//update full title TextView.
|
//update full title TextView.
|
||||||
manga_full_title.text = if (manga.title.isBlank()) {
|
manga_full_title.text = if (manga.title.isBlank()) {
|
||||||
view.context.getString(R.string.unknown)
|
view.context.getString(R.string.unknown)
|
||||||
|
@ -4,9 +4,9 @@ import eu.kanade.tachiyomi.R
|
|||||||
|
|
||||||
object MigrationFlags {
|
object MigrationFlags {
|
||||||
|
|
||||||
private const val CHAPTERS = 0b001
|
const val CHAPTERS = 0b001
|
||||||
private const val CATEGORIES = 0b010
|
const val CATEGORIES = 0b010
|
||||||
private const val TRACK = 0b100
|
const val TRACK = 0b100
|
||||||
|
|
||||||
private const val CHAPTERS2 = 0x1
|
private const val CHAPTERS2 = 0x1
|
||||||
private const val CATEGORIES2 = 0x2
|
private const val CATEGORIES2 = 0x2
|
||||||
|
176
app/src/main/java/exh/smartsearch/SmartSearchEngine.kt
Normal file
176
app/src/main/java/exh/smartsearch/SmartSearchEngine.kt
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
package exh.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 exh.ui.smartsearch.SmartSearchPresenter
|
||||||
|
import exh.util.await
|
||||||
|
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import rx.schedulers.Schedulers
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
class SmartSearchEngine(parentContext: CoroutineContext): CoroutineScope {
|
||||||
|
override val coroutineContext: CoroutineContext = parentContext + Job() + Dispatchers.Default
|
||||||
|
|
||||||
|
private val db: DatabaseHelper by injectLazy()
|
||||||
|
|
||||||
|
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 searchResults = source.fetchSearchManga(1, query, 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 searchResults = source.fetchSearchManga(1, title, 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).await()
|
||||||
|
if (localManga == null) {
|
||||||
|
val newManga = Manga.create(sManga.url, sManga.title, sourceId)
|
||||||
|
newManga.copyFrom(sManga)
|
||||||
|
val result = db.insertManga(newManga).await()
|
||||||
|
newManga.id = result.insertedId()
|
||||||
|
localManga = newManga
|
||||||
|
}
|
||||||
|
return localManga
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MIN_SMART_ELIGIBLE_THRESHOLD = 0.7
|
||||||
|
const val MIN_NORMAL_ELIGIBLE_THRESHOLD = 0.5
|
||||||
|
|
||||||
|
private val titleRegex = Regex("[^a-zA-Z0-9- ]")
|
||||||
|
private val consecutiveSpacesRegex = Regex(" +")
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
package exh.ui.base
|
package exh.ui.base
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
import android.support.annotation.LayoutRes
|
import android.support.annotation.LayoutRes
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@ -11,7 +12,7 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
abstract class BaseExhController : BaseController(), CoroutineScope {
|
abstract class BaseExhController(bundle: Bundle? = null) : BaseController(bundle), CoroutineScope {
|
||||||
abstract val layoutId: Int
|
abstract val layoutId: Int
|
||||||
@LayoutRes get
|
@LayoutRes get
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package exh.ui.migration.manga.design
|
package exh.ui.migration.manga.design
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
import android.support.v7.widget.LinearLayoutManager
|
import android.support.v7.widget.LinearLayoutManager
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||||
@ -9,34 +10,40 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault
|
|||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||||
|
import eu.kanade.tachiyomi.ui.migration.MigrationFlags
|
||||||
import exh.ui.base.BaseExhController
|
import exh.ui.base.BaseExhController
|
||||||
|
import exh.ui.migration.manga.process.MigrationProcedureConfig
|
||||||
import exh.ui.migration.manga.process.MigrationProcedureController
|
import exh.ui.migration.manga.process.MigrationProcedureController
|
||||||
import kotlinx.android.synthetic.main.eh_migration_design.*
|
import kotlinx.android.synthetic.main.eh_migration_design.*
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
// TODO Handle config changes
|
// TODO Handle config changes
|
||||||
// TODO Select all in library
|
// TODO Select all in library
|
||||||
class MigrationDesignController : BaseExhController(), FlexibleAdapter.OnItemClickListener {
|
class MigrationDesignController(bundle: Bundle? = null) : BaseExhController(bundle), FlexibleAdapter.OnItemClickListener {
|
||||||
private val sourceManager: SourceManager by injectLazy()
|
private val sourceManager: SourceManager by injectLazy()
|
||||||
private val prefs: PreferencesHelper by injectLazy()
|
private val prefs: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
override val layoutId: Int = R.layout.eh_migration_design
|
override val layoutId: Int = R.layout.eh_migration_design
|
||||||
|
|
||||||
private var adapter: FlexibleAdapter<MigrationSourceItem>? = null
|
private var adapter: MigrationSourceAdapter? = null
|
||||||
|
|
||||||
|
private val config: LongArray = args.getLongArray(MANGA_IDS_EXTRA) ?: LongArray(0)
|
||||||
|
|
||||||
override fun getTitle() = "Select target sources"
|
override fun getTitle() = "Select target sources"
|
||||||
|
|
||||||
override fun onViewCreated(view: View) {
|
override fun onViewCreated(view: View) {
|
||||||
super.onViewCreated(view)
|
super.onViewCreated(view)
|
||||||
|
|
||||||
adapter = MigrationSourceAdapter(
|
val ourAdapter = adapter ?: MigrationSourceAdapter(
|
||||||
getEnabledSources().map { MigrationSourceItem(it, true) },
|
getEnabledSources().map { MigrationSourceItem(it, true) },
|
||||||
this
|
this
|
||||||
)
|
)
|
||||||
|
adapter = ourAdapter
|
||||||
recycler.layoutManager = LinearLayoutManager(view.context)
|
recycler.layoutManager = LinearLayoutManager(view.context)
|
||||||
recycler.setHasFixedSize(true)
|
recycler.setHasFixedSize(true)
|
||||||
recycler.adapter = adapter
|
recycler.adapter = ourAdapter
|
||||||
adapter?.isHandleDragEnabled = true
|
ourAdapter.itemTouchHelperCallback = null // Reset adapter touch adapter to fix drag after rotation
|
||||||
|
ourAdapter.isHandleDragEnabled = true
|
||||||
|
|
||||||
migration_mode.setOnClickListener {
|
migration_mode.setOnClickListener {
|
||||||
prioritize_chapter_count.toggle()
|
prioritize_chapter_count.toggle()
|
||||||
@ -53,15 +60,41 @@ class MigrationDesignController : BaseExhController(), FlexibleAdapter.OnItemCli
|
|||||||
updatePrioritizeChapterCount(prioritize_chapter_count.isChecked)
|
updatePrioritizeChapterCount(prioritize_chapter_count.isChecked)
|
||||||
|
|
||||||
begin_migration_btn.setOnClickListener {
|
begin_migration_btn.setOnClickListener {
|
||||||
router.replaceTopController(MigrationProcedureController().withFadeTransaction())
|
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
|
||||||
|
)
|
||||||
|
).withFadeTransaction())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private fun updatePrioritizeChapterCount(migrationMode: Boolean) {
|
||||||
migration_mode.text = if(migrationMode) {
|
migration_mode.text = if(migrationMode) {
|
||||||
"Use source with most chapters and use the above list to break ties"
|
"Use the source with the most chapters and use the above list to break ties (slow with many sources or smart search)"
|
||||||
} else {
|
} else {
|
||||||
"Use the first source in the list that has at least one chapter of the manga"
|
"Use the first source in the list that has the manga"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,4 +121,14 @@ class MigrationDesignController : BaseExhController(), FlexibleAdapter.OnItemCli
|
|||||||
.filterNot { it.id.toString() in hiddenCatalogues }
|
.filterNot { it.id.toString() in hiddenCatalogues }
|
||||||
.sortedBy { "(${it.lang}) ${it.name}" }
|
.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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,10 +1,32 @@
|
|||||||
package exh.ui.migration.manga.design
|
package exh.ui.migration.manga.design
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||||
|
import exh.debug.DebugFunctions.sourceManager
|
||||||
|
|
||||||
class MigrationSourceAdapter(val items: List<MigrationSourceItem>,
|
class MigrationSourceAdapter(val items: List<MigrationSourceItem>,
|
||||||
val controller: MigrationDesignController): FlexibleAdapter<MigrationSourceItem>(
|
val controller: MigrationDesignController): FlexibleAdapter<MigrationSourceItem>(
|
||||||
items,
|
items,
|
||||||
controller,
|
controller,
|
||||||
true
|
true
|
||||||
)
|
) {
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
|
||||||
|
outState.putParcelableArrayList(SELECTED_SOURCES_KEY, ArrayList(currentItems.map {
|
||||||
|
it.asParcelable()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,15 @@
|
|||||||
package exh.ui.migration.manga.design
|
package exh.ui.migration.manga.design
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
import android.support.v7.widget.RecyclerView
|
import android.support.v7.widget.RecyclerView
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
import eu.davidea.flexibleadapter.items.IFlexible
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import kotlinx.android.parcel.Parcelize
|
||||||
|
|
||||||
class MigrationSourceItem(val source: HttpSource, var sourceEnabled: Boolean): AbstractFlexibleItem<MigrationSourceHolder>() {
|
class MigrationSourceItem(val source: HttpSource, var sourceEnabled: Boolean): AbstractFlexibleItem<MigrationSourceHolder>() {
|
||||||
override fun getLayoutRes() = R.layout.eh_source_item
|
override fun getLayoutRes() = R.layout.eh_source_item
|
||||||
@ -48,4 +51,22 @@ class MigrationSourceItem(val source: HttpSource, var sourceEnabled: Boolean): A
|
|||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
return source.id.hashCode()
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package exh.ui.migration.manga.process
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.support.v4.view.ViewPager
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.MotionEvent
|
||||||
|
|
||||||
|
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,38 @@
|
|||||||
|
package exh.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 exh.util.DeferredField
|
||||||
|
import exh.util.await
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.BroadcastChannel
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
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).await()
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mangaSource(): Source {
|
||||||
|
return sourceManager.getOrStub(manga()?.source ?: -1)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,180 @@
|
|||||||
|
package exh.ui.migration.manga.process
|
||||||
|
|
||||||
|
import android.support.v4.view.PagerAdapter
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
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.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.manga.info.MangaInfoController
|
||||||
|
import eu.kanade.tachiyomi.util.gone
|
||||||
|
import eu.kanade.tachiyomi.util.inflate
|
||||||
|
import exh.MERGED_SOURCE_ID
|
||||||
|
import exh.debug.DebugFunctions.sourceManager
|
||||||
|
import exh.util.await
|
||||||
|
import kotlinx.android.synthetic.main.eh_manga_card.view.*
|
||||||
|
import kotlinx.android.synthetic.main.eh_migration_process_item.view.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
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.eh_migration_process_item)
|
||||||
|
container.addView(view)
|
||||||
|
|
||||||
|
view.skip_migration.setOnClickListener {
|
||||||
|
controller.nextMigration()
|
||||||
|
}
|
||||||
|
|
||||||
|
view.accept_migration.setOnClickListener {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewTag = ViewTag(coroutineContext)
|
||||||
|
view.tag = viewTag
|
||||||
|
view.setupView(viewTag, item)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
fun View.setupView(tag: ViewTag, migratingManga: MigratingManga) {
|
||||||
|
tag.launch {
|
||||||
|
val manga = migratingManga.manga()
|
||||||
|
val source = migratingManga.mangaSource()
|
||||||
|
if(manga != null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
eh_manga_card_from.loading_group.gone()
|
||||||
|
eh_manga_card_from.attachManga(tag, manga, source)
|
||||||
|
eh_manga_card_from.setOnClickListener {
|
||||||
|
controller.router.pushController(MangaController(manga, true).withFadeTransaction())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.launch {
|
||||||
|
migratingManga.progress.asFlow().collect { (max, progress) ->
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
eh_manga_card_to.search_progress.let { progressBar ->
|
||||||
|
progressBar.max = max
|
||||||
|
progressBar.progress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val searchResult = migratingManga.searchResult.get()?.let {
|
||||||
|
db.getManga(it).await()
|
||||||
|
}
|
||||||
|
val resultSource = searchResult?.source?.let {
|
||||||
|
sourceManager.get(it)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if(searchResult != null && resultSource != null) {
|
||||||
|
eh_manga_card_to.loading_group.gone()
|
||||||
|
eh_manga_card_to.attachManga(tag, searchResult, resultSource)
|
||||||
|
eh_manga_card_to.setOnClickListener {
|
||||||
|
controller.router.pushController(MangaController(manga, true).withFadeTransaction())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eh_manga_card_to.search_progress.gone()
|
||||||
|
eh_manga_card_to.search_status.text = "Found no manga"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package exh.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
|
||||||
|
): Parcelable
|
@ -2,15 +2,43 @@ package exh.ui.migration.manga.process
|
|||||||
|
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import com.elvishew.xlog.XLog
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
|
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.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.util.toast
|
||||||
|
import exh.smartsearch.SmartSearchEngine
|
||||||
import exh.ui.base.BaseExhController
|
import exh.ui.base.BaseExhController
|
||||||
|
import exh.util.await
|
||||||
|
import kotlinx.android.synthetic.main.eh_migration_process.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import rx.schedulers.Schedulers
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class MigrationProcedureController : BaseExhController() {
|
class MigrationProcedureController(bundle: Bundle? = null) : BaseExhController(bundle), CoroutineScope {
|
||||||
override val layoutId = R.layout.eh_migration_process
|
override val layoutId = R.layout.eh_migration_process
|
||||||
|
|
||||||
private var titleText = "Migrate manga (1/300)"
|
private var titleText = "Migrate manga (1/300)"
|
||||||
|
|
||||||
|
private var adapter: MigrationProcedureAdapter? = null
|
||||||
|
|
||||||
|
private val config: MigrationProcedureConfig = args.getParcelable(CONFIG_EXTRA)
|
||||||
|
|
||||||
|
private val db: DatabaseHelper by injectLazy()
|
||||||
|
private val sourceManager: SourceManager by injectLazy()
|
||||||
|
|
||||||
|
private val smartSearchEngine = SmartSearchEngine(coroutineContext)
|
||||||
|
|
||||||
|
private val logger = XLog.tag("MigrationProcedureController")
|
||||||
|
|
||||||
|
private var migrationsJob: Job? = null
|
||||||
|
private var migratingManga: List<MigratingManga>? = null
|
||||||
|
|
||||||
override fun getTitle(): String {
|
override fun getTitle(): String {
|
||||||
return titleText
|
return titleText
|
||||||
}
|
}
|
||||||
@ -20,9 +48,152 @@ class MigrationProcedureController : BaseExhController() {
|
|||||||
setTitle()
|
setTitle()
|
||||||
|
|
||||||
activity?.requestedOrientation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
|
activity?.requestedOrientation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
|
||||||
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
|
||||||
} else {
|
} else {
|
||||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
ActivityInfo.SCREEN_ORIENTATION_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextMigration() {
|
||||||
|
adapter?.let { adapter ->
|
||||||
|
if(pager.currentItem >= adapter.count - 1) {
|
||||||
|
applicationContext?.toast("All migrations complete!")
|
||||||
|
router.popCurrentController()
|
||||||
|
} else {
|
||||||
|
pager.setCurrentItem(pager.currentItem + 1, true)
|
||||||
|
titleText = "Migrate manga (${pager.currentItem + 1}/${adapter.count})"
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
setTitle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun runMigrations(mangas: List<MigratingManga>) {
|
||||||
|
val sources = config.targetSourceIds.mapNotNull { sourceManager.get(it) as? CatalogueSource }
|
||||||
|
|
||||||
|
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 sourceQueue = Channel<CatalogueSource>(Channel.RENDEZVOUS)
|
||||||
|
launch {
|
||||||
|
validSources.forEachIndexed { index, catalogueSource ->
|
||||||
|
sourceQueue.send(catalogueSource)
|
||||||
|
manga.progress.send(validSources.size to index)
|
||||||
|
}
|
||||||
|
sourceQueue.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val results = mutableListOf<Pair<Manga, Int>>()
|
||||||
|
|
||||||
|
(1 .. 3).map {
|
||||||
|
launch {
|
||||||
|
for(source in sourceQueue) {
|
||||||
|
try {
|
||||||
|
supervisorScope {
|
||||||
|
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())
|
||||||
|
results += localManga to chapters.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e: Exception) {
|
||||||
|
logger.e("Failed to search in source: ${source.id}!", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.forEach { it.join() }
|
||||||
|
|
||||||
|
results.maxBy { it.second }?.first
|
||||||
|
} else {
|
||||||
|
validSources.forEachIndexed { index, source ->
|
||||||
|
val searchResult = try {
|
||||||
|
supervisorScope {
|
||||||
|
val searchResult = if (config.enableLenientSearch) {
|
||||||
|
smartSearchEngine.smartSearch(source, mangaObj.title)
|
||||||
|
} else {
|
||||||
|
smartSearchEngine.normalSearch(source, mangaObj.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchResult != null) {
|
||||||
|
smartSearchEngine.networkToLocalManga(searchResult, source.id)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
} catch(e: Exception) {
|
||||||
|
logger.e("Failed to search in source: ${source.id}!", e)
|
||||||
|
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 {
|
||||||
|
supervisorScope {
|
||||||
|
val newManga = sourceManager.getOrStub(result.source)
|
||||||
|
.fetchMangaDetails(result)
|
||||||
|
.toSingle()
|
||||||
|
.await()
|
||||||
|
result.copyFrom(newManga)
|
||||||
|
|
||||||
|
db.insertManga(result).await()
|
||||||
|
}
|
||||||
|
} catch(e: Exception) {
|
||||||
|
logger.e("Could not load search manga details", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manga.searchResult.initialize(result?.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,4 +202,14 @@ class MigrationProcedureController : BaseExhController() {
|
|||||||
|
|
||||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -2,39 +2,34 @@ package exh.ui.smartsearch
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import com.elvishew.xlog.XLog
|
import com.elvishew.xlog.XLog
|
||||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
|
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
|
||||||
import exh.util.await
|
import exh.smartsearch.SmartSearchEngine
|
||||||
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import rx.schedulers.Schedulers
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
class SmartSearchPresenter(private val source: CatalogueSource?, private val config: CatalogueController.SmartSearchConfig?):
|
class SmartSearchPresenter(private val source: CatalogueSource?, private val config: CatalogueController.SmartSearchConfig?):
|
||||||
BasePresenter<SmartSearchController>(), CoroutineScope {
|
BasePresenter<SmartSearchController>(), CoroutineScope {
|
||||||
private val db: DatabaseHelper by injectLazy()
|
|
||||||
|
|
||||||
private val logger = XLog.tag("SmartSearchPresenter")
|
private val logger = XLog.tag("SmartSearchPresenter")
|
||||||
|
|
||||||
override val coroutineContext = Job() + Dispatchers.Main
|
override val coroutineContext = Job() + Dispatchers.Main
|
||||||
|
|
||||||
val smartSearchChannel = Channel<SearchResults>()
|
val smartSearchChannel = Channel<SearchResults>()
|
||||||
|
|
||||||
|
private val smartSearchEngine = SmartSearchEngine(coroutineContext)
|
||||||
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
override fun onCreate(savedState: Bundle?) {
|
||||||
super.onCreate(savedState)
|
super.onCreate(savedState)
|
||||||
|
|
||||||
if(source != null && config != null) {
|
if(source != null && config != null) {
|
||||||
launch(Dispatchers.Default) {
|
launch(Dispatchers.Default) {
|
||||||
val result = try {
|
val result = try {
|
||||||
val resultManga = smartSearch(source, config)
|
val resultManga = smartSearchEngine.smartSearch(source, config.origTitle)
|
||||||
if (resultManga != null) {
|
if (resultManga != null) {
|
||||||
val localManga = networkToLocalManga(resultManga, source.id)
|
val localManga = smartSearchEngine.networkToLocalManga(resultManga, source.id)
|
||||||
SearchResults.Found(localManga)
|
SearchResults.Found(localManga)
|
||||||
} else {
|
} else {
|
||||||
SearchResults.NotFound
|
SearchResults.NotFound
|
||||||
@ -53,138 +48,6 @@ class SmartSearchPresenter(private val source: CatalogueSource?, private val con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun smartSearch(source: CatalogueSource, config: CatalogueController.SmartSearchConfig): SManga? {
|
|
||||||
val cleanedTitle = cleanSmartSearchTitle(config.origTitle)
|
|
||||||
|
|
||||||
val queries = getSmartSearchQueries(cleanedTitle)
|
|
||||||
|
|
||||||
val eligibleManga = supervisorScope {
|
|
||||||
queries.map { query ->
|
|
||||||
async(Dispatchers.Default) {
|
|
||||||
val searchResults = source.fetchSearchManga(1, query, FilterList()).toSingle().await(Schedulers.io())
|
|
||||||
|
|
||||||
searchResults.mangas.map {
|
|
||||||
val cleanedMangaTitle = cleanSmartSearchTitle(it.title)
|
|
||||||
val normalizedDistance = NormalizedLevenshtein().similarity(cleanedTitle, cleanedMangaTitle)
|
|
||||||
SearchEntry(it, normalizedDistance)
|
|
||||||
}.filter { (_, normalizedDistance) ->
|
|
||||||
normalizedDistance >= MIN_ELIGIBLE_THRESHOLD
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.flatMap { it.await() }
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@ -199,11 +62,4 @@ class SmartSearchPresenter(private val source: CatalogueSource?, private val con
|
|||||||
object NotFound: SearchResults()
|
object NotFound: SearchResults()
|
||||||
object Error: SearchResults()
|
object Error: SearchResults()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val MIN_ELIGIBLE_THRESHOLD = 0.7
|
|
||||||
|
|
||||||
private val titleRegex = Regex("[^a-zA-Z0-9- ]")
|
|
||||||
private val consecutiveSpacesRegex = Regex(" +")
|
|
||||||
}
|
|
||||||
}
|
}
|
46
app/src/main/java/exh/util/DeferredField.kt
Normal file
46
app/src/main/java/exh/util/DeferredField.kt
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package exh.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!!
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true">
|
||||||
|
|
||||||
<android.support.constraint.ConstraintLayout
|
<android.support.constraint.ConstraintLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content"
|
||||||
|
android:clickable="false">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/manga_cover"
|
android:id="@+id/manga_cover"
|
||||||
@ -17,183 +21,180 @@
|
|||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:contentDescription="@string/description_cover"
|
android:contentDescription="@string/description_cover"
|
||||||
app:layout_constraintWidth_min="100dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintDimensionRatio="h,3:2"
|
app:layout_constraintDimensionRatio="h,3:2"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintWidth_min="100dp"
|
||||||
tools:background="@color/material_grey_700" />
|
tools:background="@color/material_grey_700" />
|
||||||
|
|
||||||
<android.support.v4.widget.NestedScrollView
|
<android.support.constraint.ConstraintLayout
|
||||||
android:id="@+id/info_scrollview"
|
android:id="@+id/card_scroll_content"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_marginLeft="8dp"
|
android:layout_marginLeft="16dp"
|
||||||
android:layout_marginRight="16dp"
|
android:layout_marginRight="16dp"
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/manga_cover"
|
app:layout_constraintBottom_toBottomOf="@+id/manga_cover"
|
||||||
app:layout_constraintLeft_toRightOf="@+id/manga_cover"
|
app:layout_constraintLeft_toRightOf="@+id/manga_cover"
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="@+id/manga_cover"
|
app:layout_constraintTop_toTopOf="@+id/manga_cover">
|
||||||
tools:layout_height="200dp">
|
|
||||||
|
|
||||||
<android.support.constraint.ConstraintLayout
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:id="@+id/manga_full_title"
|
||||||
android:layout_height="wrap_content">
|
style="@style/TextAppearance.Medium.Title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clickable="false"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:text="@string/manga_info_full_title_label"
|
||||||
|
android:textIsSelectable="false"
|
||||||
|
app:autoSizeMaxTextSize="20sp"
|
||||||
|
app:autoSizeMinTextSize="12sp"
|
||||||
|
app:autoSizeStepGranularity="2sp"
|
||||||
|
app:autoSizeTextType="uniform"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/manga_full_title"
|
android:id="@+id/manga_author_label"
|
||||||
style="@style/TextAppearance.Medium.Title"
|
style="@style/TextAppearance.Medium.Body2"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:maxLines="2"
|
android:clickable="false"
|
||||||
android:text="@string/manga_info_full_title_label"
|
android:text="@string/manga_info_author_label"
|
||||||
android:textIsSelectable="false"
|
android:textIsSelectable="false"
|
||||||
app:autoSizeMaxTextSize="20sp"
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
app:autoSizeMinTextSize="12sp"
|
app:layout_constraintTop_toBottomOf="@+id/manga_full_title" />
|
||||||
app:autoSizeStepGranularity="2sp"
|
|
||||||
app:autoSizeTextType="uniform"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/manga_author_label"
|
android:id="@+id/manga_author"
|
||||||
style="@style/TextAppearance.Medium.Body2"
|
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/manga_info_author_label"
|
android:layout_marginLeft="8dp"
|
||||||
android:textIsSelectable="false"
|
android:clickable="false"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textIsSelectable="false"
|
||||||
|
app:layout_constraintBaseline_toBaselineOf="@+id/manga_author_label"
|
||||||
|
app:layout_constraintLeft_toRightOf="@+id/manga_author_label"
|
||||||
|
app:layout_constraintRight_toRightOf="parent" />
|
||||||
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
<TextView
|
||||||
app:layout_constraintTop_toBottomOf="@+id/manga_full_title" />
|
android:id="@+id/manga_artist_label"
|
||||||
|
style="@style/TextAppearance.Medium.Body2"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clickable="false"
|
||||||
|
android:text="@string/manga_info_artist_label"
|
||||||
|
android:textIsSelectable="false"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/manga_author_label" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/manga_author"
|
android:id="@+id/manga_artist"
|
||||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginLeft="8dp"
|
android:layout_marginLeft="8dp"
|
||||||
android:ellipsize="end"
|
android:clickable="false"
|
||||||
android:maxLines="1"
|
android:ellipsize="end"
|
||||||
android:textIsSelectable="false"
|
android:maxLines="1"
|
||||||
app:layout_constraintBaseline_toBaselineOf="@+id/manga_author_label"
|
android:textIsSelectable="false"
|
||||||
app:layout_constraintLeft_toRightOf="@+id/manga_author_label"
|
app:layout_constraintBaseline_toBaselineOf="@+id/manga_artist_label"
|
||||||
app:layout_constraintRight_toRightOf="parent" />
|
app:layout_constraintLeft_toRightOf="@+id/manga_artist_label"
|
||||||
|
app:layout_constraintRight_toRightOf="parent" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/manga_artist_label"
|
android:id="@+id/manga_status_label"
|
||||||
style="@style/TextAppearance.Medium.Body2"
|
style="@style/TextAppearance.Medium.Body2"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/manga_info_artist_label"
|
android:clickable="false"
|
||||||
android:textIsSelectable="false"
|
android:text="@string/manga_info_status_label"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
android:textIsSelectable="false"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/manga_author_label" />
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/manga_artist_label" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/manga_artist"
|
android:id="@+id/manga_status"
|
||||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginLeft="8dp"
|
android:layout_marginLeft="8dp"
|
||||||
android:ellipsize="end"
|
android:clickable="false"
|
||||||
android:maxLines="1"
|
android:ellipsize="end"
|
||||||
android:textIsSelectable="false"
|
android:maxLines="1"
|
||||||
app:layout_constraintBaseline_toBaselineOf="@+id/manga_artist_label"
|
android:textIsSelectable="false"
|
||||||
app:layout_constraintLeft_toRightOf="@+id/manga_artist_label"
|
app:layout_constraintBaseline_toBaselineOf="@+id/manga_status_label"
|
||||||
app:layout_constraintRight_toRightOf="parent" />
|
app:layout_constraintLeft_toRightOf="@+id/manga_status_label"
|
||||||
|
app:layout_constraintRight_toRightOf="parent" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/manga_chapters_label"
|
android:id="@+id/manga_source_label"
|
||||||
style="@style/TextAppearance.Medium.Body2"
|
style="@style/TextAppearance.Medium.Body2"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/manga_info_last_chapter_label"
|
android:clickable="false"
|
||||||
android:textIsSelectable="false"
|
android:text="@string/manga_info_source_label"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
android:textIsSelectable="false"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/manga_artist_label" />
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/manga_status_label" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/manga_chapters"
|
android:id="@+id/manga_source"
|
||||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginLeft="8dp"
|
android:layout_marginLeft="8dp"
|
||||||
android:ellipsize="end"
|
android:clickable="false"
|
||||||
android:maxLines="1"
|
android:textIsSelectable="false"
|
||||||
android:textIsSelectable="false"
|
app:layout_constraintLeft_toRightOf="@+id/manga_source_label"
|
||||||
app:layout_constraintBaseline_toBaselineOf="@+id/manga_chapters_label"
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
app:layout_constraintLeft_toRightOf="@+id/manga_chapters_label"
|
app:layout_constraintTop_toBottomOf="@+id/manga_status_label" />
|
||||||
app:layout_constraintRight_toRightOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
</android.support.constraint.ConstraintLayout>
|
||||||
android:id="@+id/manga_last_update_label"
|
|
||||||
style="@style/TextAppearance.Medium.Body2"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/manga_info_latest_data_label"
|
|
||||||
android:textIsSelectable="false"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/manga_chapters_label" />
|
|
||||||
|
|
||||||
<TextView
|
<View
|
||||||
android:id="@+id/manga_last_update"
|
android:id="@+id/card_shim"
|
||||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
android:layout_width="0dp"
|
||||||
android:layout_width="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:background="#E6FFFFFF"
|
||||||
android:layout_marginLeft="8dp"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
android:ellipsize="end"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
android:maxLines="1"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
android:textIsSelectable="false"
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
app:layout_constraintBaseline_toBaselineOf="@+id/manga_last_update_label"
|
|
||||||
app:layout_constraintLeft_toRightOf="@+id/manga_last_update_label"
|
|
||||||
app:layout_constraintRight_toRightOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/manga_status_label"
|
android:id="@+id/search_status"
|
||||||
style="@style/TextAppearance.Medium.Body2"
|
android:layout_width="wrap_content"
|
||||||
android:layout_width="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_marginStart="8dp"
|
||||||
android:text="@string/manga_info_status_label"
|
android:layout_marginLeft="8dp"
|
||||||
android:textIsSelectable="false"
|
android:layout_marginTop="8dp"
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
android:layout_marginEnd="8dp"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/manga_last_update_label" />
|
android:layout_marginRight="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="Searching..."
|
||||||
|
android:textAppearance="@style/TextAppearance.AppCompat.Large"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/search_progress"
|
||||||
|
app:layout_constraintEnd_toEndOf="@+id/card_shim"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/card_shim" />
|
||||||
|
|
||||||
<TextView
|
<ProgressBar
|
||||||
android:id="@+id/manga_status"
|
android:id="@+id/search_progress"
|
||||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginLeft="8dp"
|
app:layout_constraintBottom_toBottomOf="@+id/card_shim"
|
||||||
android:ellipsize="end"
|
app:layout_constraintEnd_toEndOf="@+id/card_shim"
|
||||||
android:maxLines="1"
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
android:textIsSelectable="false"
|
|
||||||
app:layout_constraintBaseline_toBaselineOf="@+id/manga_status_label"
|
|
||||||
app:layout_constraintLeft_toRightOf="@+id/manga_status_label"
|
|
||||||
app:layout_constraintRight_toRightOf="parent" />
|
|
||||||
|
|
||||||
<TextView
|
<android.support.constraint.Group
|
||||||
android:id="@+id/manga_source_label"
|
android:id="@+id/loading_group"
|
||||||
style="@style/TextAppearance.Medium.Body2"
|
android:layout_width="wrap_content"
|
||||||
android:layout_width="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
app:constraint_referenced_ids="card_shim,search_status,search_progress" />
|
||||||
android:text="@string/manga_info_source_label"
|
|
||||||
android:textIsSelectable="false"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/manga_status_label" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/manga_source"
|
|
||||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginLeft="8dp"
|
|
||||||
android:textIsSelectable="false"
|
|
||||||
app:layout_constraintLeft_toRightOf="@+id/manga_source_label"
|
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/manga_status_label" />
|
|
||||||
|
|
||||||
</android.support.constraint.ConstraintLayout>
|
|
||||||
|
|
||||||
</android.support.v4.widget.NestedScrollView>
|
|
||||||
</android.support.constraint.ConstraintLayout>
|
</android.support.constraint.ConstraintLayout>
|
||||||
</android.support.v7.widget.CardView>
|
</android.support.v7.widget.CardView>
|
@ -1,22 +1,66 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<android.support.constraint.ConstraintLayout
|
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent"
|
||||||
|
android:animateLayoutChanges="true">
|
||||||
|
|
||||||
<android.support.v7.widget.RecyclerView
|
<android.support.v7.widget.RecyclerView
|
||||||
android:id="@+id/recycler"
|
android:id="@+id/recycler"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/textView"
|
app:layout_constraintBottom_toTopOf="@+id/textView2"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:listitem="@layout/eh_source_item" />
|
tools:listitem="@layout/eh_source_item">
|
||||||
|
|
||||||
|
</android.support.v7.widget.RecyclerView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView2"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="Data to include in migration"
|
||||||
|
android:textAppearance="@style/TextAppearance.Medium.Body2"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/mig_chapters"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/textView" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/mig_chapters"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="@string/chapters"
|
||||||
|
android:checked="true"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/textView"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/textView2" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/mig_categories"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginLeft="8dp"
|
||||||
|
android:text="@string/categories"
|
||||||
|
android:checked="true"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/mig_chapters"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/mig_chapters" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/mig_tracking"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginLeft="8dp"
|
||||||
|
android:text="@string/track"
|
||||||
|
android:checked="true"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/mig_categories"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/mig_categories" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/textView"
|
android:id="@+id/textView"
|
||||||
@ -25,7 +69,7 @@
|
|||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:layout_marginLeft="8dp"
|
android:layout_marginLeft="8dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:text="Migration mode"
|
android:text="Search options"
|
||||||
android:textAppearance="@style/TextAppearance.Medium.Body2"
|
android:textAppearance="@style/TextAppearance.Medium.Body2"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/prioritize_chapter_count"
|
app:layout_constraintBottom_toTopOf="@+id/prioritize_chapter_count"
|
||||||
app:layout_constraintStart_toStartOf="parent" />
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
@ -70,7 +114,7 @@
|
|||||||
android:layout_marginRight="8dp"
|
android:layout_marginRight="8dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:gravity="start|center_vertical"
|
android:gravity="start|center_vertical"
|
||||||
android:text="Use lenient search algorithm"
|
android:text="Use smart search algorithm"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/begin_migration_btn"
|
app:layout_constraintBottom_toTopOf="@+id/begin_migration_btn"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/prioritize_chapter_count" />
|
app:layout_constraintStart_toEndOf="@+id/prioritize_chapter_count" />
|
||||||
|
@ -7,18 +7,10 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="?attr/colorPrimary" >
|
android:background="?attr/colorPrimary" >
|
||||||
|
|
||||||
<!--<android.support.v4.view.ViewPager-->
|
<exh.ui.migration.manga.process.DeactivatableViewPager
|
||||||
<!--android:id="@+id/pager"-->
|
android:id="@+id/pager"
|
||||||
<!--android:layout_width="match_parent"-->
|
android:layout_width="match_parent"
|
||||||
<!--android:layout_height="match_parent"-->
|
android:layout_height="match_parent"
|
||||||
<!--app:layout_constraintBottom_toBottomOf="parent"-->
|
|
||||||
<!--app:layout_constraintEnd_toEndOf="parent"-->
|
|
||||||
<!--app:layout_constraintStart_toStartOf="parent"-->
|
|
||||||
<!--app:layout_constraintTop_toTopOf="parent" />-->
|
|
||||||
<include
|
|
||||||
layout="@layout/eh_migration_process_item"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user